diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 320e98620..bde215123 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -116,4 +116,16 @@ describe('orchestration skill guidance', () => { expect(skill).toContain('post-review corrections and PR prep belong to that named owner') expect(skill).toContain('the named owner edits files and creates the PR') }) + + it('keeps worker_done post-completion guidance idle instead of polling', () => { + const skill = readSkill() + const agentGuidance = getSection(skill, 'Agent Guidance') + + expect(agentGuidance).toContain('After sending `worker_done`, end your turn') + expect(agentGuidance).toContain('idle at the agent prompt') + expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`') + expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input') + expect(skill).not.toContain('post-completion polling messages') + expect(skill).not.toContain('every 2 minutes') + }) }) diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 5b583d495..747d1a98b 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -46,7 +46,7 @@ Classify inherited context before sending lifecycle messages: - Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise. - Classify requests containing "hand off", "handoff", "handover", "give this to another agent", "give this to another worktree", "another agent", or "another worktree" as full handoffs by default, even when the user names a custom model or reasoning effort. - Use supervised orchestration only when the user explicitly asks you to "supervise", "monitor", "wait", "track completion", "wait for worker_done", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow. -- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, `ask`, and post-completion polling messages back to the original terminal. +- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle. - Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress. - A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes. - If the user's plan names a next owner agent (for example, "then use opencode to create a PR"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR. @@ -197,6 +197,7 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding - Workers with a valid live preamble must send `worker_done` exactly once, even on failure: `orca orchestration send --to --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --payload '{"taskId":"","dispatchId":"","filesModified":["path/a"],"reportPath":""}' --json` +- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input. - For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs: `orca orchestration send --to --type heartbeat --subject "alive" --payload '{"taskId":"","dispatchId":"","phase":"implementing"}' --json` - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index 0431919a9..2f8c48d1a 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -73,15 +73,16 @@ Slack, GitHub comments, or any other channel to reach a human during the run. === AFTER YOU SEND worker_done === -Keep the shell session open for a grace period (10 minutes) in case the -coordinator sends a follow-up or re-dispatches you. Poll with -\`orca orchestration check\` every 2 minutes during that window. If no -follow-up arrives, you may exit after the grace period — the coordinator -will not expect further output from you. +worker_done ends your turn for this task. Your dispatched work is complete: +stop, return to an idle prompt, and take no further actions — do NOT start +new or unrelated work, do NOT run a sleep/poll loop, and do NOT keep calling +\`orca orchestration check\`. The coordinator has already recorded your +completion and expects no further output. -If the coordinator re-dispatches you (you will receive a fresh preamble + -TASK block), reset your polling and start the new task. Do not respond -to the previous task's follow-ups after a re-dispatch. +Do not exit the shell. Your terminal stays available, and if the +coordinator has more for you it will re-engage this terminal with a fresh +preamble + TASK block, which arrives as new input. When that happens, +reset and start the new task; ignore the previous task's follow-ups. === TASK === TASK_BODY" diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 10e3692ee..23fac4843 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -12,6 +12,16 @@ function baseParams(overrides: Partial[ } } +function afterWorkerDoneSection(result: string) { + const sectionStart = result.indexOf('=== AFTER YOU SEND worker_done ===') + const sectionEnd = result.indexOf('=== TASK ===') + + expect(sectionStart).toBeGreaterThan(-1) + expect(sectionEnd).toBeGreaterThan(sectionStart) + + return result.slice(sectionStart, sectionEnd) +} + describe('buildDispatchPreamble', () => { it('substitutes template variables', () => { const result = buildDispatchPreamble(baseParams()) @@ -93,11 +103,33 @@ describe('buildDispatchPreamble', () => { expect(occurrences).toBe(3) }) - it('includes AFTER YOU SEND block with 2-minute poll cadence and release signal', () => { + it('tells prompt-returning workers to idle without post-done polling', () => { const result = buildDispatchPreamble(baseParams()) - expect(result).toContain('=== AFTER YOU SEND worker_done ===') - expect(result).toMatch(/2 minutes/) - expect(result).toMatch(/may exit/) + const section = afterWorkerDoneSection(result) + + expect(section).toContain('=== AFTER YOU SEND worker_done ===') + expect(section).toContain('worker_done ends your turn for this task') + expect(section).toContain('return to an idle prompt') + expect(section).toContain('Do not exit the shell') + expect(section).toContain('do NOT run a sleep/poll loop') + expect(section).toContain('do NOT keep calling') + expect(section).toMatch(/fresh\s+preamble \+ TASK block/) + expect(section).not.toMatch(/2 minutes/) + expect(section).not.toMatch(/10 minutes/) + expect(section).not.toMatch(/may exit/) + expect(section).not.toMatch(/grace period/) + }) + + it('tells bare-shell workers to exit after worker_done', () => { + const result = buildDispatchPreamble(baseParams({ workerKind: 'bare-shell' })) + const section = afterWorkerDoneSection(result) + + expect(section).toContain('Exit the shell after completion') + expect(section).toContain('Bare-shell workers have no idle agent') + expect(section).toContain('do NOT run a sleep/poll loop') + expect(section).not.toContain('Do not exit the shell') + expect(section).not.toMatch(/2 minutes/) + expect(section).not.toMatch(/may exit/) }) it('uses === TASK === separator with the task spec appended', () => { diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index d0bbf80b9..fdee65505 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -21,6 +21,9 @@ export type PreambleParams = { behind: number recentSubjects: string[] } + // Why: prompt-returning agents should idle after worker_done, while bare + // shells have no agent prompt for Orca to reuse. + workerKind?: 'prompt-returning-agent' | 'bare-shell' } // Why: 5 minutes is frequent enough that the coordinator's stale-heartbeat @@ -39,6 +42,10 @@ export function buildDispatchPreamble(params: PreambleParams): string { // socket. Without this, agents inside the dev Electron app would call the // production CLI and talk to the wrong Orca instance (Section 6.4). const cli = params.devMode ? 'orca-dev' : 'orca' + const postDoneInstructions = buildPostWorkerDoneInstructions({ + cli, + workerKind: params.workerKind ?? 'prompt-returning-agent' + }) const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. Your coordinator's terminal handle is: ${params.coordinatorHandle} @@ -110,17 +117,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # Check for messages from the coordinator: ${cli} orchestration check -=== AFTER YOU SEND worker_done === - -Keep the shell session open for a grace period (10 minutes) in case the -coordinator sends a follow-up or re-dispatches you. Poll with -\`${cli} orchestration check\` every 2 minutes during that window. If no -follow-up arrives, you may exit after the grace period — the coordinator -will not expect further output from you. - -If the coordinator re-dispatches you (you will receive a fresh preamble + -TASK block), reset your polling and start the new task. Do not respond -to the previous task's follow-ups after a re-dispatch.` +${postDoneInstructions}` // Why: the drift section fires only when the coordinator allowed dispatch // against a stale worktree (via `allow-stale-base: true` in the task spec, @@ -136,6 +133,43 @@ to the previous task's follow-ups after a re-dispatch.` ${params.taskSpec}` } +function buildPostWorkerDoneInstructions({ + cli, + workerKind +}: { + cli: string + workerKind: NonNullable +}): string { + // Why: re-dispatch reaches idle agents as terminal input; inbox polling + // after completion cannot receive that new TASK block and looks hung. + if (workerKind === 'bare-shell') { + return `=== AFTER YOU SEND worker_done === + +worker_done ends your turn for this task. Your dispatched work is complete: +stop and take no further actions — do NOT start new or unrelated work, +do NOT run a sleep/poll loop, and do NOT keep calling +\`${cli} orchestration check\`. The coordinator has already recorded your +completion and expects no further output. + +Exit the shell after completion. Bare-shell workers have no idle agent +prompt for Orca to reuse; if the coordinator has more for you it will +dispatch or prompt another worker with a fresh TASK block.` + } + + return `=== AFTER YOU SEND worker_done === + +worker_done ends your turn for this task. Your dispatched work is complete: +stop, return to an idle prompt, and take no further actions — do NOT start +new or unrelated work, do NOT run a sleep/poll loop, and do NOT keep calling +\`${cli} orchestration check\`. The coordinator has already recorded your +completion and expects no further output. + +Do not exit the shell. Your terminal stays available, and if the +coordinator has more for you it will re-engage this terminal with a fresh +preamble + TASK block, which arrives as new input. When that happens, +reset and start the new task; ignore the previous task's follow-ups.` +} + function buildDriftSection(drift: NonNullable): string { const subjects = drift.recentSubjects.map((s) => ` - ${s}`).join('\n') return `