mirror of
https://github.com/wassname/pi-plan.git
synced 2026-10-03 12:42:00 +08:00
Continue approved goals with loud solo recovery on supervisor failure
This commit is contained in:
1 parent
15dd7f0222
commit
79ec5f350a
16 files changed
+594
-57
No files matched your search
@@ -9,7 +9,7 @@ Plan in one Pi session, then do the work there while a stronger visible Pi sessi
|
||||
3. **Ready** opens a second Herdr pane. The new Pi session explicitly forks the planning session and compacts that fork.
|
||||
4. The original session becomes the implementation worker. It keeps the full conversation and normal tools.
|
||||
5. The fork becomes an inspection-only supervisor by instruction, with normal Pi tools and extensions available. Worker views and supervisor instructions travel over pi-intercom's extension channel, scoped to this plan pairing.
|
||||
6. Ready waits for the supervisor's Intercom readiness message; the worker does not begin before the fork has compacted and started.
|
||||
6. Ready approves the displayed plan and waits for the supervisor's Intercom readiness message. If startup fails, the worker loudly switches to unsupervised work only after rechecking that approved content and restoring its worker model.
|
||||
7. The supervisor compacts again when its context reaches 100k tokens.
|
||||
8. The supervisor records a private approval only after it sees a stopped worker, no active work, a clean worktree (or an explicit inspected-state override), evidence, and saved verification output. `CompleteGoal` checks that approval against the exact plan block and Git tree before it ticks `[x]`.
|
||||
|
||||
@@ -43,20 +43,35 @@ Run Pi from the Git repository that the plan will change. **Ready** fails if the
|
||||
/goals model use the remembered supervisor model
|
||||
/goals work reconnect the existing approved worker pairing
|
||||
/goals supervise reconnect from the saved supervisor session
|
||||
/goals solo continue an already-approved plan without supervision
|
||||
/goals reconnect retry the existing pairing/model without replacing its pane
|
||||
/goals restart replace the tracked supervisor, including return from solo
|
||||
/goals noplan exit planning, preserving the draft without approving work
|
||||
/goals clear close the supervisor pane and disconnect the plan
|
||||
```
|
||||
|
||||
Pi argument autocomplete shows a short description for each available verb. The worker status distinguishes **supervised worker** from **UNSUPERVISED**; the supervisor status says **supervising**, **starting/reconnecting**, or **paused**.
|
||||
|
||||
`work` and `supervise` are role-aware recovery commands, not role conversion or new-pairing commands. Wrong or missing identities are rejected. `noplan` preserves the draft/history, leaves planning restrictions, and does not select Ready, start implementation or launch a supervisor. `/goals clear` closes the tracked pane and keeps the plan file. Starting another plan also keeps older versions.
|
||||
|
||||
If a required model or supervisor is unavailable, the widget says **goals paused** and implementation/sign-off tools are gated. Human input, read-only diagnosis, `/model`, and recovery commands remain available:
|
||||
If the worker model is unavailable or fails after Pi's automatic recovery, work stays **paused**; solo does not bypass that failure or substitute another model. Human input, read-only diagnosis, `/model`, and recovery commands remain available.
|
||||
|
||||
- `/goals reconnect` retries the remembered role model and existing supervisor binding. Worker readiness/reconnect waits allow five minutes, including an ordinary 60-second supervisor compaction, and never replace a slow or missing pane automatically. A returning peer clears the connection pause automatically; an established active worker pairing publishes a fresh current view so supervisor-only reload can resume review even when its previous view was already accepted.
|
||||
**Supervisor failure falls back automatically, but never silently.** On reload/disconnect or a not-ready peer, goal work pauses while the existing Intercom readiness window allows up to five minutes for recovery. A returning ready peer keeps supervised mode. A timeout, explicit supervisor startup/readiness failure, or terminal supervisor model error after Pi's own retries ends that wait and switches an approved working plan to **UNSUPERVISED**. The visible warning and saved session message state the exact reported reason (or readiness timeout, not an invented root cause), the mode transition, preserved plan/evidence, unavailable supervisor sign-off, and `/goals restart` recovery. A continuation message tells the worker to keep implementing and save verification evidence. Ordinary supervisor tool errors and recoverable manual-compaction failures are not by themselves terminal peer failures.
|
||||
|
||||
`/goals solo` explicitly chooses the same mode for an already-approved working plan. Solo mode/reason persist across reload, resume and compaction; the tracked supervisor pane stays available for inspection but its binding is detached. No late peer can silently restore supervision. `CompleteGoal` is unavailable even with a previous approval checkpoint, and manually checked goals remain unreviewed claims. Solo does not auto-complete the plan or erase evidence. Use `/goals restart` for a fresh supervisor and new approval binding; use `/goals clear` to disconnect the plan when appropriate.
|
||||
|
||||
An initial **Ready** selection also authorizes fallback on supervisor launch/readiness failure, but only if the exact displayed plan still matches after all waits and the worker model restores successfully. Cancellation, changed content, unapproved drafts, repository/session preflight errors, and worker-model failures never authorize fallback. Recovery does not turn a planning draft into approved work.
|
||||
|
||||
Recovery commands:
|
||||
|
||||
- `/goals reconnect` retries the remembered role model and existing supervisor binding. Worker readiness/reconnect waits allow five minutes, including an ordinary 60-second supervisor compaction, and never replace a slow or missing pane automatically. A peer returning within that window clears the connection pause automatically; an established active worker pairing publishes a fresh current view so supervisor-only reload can resume review even when its previous view was already accepted.
|
||||
- `/goals restart` explicitly closes only the tracked supervisor pane and starts a replacement for a working plan, preserving its file/version but invalidating old approvals. During planning it clears the failed pane so Ready can launch again.
|
||||
- In the supervisor pane, use `/model` then `/goals supervise` (or `/goals reconnect`) to recover an unavailable supervisor model. Startup failure is reported to the waiting worker; it need not wait for the timeout to learn the cause.
|
||||
- In the supervisor pane, use `/model` then `/goals supervise` (or `/goals reconnect`) to recover an unavailable supervisor model. Startup failure is reported to the waiting worker; it need not wait for the timeout to learn the cause and enter the announced solo fallback.
|
||||
|
||||
Both sessions must load the updated transport for the request/reply reconnect fix; mixed-version peers are not a supported recovery configuration. Ready announces worker readiness only after its model is restored. Plan content is rechecked across startup/model-restore waits; changed content returns to review using the existing pane instead of starting different work. Clearing or leaving planning cancels its pending Ready attempt. `CompleteGoal` checks cancellation and the original binding/version after its asynchronous status lookup and before recording completion.
|
||||
|
||||
In solo, `/goals reconnect` restores only the worker model and explicitly stays unsupervised; `/goals work` explains that state. `/goals restart` waits for the replacement supervisor and announces restoration of supervised work only on success. A failed replacement stays loudly solo.
|
||||
|
||||
A new supervisor may still need up to five minutes for initial compaction. Recovery does not terminate background jobs. Planning/diagnostic command checks are guardrails, not an OS sandbox; loaded extensions and repository Git configuration must be trusted.
|
||||
|
||||
Model choices are remembered per project and role in `.pi/pi-goals/models/`. Use `/model` in planning, worker, or supervisor sessions to change that role's choice. Ready restores the worker choice after the planning fork is ready. An unavailable saved model stops the transition instead of substituting another. `/goals model <model>` explicitly overrides the supervisor choice for launch. -- Pi/OpenAI
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
2026-09-09T17:02:05+08:00
|
||||
15dd7f02225d366ae920509bb23066be83956fb8 Recover supervisor identity and messages across lifecycle changes
|
||||
M README.md
|
||||
M slop/reviews/review-fixes-native/supervisor-events.jsonl
|
||||
M slop/reviews/review-fixes-native/worker-events.jsonl
|
||||
M src/index.ts
|
||||
M src/intercom.ts
|
||||
M src/prompts.ts
|
||||
M src/supervisor-session.ts
|
||||
M test/goals-flow.test.ts
|
||||
M test/supervisor-session.test.ts
|
||||
?? docs/human_journal.md
|
||||
?? slop/reviews/solo-recovery/
|
||||
?? src/command-help.ts
|
||||
|
||||
Pi command: /home/code/.local/bin/pi
|
||||
0.85.1
|
||||
|
||||
Evidence files before final validation:
|
||||
a8f1facd16fd6ff2938195a7adbdc3b4b1e1dea61533e90a39e5eb18d201e190 slop/reviews/review-fixes-native/supervisor-events.jsonl
|
||||
de5bb7171e71508c40e19f3929303a63f1fa0f2ea7789a2bcc8728ea038310d2 slop/reviews/review-fixes-native/worker-events.jsonl
|
||||
@@ -0,0 +1,19 @@
|
||||
{"type":"extension_ui_request","id":"c633db2c-92e2-4750-8de5-e8dae28f3bcc","method":"setStatus","statusKey":"pi-goals","statusText":"supervisor · paused"}
|
||||
{"type":"extension_ui_request","id":"6471153a-35cc-493d-8781-888257444b17","method":"notify","message":"supervisor model: offline/test","notifyType":"info"}
|
||||
{"type":"extension_ui_request","id":"7c874894-28fc-4478-bac3-4771d1b5a299","method":"setStatus","statusKey":"pi-goals","statusText":"supervisor · starting/reconnecting"}
|
||||
{"type":"extension_ui_request","id":"d249c5a9-ae69-4508-8a77-804a239bd645","method":"setStatus","statusKey":"pi-goals","statusText":"supervisor · starting/reconnecting"}
|
||||
{"id":"resumed-review","type":"response","command":"prompt","success":true}
|
||||
{"type":"agent_start"}
|
||||
{"type":"turn_start"}
|
||||
{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Confirm resumed identity without sending another instruction."}],"timestamp":1788944598608}}
|
||||
{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"Confirm resumed identity without sending another instruction."}],"timestamp":1788944598608}}
|
||||
{"type":"message_start","message":{"role":"custom","customType":"pi-goals-supervisor-role","content":"Your job is to be a diligent supervisor, autonomously extending the user's agency by correctly understanding their goals and preferences. Supervise the worker according to /tmp/goals-native-pair-YjSYKb/plan.md, which the user helped write. Inspect and diagnose directly. Delegate changes to the worker through SteerWorker; do not take over implementation or alter shared state.\n\nAt startup and after compaction, read the applicable AGENTS.md instructions and relevant skills to understand the user's goals, preferences, and working standards. Do not assume a particular project or workflow. Read the plan's appendices when needed.\n\nUnderstand the user's immediate request without interpreting it too literally or too liberally. Consider their final goals and the background standards and preferences the work should meet. Use good planning, taste, context, and high-level perspective. Infer ordinary implementation details, but do not silently replace the agreed outcome or invent restrictions.\n\nProtect the user's epistemic autonomy and rational agency. Make consequential uncertainty and disagreement visible. Respect their authorized decisions without requiring them to justify reasonable preferences; voice concerns without substituting your preferences for theirs.\n\nYou are the visible pi-goals supervisor for /tmp/goals-native-pair-YjSYKb/plan.md. You are a stronger reviewer with normal Pi tools and extensions. Your inspection-only role is an instruction, not an enforced sandbox: tool availability does not authorize taking over the worker's changes. The other Pi session is the implementation worker and keeps the full conversation. You keep the high-level intent from the compacted planning conversation and worker views. The complete plan at /tmp/goals-native-pair-YjSYKb/plan.md is the source of truth; read it directly after every compaction.\n\nSupervise autonomously until the agreed goal is achieved and you have inspected the actual result. Use judgment: identify the missing user-visible result, decide the next useful action, and supervise it through to delivery. Approval records support this work; they are not the outcome. Seek justified confidence, not certainty at any cost. Investigate uncertainty with the cheapest useful check, then decide. Never repeat a steer that had no effect: inspect what happened and change the approach. Do not prolong completed work for optional polish.\n\nThe worker stopping is not a reason for you to stop. Treat \"blocked\", \"waiting\", \"impossible\", and \"already done\" as claims to investigate, not conclusions to repeat. Check the evidence and whether the claimed dependency is real. Consider mistaken assumptions, bugs, and other authorized ways forward. If progress stalls, diagnose why and use SteerWorker to send a useful next instruction instead of repeating status checks. Keep independent work moving when it does not depend on the blocker. A verified external dependency may require waiting or a human decision, but it does not make an unfinished goal complete.\n\nKeep authorized work moving. Resolve technical choices within the agreed scope yourself. If idle with unfinished goals, use SteerWorker to resume useful work; a recap alone does not restart the worker. If useful work is running, do not invent work or repeat an instruction already awaiting execution. Waiting is warranted when a verified dependency remains; identify what event will resume progress and how it will be observed. Escalate only a specific unresolved human decision, permission, credential, or spending need after checking what is already authorized. Do not dismiss genuine limits or expand scope to avoid reporting a blocker.\n\nAt each review, give a brief visible recap of how work is tracking against the goal: what the evidence shows and your judgment about the next step. Add perspective rather than repeating status. Distinguish observations from guesses. Keep routine recaps short, but do not suppress useful explanation or thinking. Do not edit files or execute the worker's work.\n\nGround consequential judgments in verbatim evidence with a source path or link and enough surrounding context to check the interpretation. Keep the observation separate from your inference. A worker summary is a claim, not an independent observation; repeated summaries of one result are not independent evidence. Say what evidence would change your mind. Missing evidence stays unknown until you inspect where it should be.\n\nCheck the actual deliverable against the user's goal. Passing tests, a confident summary, or a checked box alone do not establish success. Investigate contradictions and surprising results; choose checks that distinguish plausible explanations. Review plan changes for drift from the user's intent and steer corrections when needed.\n\nOnly if the evidence establishes completion, use ApproveGoal and direct the workeLine truncated
|
||||
{"type":"message_end","message":{"role":"custom","customType":"pi-goals-supervisor-role","content":"Your job is to be a diligent supervisor, autonomously extending the user's agency by correctly understanding their goals and preferences. Supervise the worker according to /tmp/goals-native-pair-YjSYKb/plan.md, which the user helped write. Inspect and diagnose directly. Delegate changes to the worker through SteerWorker; do not take over implementation or alter shared state.\n\nAt startup and after compaction, read the applicable AGENTS.md instructions and relevant skills to understand the user's goals, preferences, and working standards. Do not assume a particular project or workflow. Read the plan's appendices when needed.\n\nUnderstand the user's immediate request without interpreting it too literally or too liberally. Consider their final goals and the background standards and preferences the work should meet. Use good planning, taste, context, and high-level perspective. Infer ordinary implementation details, but do not silently replace the agreed outcome or invent restrictions.\n\nProtect the user's epistemic autonomy and rational agency. Make consequential uncertainty and disagreement visible. Respect their authorized decisions without requiring them to justify reasonable preferences; voice concerns without substituting your preferences for theirs.\n\nYou are the visible pi-goals supervisor for /tmp/goals-native-pair-YjSYKb/plan.md. You are a stronger reviewer with normal Pi tools and extensions. Your inspection-only role is an instruction, not an enforced sandbox: tool availability does not authorize taking over the worker's changes. The other Pi session is the implementation worker and keeps the full conversation. You keep the high-level intent from the compacted planning conversation and worker views. The complete plan at /tmp/goals-native-pair-YjSYKb/plan.md is the source of truth; read it directly after every compaction.\n\nSupervise autonomously until the agreed goal is achieved and you have inspected the actual result. Use judgment: identify the missing user-visible result, decide the next useful action, and supervise it through to delivery. Approval records support this work; they are not the outcome. Seek justified confidence, not certainty at any cost. Investigate uncertainty with the cheapest useful check, then decide. Never repeat a steer that had no effect: inspect what happened and change the approach. Do not prolong completed work for optional polish.\n\nThe worker stopping is not a reason for you to stop. Treat \"blocked\", \"waiting\", \"impossible\", and \"already done\" as claims to investigate, not conclusions to repeat. Check the evidence and whether the claimed dependency is real. Consider mistaken assumptions, bugs, and other authorized ways forward. If progress stalls, diagnose why and use SteerWorker to send a useful next instruction instead of repeating status checks. Keep independent work moving when it does not depend on the blocker. A verified external dependency may require waiting or a human decision, but it does not make an unfinished goal complete.\n\nKeep authorized work moving. Resolve technical choices within the agreed scope yourself. If idle with unfinished goals, use SteerWorker to resume useful work; a recap alone does not restart the worker. If useful work is running, do not invent work or repeat an instruction already awaiting execution. Waiting is warranted when a verified dependency remains; identify what event will resume progress and how it will be observed. Escalate only a specific unresolved human decision, permission, credential, or spending need after checking what is already authorized. Do not dismiss genuine limits or expand scope to avoid reporting a blocker.\n\nAt each review, give a brief visible recap of how work is tracking against the goal: what the evidence shows and your judgment about the next step. Add perspective rather than repeating status. Distinguish observations from guesses. Keep routine recaps short, but do not suppress useful explanation or thinking. Do not edit files or execute the worker's work.\n\nGround consequential judgments in verbatim evidence with a source path or link and enough surrounding context to check the interpretation. Keep the observation separate from your inference. A worker summary is a claim, not an independent observation; repeated summaries of one result are not independent evidence. Say what evidence would change your mind. Missing evidence stays unknown until you inspect where it should be.\n\nCheck the actual deliverable against the user's goal. Passing tests, a confident summary, or a checked box alone do not establish success. Investigate contradictions and surprising results; choose checks that distinguish plausible explanations. Review plan changes for drift from the user's intent and steer corrections when needed.\n\nOnly if the evidence establishes completion, use ApproveGoal and direct the worker Line truncated
|
||||
{"type":"message_start","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788944598620}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_start","contentIndex":0}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Test context retained. Actual outputs still need inspection."}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Test context retained. Actual outputs still need inspection."}}
|
||||
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944598620,"rawStopReason":"stop"}}
|
||||
{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944598620,"rawStopReason":"stop"},"toolResults":[]}
|
||||
{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"Confirm resumed identity without sending another instruction."}],"timestamp":1788944598608},{"role":"custom","customType":"pi-goals-supervisor-role","content":"Your job is to be a diligent supervisor, autonomously extending the user's agency by correctly understanding their goals and preferences. Supervise the worker according to /tmp/goals-native-pair-YjSYKb/plan.md, which the user helped write. Inspect and diagnose directly. Delegate changes to the worker through SteerWorker; do not take over implementation or alter shared state.\n\nAt startup and after compaction, read the applicable AGENTS.md instructions and relevant skills to understand the user's goals, preferences, and working standards. Do not assume a particular project or workflow. Read the plan's appendices when needed.\n\nUnderstand the user's immediate request without interpreting it too literally or too liberally. Consider their final goals and the background standards and preferences the work should meet. Use good planning, taste, context, and high-level perspective. Infer ordinary implementation details, but do not silently replace the agreed outcome or invent restrictions.\n\nProtect the user's epistemic autonomy and rational agency. Make consequential uncertainty and disagreement visible. Respect their authorized decisions without requiring them to justify reasonable preferences; voice concerns without substituting your preferences for theirs.\n\nYou are the visible pi-goals supervisor for /tmp/goals-native-pair-YjSYKb/plan.md. You are a stronger reviewer with normal Pi tools and extensions. Your inspection-only role is an instruction, not an enforced sandbox: tool availability does not authorize taking over the worker's changes. The other Pi session is the implementation worker and keeps the full conversation. You keep the high-level intent from the compacted planning conversation and worker views. The complete plan at /tmp/goals-native-pair-YjSYKb/plan.md is the source of truth; read it directly after every compaction.\n\nSupervise autonomously until the agreed goal is achieved and you have inspected the actual result. Use judgment: identify the missing user-visible result, decide the next useful action, and supervise it through to delivery. Approval records support this work; they are not the outcome. Seek justified confidence, not certainty at any cost. Investigate uncertainty with the cheapest useful check, then decide. Never repeat a steer that had no effect: inspect what happened and change the approach. Do not prolong completed work for optional polish.\n\nThe worker stopping is not a reason for you to stop. Treat \"blocked\", \"waiting\", \"impossible\", and \"already done\" as claims to investigate, not conclusions to repeat. Check the evidence and whether the claimed dependency is real. Consider mistaken assumptions, bugs, and other authorized ways forward. If progress stalls, diagnose why and use SteerWorker to send a useful next instruction instead of repeating status checks. Keep independent work moving when it does not depend on the blocker. A verified external dependency may require waiting or a human decision, but it does not make an unfinished goal complete.\n\nKeep authorized work moving. Resolve technical choices within the agreed scope yourself. If idle with unfinished goals, use SteerWorker to resume useful work; a recap alone does not restart the worker. If useful work is running, do not invent work or repeat an instruction already awaiting execution. Waiting is warranted when a verified dependency remains; identify what event will resume progress and how it will be observed. Escalate only a specific unresolved human decision, permission, credential, or spending need after checking what is already authorized. Do not dismiss genuine limits or expand scope to avoid reporting a blocker.\n\nAt each review, give a brief visible recap of how work is tracking against the goal: what the evidence shows and your judgment about the next step. Add perspective rather than repeating status. Distinguish observations from guesses. Keep routine recaps short, but do not suppress useful explanation or thinking. Do not edit files or execute the worker's work.\n\nGround consequential judgments in verbatim evidence with a source path or link and enough surrounding context to check the interpretation. Keep the observation separate from your inference. A worker summary is a claim, not an independent observation; repeated summaries of one result are not independent evidence. Say what evidence would change your mind. Missing evidence stays unknown until you inspect where it should be.\n\nCheck the actual deliverable against the user's goal. Passing tests, a confident summary, or a checked box alone do not establish success. Investigate contradictions and surprising results; choose checks that distinguish plausible explanations. Review plan changes for drift froLine truncated
|
||||
{"type":"agent_settled"}
|
||||
Whitespace-only changes.
@@ -0,0 +1,32 @@
|
||||
{"id":"planning","type":"response","command":"prompt","success":true}
|
||||
{"type":"agent_start"}
|
||||
{"type":"turn_start"}
|
||||
{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Retain this planning context for the supervisor fork."}],"timestamp":1788944597016}}
|
||||
{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"Retain this planning context for the supervisor fork."}],"timestamp":1788944597016}}
|
||||
{"type":"message_start","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788944597030}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_start","contentIndex":0}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Test context retained. Actual outputs still need inspection."}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Test context retained. Actual outputs still need inspection."}}
|
||||
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944597030,"rawStopReason":"stop"}}
|
||||
{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944597030,"rawStopReason":"stop"},"toolResults":[]}
|
||||
{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"Retain this planning context for the supervisor fork."}],"timestamp":1788944597016},{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944597030,"rawStopReason":"stop"}],"willRetry":false}
|
||||
{"type":"agent_settled"}
|
||||
{"id":"worker-state","type":"response","command":"get_state","success":true,"data":{"model":{"id":"test","name":"Offline test model","reasoning":false,"input":["text"],"contextWindow":16000,"maxTokens":1000,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"api":"openai-completions","provider":"offline","baseUrl":"http://127.0.0.1:32969"},"thinkingLevel":"off","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/tmp/goals-native-pair-YjSYKb/agent/sessions/--tmp-goals-native-pair-YjSYKb--/2026-09-09T09-03-16-790Z_01a08568-3f36-74eb-88db-1c8e717770a2.jsonl","sessionId":"01a08568-3f36-74eb-88db-1c8e717770a2","autoCompactionEnabled":true,"messageCount":2,"pendingMessageCount":0}}
|
||||
{"type":"entry_appended","entry":{"type":"custom","customType":"pi-goals-intercom","data":{"direction":"out","message":{"binding":"native-pair-test","role":"worker","kind":"view","id":"1773c830-9422-4ad0-9b49-30ecad45cafb","text":"The worker stopped.\n\nInspect the results and judge whether the agreed goal is actually achieved. If unfinished, investigate why the worker stopped and use SteerWorker to send the next useful instruction and resume work. If a verified dependency prevents progress, establish what will resume it and how that will be observed. Do not treat stopping as completion. Consider ApproveGoal only after the results satisfy the goal.\n\nreview trigger: settled\nsource session: /tmp/goals-native-pair-YjSYKb/agent/sessions/--tmp-goals-native-pair-YjSYKb--/2026-09-09T09-03-16-790Z_01a08568-3f36-74eb-88db-1c8e717770a2.jsonl\nworker model: offline/test\nlatest human direction:\nInspect actual outputs.\ntool calls with no result: none\ntracked background work: No tracked work in this fixture.\n\nnew worker overview (initial or reset view) (VCC algorithmic compression; local # refs index new messages; tool-result bodies omitted; inspect source for evidence):\n[Session Goal]\n- Retain this planning context for the supervisor fork.\n\n[user]\nRetain this planning context for the supervisor fork.\n\n[assistant]\nTest context retained. Actual outputs still need inspection. (#1)\n\nworker view id: 1773c830-9422-4ad0-9b49-30ecad45cafb","reason":"settled","backgroundQuiet":true}},"id":"510b072a","parentId":"d5a9d1aa","timestamp":"2026-09-09T09:03:17.879Z"}}
|
||||
{"type":"entry_appended","entry":{"type":"custom","customType":"pi-goals-intercom","data":{"direction":"ack","message":{"binding":"native-pair-test","role":"supervisor","kind":"received","id":"1773c830-9422-4ad0-9b49-30ecad45cafb"}},"id":"d78a3463","parentId":"510b072a","timestamp":"2026-09-09T09:03:17.885Z"}}
|
||||
{"type":"entry_appended","entry":{"type":"custom","customType":"pi-goals-intercom","data":{"direction":"ack","message":{"binding":"native-pair-test","role":"supervisor","kind":"received","id":"1773c830-9422-4ad0-9b49-30ecad45cafb"}},"id":"7520145d","parentId":"d78a3463","timestamp":"2026-09-09T09:03:17.929Z"}}
|
||||
{"type":"entry_appended","entry":{"type":"custom","customType":"pi-goals-intercom","data":{"direction":"queued","message":{"binding":"native-pair-test","role":"supervisor","kind":"steer","id":"005b4d15-eb6d-4024-a6cc-2d5d53bf49d6","text":"Read the real outputs before declaring completion."}},"id":"28237b7d","parentId":"7520145d","timestamp":"2026-09-09T09:03:17.937Z"}}
|
||||
{"type":"agent_start"}
|
||||
{"type":"turn_start"}
|
||||
{"type":"entry_appended","entry":{"type":"custom","customType":"pi-goals-intercom","data":{"direction":"in","message":{"binding":"native-pair-test","role":"supervisor","kind":"steer","id":"005b4d15-eb6d-4024-a6cc-2d5d53bf49d6","text":"Read the real outputs before declaring completion."}},"id":"edca7343","parentId":"28237b7d","timestamp":"2026-09-09T09:03:17.938Z"}}
|
||||
{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"[supervisor] Read the real outputs before declaring completion."}],"timestamp":1788944597937}}
|
||||
{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"[supervisor] Read the real outputs before declaring completion."}],"timestamp":1788944597937}}
|
||||
{"type":"message_start","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"pending","timestamp":1788944597939}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_start","contentIndex":0}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Test context retained. Actual outputs still need inspection."}}
|
||||
{"type":"message_update","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Test context retained. Actual outputs still need inspection."}}
|
||||
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944597939,"rawStopReason":"stop"}}
|
||||
{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944597939,"rawStopReason":"stop"},"toolResults":[]}
|
||||
{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"[supervisor] Read the real outputs before declaring completion."}],"timestamp":1788944597937},{"role":"assistant","content":[{"type":"text","text":"Test context retained. Actual outputs still need inspection."}],"api":"openai-completions","provider":"offline","model":"test","usage":{"input":10,"output":10,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":20,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1788944597939,"rawStopReason":"stop"}],"willRetry":false}
|
||||
{"type":"agent_settled"}
|
||||
{"type":"extension_ui_request","id":"7132e14f-ca60-4770-89ab-004da415cc94","method":"notify","message":"Goal supervision peer disconnected; reconnect the existing session.","notifyType":"warning"}
|
||||
Whitespace-only changes.
@@ -0,0 +1,31 @@
|
||||
# Solo recovery implementation review
|
||||
|
||||
Base revision: `15dd7f02225d366ae920509bb23066be83956fb8`. Tested with installed Pi 0.85.1. No user panes, worker/supervisor research sessions, or human journal were operated on/read. Pre-existing dirty native event files remain untouched; their hashes are recorded in context.txt and matched after validation.
|
||||
|
||||
## Implemented semantics
|
||||
|
||||
- A persisted `mode: supervised | solo` and `soloReason` accompany the existing working phase. Older saved working states default to supervised. Solo keeps plan version/content, evidence and previous sign-offs, but detaches the old binding and makes all further CompleteGoal calls unavailable. Manual checkboxes remain claims.
|
||||
- `/goals solo` requires an already-working/approved plan and a healthy restored worker model. It explicitly announces the reason and transition, preserved plan/evidence, unavailable supervisor sign-off and `/goals restart` recovery. Both a displayed saved message and a worker continuation message accompany the notification/widget. No goal completion is synthesized.
|
||||
- An absent/not-ready supervisor gets the existing five-minute Intercom readiness opportunity. Work is gated while waiting. A return within that window stays supervised. A timeout gives the known timeout reason, not a guessed underlying cause, and enters solo. An explicit peer failure ends the readiness wait early with that exact reason.
|
||||
- Supervisor model errors are reported only at `agent_settled`, after Pi's automatic retries/compaction have finished, not immediately on a failed attempt. Ordinary tool errors and recoverable manual-compaction failures alone do not demote a viable supervisor.
|
||||
- Explicit initial Ready is human approval: a supervisor launch/readiness failure can enter solo only after the exact displayed plan is rechecked across the waits and worker-model restoration succeeds. Cancellation, changed draft, unapproved draft, repository/session preflight failure and worker-model failure never become solo through that path.
|
||||
- Worker model failures, including terminal runtime errors after Pi recovery, remain paused; this does not substitute models or reclassify worker errors as supervisor failures.
|
||||
- `/goals restart` closes only the tracked pane, creates a new binding and restores supervised mode only after successful startup/readiness. Failed supervisor replacement remains loudly solo; missing worker model remains gated. `/goals reconnect` in solo restores only the worker model and explicitly stays solo. A late old peer never silently changes mode.
|
||||
- Argument autocomplete uses Pi's documented `getArgumentCompletions` and `AutocompleteItem.description`; workers and supervisors see descriptions appropriate to their commands. No custom tooltip UI was added.
|
||||
- Worker status says supervised worker or UNSUPERVISED. Active worker goal lines say working, not supervising. Supervisor status says supervising, starting/reconnecting or paused and is cleared on shutdown.
|
||||
|
||||
## Validation
|
||||
|
||||
Final `validation.txt`: 22 test files / 138 tests passed; typecheck, lint, build and diff whitespace checks passed. The expected `fatal: not a git repository` stderr is from the negative preflight regression that removes the isolated test repository's .git directory; that test passes by verifying it does not authorize solo.
|
||||
|
||||
Focused regressions cover explicit solo, retained plan/state across reload and compaction, in-flight/future sign-off rejection, return through restart, replacement failure/cancellation, five-minute startup/resume/disconnect boundaries, transient reconnect, exact peer failure, worker-model startup/runtime failure, rejected changed/cancelled/unapproved Ready attempts, repository preflight rejection, command descriptions and supervisor role/status plus retry-aware failure reporting. Prior cancellation, content-change, native fork/profile, fresh-shell identity, native compaction and RPC review tests still pass.
|
||||
|
||||
`native/` saves deterministic real-Pi full-profile fork/resume messages/stderr from the existing native test. These establish no regression in that path, not functional proof of the new fallback behavior. `validation-initial.txt` retains the earlier stale status-assertion failure; the assertion was updated for the intentional supervised-worker label. An earlier development npm test also exposed inherited PI_SUBAGENT_CHILD=1 interfering with the RPC planner; final tests unset role/child variables and redirect native evidence into this fresh directory.
|
||||
|
||||
## Functional gaps / acceptance boundary
|
||||
|
||||
Parent-owned real-model Herdr acceptance remains required: inspect loud fallback and per-verb descriptions in the rendered terminal, see actual useful worker continuation after supervisor failure, inspect artifacts/evidence, test both reload orders and restore supervision through restart, then complete actual ApproveGoal -> CompleteGoal. Automated flow tests exercise fallback with small API mocks; no autonomous artifact result or full interactive fallback/recovery success is claimed here.
|
||||
|
||||
The five-minute readiness window is not a liveness watchdog for a peer that continues to claim ready. An alive but hung/stalled supervisor with no reported failure still requires judgment or explicit `/goals solo`/`restart`. This change does not infer failure from slow thinking, queued jobs, ordinary tool errors or lack of advice. A failed pane remains inspectable until explicit restart/clear. Solo mode is persisted, but delivery of the continuation still relies on Pi's public messaging API; no new delivery framework or exactly-once guarantee was added.
|
||||
|
||||
Independent parent/reviewer acceptance is pending. Implementation is committed for review, not pushed.
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
|
||||
|
||||
fatal: not a git repository (or any of the parent directories): .git
|
||||
❯ test/goals-flow.test.ts (40 tests | 1 failed) 1794ms
|
||||
× restores sign-off markers but clears one when a goal is reopened 33ms
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||
|
||||
FAIL test/goals-flow.test.ts > /goals flow > restores sign-off markers but clears one when a goal is reopened
|
||||
AssertionError: expected last "vi.fn()" call to have been called with [ 'pi-goals', StringContaining{…} ]
|
||||
|
||||
- Expected
|
||||
+ Received
|
||||
|
||||
[
|
||||
"pi-goals",
|
||||
- StringContaining "0/2 goals · 1 claimed, awaiting review",
|
||||
+ "◷ 0/2 goals · supervised worker · 1 claimed, awaiting review",
|
||||
]
|
||||
|
||||
❯ test/goals-flow.test.ts:193:34
|
||||
191| await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
192| await flow.hooks.get("turn_end")({}, flow.ctx);
|
||||
193| expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", …
|
||||
| ^
|
||||
194| } finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.…
|
||||
195| });
|
||||
|
||||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||
|
||||
|
||||
Test Files 1 failed | 21 passed (22)
|
||||
Tests 1 failed | 137 passed (138)
|
||||
Start at 17:02:05
|
||||
Duration 5.12s (transform 5.40s, setup 0ms, import 10.36s, tests 12.67s, environment 3ms)
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 lint
|
||||
> biome check src/ test/
|
||||
|
||||
Checked 42 files in 52ms. No fixes applied.
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 build
|
||||
> tsc
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
Commands: env -u PI_SUBAGENT_CHILD -u PI_GOALS_ROLE PI_GOALS_EVIDENCE_DIR="$PWD/slop/reviews/solo-recovery/native" npm test; npm run typecheck; npm run lint; npm run build
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
|
||||
|
||||
fatal: not a git repository (or any of the parent directories): .git
|
||||
|
||||
Test Files 22 passed (22)
|
||||
Tests 138 passed (138)
|
||||
Start at 17:03:15
|
||||
Duration 5.85s (transform 5.41s, setup 0ms, import 11.40s, tests 12.15s, environment 3ms)
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 lint
|
||||
> biome check src/ test/
|
||||
|
||||
Checked 42 files in 88ms. No fixes applied.
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 build
|
||||
> tsc
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
||||
|
||||
const workerCommands: Record<string, string> = {
|
||||
work: "Reconnect the existing approved worker pairing (not a role conversion)",
|
||||
supervise: "Use in the saved supervisor pane to reconnect it",
|
||||
solo: "Continue an approved plan unsupervised; supervisor sign-off unavailable",
|
||||
noplan: "Exit planning; preserve the draft without approving implementation",
|
||||
reconnect: "Retry the existing pairing/model; never replace its pane",
|
||||
restart: "Replace the tracked supervisor and restore supervision; preserve the plan",
|
||||
clear: "Close the tracked supervisor and disconnect the plan; keep its file",
|
||||
model: "Select the supervisor model: /goals model <model>",
|
||||
};
|
||||
|
||||
export function goalCommandCompletions(prefix: string, role: "worker" | "supervisor"): AutocompleteItem[] | null {
|
||||
const commands = role === "worker" ? workerCommands : {
|
||||
supervise: "Reconnect this saved supervisor role and pairing",
|
||||
reconnect: "Retry this supervisor's model and existing pairing",
|
||||
};
|
||||
const matches = Object.entries(commands).filter(([value]) => value.startsWith(prefix)).map(([value, description]) => ({ value, label: value, description }));
|
||||
return matches.length ? matches : null;
|
||||
}
|
||||
+130
-29
@@ -22,6 +22,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
||||
import { Type } from "typebox";
|
||||
import { approvalMatches, approvalPath, goalBlock, hashGoalBlock, readApproval, repositoryState } from "./approval.js";
|
||||
import { backgroundState } from "./background.js";
|
||||
import { goalCommandCompletions } from "./command-help.js";
|
||||
import { closeSupervisorPane, openSupervisorPane } from "./herdr.js";
|
||||
import { GoalIntercom } from "./intercom.js";
|
||||
import { FOLD_LINE, foldPlan, GOAL_LINE } from "./plan.js";
|
||||
@@ -83,12 +84,17 @@ export function nextPlanVersion(planNames: string[], sessionId: string): number
|
||||
|
||||
type Phase = "planning" | "working" | null;
|
||||
|
||||
// Only launch/readiness failures authorize fallback, not local model, plan or repository errors.
|
||||
class SupervisorFailure extends Error {}
|
||||
|
||||
export function isMainSession(isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1"): boolean {
|
||||
return !isSubagentChild && !isVisibleSupervisor();
|
||||
}
|
||||
|
||||
interface PlanState {
|
||||
phase: Phase;
|
||||
mode: "supervised" | "solo";
|
||||
soloReason: string | null;
|
||||
supervisorModel: string | null;
|
||||
supervisorPaneId: string | null;
|
||||
approvalId: string | null;
|
||||
@@ -114,11 +120,13 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
const intercom = new GoalIntercom(pi);
|
||||
const models = new RoleModels(pi);
|
||||
intercom.onSteer = (instruction) => {
|
||||
if (state.phase !== "working" || modelError) throw new Error("Worker is paused or its plan is not active; instruction not delivered. Use /goals reconnect after selecting an available model.");
|
||||
if (state.phase !== "working" || state.mode === "solo" || modelError) throw new Error("Worker is paused or its plan is not active; instruction not delivered. Use /goals reconnect after selecting an available model.");
|
||||
pi.sendUserMessage(`[supervisor] ${instruction}`, { deliverAs: "steer" });
|
||||
};
|
||||
let state: PlanState = {
|
||||
phase: null,
|
||||
mode: "supervised",
|
||||
soloReason: null,
|
||||
supervisorModel: null,
|
||||
supervisorPaneId: null,
|
||||
approvalId: null,
|
||||
@@ -128,15 +136,20 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
previousPlan: null,
|
||||
};
|
||||
let modelError: string | null = null;
|
||||
let lastAssistantError: string | undefined;
|
||||
let readyAttempt: object | undefined;
|
||||
let wasConnected = false;
|
||||
let recoveryAttempt: object | undefined;
|
||||
let commandAttempt: object | undefined;
|
||||
let recoveryCommand: object | undefined;
|
||||
intercom.onConnectionChange = (ctx) => {
|
||||
const connected = intercom.connected;
|
||||
const rejoined = connected && !wasConnected;
|
||||
wasConnected = connected;
|
||||
updateWidget(ctx);
|
||||
if (!connected) recoverSupervisor(ctx);
|
||||
// Ready publishes its own first view. Subsequent rejoins need a new ID even if the old view was accepted.
|
||||
if (rejoined && !readyAttempt && state.phase === "working" && !modelError) {
|
||||
if (rejoined && !readyAttempt && state.phase === "working" && state.mode === "supervised" && !modelError) {
|
||||
void publishWorkerView(ctx, "settled").catch(error => { if (!intercom.ended) ctx.ui.notify(`Recovery view failed: ${String(error)}`, "error"); });
|
||||
}
|
||||
};
|
||||
@@ -185,16 +198,47 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
return supervisorPlanReview(claims.map(goal => goal.subject), changes, planDiff(state.previousPlan ?? "", plan));
|
||||
}
|
||||
|
||||
function enterSolo(ctx: ExtensionContext, reason: string): void {
|
||||
if (state.phase !== "working" || modelError || intercom.ended) return;
|
||||
recoveryAttempt = undefined;
|
||||
state = { ...state, mode: "solo", soloReason: reason, approvalId: null };
|
||||
persist();
|
||||
stopWorkerTimers();
|
||||
intercom.detach();
|
||||
const message = `UNSUPERVISED WORKER: ${reason} Continuing in solo mode with the same approved plan (${planRel(ctx)}) and evidence preserved. Supervisor sign-off is unavailable; do not call CompleteGoal or claim supervised completion. Continue useful implementation and save verification evidence. Use /goals restart to restore supervision.`;
|
||||
ctx.ui.notify(message, "warning");
|
||||
pi.sendMessage({ customType: "pi-goals-mode", content: message, display: true });
|
||||
updateWidget(ctx);
|
||||
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
||||
}
|
||||
|
||||
// Reuse the existing five-minute readiness window; an explicit peer failure ends it early.
|
||||
function recoverSupervisor(ctx: ExtensionContext): void {
|
||||
if (recoveryAttempt || recoveryCommand || readyAttempt || state.phase !== "working" || state.mode !== "supervised" || modelError || !intercom.bound || intercom.connected) return;
|
||||
const attempt = {};
|
||||
recoveryAttempt = attempt;
|
||||
const binding = state.approvalId;
|
||||
ctx.ui.notify("Supervisor connection is not ready. Goal work is paused; the plan is preserved. Waiting up to five minutes for the existing supervisor to recover. An explicit failure or timeout will switch to unsupervised work with a visible reason.", "warning");
|
||||
void intercom.waitReady().catch(error => {
|
||||
if (recoveryAttempt === attempt && state.approvalId === binding && state.phase === "working" && state.mode === "supervised" && !modelError && !intercom.ended) enterSolo(ctx, `Supervisor recovery failed: ${String(error)}`);
|
||||
}).finally(() => {
|
||||
if (recoveryAttempt !== attempt) return;
|
||||
recoveryAttempt = undefined;
|
||||
if (!intercom.ended && !intercom.connected) recoverSupervisor(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
function pauseReason(): string | null {
|
||||
if (!state.phase) return null;
|
||||
if (modelError) return `${modelError} Select /model, then run /goals reconnect.`;
|
||||
if (state.phase === "working" && !intercom.connected) return intercom.peerPresent
|
||||
if (state.phase === "working" && state.mode === "supervised" && !intercom.connected) return intercom.peerPresent
|
||||
? "Supervisor is present but not ready. Inspect its pane for startup/compaction or model errors; recover with /model then /goals reconnect in the supervisor pane if needed."
|
||||
: "Supervisor disconnected. Run /goals reconnect, or /goals restart to replace its tracked pane without discarding the plan.";
|
||||
return null;
|
||||
}
|
||||
|
||||
async function restoreModel(role: "planning" | "worker", ctx: ExtensionContext): Promise<void> {
|
||||
lastAssistantError = undefined;
|
||||
modelError = `${role} model restoration is pending.`;
|
||||
intercom.markNotReady();
|
||||
try {
|
||||
@@ -220,6 +264,11 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
async function waitSupervisor(): Promise<void> {
|
||||
try { await intercom.waitReady(undefined, { peerOnly: true }); }
|
||||
catch (error) { throw new SupervisorFailure(String(error)); }
|
||||
}
|
||||
|
||||
async function startSupervisor(ctx: ExtensionContext, isCurrent = () => !intercom.ended): Promise<void> {
|
||||
if (intercom.ended) throw new Error("Session ended before supervisor startup.");
|
||||
repositoryRoot(ctx.cwd);
|
||||
@@ -227,7 +276,7 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
if (!sourceSessionFile) throw new Error("The current session is not persisted, so it cannot be forked.");
|
||||
if (state.supervisorPaneId && state.approvalId) {
|
||||
intercom.configure(state.approvalId, "worker", ctx, false);
|
||||
await intercom.waitReady(undefined, { peerOnly: true });
|
||||
await waitSupervisor();
|
||||
return;
|
||||
}
|
||||
beginReview(ctx);
|
||||
@@ -250,13 +299,13 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
persist();
|
||||
});
|
||||
} catch (error) {
|
||||
if (paneId) throw new Error(`Supervisor startup failed in Herdr pane ${paneId}; it remains open for inspection. ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
if (paneId) throw new SupervisorFailure(`Supervisor startup failed in Herdr pane ${paneId}; it remains open for inspection. ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw new SupervisorFailure(String(error));
|
||||
}
|
||||
if (!current()) throw new Error("Supervisor startup was cancelled.");
|
||||
state = { ...state, supervisorPaneId: paneId };
|
||||
persist();
|
||||
await intercom.waitReady(undefined, { peerOnly: true });
|
||||
await waitSupervisor();
|
||||
}
|
||||
|
||||
let workerTurns = 0;
|
||||
@@ -266,7 +315,7 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
let planEditTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started" | "plan"): Promise<void> {
|
||||
if (state.phase !== "working" || modelError || !intercom.bound) return;
|
||||
if (state.phase !== "working" || state.mode === "solo" || modelError || !intercom.bound) return;
|
||||
const generation = ++viewGeneration;
|
||||
const binding = state.approvalId;
|
||||
const background = reason === "started" ? { quiet: false, description: "agent starting; background state not queried" } : await backgroundState(pi);
|
||||
@@ -333,6 +382,7 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
async function stopSupervisor(): Promise<boolean> {
|
||||
recoveryAttempt = undefined;
|
||||
readyAttempt = undefined;
|
||||
if (!state.supervisorPaneId) { stopWorkerTimers(); intercom.detach(); return true; }
|
||||
try {
|
||||
@@ -362,15 +412,16 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
}
|
||||
const goals = scanGoals(readPlan(ctx));
|
||||
if (goals.length === 0) {
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
||||
const solo = state.phase === "working" && state.mode === "solo";
|
||||
ctx.ui.setStatus(STATUS_KEY, solo ? "UNSUPERVISED worker" : undefined);
|
||||
ctx.ui.setWidget(WIDGET_KEY, solo ? ["UNSUPERVISED: no goal lines found. Plan retained; supervisor sign-off unavailable. /goals restart"] : undefined);
|
||||
return;
|
||||
}
|
||||
const isSignedOff = (subject: string) => state.signedOffGoals.includes(goalKey(subject));
|
||||
const done = goals.filter(g => g.status === "done" && isSignedOff(g.subject)).length;
|
||||
const claimed = goals.filter(g => g.status === "done" && !isSignedOff(g.subject));
|
||||
const liveGoals = goals.filter(g => g.status === "active" || g.status === "open");
|
||||
const stateLabel = claimed.length ? ` · ${claimed.length} claimed, awaiting review` : liveGoals.length > 0 ? " · supervised" : " · complete";
|
||||
const stateLabel = state.phase === "working" && state.mode === "solo" ? " · UNSUPERVISED" : claimed.length ? ` · supervised worker · ${claimed.length} claimed, awaiting review` : liveGoals.length > 0 ? state.phase === "working" ? " · supervised worker" : " · inactive draft" : " · complete";
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${stateLabel}`));
|
||||
const mark: Record<GoalStatus, string> = { done: "✔", active: "▸", open: "◻", cancelled: "✗" };
|
||||
// Only live goals get lines so finished work never pushes current work off screen. The active
|
||||
@@ -378,9 +429,10 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
// No path line: the session id makes it too long to be useful in the widget.
|
||||
const plan = readPlan(ctx);
|
||||
const lines: string[] = claimed.map(g => `? claimed complete; awaiting supervisor review: ${g.subject}`);
|
||||
if (liveGoals.length === 0 && claimed.length === 0) lines.push("✔ complete");
|
||||
if (state.phase === "working" && state.mode === "solo") lines.unshift(`UNSUPERVISED: ${state.soloReason} Supervisor sign-off unavailable. /goals restart`);
|
||||
else if (liveGoals.length === 0 && claimed.length === 0) lines.push("✔ complete");
|
||||
for (const g of liveGoals) {
|
||||
lines.push(`${mark[g.status]} ${g.status === "active" ? "supervising… " : ""}${g.subject}`);
|
||||
lines.push(`${mark[g.status]} ${g.status === "active" && state.mode !== "solo" ? "working… " : ""}${g.subject}`);
|
||||
if (g.status === "active") lines.push(...openSubtasks(plan, g.line).slice(0, 3).map((s) => ctx.ui.theme.fg("muted", ` ◦ ${s}`)));
|
||||
}
|
||||
ctx.ui.setWidget(WIDGET_KEY, lines);
|
||||
@@ -389,11 +441,23 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
// --- /goals: enter plan mode or configure supervision -- Pi/Codex -----------------------------
|
||||
|
||||
pi.registerCommand("goals", {
|
||||
description: `Plan goals, then open a visible supervisor session. /goals <objective> | work | supervise | noplan | reconnect | restart | clear | model <supervisor>`,
|
||||
description: `Plan goals, then open a visible supervisor session. /goals <objective> | work | supervise | solo | noplan | reconnect | restart | clear | model <supervisor>`,
|
||||
getArgumentCompletions: prefix => goalCommandCompletions(prefix, "worker"),
|
||||
handler: async (args, ctx) => {
|
||||
const command = {};
|
||||
commandAttempt = command;
|
||||
let arg = args.trim();
|
||||
if (arg === "solo") {
|
||||
if (state.phase !== "working") { ctx.ui.notify("Solo requires an already-approved plan. A draft still needs Ready.", "warning"); return; }
|
||||
if (modelError) { ctx.ui.notify(`Cannot enter solo: ${pauseReason()}`, "warning"); return; }
|
||||
if (state.mode === "solo") { ctx.ui.notify("Already UNSUPERVISED; plan preserved, supervisor sign-off unavailable. /goals restart restores supervision.", "warning"); return; }
|
||||
readyAttempt = undefined;
|
||||
enterSolo(ctx, "You explicitly selected /goals solo.");
|
||||
return;
|
||||
}
|
||||
if (arg === "supervise") { ctx.ui.notify("This is the worker session. Run /goals supervise in the saved supervisor session; no new pairing was created.", "warning"); return; }
|
||||
if (arg === "work") {
|
||||
if (state.phase === "working" && state.mode === "solo") { ctx.ui.notify("Already an unsupervised worker. Use /goals reconnect for worker-model recovery or /goals restart to restore supervision.", "warning"); return; }
|
||||
if (state.phase !== "working" || !state.approvalId || !state.supervisorPaneId) { ctx.ui.notify("No approved worker pairing to reconnect. A retained draft still needs Ready.", "warning"); return; }
|
||||
arg = "reconnect";
|
||||
}
|
||||
@@ -415,10 +479,22 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
if (!state.phase) { ctx.ui.notify("No active plan to recover.", "info"); return; }
|
||||
if (!ctx.isIdle()) { ctx.ui.notify("Stop the current turn before recovering goal supervision.", "warning"); return; }
|
||||
readyAttempt = undefined;
|
||||
recoveryAttempt = undefined;
|
||||
const version = state.planVersion;
|
||||
const phase = state.phase;
|
||||
const current = () => !intercom.ended && commandAttempt === command && state.planVersion === version && state.phase === phase;
|
||||
recoveryCommand = command;
|
||||
try {
|
||||
await restoreModel(state.phase === "planning" ? "planning" : "worker", ctx);
|
||||
if (!current()) return;
|
||||
if (state.mode === "solo" && arg === "reconnect") {
|
||||
ctx.ui.notify("Worker model restored; remaining UNSUPERVISED. Use /goals restart to restore supervision.", "warning");
|
||||
updateWidget(ctx);
|
||||
return;
|
||||
}
|
||||
if (arg === "restart") {
|
||||
if (!(await stopSupervisor())) throw new Error("Could not close the tracked supervisor pane; no replacement was opened.");
|
||||
if (!(await stopSupervisor())) throw new SupervisorFailure("Could not close the tracked supervisor pane; no replacement was opened.");
|
||||
if (!current()) return;
|
||||
state = { ...state, supervisorPaneId: null, approvalId: null };
|
||||
persist();
|
||||
}
|
||||
@@ -426,19 +502,22 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
if (arg === "reconnect") {
|
||||
if (!state.approvalId) throw new Error("No saved supervision binding. Use /goals restart.");
|
||||
intercom.configure(state.approvalId, "worker", ctx, false);
|
||||
await intercom.waitReady(undefined, { peerOnly: true });
|
||||
} else await startSupervisor(ctx);
|
||||
await waitSupervisor();
|
||||
} else await startSupervisor(ctx, current);
|
||||
}
|
||||
if (intercom.ended) return;
|
||||
if (!current()) return;
|
||||
if (state.phase === "working") {
|
||||
state = { ...state, mode: "supervised", soloReason: null };
|
||||
persist();
|
||||
intercom.markReady();
|
||||
startWorkerTimers(ctx);
|
||||
}
|
||||
ctx.ui.notify(state.phase === "planning" ? "Planning model restored. Choose Ready when the plan is agreed." : "Goal supervision reconnected; the current plan is unchanged.", "info");
|
||||
} catch (error) {
|
||||
if (intercom.ended) return;
|
||||
if (!current()) return;
|
||||
if (error instanceof SupervisorFailure && state.phase === "working" && !modelError) { enterSolo(ctx, `Supervisor recovery failed: ${error.message}`); return; }
|
||||
ctx.ui.notify(`Goal recovery failed: ${String(error)} Use /goals reconnect to retry, or /goals restart to explicitly replace the tracked pane.`, "warning");
|
||||
}
|
||||
} finally { if (recoveryCommand === command) recoveryCommand = undefined; }
|
||||
updateWidget(ctx);
|
||||
return;
|
||||
}
|
||||
@@ -480,7 +559,7 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
return;
|
||||
}
|
||||
await restoreModel("planning", ctx);
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg, signedOffGoals: [], previousPlan: null };
|
||||
state = { ...state, phase: "planning", mode: "supervised", soloReason: null, supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg, signedOffGoals: [], previousPlan: null };
|
||||
planningContextPending = true;
|
||||
resyncReason = null;
|
||||
writePlan(ctx, "");
|
||||
@@ -503,13 +582,14 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
if (state.phase === "planning" || !plan.trim() || !resyncReason) return null;
|
||||
const why = resyncReason;
|
||||
resyncReason = null;
|
||||
return resync(plan, planRel(ctx), why);
|
||||
return resync(plan, planRel(ctx), why, state.mode === "solo");
|
||||
}
|
||||
|
||||
// The phase snapshot enters context only when planning starts or context was lost.
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
const paused = pauseReason();
|
||||
if (paused) return { systemPrompt: `${ctx.getSystemPrompt()}\n\nGoal work is paused: ${paused} Do not implement or sign off goals. Human input and read-only diagnosis remain available; wait for recovery before resuming autonomous work.` };
|
||||
if (state.phase === "working" && state.mode === "solo") return { systemPrompt: `${ctx.getSystemPrompt()}\n\nYou are the UNSUPERVISED implementation worker for ${planRel(ctx)}. Reason: ${state.soloReason} Continue the approved plan, preserve its goals and save evidence and verification results. There is no supervisor; do not wait for steering, call CompleteGoal, or claim supervised sign-off. Report completion as unreviewed. Use /goals restart to restore supervision.` };
|
||||
if (state.phase === "working") {
|
||||
return {
|
||||
systemPrompt: `${ctx.getSystemPrompt()}\n\nYou are the implementation worker for ${planRel(ctx)}. Keep the full conversation and do the work directly. A stronger read-only supervisor watches this session through pi-intercom and can steer you. Commit your evidence before asking for sign-off; never commit or discard unrelated changes to satisfy the clean-worktree gate. The supervisor can explicitly accept an inspected unchanged dirty state with ApproveGoal force and a reason. Stop when a goal appears complete so the supervisor can inspect a settled worker view. Call CompleteGoal only after the supervisor says it recorded approval. -- PI[Kimi K3]`,
|
||||
@@ -579,8 +659,21 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
else resyncReason = "The session was just compacted.";
|
||||
});
|
||||
|
||||
pi.on("agent_end", async event => {
|
||||
const last = event.messages.filter(message => message.role === "assistant").at(-1);
|
||||
lastAssistantError = last?.stopReason === "error" ? last.errorMessage ?? "Worker model returned an error without a reason." : undefined;
|
||||
});
|
||||
|
||||
// PI: Print after Pi settles. agent_end is still streaming, so its message queues behind the menu.
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
if (state.phase === "working" && lastAssistantError) {
|
||||
modelError = `Worker model failed after Pi recovery: ${lastAssistantError}`;
|
||||
lastAssistantError = undefined;
|
||||
intercom.markNotReady();
|
||||
ctx.ui.notify(`Goal work paused: ${pauseReason()} No mode or model substitution was made.`, "error");
|
||||
updateWidget(ctx);
|
||||
return;
|
||||
}
|
||||
if (state.phase === "working") {
|
||||
await publishWorkerView(ctx, "settled");
|
||||
return;
|
||||
@@ -638,15 +731,22 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
};
|
||||
try {
|
||||
checkApprovedPlan();
|
||||
await startSupervisor(ctx, current);
|
||||
let supervisorFailure: SupervisorFailure | undefined;
|
||||
try { await startSupervisor(ctx, current); }
|
||||
catch (error) { if (!(error instanceof SupervisorFailure)) throw error; supervisorFailure = error; }
|
||||
if (!current()) return;
|
||||
checkApprovedPlan();
|
||||
await restoreModel("worker", ctx);
|
||||
if (!current()) return;
|
||||
checkApprovedPlan();
|
||||
state = { ...state, phase: "working" };
|
||||
state = { ...state, phase: "working", mode: "supervised", soloReason: null };
|
||||
resyncReason = "The plan was approved.";
|
||||
persist();
|
||||
if (supervisorFailure) {
|
||||
readyAttempt = undefined;
|
||||
enterSolo(ctx, `Supervisor startup failed after you selected Ready: ${supervisorFailure.message}`);
|
||||
return;
|
||||
}
|
||||
intercom.markReady();
|
||||
startWorkerTimers(ctx);
|
||||
await publishWorkerView(ctx, "ready");
|
||||
@@ -676,6 +776,8 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
.pop() as { data?: PlanState } | undefined;
|
||||
state = {
|
||||
phase: last?.data?.phase ?? null,
|
||||
mode: last?.data?.mode ?? "supervised",
|
||||
soloReason: last?.data?.soloReason ?? null,
|
||||
supervisorModel: last?.data?.supervisorModel ?? null,
|
||||
supervisorPaneId: last?.data?.supervisorPaneId ?? null,
|
||||
approvalId: last?.data?.approvalId ?? null,
|
||||
@@ -687,7 +789,7 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
modelError = state.phase ? "Role model restoration is pending." : null;
|
||||
planningContextPending = state.phase === "planning";
|
||||
resyncReason = state.phase === "working" ? "New session." : null;
|
||||
if (state.phase === "working" && state.approvalId) {
|
||||
if (state.phase === "working" && state.mode === "supervised" && state.approvalId) {
|
||||
intercom.configure(state.approvalId, "worker", ctx, false);
|
||||
startWorkerTimers(ctx);
|
||||
}
|
||||
@@ -697,11 +799,9 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
if (!intercom.ended) ctx.ui.notify(`Goal work paused: ${String(error)} Use /model, then /goals reconnect.`, "warning");
|
||||
}
|
||||
if (intercom.ended) return;
|
||||
if (state.phase === "working" && state.approvalId && !modelError) {
|
||||
if (state.phase === "working" && state.mode === "supervised" && state.approvalId && !modelError) {
|
||||
intercom.markReady();
|
||||
void intercom.waitReady().catch(error => {
|
||||
if (!intercom.ended && state.phase === "working") ctx.ui.notify(`Goal work paused: ${String(error)} Use /goals reconnect or /goals restart.`, "warning");
|
||||
});
|
||||
recoverSupervisor(ctx);
|
||||
}
|
||||
updateWidget(ctx);
|
||||
});
|
||||
@@ -722,6 +822,7 @@ export function registerWorker(pi: ExtensionAPI): void {
|
||||
const binding = state.approvalId;
|
||||
const version = state.planVersion;
|
||||
if (state.phase !== "working") return result("Planning is not approved. Choose Ready before signing off a goal.", true);
|
||||
if (state.mode === "solo") return result("Supervisor sign-off is unavailable in solo mode. Save evidence and use /goals restart for review; no completion recorded.", true);
|
||||
if (pauseReason()) return result(`Goal sign-off blocked: ${pauseReason()}`, true);
|
||||
if (!state.approvalId) return result("Goal sign-off blocked: no current supervisor review.", true);
|
||||
const background = await backgroundState(pi);
|
||||
|
||||
+2
-1
@@ -131,6 +131,7 @@ export class GoalIntercom {
|
||||
this.hello();
|
||||
if (this.ctx) this.onConnectionChange(this.ctx);
|
||||
}
|
||||
get readinessFailure(): string | undefined { return this.failure; }
|
||||
get ended(): boolean { return this.stopped; }
|
||||
get bound(): boolean { return !this.stopped && Boolean(this.binding); }
|
||||
get peerPresent(): boolean { return Boolean(this.bound && this.peer && this.channel?.snapshot().connected); }
|
||||
@@ -239,7 +240,7 @@ export class GoalIntercom {
|
||||
if (!message || message.binding !== this.binding || message.role !== (this.role === "worker" ? "supervisor" : "worker")) return;
|
||||
if (message.kind === "hello") {
|
||||
if (this.peer && this.peer !== event.fromSessionId) throw new Error("Two peers claim this supervision binding. Stop the duplicate session.");
|
||||
const changed = !this.peer || this.peerReady !== Boolean(message.ready);
|
||||
const changed = !this.peer || this.peerReady !== Boolean(message.ready) || this.peerFailure !== message.failure;
|
||||
this.peer = event.fromSessionId;
|
||||
this.peerReady = Boolean(message.ready);
|
||||
this.peerFailure = message.failure;
|
||||
|
||||
+2
-3
@@ -160,12 +160,11 @@ Ready.`;
|
||||
* appendix included. Modelled on pi-goal-x's [POST-COMPACTION RESYNC] one-shot. This is the
|
||||
* only place the below-the-fold sections are pushed; otherwise the agent reads them on demand.
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
export function resync(plan: string, planRel: string, why: string): string {
|
||||
export function resync(plan: string, planRel: string, why: string, solo = false): string {
|
||||
return `\
|
||||
<system-reminder>
|
||||
${why} This is the whole plan file (${planRel}), appendix included. You are the implementation worker.
|
||||
Keep the high-level goal and human intent stable and do the work directly. A visible read-only Pi
|
||||
session supervises you through pi-intercom. The human's latest message outranks the plan: if it
|
||||
Keep the high-level goal and human intent stable and do the work directly. ${solo ? "You are UNSUPERVISED. Continue implementation and save evidence; supervisor sign-off is unavailable. Do not wait for steering or call CompleteGoal. /goals restart restores supervision." : "A visible read-only Pi session supervises you through pi-intercom."} The human's latest message outranks the plan: if it
|
||||
changes scope, amend the plan rather than preserving an obsolete decision.
|
||||
|
||||
${plan}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
import { approvalPath, goalBlock, hashGoalBlock, repositoryState, verifyOutputPath, writeApproval } from "./approval.js";
|
||||
import { goalCommandCompletions } from "./command-help.js";
|
||||
import { GoalIntercom } from "./intercom.js";
|
||||
import { planViews } from "./plan-view.js";
|
||||
import { approveGoalDescription, approveGoalParameters, goalApprovalRecorded, steerWorkerDescription, steerWorkerInstructionDescription, supervisorCompaction, supervisorOrientation, supervisorReviewContext, workerInstructionSent } from "./prompts.js";
|
||||
@@ -93,7 +94,8 @@ export function registerVisibleSupervisor(pi: ExtensionAPI, restored?: Superviso
|
||||
let compacting = false;
|
||||
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let startupChecks = 0;
|
||||
pi.on("session_shutdown", async () => { if (startupTimer) clearTimeout(startupTimer); });
|
||||
let statusContext: ExtensionContext | undefined;
|
||||
pi.on("session_shutdown", async () => { if (startupTimer) clearTimeout(startupTimer); statusContext?.ui.setStatus("pi-goals", undefined); });
|
||||
let repeatFullPrompt = true;
|
||||
pi.on("session_compact", async () => { repeatFullPrompt = true; });
|
||||
let bootstrapping = false;
|
||||
@@ -101,6 +103,16 @@ export function registerVisibleSupervisor(pi: ExtensionAPI, restored?: Superviso
|
||||
let modelError: string | null = null;
|
||||
const intercom = new GoalIntercom(pi);
|
||||
const models = new RoleModels(pi);
|
||||
const updateStatus = (ctx: ExtensionContext) => {
|
||||
const label = modelError || intercom.readinessFailure ? "supervisor · paused" : intercom.connected ? "supervising" : "supervisor · starting/reconnecting";
|
||||
ctx.ui.setStatus("pi-goals", label);
|
||||
};
|
||||
intercom.onConnectionChange = updateStatus;
|
||||
let lastAssistantError: string | undefined;
|
||||
pi.on("agent_end", async event => {
|
||||
const last = event.messages.filter(message => message.role === "assistant").at(-1);
|
||||
lastAssistantError = last?.stopReason === "error" ? last.errorMessage ?? "Supervisor model returned an error without a reason." : undefined;
|
||||
});
|
||||
intercom.onView = (view) => pi.sendUserMessage(view.text, { deliverAs: "followUp" });
|
||||
pi.on("session_compact_failed", async event => {
|
||||
if (!startupTimer) return;
|
||||
@@ -159,15 +171,19 @@ export function registerVisibleSupervisor(pi: ExtensionAPI, restored?: Superviso
|
||||
};
|
||||
|
||||
const start = async (ctx: ExtensionContext): Promise<void> => {
|
||||
statusContext = ctx;
|
||||
lastAssistantError = undefined;
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
startupTimer = undefined;
|
||||
startupChecks = 0;
|
||||
modelError = "Supervisor model restoration is pending.";
|
||||
updateStatus(ctx);
|
||||
if (!ctx.sessionManager.getEntries().some(entry => entry.type === "custom" && entry.customType === ROLE_STATE)) pi.appendEntry(ROLE_STATE, settings);
|
||||
intercom.configure(settings.approvalId, "supervisor", ctx);
|
||||
try {
|
||||
await models.enter("supervisor", ctx, process.env.PI_GOALS_MODEL_EXPLICIT === "1");
|
||||
modelError = null;
|
||||
updateStatus(ctx);
|
||||
setImmediate(() => { if (!intercom.ended) bootstrapAfterInitialCompaction(ctx); });
|
||||
} catch (error) {
|
||||
modelError = String(error);
|
||||
@@ -180,6 +196,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI, restored?: Superviso
|
||||
pi.on("session_start", async (_event, ctx) => start(ctx));
|
||||
pi.registerCommand("goals", {
|
||||
description: "Reconnect this saved supervisor role and pairing: /goals supervise (or reconnect)",
|
||||
getArgumentCompletions: prefix => goalCommandCompletions(prefix, "supervisor"),
|
||||
handler: async (args, ctx) => {
|
||||
if (!["reconnect", "supervise"].includes(args.trim())) { ctx.ui.notify("This is the supervisor session. Use /goals supervise here; /goals work and /goals noplan belong to the worker. No role or plan was changed.", "info"); return; }
|
||||
if (!ctx.isIdle() || compacting) { ctx.ui.notify("Wait for the supervisor to settle before reconnecting.", "warning"); return; }
|
||||
@@ -196,6 +213,12 @@ export function registerVisibleSupervisor(pi: ExtensionAPI, restored?: Superviso
|
||||
};
|
||||
});
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
// Settled is after Pi's automatic retries/compaction, unlike agent_end.
|
||||
if (lastAssistantError) {
|
||||
intercom.failReady(`Supervisor model failed after Pi recovery: ${lastAssistantError}`);
|
||||
lastAssistantError = undefined;
|
||||
return;
|
||||
}
|
||||
if (compacting) return;
|
||||
const usage = ctx.getContextUsage();
|
||||
if (!usage && !warnedUnknownUsage) {
|
||||
|
||||
+186
-18
@@ -86,7 +86,7 @@ function approvedPlan(cwd: string): string {
|
||||
afterEach(async () => {
|
||||
for (const shutdown of shutdowns.splice(0)) await shutdown();
|
||||
vi.useRealTimers();
|
||||
openSupervisorPane.mockClear();
|
||||
openSupervisorPane.mockReset();
|
||||
closeSupervisorPane.mockClear();
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("/goals flow", () => {
|
||||
expect(views().at(-1)?.text).toContain("use SteerWorker to send the next useful instruction and resume work");
|
||||
expect(views().at(-1)?.text).toContain("Manual checkbox edits are claims, not proof of completion");
|
||||
expect(readFileSync(path, "utf8")).toContain("[x] goal:");
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/1 goals · 1 claimed, awaiting review"));
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/1 goals · supervised worker · 1 claimed, awaiting review"));
|
||||
expect(flow.ctx.ui.setWidget).toHaveBeenLastCalledWith("pi-goals-widget", [expect.stringContaining("claimed complete; awaiting supervisor review")]);
|
||||
const binding = (flow.entries.at(-1)?.data as any).approvalId;
|
||||
flow.transport.receive({ binding, role: "supervisor", kind: "steer", id: "review-claim", text: "Reopen the goal; verify the missing output first." });
|
||||
@@ -190,7 +190,7 @@ describe("/goals flow", () => {
|
||||
writeFileSync(path, "1. [x] goal: first\n2. [ ] goal: second\n");
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await flow.hooks.get("turn_end")({}, flow.ctx);
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/2 goals · 1 claimed, awaiting review"));
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("0/2 goals · supervised worker · 1 claimed, awaiting review"));
|
||||
} finally { await flow.hooks.get("session_shutdown")(); rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -396,14 +396,16 @@ it("shows a missing resumed supervisor, pauses writes, and automatically unpause
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "goals paused");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(flow.notifications.some(text => text.includes("/goals restart"))).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
expect(flow.notifications.some(text => text.includes("/goals restart"))).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(flow.notifications.some(text => text.includes("UNSUPERVISED"))).toBe(false);
|
||||
expect((await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "code.ts" } }, flow.ctx)).terminate).toBe(true);
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "git status" } }, flow.ctx)).toBeUndefined();
|
||||
flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: true });
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("supervised"));
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "code.ts" } }, flow.ctx)).toBeUndefined();
|
||||
expect(openSupervisorPane).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(flow.notifications.some(text => text.includes("UNSUPERVISED WORKER"))).toBe(false);
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -484,24 +486,24 @@ it.each(["launch", "model"])("rejects plan content changes during Ready %s witho
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("allows five minutes for stale Ready retries, without replacing the pane automatically", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] });
|
||||
const flow = setup(["Ready", "Ready"]);
|
||||
it("falls back loudly after explicit Ready launch failure, preserving its failed pane and approved content", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
approvedPlan(flow.cwd);
|
||||
flow.transport.replyToHello(false);
|
||||
const path = approvedPlan(flow.cwd);
|
||||
const before = readFileSync(path, "utf8");
|
||||
openSupervisorPane.mockImplementationOnce(async (_input: any, opened: any) => { opened("failed-pane"); throw new Error("pane run failed"); });
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(flow.notifications.at(-1)).toContain("failed-pane");
|
||||
const retry = flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" });
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
await retry;
|
||||
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", mode: "solo", supervisorPaneId: "failed-pane", approvalId: null });
|
||||
expect(readFileSync(path, "utf8")).toBe(before);
|
||||
expect(flow.notifications.at(-1)).toContain("pane run failed");
|
||||
expect(flow.notifications.at(-1)).toContain("UNSUPERVISED WORKER");
|
||||
expect(flow.notifications.at(-1)).toContain("same approved plan");
|
||||
expect(flow.notifications.at(-1)).toContain("Supervisor sign-off is unavailable");
|
||||
expect(flow.notifications.at(-1)).toContain("/goals restart");
|
||||
expect(flow.messages.at(-1)?.content).toContain("Continue useful implementation");
|
||||
expect(closeSupervisorPane).not.toHaveBeenCalled();
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", supervisorPaneId: "failed-pane" });
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "result.txt" } }, flow.ctx)).toBeUndefined();
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
@@ -640,3 +642,169 @@ it("clear before the launcher resolves rejects late pane callbacks without resto
|
||||
expect(flow.messages.some(message => message.content.includes("Begin implementation"))).toBe(false);
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
it("explicit solo persists across reload/compaction, rejects sign-off and restores supervision only through restart", async () => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
const path = restoredPlan(flow);
|
||||
const before = readFileSync(path, "utf8");
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
const pendingSignoff = flow.tools.get("CompleteGoal").execute("pending", { goal: "make the file" }, undefined, undefined, flow.ctx);
|
||||
await flow.commands.get("goals").handler("solo", flow.ctx);
|
||||
expect((await pendingSignoff).isError).toBe(true);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", mode: "solo", approvalId: null, planVersion: 1 });
|
||||
expect(readFileSync(path, "utf8")).toBe(before);
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("UNSUPERVISED"));
|
||||
expect(closeSupervisorPane).not.toHaveBeenCalled();
|
||||
const signoff = await flow.tools.get("CompleteGoal").execute("solo", { goal: "make the file" }, undefined, undefined, flow.ctx);
|
||||
expect(signoff.isError).toBe(true);
|
||||
expect(signoff.content[0].text).toContain("unavailable in solo mode");
|
||||
const count = flow.transport.sent.length;
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
await flow.hooks.get("session_compact")({}, flow.ctx);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(flow.transport.sent).toHaveLength(count);
|
||||
expect((await flow.hooks.get("before_agent_start")({}, flow.ctx)).systemPrompt).toContain("UNSUPERVISED implementation worker");
|
||||
expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages[0].content[0].text).toContain("You are UNSUPERVISED");
|
||||
await flow.commands.get("goals").handler("reconnect", flow.ctx);
|
||||
expect(openSupervisorPane).not.toHaveBeenCalled();
|
||||
expect(flow.notifications.at(-1)).toContain("remaining UNSUPERVISED");
|
||||
await flow.commands.get("goals").handler("restart", flow.ctx);
|
||||
expect(closeSupervisorPane).toHaveBeenCalledExactlyOnceWith("owned-pane");
|
||||
expect(openSupervisorPane).toHaveBeenCalledTimes(1);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", mode: "supervised", soloReason: null, planVersion: 1 });
|
||||
expect(readFileSync(path, "utf8")).toBe(before);
|
||||
expect(flow.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", expect.stringContaining("supervised worker"));
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["startup", "resume", "disconnect"])("allows the existing five-minute recovery window before loud %s timeout fallback", async stage => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] });
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
let waiting: Promise<void> | undefined;
|
||||
if (stage === "startup") {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
approvedPlan(flow.cwd);
|
||||
flow.transport.replyToHello(false);
|
||||
waiting = flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
} else {
|
||||
restoredPlan(flow);
|
||||
if (stage === "resume") flow.transport.replyToHello(false);
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
if (stage === "disconnect") {
|
||||
flow.transport.replyToHello(false);
|
||||
flow.transport.event({ type: "session_left", sessionId: "peer" });
|
||||
}
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(299_999);
|
||||
expect(flow.notifications.some(text => text.includes("UNSUPERVISED WORKER"))).toBe(false);
|
||||
expect((await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "result.txt" } }, flow.ctx)).block).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await waiting;
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", mode: "solo", approvalId: null });
|
||||
expect(flow.notifications.at(-1)).toContain("Supervisor did not become ready through pi-intercom");
|
||||
expect(await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "result.txt" } }, flow.ctx)).toBeUndefined();
|
||||
const count = flow.messages.length;
|
||||
flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: true });
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(flow.messages).toHaveLength(count); // A late old peer never silently restores supervision.
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ mode: "solo" });
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("falls back with the exact reported terminal peer failure without confusing it with worker model failure", async () => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
restoredPlan(flow);
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
flow.transport.replyToHello(false);
|
||||
flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: false });
|
||||
flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: false, failure: "Supervisor model failed: quota exceeded (429)" });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ mode: "solo" });
|
||||
expect(flow.notifications.at(-1)).toContain("quota exceeded (429)");
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["solo", "peer failure"])("does not bypass an unavailable worker model on %s", async action => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
restoredPlan(flow);
|
||||
flow.pi.setModel.mockResolvedValue(false);
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
if (action === "solo") await flow.commands.get("goals").handler("solo", flow.ctx);
|
||||
else flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: false, failure: "Supervisor unavailable" });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(flow.notifications.some(text => text.includes("UNSUPERVISED WORKER"))).toBe(false);
|
||||
expect((await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "result.txt" } }, flow.ctx)).block).toBe(true);
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["changed", "noplan", "worker model", "repository"])("never uses initial supervisor failure to approve a %s Ready attempt", async cause => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
const path = approvedPlan(flow.cwd);
|
||||
if (cause === "worker model") flow.pi.setModel.mockResolvedValue(false);
|
||||
if (cause === "repository") rmSync(join(flow.cwd, ".git"), { recursive: true, force: true });
|
||||
openSupervisorPane.mockImplementationOnce(async () => {
|
||||
if (cause === "changed") writeFileSync(path, readFileSync(path, "utf8").replace("make the file", "spend money"));
|
||||
if (cause === "noplan") await flow.commands.get("goals").handler("noplan", flow.ctx);
|
||||
throw new Error("Supervisor launch failed");
|
||||
});
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(flow.notifications.some(text => text.includes("UNSUPERVISED WORKER"))).toBe(false);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: cause === "noplan" ? null : "planning" });
|
||||
await flow.commands.get("goals").handler("solo", flow.ctx);
|
||||
expect(flow.notifications.at(-1)).toContain("already-approved plan");
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("stays loudly solo when replacement fails and never restores a cleared plan after a pending recovery", async () => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
restoredPlan(flow);
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
await flow.commands.get("goals").handler("solo", flow.ctx);
|
||||
openSupervisorPane.mockRejectedValueOnce(new Error("Herdr launch refused"));
|
||||
await flow.commands.get("goals").handler("restart", flow.ctx);
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ mode: "solo" });
|
||||
expect(flow.notifications.at(-1)).toContain("Herdr launch refused");
|
||||
let finish: (() => void) | undefined;
|
||||
openSupervisorPane.mockImplementationOnce(() => new Promise(resolve => { finish = () => resolve("late-pane"); }));
|
||||
const restarting = flow.commands.get("goals").handler("restart", flow.ctx);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await flow.commands.get("goals").handler("clear", flow.ctx);
|
||||
finish!();
|
||||
await restarting;
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, planVersion: null });
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("offers per-verb autocomplete descriptions without treating an objective as a verb", () => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
const complete = flow.commands.get("goals").getArgumentCompletions;
|
||||
expect(complete("solo")).toEqual([{ value: "solo", label: "solo", description: expect.stringContaining("sign-off unavailable") }]);
|
||||
expect(complete("re").map((item: any) => item.value)).toEqual(["reconnect", "restart"]);
|
||||
expect(complete("write a report")).toBeNull();
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
it("keeps worker runtime errors paused after Pi retries, rather than treating them as supervisor failures", async () => {
|
||||
const flow = setup([]);
|
||||
try {
|
||||
restoredPlan(flow);
|
||||
await flow.hooks.get("session_start")({}, flow.ctx);
|
||||
await flow.hooks.get("agent_end")({ messages: [{ role: "assistant", stopReason: "error", errorMessage: "worker quota exceeded" }] }, flow.ctx);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
flow.transport.receive({ binding: "restored-binding", role: "supervisor", kind: "hello", id: "hello", ready: false, failure: "supervisor unavailable too" });
|
||||
await flow.commands.get("goals").handler("solo", flow.ctx);
|
||||
expect(flow.notifications.at(-1)).toContain("worker quota exceeded");
|
||||
expect(flow.notifications.some(text => text.includes("UNSUPERVISED WORKER"))).toBe(false);
|
||||
expect((await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "result.txt" } }, flow.ctx)).block).toBe(true);
|
||||
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -34,7 +34,7 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
|
||||
getContextUsage: () => tokens === null ? undefined : ({ tokens }),
|
||||
compact: vi.fn(onCompact),
|
||||
sessionManager: { getEntries: () => entries, getBranch: () => [...entries, ...branch], getSessionId: () => "supervisor-session" },
|
||||
ui: { notify: vi.fn() },
|
||||
ui: { notify: vi.fn(), setStatus: vi.fn() },
|
||||
};
|
||||
const pi = {
|
||||
events: transport.events,
|
||||
@@ -339,3 +339,27 @@ it("warns once on unavailable usage but stays quiet for Pi's post-compaction nul
|
||||
expect(runtime.ctx.compact).not.toHaveBeenCalled();
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
it("shows the supervisor role, waits through Pi retries, then reports a settled model failure to its worker", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-status-"));
|
||||
try {
|
||||
const runtime = setup(cwd, join(cwd, "plan.md"));
|
||||
await runtime.start();
|
||||
expect(runtime.ctx.ui.setStatus).toHaveBeenCalledWith("pi-goals", "supervisor · starting/reconnecting");
|
||||
expect(runtime.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "supervising");
|
||||
const end = (stopReason: string, errorMessage?: string) => runtime.hooks.get("agent_end")({ messages: [{ role: "assistant", stopReason, errorMessage }] }, runtime.ctx);
|
||||
await end("error", "transient 503");
|
||||
expect(runtime.transport.sent.some(message => message.failure?.includes("transient 503"))).toBe(false);
|
||||
await end("stop");
|
||||
await runtime.hooks.get("agent_settled")({}, runtime.ctx);
|
||||
expect(runtime.transport.sent.some(message => message.failure)).toBe(false);
|
||||
await end("error", "quota exceeded (429)");
|
||||
await runtime.hooks.get("agent_settled")({}, runtime.ctx);
|
||||
expect(runtime.transport.sent.at(-1)).toMatchObject({ kind: "hello", ready: false, failure: "Supervisor model failed after Pi recovery: quota exceeded (429)" });
|
||||
expect(runtime.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", "supervisor · paused");
|
||||
expect(runtime.commands.get("goals").getArgumentCompletions("super")).toEqual([{ value: "supervise", label: "supervise", description: expect.stringContaining("saved supervisor") }]);
|
||||
await runtime.hooks.get("session_shutdown")();
|
||||
expect(runtime.ctx.ui.setStatus).toHaveBeenLastCalledWith("pi-goals", undefined);
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in new issue
Block a user