fix(automations): identify dispatched agent turns

This commit is contained in:
Brennan Benson
2026-08-27 18:37:07 -07:00
parent fc90a0b6c4
commit e8d14ec1e3
13 changed files with 256 additions and 43 deletions
@@ -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
View File
@@ -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([
+111 -3
View File
@@ -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)
})
+40 -14
View File
@@ -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) => {
+24
View File
@@ -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)
})
})
+20
View File
@@ -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
}