mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(automations): identify dispatched agent turns
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Automation } from '../../shared/automations-types'
|
||||
import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create'
|
||||
|
||||
@@ -72,7 +73,7 @@ describe('headless automation workspace create args', () => {
|
||||
activate: false,
|
||||
createdWithAgent: 'codex',
|
||||
startupAgent: 'codex',
|
||||
startupPrompt: 'Review changes',
|
||||
startupPrompt: buildAutomationTurnPrompt('Review changes', 'run-1'),
|
||||
telemetrySource: 'unknown',
|
||||
automationProvenance: {
|
||||
kind: 'created-by-automation',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance'
|
||||
import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
@@ -40,7 +41,7 @@ export function buildHeadlessAutomationWorktreeCreateArgs({
|
||||
activate: false,
|
||||
createdWithAgent: automation.agentId,
|
||||
startupAgent: automation.agentId,
|
||||
startupPrompt: automation.prompt,
|
||||
startupPrompt: buildAutomationTurnPrompt(automation.prompt, run.id),
|
||||
telemetrySource: 'unknown',
|
||||
automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Repo } from '../../shared/repo-types'
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import type { AutomationsChangedPayload } from '../../shared/runtime-client-events'
|
||||
import { normalizePromptField } from '../../shared/agent-status-field-normalization'
|
||||
import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt'
|
||||
import { AutomationService } from './service'
|
||||
import type {
|
||||
AutomationRunCompletionObservation,
|
||||
@@ -85,15 +86,16 @@ function createObserver(
|
||||
observe: (
|
||||
signal: AbortSignal,
|
||||
observedAfter: number,
|
||||
expectedPrompt?: string
|
||||
expectedPrompt?: string,
|
||||
expectedTurnId?: string
|
||||
) => Promise<AutomationRunCompletionObservation>,
|
||||
resolveRunTerminal: (run: AutomationRun) => string | null = (run) =>
|
||||
run.terminalPaneKey ? 'handle-1' : null
|
||||
): AutomationRunTerminalObserver {
|
||||
return {
|
||||
resolveRunTerminal,
|
||||
observeCompletion: (_handle, { signal, observedAfter, expectedPrompt }) =>
|
||||
observe(signal, observedAfter, expectedPrompt)
|
||||
observeCompletion: (_handle, { signal, observedAfter, expectedPrompt, expectedTurnId }) =>
|
||||
observe(signal, observedAfter, expectedPrompt, expectedTurnId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,18 +282,22 @@ describe('authority-owned automation run completion', () => {
|
||||
error: null
|
||||
})
|
||||
let expectedPrompt: string | undefined
|
||||
let expectedTurnId: string | undefined
|
||||
const service = new AutomationService(store, {
|
||||
terminalObserver: createObserver((_signal, _boundary, promptPreview) => {
|
||||
terminalObserver: createObserver((_signal, _boundary, promptPreview, turnId) => {
|
||||
expectedPrompt = promptPreview
|
||||
expectedTurnId = turnId
|
||||
return new Promise(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
service.start()
|
||||
|
||||
await vi.waitFor(() => expect(expectedPrompt).toBe(normalizePromptField(prompt)))
|
||||
const turnPrompt = buildAutomationTurnPrompt(prompt, run.id)
|
||||
await vi.waitFor(() => expect(expectedPrompt).toBe(normalizePromptField(turnPrompt)))
|
||||
expect(expectedTurnId).toBe(run.id)
|
||||
expect(readRun(store, automation.id, run.id).dispatchPromptPreview).toBe(
|
||||
normalizePromptField(prompt)
|
||||
normalizePromptField(turnPrompt)
|
||||
)
|
||||
service.stop()
|
||||
})
|
||||
|
||||
@@ -18,7 +18,12 @@ export type AutomationRunTerminalObserver = {
|
||||
resolveRunTerminal: (run: AutomationRun) => string | null
|
||||
observeCompletion: (
|
||||
handle: string,
|
||||
options: { signal: AbortSignal; observedAfter: number; expectedPrompt?: string }
|
||||
options: {
|
||||
signal: AbortSignal
|
||||
observedAfter: number
|
||||
expectedPrompt?: string
|
||||
expectedTurnId?: string
|
||||
}
|
||||
) => Promise<AutomationRunCompletionObservation>
|
||||
}
|
||||
|
||||
@@ -118,7 +123,8 @@ export class AutomationRunCompletionWatcher {
|
||||
observation = await this.observer.observeCompletion(handle, {
|
||||
signal: controller.signal,
|
||||
observedAfter: run.dispatchedAt ?? run.startedAt ?? run.createdAt,
|
||||
expectedPrompt: run.dispatchPromptPreview
|
||||
expectedPrompt: run.dispatchPromptPreview,
|
||||
expectedTurnId: run.id
|
||||
})
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
|
||||
@@ -25,7 +25,8 @@ export type AutomationRunTerminalHost = {
|
||||
hasTerminalAgentWorkedSince(
|
||||
handle: string,
|
||||
observedAfter: number,
|
||||
expectedPrompt?: string
|
||||
expectedPrompt?: string,
|
||||
expectedTurnId?: string
|
||||
): boolean
|
||||
waitForTerminal(
|
||||
handle: string,
|
||||
@@ -35,6 +36,7 @@ export type AutomationRunTerminalHost = {
|
||||
signal?: AbortSignal
|
||||
agentTurnStartedAfter?: number
|
||||
agentTurnPrompt?: string
|
||||
agentTurnId?: string
|
||||
}
|
||||
): Promise<{ satisfied: boolean; blockedReason?: string }>
|
||||
readTerminal(handle: string, opts?: { limit?: number }): Promise<{ tail: string[] }>
|
||||
@@ -96,7 +98,10 @@ export function createRuntimeAutomationRunTerminalObserver(
|
||||
return {
|
||||
resolveRunTerminal: (run) =>
|
||||
run.terminalPaneKey ? runtime.getTerminalHandleForPaneKey(run.terminalPaneKey) : null,
|
||||
observeCompletion: async (handle, { signal, observedAfter, expectedPrompt }) => {
|
||||
observeCompletion: async (
|
||||
handle,
|
||||
{ signal, observedAfter, expectedPrompt, expectedTurnId }
|
||||
) => {
|
||||
const startedAt = Date.now()
|
||||
// Why: shell startup and a ready prompt can both satisfy opposite sides of
|
||||
// tui-idle before the agent begins. Only a working lifecycle observed for
|
||||
@@ -107,14 +112,22 @@ export function createRuntimeAutomationRunTerminalObserver(
|
||||
timeoutMs: AGENT_START_DEADLINE_MS,
|
||||
signal,
|
||||
agentTurnStartedAfter: observedAfter,
|
||||
agentTurnPrompt: expectedPrompt
|
||||
agentTurnPrompt: expectedPrompt,
|
||||
agentTurnId: expectedTurnId
|
||||
})
|
||||
return await buildObservation(runtime, handle, initialWait)
|
||||
} catch (error) {
|
||||
if (signal.aborted || !isTerminalWaitTimeout(error)) {
|
||||
throw error
|
||||
}
|
||||
if (!runtime.hasTerminalAgentWorkedSince(handle, observedAfter, expectedPrompt)) {
|
||||
if (
|
||||
!runtime.hasTerminalAgentWorkedSince(
|
||||
handle,
|
||||
observedAfter,
|
||||
expectedPrompt,
|
||||
expectedTurnId
|
||||
)
|
||||
) {
|
||||
return await buildUnobservedObservation(
|
||||
runtime,
|
||||
handle,
|
||||
@@ -129,7 +142,8 @@ export function createRuntimeAutomationRunTerminalObserver(
|
||||
condition: 'tui-idle',
|
||||
signal,
|
||||
agentTurnStartedAfter: observedAfter,
|
||||
agentTurnPrompt: expectedPrompt
|
||||
agentTurnPrompt: expectedPrompt,
|
||||
agentTurnId: expectedTurnId
|
||||
})
|
||||
return await buildObservation(runtime, handle, wait)
|
||||
} catch (error) {
|
||||
|
||||
+2
-1
@@ -322,6 +322,7 @@ import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
|
||||
import { setUnreadDockBadgeCount } from './dock/unread-badge'
|
||||
import { AutomationService } from './automations/service'
|
||||
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
|
||||
import { buildAutomationTurnPrompt } from '../shared/automation-turn-prompt'
|
||||
import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer'
|
||||
import { AgentAwakeService } from './agent-awake-service'
|
||||
import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode'
|
||||
@@ -2834,7 +2835,7 @@ void app.whenReady().then(async () => {
|
||||
`id:${automation.workspaceId}`,
|
||||
{
|
||||
agent: automation.agentId,
|
||||
prompt: automation.prompt,
|
||||
prompt: buildAutomationTurnPrompt(automation.prompt, run.id),
|
||||
title: run.title
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AutomationRunTrigger
|
||||
} from '../../../shared/automations-types'
|
||||
import { normalizePromptField } from '../../../shared/agent-status-field-normalization'
|
||||
import { buildAutomationTurnPrompt } from '../../../shared/automation-turn-prompt'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import {
|
||||
nextAutomationRunNumber,
|
||||
@@ -62,8 +63,9 @@ export function createAutomationRun(
|
||||
const runNumber = nextAutomationRunNumber(
|
||||
(operations.state.automationRuns ?? []).filter((run) => run.automationId === automation.id)
|
||||
)
|
||||
const runId = randomUUID()
|
||||
const run: AutomationRun = {
|
||||
id: randomUUID(),
|
||||
id: runId,
|
||||
automationId: automation.id,
|
||||
runNumber,
|
||||
runContext: automation.runContext ?? null,
|
||||
@@ -85,7 +87,9 @@ export function createAutomationRun(
|
||||
error: null,
|
||||
startedAt: null,
|
||||
dispatchedAt: null,
|
||||
dispatchPromptPreview: normalizePromptField(automation.prompt),
|
||||
dispatchPromptPreview: normalizePromptField(
|
||||
buildAutomationTurnPrompt(automation.prompt, runId)
|
||||
),
|
||||
createdAt: now
|
||||
}
|
||||
operations.state.automationRuns = pruneAutomationRuns([
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/li
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types'
|
||||
import { buildAutomationTurnPrompt } from '../../shared/automation-turn-prompt'
|
||||
import {
|
||||
reviewHeadRemoteRefComponent,
|
||||
REVIEW_HEAD_FETCH_TIMEOUT_MS
|
||||
@@ -37665,10 +37666,11 @@ describe('OrcaRuntimeService', () => {
|
||||
it('restores ordered completion from persisted working evidence after restart', async () => {
|
||||
const observedAfter = Date.now() - 1_000
|
||||
const leafId = '11111111-2222-4333-8444-555555555555'
|
||||
const turnPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1')
|
||||
const statuses = [
|
||||
{
|
||||
state: 'working' as const,
|
||||
prompt: 'automation prompt',
|
||||
prompt: turnPrompt,
|
||||
agentType: 'codex' as const,
|
||||
paneKey: `tab-1:${leafId}`,
|
||||
connectionId: null,
|
||||
@@ -37708,7 +37710,8 @@ describe('OrcaRuntimeService', () => {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 1_000,
|
||||
agentTurnStartedAfter: observedAfter,
|
||||
agentTurnPrompt: 'automation prompt'
|
||||
agentTurnPrompt: turnPrompt,
|
||||
agentTurnId: 'run-1'
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 200)
|
||||
@@ -37719,6 +37722,7 @@ describe('OrcaRuntimeService', () => {
|
||||
it('does not let a different post-restart prompt consume retained dispatch evidence', async () => {
|
||||
const observedAfter = Date.now() - 1_000
|
||||
const leafId = '11111111-2222-4333-8444-555555555555'
|
||||
const turnPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1')
|
||||
const statuses = [
|
||||
{
|
||||
state: 'working' as const,
|
||||
@@ -37765,7 +37769,111 @@ describe('OrcaRuntimeService', () => {
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
expect(
|
||||
runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, 'automation prompt')
|
||||
runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, turnPrompt, 'run-1')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an exact marker-matched SSH completion without working or clock ordering', async () => {
|
||||
const observedAfter = Date.now()
|
||||
const leafId = '11111111-2222-4333-8444-555555555555'
|
||||
const turnPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1')
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
state: 'done',
|
||||
prompt: turnPrompt,
|
||||
agentType: 'codex',
|
||||
paneKey: `tab-1:${leafId}`,
|
||||
connectionId: 'ssh-1',
|
||||
receivedAt: observedAfter - 100,
|
||||
stateStartedAt: observedAfter - 200,
|
||||
observation: {
|
||||
origin: 'hook',
|
||||
authorityId: 'remote-authority',
|
||||
incarnation: 0,
|
||||
revision: 2,
|
||||
observedAt: observedAfter + 60 * 60 * 1_000,
|
||||
kind: 'snapshot'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Codex',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
expect(
|
||||
runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, turnPrompt, 'run-1')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects raw same-text and different-run completion evidence', async () => {
|
||||
const observedAfter = Date.now() - 1_000
|
||||
const leafId = '11111111-2222-4333-8444-555555555555'
|
||||
const expectedPrompt = buildAutomationTurnPrompt('automation prompt', 'run-1')
|
||||
let prompt = 'automation prompt'
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
state: 'done',
|
||||
prompt,
|
||||
agentType: 'codex',
|
||||
paneKey: `tab-1:${leafId}`,
|
||||
connectionId: null,
|
||||
receivedAt: observedAfter + 100,
|
||||
stateStartedAt: observedAfter + 100,
|
||||
turnCompletedAt: observedAfter + 100
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Codex',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
|
||||
expect(
|
||||
runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, expectedPrompt, 'run-1')
|
||||
).toBe(false)
|
||||
prompt = buildAutomationTurnPrompt('automation prompt', 'run-2')
|
||||
expect(
|
||||
runtime.hasTerminalAgentWorkedSince(terminal.handle, observedAfter, expectedPrompt, 'run-1')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
type AgentStatusOrchestrationContext,
|
||||
type AgentStatusEntry
|
||||
} from '../../shared/agent-status-types'
|
||||
import { isAutomationTurnPrompt } from '../../shared/automation-turn-prompt'
|
||||
import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence'
|
||||
import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index'
|
||||
import type { AgentHookAuthorityAttestation } from '../agent-hooks/server'
|
||||
@@ -2455,6 +2456,7 @@ type TerminalWaiter = {
|
||||
condition: RuntimeTerminalWaitCondition
|
||||
agentTurnStartedAfter: number | null
|
||||
agentTurnPrompt: string | null
|
||||
agentTurnId: string | null
|
||||
resolve: (result: RuntimeTerminalWait) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: NodeJS.Timeout | null
|
||||
@@ -19528,9 +19530,11 @@ export class OrcaRuntimeService {
|
||||
hasTerminalAgentWorkedSince(
|
||||
handle: string,
|
||||
observedAfter: number,
|
||||
expectedPrompt?: string
|
||||
expectedPrompt?: string,
|
||||
expectedTurnId?: string
|
||||
): boolean {
|
||||
return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt).started
|
||||
return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt, expectedTurnId)
|
||||
.started
|
||||
}
|
||||
|
||||
recordAutomationAgentStatus(entry: AgentStatusIpcPayload): void {
|
||||
@@ -19584,15 +19588,18 @@ export class OrcaRuntimeService {
|
||||
private hasTerminalAgentFinishedSince(
|
||||
handle: string,
|
||||
observedAfter: number,
|
||||
expectedPrompt?: string | null
|
||||
expectedPrompt?: string | null,
|
||||
expectedTurnId?: string | null
|
||||
): boolean {
|
||||
return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt).finished
|
||||
return this.getTerminalAgentTurnEvidence(handle, observedAfter, expectedPrompt, expectedTurnId)
|
||||
.finished
|
||||
}
|
||||
|
||||
private getTerminalAgentTurnEvidence(
|
||||
handle: string,
|
||||
observedAfter: number,
|
||||
expectedPrompt?: string | null
|
||||
expectedPrompt?: string | null,
|
||||
expectedTurnId?: string | null
|
||||
): { started: boolean; finished: boolean } {
|
||||
const ptyId = this.getTerminalAgentStatusPtyId(handle)
|
||||
const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId)
|
||||
@@ -19605,21 +19612,27 @@ export class OrcaRuntimeService {
|
||||
: null
|
||||
let completedFromHook = false
|
||||
const paneKey = this.getPaneKeyForTerminalHandle(handle)
|
||||
const hasTurnIdentity =
|
||||
expectedPrompt && expectedTurnId
|
||||
? isAutomationTurnPrompt(expectedPrompt, expectedTurnId)
|
||||
: false
|
||||
const liveHookEvidence = paneKey
|
||||
? this.automationHookTurnEvidenceByPaneKey.get(paneKey)
|
||||
: undefined
|
||||
const liveEvidenceMatchesTurn = hasTurnIdentity && liveHookEvidence?.prompt === expectedPrompt
|
||||
if (
|
||||
liveHookEvidence?.startedAt !== null &&
|
||||
liveHookEvidence?.startedAt !== undefined &&
|
||||
(!expectedPrompt || liveHookEvidence.prompt === expectedPrompt)
|
||||
) {
|
||||
if (liveHookEvidence.startedAt >= observedAfter) {
|
||||
if (liveEvidenceMatchesTurn || liveHookEvidence.startedAt >= observedAfter) {
|
||||
startedAt = Math.max(startedAt ?? observedAfter, liveHookEvidence.startedAt)
|
||||
}
|
||||
if (
|
||||
liveHookEvidence.finishedAt !== null &&
|
||||
liveHookEvidence.finishedAt > observedAfter &&
|
||||
(liveHookEvidence.startedAt >= observedAfter || liveHookEvidence.startedAt === null)
|
||||
(liveEvidenceMatchesTurn ||
|
||||
(liveHookEvidence.finishedAt > observedAfter &&
|
||||
(liveHookEvidence.startedAt >= observedAfter || liveHookEvidence.startedAt === null)))
|
||||
) {
|
||||
completedFromHook = true
|
||||
}
|
||||
@@ -19636,16 +19649,19 @@ export class OrcaRuntimeService {
|
||||
entry.observation.origin === 'hook' &&
|
||||
entry.observation.replayedAt !== undefined &&
|
||||
entry.observation.replayedAt > observedAfter
|
||||
const matchesTurnIdentity =
|
||||
hasTurnIdentity && expectedPrompt !== null && entry.prompt === expectedPrompt
|
||||
if (
|
||||
entry.observation?.kind === 'snapshot' &&
|
||||
entry.state !== 'working' &&
|
||||
!replayedCompletion
|
||||
!replayedCompletion &&
|
||||
!matchesTurnIdentity
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
entry.state === 'working' &&
|
||||
entry.stateStartedAt >= observedAfter &&
|
||||
(matchesTurnIdentity || entry.stateStartedAt >= observedAfter) &&
|
||||
(!expectedPrompt || entry.prompt === expectedPrompt)
|
||||
) {
|
||||
startedAt = Math.max(startedAt ?? observedAfter, entry.stateStartedAt)
|
||||
@@ -19653,8 +19669,8 @@ export class OrcaRuntimeService {
|
||||
if (
|
||||
entry.state === 'done' &&
|
||||
entry.sessionBoundary !== true &&
|
||||
(entry.turnCompletedAt !== undefined || replayedCompletion) &&
|
||||
entry.receivedAt > observedAfter &&
|
||||
(entry.turnCompletedAt !== undefined || replayedCompletion || matchesTurnIdentity) &&
|
||||
(matchesTurnIdentity || entry.receivedAt > observedAfter) &&
|
||||
(!expectedPrompt || entry.prompt === expectedPrompt)
|
||||
) {
|
||||
completedFromHook = true
|
||||
@@ -19678,7 +19694,8 @@ export class OrcaRuntimeService {
|
||||
this.hasTerminalAgentFinishedSince(
|
||||
waiter.handle,
|
||||
waiter.agentTurnStartedAfter,
|
||||
waiter.agentTurnPrompt
|
||||
waiter.agentTurnPrompt,
|
||||
waiter.agentTurnId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -20648,14 +20665,21 @@ export class OrcaRuntimeService {
|
||||
signal?: AbortSignal
|
||||
agentTurnStartedAfter?: number
|
||||
agentTurnPrompt?: string
|
||||
agentTurnId?: string
|
||||
}
|
||||
): Promise<RuntimeTerminalWait> {
|
||||
const condition = options?.condition ?? 'exit'
|
||||
const agentTurnStartedAfter = options?.agentTurnStartedAfter ?? null
|
||||
const agentTurnPrompt = options?.agentTurnPrompt ?? null
|
||||
const agentTurnId = options?.agentTurnId ?? null
|
||||
const hasOrderedAgentCompletion = (): boolean =>
|
||||
agentTurnStartedAfter !== null &&
|
||||
this.hasTerminalAgentFinishedSince(handle, agentTurnStartedAfter, agentTurnPrompt)
|
||||
this.hasTerminalAgentFinishedSince(
|
||||
handle,
|
||||
agentTurnStartedAfter,
|
||||
agentTurnPrompt,
|
||||
agentTurnId
|
||||
)
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
if (condition === 'exit' && !pty.pty.connected) {
|
||||
@@ -20700,6 +20724,7 @@ export class OrcaRuntimeService {
|
||||
condition,
|
||||
agentTurnStartedAfter,
|
||||
agentTurnPrompt,
|
||||
agentTurnId,
|
||||
resolve,
|
||||
reject,
|
||||
timeout: null,
|
||||
@@ -20811,6 +20836,7 @@ export class OrcaRuntimeService {
|
||||
condition,
|
||||
agentTurnStartedAfter,
|
||||
agentTurnPrompt,
|
||||
agentTurnId,
|
||||
resolve,
|
||||
reject,
|
||||
timeout: null,
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-created',
|
||||
prompt: 'run this'
|
||||
prompt: '<!-- ORCA_AUTOMATION_RUN_ID:run-1 -->\nrun this'
|
||||
})
|
||||
)
|
||||
expect(order).toEqual(['agent'])
|
||||
@@ -320,7 +320,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-created',
|
||||
prompt: 'run this'
|
||||
prompt: '<!-- ORCA_AUTOMATION_RUN_ID:run-1 -->\nrun this'
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -339,7 +339,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: 'claude',
|
||||
prompt: 'run this',
|
||||
prompt: '<!-- ORCA_AUTOMATION_RUN_ID:run-1 -->\nrun this',
|
||||
worktreeId: 'wt-created'
|
||||
})
|
||||
)
|
||||
@@ -358,7 +358,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-created',
|
||||
prompt: 'run this'
|
||||
prompt: '<!-- ORCA_AUTOMATION_RUN_ID:run-1 -->\nrun this'
|
||||
})
|
||||
)
|
||||
expect(mockMarkDispatchResult).toHaveBeenCalledWith(
|
||||
@@ -393,7 +393,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: 'wt-existing',
|
||||
prompt: 'run this'
|
||||
prompt: '<!-- ORCA_AUTOMATION_RUN_ID:run-1 -->\nrun this'
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -439,7 +439,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
|
||||
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: folderWorkspace.id,
|
||||
prompt: 'run this'
|
||||
prompt: '<!-- ORCA_AUTOMATION_RUN_ID:run-1 -->\nrun this'
|
||||
})
|
||||
)
|
||||
expect(mockMarkDispatchResult).toHaveBeenCalledWith(
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { AgentStateHistoryEntry } from '../../../shared/agent-status-types'
|
||||
import { resolveFolderWorkspaceHost } from '../../../shared/folder-workspace-execution-host'
|
||||
import { buildAutomationTurnPrompt } from '../../../shared/automation-turn-prompt'
|
||||
|
||||
const activeReuseDispatchTabIds = new Set<string>()
|
||||
|
||||
@@ -90,6 +91,7 @@ export function useAutomationDispatchEvents(): void {
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.api.automations.onDispatchRequested(
|
||||
async ({ automation, run, dispatchToken }) => {
|
||||
const dispatchPrompt = buildAutomationTurnPrompt(automation.prompt, run.id)
|
||||
const markDispatchResult = async (result: AutomationDispatchResult): Promise<void> => {
|
||||
// Deliberately no local emit: the write publishes its own host-scoped event
|
||||
// (automation-run-writer.ts) before this reply returns, and an unscoped one
|
||||
@@ -527,7 +529,7 @@ export function useAutomationDispatchEvents(): void {
|
||||
const submitted = await submitPromptToAgentPty({
|
||||
tabId: reusableSession.tabId,
|
||||
ptyId: reusableSession.ptyId,
|
||||
content: automation.prompt
|
||||
content: dispatchPrompt
|
||||
})
|
||||
if (!submitted) {
|
||||
cleanupRunObservers()
|
||||
@@ -600,7 +602,7 @@ export function useAutomationDispatchEvents(): void {
|
||||
const result = await launchAgentBackgroundSession({
|
||||
agent: automation.agentId,
|
||||
worktreeId: worktree.id,
|
||||
prompt: automation.prompt,
|
||||
prompt: dispatchPrompt,
|
||||
launchSource: 'unknown',
|
||||
title: run.title,
|
||||
onData: (chunk) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizePromptField } from './agent-status-field-normalization'
|
||||
import { buildAutomationTurnPrompt, isAutomationTurnPrompt } from './automation-turn-prompt'
|
||||
|
||||
describe('automation turn prompts', () => {
|
||||
it('gives identical task text distinct authority-owned turn identities', () => {
|
||||
const first = buildAutomationTurnPrompt('run this', 'run-1')
|
||||
const second = buildAutomationTurnPrompt('run this', 'run-2')
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
expect(isAutomationTurnPrompt(first)).toBe(true)
|
||||
expect(isAutomationTurnPrompt(second)).toBe(true)
|
||||
expect(isAutomationTurnPrompt(first, 'run-1')).toBe(true)
|
||||
expect(isAutomationTurnPrompt(first, 'run-2')).toBe(false)
|
||||
expect(isAutomationTurnPrompt(normalizePromptField(first))).toBe(true)
|
||||
expect(isAutomationTurnPrompt('run this')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not trust a legacy user-authored marker for another run', () => {
|
||||
const legacyPrompt = '<!-- ORCA_AUTOMATION_RUN_ID:not-authority -->\nlegacy task'
|
||||
|
||||
expect(isAutomationTurnPrompt(normalizePromptField(legacyPrompt), 'real-run-id')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
const AUTOMATION_TURN_PREFIX = '<!-- ORCA_AUTOMATION_RUN_ID:'
|
||||
const AUTOMATION_TURN_SUFFIX = ' -->'
|
||||
|
||||
/** Adds authority-generated turn identity without changing the user's task body. */
|
||||
export function buildAutomationTurnPrompt(prompt: string, runId: string): string {
|
||||
return `${AUTOMATION_TURN_PREFIX}${runId}${AUTOMATION_TURN_SUFFIX}\n${prompt}`
|
||||
}
|
||||
|
||||
export function isAutomationTurnPrompt(prompt: string, runId?: string): boolean {
|
||||
if (!prompt.startsWith(AUTOMATION_TURN_PREFIX)) {
|
||||
return false
|
||||
}
|
||||
const markerEnd = prompt.indexOf(AUTOMATION_TURN_SUFFIX, AUTOMATION_TURN_PREFIX.length)
|
||||
const delimiter = prompt[markerEnd + AUTOMATION_TURN_SUFFIX.length]
|
||||
if (markerEnd <= AUTOMATION_TURN_PREFIX.length || (delimiter !== '\n' && delimiter !== ' ')) {
|
||||
return false
|
||||
}
|
||||
const markerRunId = prompt.slice(AUTOMATION_TURN_PREFIX.length, markerEnd)
|
||||
return runId === undefined || markerRunId === runId
|
||||
}
|
||||
Reference in New Issue
Block a user