detached ec485d7

fix(subagents): harden required worker recovery

commit
ec485d726a25e9d2631d19f39ffefec70853cb9d
author
brice · · 2026-08-10 04:58 UTC
parent
a6e4bd2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +181 -60
M docs/subagents.md +2 -2
@@ -158,9 +158,9 @@ A subagent timeout applies to each active delegated turn. When the deadline expi
158158
159159 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
160160
161-When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
161+When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
162162
163-Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
163+Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
164164
165165 ## Resource policy
166166
@@ -158,9 +158,9 @@ A subagent timeout applies to each active delegated turn. When the deadline expi
158 158
159 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.159 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
160 160
161-When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.161+When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
162 162
163-Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.163+Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, blocks for its new terminal outcome, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional completion uses the normal auto-subagent update; required completion returns from the blocking resume call. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
164 164
165 ## Resource policy165 ## Resource policy
166 166
M packages/agent/modes/auto_subagents_test.go +9 -9
@@ -91,7 +91,7 @@ func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testi
9191 }
9292
9393 iv := newQueuedAutoSubagentsInteractive()
94- iv.TrackSubagentWorker(a, a.Task)
94+ iv.TrackSubagentWorker(a, a.Task, false)
9595 update := waitForQueuedPrompt(t, iv)
9696 if !strings.Contains(update, "final response:\n"+response) {
9797 t.Fatalf("update missing complete final response: %q", update)
@@ -114,7 +114,7 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
114114 }
115115
116116 iv := newQueuedAutoSubagentsInteractive()
117- iv.TrackSubagentWorker(a, a.Task)
117+ iv.TrackSubagentWorker(a, a.Task, false)
118118
119119 update := waitForQueuedPrompt(t, iv)
120120 if !strings.Contains(update, "status: failed") {
@@ -182,7 +182,7 @@ func TestTrackDeadlineFailureUsesAttributedResultError(t *testing.T) {
182182 }
183183
184184 iv := newQueuedAutoSubagentsInteractive()
185- iv.TrackSubagentWorker(a, a.Task)
185+ iv.TrackSubagentWorker(a, a.Task, false)
186186 update := waitForQueuedPrompt(t, iv)
187187 if !strings.Contains(update, "status: failed") {
188188 t.Fatalf("deadline update changed failed status: %q", update)
@@ -218,7 +218,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
218218
219219 iv := newQueuedAutoSubagentsInteractive()
220220 const followUp = "I applied your review. What do you think now?"
221- iv.TrackResumedSubagentWorker(a, followUp)
221+ iv.TrackResumedSubagentWorker(a, followUp, false)
222222 if a.OnTurnEnd == nil {
223223 t.Fatal("resumed worker has no completion watcher")
224224 }
@@ -260,9 +260,9 @@ func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T
260260 })
261261
262262 iv := newQueuedAutoSubagentsInteractive()
263- iv.TrackSubagentWorker(first, first.Task)
263+ iv.TrackSubagentWorker(first, first.Task, false)
264264 const followUp = "queued follow-up"
265- cancel := iv.PrepareResumedSubagentWorker(first, followUp)
265+ cancel := iv.PrepareResumedSubagentWorker(first, followUp, false)
266266 if cancel == nil {
267267 t.Fatal("resume registration returned nil cleanup")
268268 }
@@ -309,9 +309,9 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
309309
310310 iv := newQueuedAutoSubagentsInteractive()
311311 release := iv.beginCompletionDeliveryHold()
312- iv.TrackSubagentWorker(first, first.Task)
312+ iv.TrackSubagentWorker(first, first.Task, false)
313313 first.OnTurnEnd(1, "")
314- iv.TrackSubagentWorker(second, second.Task)
314+ iv.TrackSubagentWorker(second, second.Task, false)
315315 second.OnTurnEnd(1, "")
316316 release()
317317
@@ -354,7 +354,7 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
354354 })
355355
356356 iv := newQueuedAutoSubagentsInteractive()
357- iv.TrackSubagentWorker(a, a.Task)
357+ iv.TrackSubagentWorker(a, a.Task, false)
358358 if a.OnTurnEnd == nil {
359359 t.Fatal("worker has no completion watcher")
360360 }
@@ -91,7 +91,7 @@ func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testi
91 }91 }
92 92
93 iv := newQueuedAutoSubagentsInteractive()93 iv := newQueuedAutoSubagentsInteractive()
94- iv.TrackSubagentWorker(a, a.Task)94+ iv.TrackSubagentWorker(a, a.Task, false)
95 update := waitForQueuedPrompt(t, iv)95 update := waitForQueuedPrompt(t, iv)
96 if !strings.Contains(update, "final response:\n"+response) {96 if !strings.Contains(update, "final response:\n"+response) {
97 t.Fatalf("update missing complete final response: %q", update)97 t.Fatalf("update missing complete final response: %q", update)
@@ -114,7 +114,7 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
114 }114 }
115 115
116 iv := newQueuedAutoSubagentsInteractive()116 iv := newQueuedAutoSubagentsInteractive()
117- iv.TrackSubagentWorker(a, a.Task)117+ iv.TrackSubagentWorker(a, a.Task, false)
118 118
119 update := waitForQueuedPrompt(t, iv)119 update := waitForQueuedPrompt(t, iv)
120 if !strings.Contains(update, "status: failed") {120 if !strings.Contains(update, "status: failed") {
@@ -182,7 +182,7 @@ func TestTrackDeadlineFailureUsesAttributedResultError(t *testing.T) {
182 }182 }
183 183
184 iv := newQueuedAutoSubagentsInteractive()184 iv := newQueuedAutoSubagentsInteractive()
185- iv.TrackSubagentWorker(a, a.Task)185+ iv.TrackSubagentWorker(a, a.Task, false)
186 update := waitForQueuedPrompt(t, iv)186 update := waitForQueuedPrompt(t, iv)
187 if !strings.Contains(update, "status: failed") {187 if !strings.Contains(update, "status: failed") {
188 t.Fatalf("deadline update changed failed status: %q", update)188 t.Fatalf("deadline update changed failed status: %q", update)
@@ -218,7 +218,7 @@ func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
218 218
219 iv := newQueuedAutoSubagentsInteractive()219 iv := newQueuedAutoSubagentsInteractive()
220 const followUp = "I applied your review. What do you think now?"220 const followUp = "I applied your review. What do you think now?"
221- iv.TrackResumedSubagentWorker(a, followUp)221+ iv.TrackResumedSubagentWorker(a, followUp, false)
222 if a.OnTurnEnd == nil {222 if a.OnTurnEnd == nil {
223 t.Fatal("resumed worker has no completion watcher")223 t.Fatal("resumed worker has no completion watcher")
224 }224 }
@@ -260,9 +260,9 @@ func TestTrackResumedSubagentWorkerQueuesActiveFollowUpOnSameWorker(t *testing.T
260 })260 })
261 261
262 iv := newQueuedAutoSubagentsInteractive()262 iv := newQueuedAutoSubagentsInteractive()
263- iv.TrackSubagentWorker(first, first.Task)263+ iv.TrackSubagentWorker(first, first.Task, false)
264 const followUp = "queued follow-up"264 const followUp = "queued follow-up"
265- cancel := iv.PrepareResumedSubagentWorker(first, followUp)265+ cancel := iv.PrepareResumedSubagentWorker(first, followUp, false)
266 if cancel == nil {266 if cancel == nil {
267 t.Fatal("resume registration returned nil cleanup")267 t.Fatal("resume registration returned nil cleanup")
268 }268 }
@@ -309,9 +309,9 @@ func TestCompletionDeliveryHoldBatchesFastAndLateRegistrations(t *testing.T) {
309 309
310 iv := newQueuedAutoSubagentsInteractive()310 iv := newQueuedAutoSubagentsInteractive()
311 release := iv.beginCompletionDeliveryHold()311 release := iv.beginCompletionDeliveryHold()
312- iv.TrackSubagentWorker(first, first.Task)312+ iv.TrackSubagentWorker(first, first.Task, false)
313 first.OnTurnEnd(1, "")313 first.OnTurnEnd(1, "")
314- iv.TrackSubagentWorker(second, second.Task)314+ iv.TrackSubagentWorker(second, second.Task, false)
315 second.OnTurnEnd(1, "")315 second.OnTurnEnd(1, "")
316 release()316 release()
317 317
@@ -354,7 +354,7 @@ func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
354 })354 })
355 355
356 iv := newQueuedAutoSubagentsInteractive()356 iv := newQueuedAutoSubagentsInteractive()
357- iv.TrackSubagentWorker(a, a.Task)357+ iv.TrackSubagentWorker(a, a.Task, false)
358 if a.OnTurnEnd == nil {358 if a.OnTurnEnd == nil {
359 t.Fatal("worker has no completion watcher")359 t.Fatal("worker has no completion watcher")
360 }360 }
M packages/agent/modes/interactive.go +6 -6
@@ -8743,8 +8743,8 @@ func (a telegramSenderAdapter) Active() bool {
87438743
87448744 // TrackSubagentWorker is the exported entry point used by the cli to
87458745 // hand a freshly-spawned auto-subagents agent off to the shared tracker.
8746-func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required ...bool) {
8747- if len(required) != 0 && required[0] {
8746+func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required bool) {
8747+ if required {
87488748 if i != nil {
87498749 i.invalidate()
87508750 }
@@ -8756,8 +8756,8 @@ func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, requi
87568756 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
87578757 // worker's original task. A long-lived daemon can complete several manager
87588758 // turns, each of which must produce its own automatic delivery.
8759-func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) {
8760- if len(required) != 0 && required[0] {
8759+func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) {
8760+ if required {
87618761 if i != nil {
87628762 i.invalidate()
87638763 }
@@ -8770,8 +8770,8 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri
87708770 // sent. The returned cleanup removes the registration if the resume operation
87718771 // fails before delivery. It is used by the resume tool's pre-send hook; the
87728772 // existing TrackResumedSubagentWorker signature remains the post-success API.
8773-func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) func() {
8774- if len(required) != 0 && required[0] {
8773+func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() {
8774+ if required {
87758775 if i != nil {
87768776 i.invalidate()
87778777 }
@@ -8743,8 +8743,8 @@ func (a telegramSenderAdapter) Active() bool {
8743 8743
8744 // TrackSubagentWorker is the exported entry point used by the cli to8744 // TrackSubagentWorker is the exported entry point used by the cli to
8745 // hand a freshly-spawned auto-subagents agent off to the shared tracker.8745 // hand a freshly-spawned auto-subagents agent off to the shared tracker.
8746-func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required ...bool) {8746+func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, required bool) {
8747- if len(required) != 0 && required[0] {8747+ if required {
8748 if i != nil {8748 if i != nil {
8749 i.invalidate()8749 i.invalidate()
8750 }8750 }
@@ -8756,8 +8756,8 @@ func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string, requi
8756 // TrackResumedSubagentWorker watches a resumed follow-up independently of the8756 // TrackResumedSubagentWorker watches a resumed follow-up independently of the
8757 // worker's original task. A long-lived daemon can complete several manager8757 // worker's original task. A long-lived daemon can complete several manager
8758 // turns, each of which must produce its own automatic delivery.8758 // turns, each of which must produce its own automatic delivery.
8759-func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) {8759+func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) {
8760- if len(required) != 0 && required[0] {8760+ if required {
8761 if i != nil {8761 if i != nil {
8762 i.invalidate()8762 i.invalidate()
8763 }8763 }
@@ -8770,8 +8770,8 @@ func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt stri
8770 // sent. The returned cleanup removes the registration if the resume operation8770 // sent. The returned cleanup removes the registration if the resume operation
8771 // fails before delivery. It is used by the resume tool's pre-send hook; the8771 // fails before delivery. It is used by the resume tool's pre-send hook; the
8772 // existing TrackResumedSubagentWorker signature remains the post-success API.8772 // existing TrackResumedSubagentWorker signature remains the post-success API.
8773-func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required ...bool) func() {8773+func (i *Interactive) PrepareResumedSubagentWorker(a *subagents.Agent, prompt string, required bool) func() {
8774- if len(required) != 0 && required[0] {8774+ if required {
8775 if i != nil {8775 if i != nil {
8776 i.invalidate()8776 i.invalidate()
8777 }8777 }
M packages/agent/settings_store.go +1 -1
@@ -280,7 +280,7 @@ When workers finish, use the required tool result or host update's agent ID, sta
280280
281281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
282282 // available without enabling proactive primary-agent orchestration.
283-const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion; failed required work must be retried and cannot be silently ignored. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
283+const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Required calls wait for a durable terminal outcome: timed-out or canceled work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Deliver required results through the blocking tool result and optional results only through host updates. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
284284
285285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
286286 // orchestrator contract still applies when launch-time policy withholds the
@@ -280,7 +280,7 @@ When workers finish, use the required tool result or host update's agent ID, sta
280 280
281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools281 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
282 // available without enabling proactive primary-agent orchestration.282 // available without enabling proactive primary-agent orchestration.
283-const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion; failed required work must be retried and cannot be silently ignored. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`283+const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Required calls wait for a durable terminal outcome: timed-out or canceled work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Deliver required results through the blocking tool result and optional results only through host updates. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
284 284
285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict285 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
286 // orchestrator contract still applies when launch-time policy withholds the286 // orchestrator contract still applies when launch-time policy withholds the
M packages/agent/subagent_requirement_gate_test.go +1 -1
@@ -127,7 +127,7 @@ func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) {
127127 parent := &core.Agent{}
128128 rt.WireRequiredWorkerGate(parent)
129129 allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1)
130- if allowed || reason != "loading persisted subagents failed; resolve the supervisor reload error before continuing" {
130+ if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" {
131131 t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason)
132132 }
133133 }
@@ -127,7 +127,7 @@ func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) {
127 parent := &core.Agent{}127 parent := &core.Agent{}
128 rt.WireRequiredWorkerGate(parent)128 rt.WireRequiredWorkerGate(parent)
129 allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1)129 allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1)
130- if allowed || reason != "loading persisted subagents failed; resolve the supervisor reload error before continuing" {130+ if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" {
131 t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason)131 t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason)
132 }132 }
133 }133 }
M packages/agent/subagent_requirements.go +67 -5
@@ -4,9 +4,11 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "sort"
78 "strings"
89
910 "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/agent/tools"
1012 "github.com/bnema/zut/packages/core"
1113 "github.com/bnema/zut/packages/provider"
1214 )
@@ -33,13 +35,13 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
3335 inherited = contextText
3436 }
3537 if err := rt.waitRequiredWorkerReady(ctx); err != nil {
36- return false, "loading persisted subagents failed; resolve the supervisor reload error before continuing", inherited
38+ return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited
3739 }
3840 required, err := rt.supervisor.WaitActiveRequirements(ctx)
3941 if err != nil {
4042 return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited
4143 }
42- update := formatRequiredWorkerContext(required)
44+ update := rt.formatRequiredWorkerContext(required)
4345 if update == "" {
4446 return true, "", inherited
4547 }
@@ -63,9 +65,12 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
6365 unmet := rt.supervisor.UnmetRequirements()
6466 if len(unmet) != 0 {
6567 reason := formatRequiredWorkerBlock(unmet)
66- parent.QueueMessage(reason)
68+ if rt.noteRequiredBlock(unmet) {
69+ parent.QueueMessage(reason)
70+ }
6771 return false, reason, ""
6872 }
73+ rt.clearRequiredBlock()
6974 if previousBeforeAssistant != nil {
7075 allowed, reason, replacement := previousBeforeAssistant(text)
7176 if !allowed {
@@ -119,9 +124,48 @@ func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() {
119124 }
120125 }
121126
127+const requiredWorkerContextRuneBudget = 6000
128+
129+func (rt *subagentRuntime) noteRequiredBlock(unmet []subagents.AgentSnapshot) bool {
130+ ids := make([]string, 0, len(unmet))
131+ for _, snapshot := range unmet {
132+ ids = append(ids, fmt.Sprintf("%s:%s:%d:%s", snapshot.ID, snapshot.Requirement.State, snapshot.Requirement.TargetTurn, snapshot.Requirement.ErrorCode))
133+ }
134+ sort.Strings(ids)
135+ key := strings.Join(ids, "\n")
136+ rt.requiredContextMu.Lock()
137+ defer rt.requiredContextMu.Unlock()
138+ if key == rt.lastRequiredBlock {
139+ return false
140+ }
141+ rt.lastRequiredBlock = key
142+ return true
143+}
144+
145+func (rt *subagentRuntime) clearRequiredBlock() {
146+ rt.requiredContextMu.Lock()
147+ rt.lastRequiredBlock = ""
148+ rt.requiredContextMu.Unlock()
149+}
150+
151+func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
152+ rt.requiredContextMu.Lock()
153+ defer rt.requiredContextMu.Unlock()
154+ contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet)
155+ if rt.reportedRequiredUnmet == nil {
156+ rt.reportedRequiredUnmet = make(map[string]struct{})
157+ }
158+ for _, snapshot := range required {
159+ if snapshot.Requirement.Unmet() {
160+ rt.reportedRequiredUnmet[snapshot.ID] = struct{}{}
161+ }
162+ }
163+ return contextText
164+}
165+
122166 func requiredRecoveryTool(name string) bool {
123167 switch name {
124- case "subagent_resume", "subagent_status", "subagent_stop":
168+ case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName:
125169 return true
126170 default:
127171 return false
@@ -129,6 +173,10 @@ func requiredRecoveryTool(name string) bool {
129173 }
130174
131175 func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
176+ return formatRequiredWorkerContextWithReported(required, nil)
177+}
178+
179+func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string {
132180 var items []subagents.AgentSnapshot
133181 for _, snapshot := range required {
134182 requirement := snapshot.Requirement
@@ -148,6 +196,12 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
148196 sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n")
149197 for _, snapshot := range items {
150198 requirement := snapshot.Requirement
199+ if requirement.Unmet() {
200+ if _, reported := previouslyReported[snapshot.ID]; reported {
201+ fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID))
202+ continue
203+ }
204+ }
151205 fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task))
152206 if requirement.ErrorCode != "" {
153207 fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
@@ -167,7 +221,15 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
167221 } else {
168222 sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.")
169223 }
170- return sb.String()
224+ return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget)
225+}
226+
227+func truncateRequiredContext(text string, limit int) string {
228+ runes := []rune(text)
229+ if len(runes) <= limit {
230+ return text
231+ }
232+ return string(runes[:limit])
171233 }
172234
173235 func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string {
@@ -4,9 +4,11 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7+ "sort"
7 "strings"8 "strings"
8 9
9 "github.com/bnema/zut/packages/agent/subagents"10 "github.com/bnema/zut/packages/agent/subagents"
11+ "github.com/bnema/zut/packages/agent/tools"
10 "github.com/bnema/zut/packages/core"12 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
12 )14 )
@@ -33,13 +35,13 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
33 inherited = contextText35 inherited = contextText
34 }36 }
35 if err := rt.waitRequiredWorkerReady(ctx); err != nil {37 if err := rt.waitRequiredWorkerReady(ctx); err != nil {
36- return false, "loading persisted subagents failed; resolve the supervisor reload error before continuing", inherited38+ return false, fmt.Sprintf("loading persisted subagents failed: %v; resolve the supervisor reload error before continuing", err), inherited
37 }39 }
38 required, err := rt.supervisor.WaitActiveRequirements(ctx)40 required, err := rt.supervisor.WaitActiveRequirements(ctx)
39 if err != nil {41 if err != nil {
40 return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited42 return false, fmt.Sprintf("waiting for required subagents: %v", err), inherited
41 }43 }
42- update := formatRequiredWorkerContext(required)44+ update := rt.formatRequiredWorkerContext(required)
43 if update == "" {45 if update == "" {
44 return true, "", inherited46 return true, "", inherited
45 }47 }
@@ -63,9 +65,12 @@ func (rt *subagentRuntime) WireRequiredWorkerGate(parent *core.Agent) {
63 unmet := rt.supervisor.UnmetRequirements()65 unmet := rt.supervisor.UnmetRequirements()
64 if len(unmet) != 0 {66 if len(unmet) != 0 {
65 reason := formatRequiredWorkerBlock(unmet)67 reason := formatRequiredWorkerBlock(unmet)
66- parent.QueueMessage(reason)68+ if rt.noteRequiredBlock(unmet) {
69+ parent.QueueMessage(reason)
70+ }
67 return false, reason, ""71 return false, reason, ""
68 }72 }
73+ rt.clearRequiredBlock()
69 if previousBeforeAssistant != nil {74 if previousBeforeAssistant != nil {
70 allowed, reason, replacement := previousBeforeAssistant(text)75 allowed, reason, replacement := previousBeforeAssistant(text)
71 if !allowed {76 if !allowed {
@@ -119,9 +124,48 @@ func (rt *subagentRuntime) acknowledgeSatisfiedRequirements() {
119 }124 }
120 }125 }
121 126
127+const requiredWorkerContextRuneBudget = 6000
128+
129+func (rt *subagentRuntime) noteRequiredBlock(unmet []subagents.AgentSnapshot) bool {
130+ ids := make([]string, 0, len(unmet))
131+ for _, snapshot := range unmet {
132+ ids = append(ids, fmt.Sprintf("%s:%s:%d:%s", snapshot.ID, snapshot.Requirement.State, snapshot.Requirement.TargetTurn, snapshot.Requirement.ErrorCode))
133+ }
134+ sort.Strings(ids)
135+ key := strings.Join(ids, "\n")
136+ rt.requiredContextMu.Lock()
137+ defer rt.requiredContextMu.Unlock()
138+ if key == rt.lastRequiredBlock {
139+ return false
140+ }
141+ rt.lastRequiredBlock = key
142+ return true
143+}
144+
145+func (rt *subagentRuntime) clearRequiredBlock() {
146+ rt.requiredContextMu.Lock()
147+ rt.lastRequiredBlock = ""
148+ rt.requiredContextMu.Unlock()
149+}
150+
151+func (rt *subagentRuntime) formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
152+ rt.requiredContextMu.Lock()
153+ defer rt.requiredContextMu.Unlock()
154+ contextText := formatRequiredWorkerContextWithReported(required, rt.reportedRequiredUnmet)
155+ if rt.reportedRequiredUnmet == nil {
156+ rt.reportedRequiredUnmet = make(map[string]struct{})
157+ }
158+ for _, snapshot := range required {
159+ if snapshot.Requirement.Unmet() {
160+ rt.reportedRequiredUnmet[snapshot.ID] = struct{}{}
161+ }
162+ }
163+ return contextText
164+}
165+
122 func requiredRecoveryTool(name string) bool {166 func requiredRecoveryTool(name string) bool {
123 switch name {167 switch name {
124- case "subagent_resume", "subagent_status", "subagent_stop":168+ case tools.SubagentResumeToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName:
125 return true169 return true
126 default:170 default:
127 return false171 return false
@@ -129,6 +173,10 @@ func requiredRecoveryTool(name string) bool {
129 }173 }
130 174
131 func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {175 func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
176+ return formatRequiredWorkerContextWithReported(required, nil)
177+}
178+
179+func formatRequiredWorkerContextWithReported(required []subagents.AgentSnapshot, previouslyReported map[string]struct{}) string {
132 var items []subagents.AgentSnapshot180 var items []subagents.AgentSnapshot
133 for _, snapshot := range required {181 for _, snapshot := range required {
134 requirement := snapshot.Requirement182 requirement := snapshot.Requirement
@@ -148,6 +196,12 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
148 sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n")196 sb.WriteString("\nThe host waited for required delegated work before this model turn. These outcomes are durable and authoritative:\n")
149 for _, snapshot := range items {197 for _, snapshot := range items {
150 requirement := snapshot.Requirement198 requirement := snapshot.Requirement
199+ if requirement.Unmet() {
200+ if _, reported := previouslyReported[snapshot.ID]; reported {
201+ fmt.Fprintf(&sb, "\n- %s: %s (turn %d; previously reported; diagnostic: %s)\n", snapshot.ID, requirement.State, requirement.TargetTurn, subagents.ResultRef(snapshot.ID))
202+ continue
203+ }
204+ }
151 fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task))205 fmt.Fprintf(&sb, "\n- %s: %s (turn %d)\n task: %s\n", snapshot.ID, requirement.State, requirement.TargetTurn, strings.TrimSpace(snapshot.Task))
152 if requirement.ErrorCode != "" {206 if requirement.ErrorCode != "" {
153 fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID))207 fmt.Fprintf(&sb, " error_code: %s\n diagnostic: %s\n", requirement.ErrorCode, subagents.ResultRef(snapshot.ID))
@@ -167,7 +221,15 @@ func formatRequiredWorkerContext(required []subagents.AgentSnapshot) string {
167 } else {221 } else {
168 sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.")222 sb.WriteString("\nAll listed requirements are satisfied. Use their results before answering.")
169 }223 }
170- return sb.String()224+ return truncateRequiredContext(sb.String(), requiredWorkerContextRuneBudget)
225+}
226+
227+func truncateRequiredContext(text string, limit int) string {
228+ runes := []rune(text)
229+ if len(runes) <= limit {
230+ return text
231+ }
232+ return string(runes[:limit])
171 }233 }
172 234
173 func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string {235 func formatRequiredWorkerBlock(unmet []subagents.AgentSnapshot) string {
M packages/agent/subagent_runtime.go +9 -6
@@ -43,12 +43,15 @@ type subagentRuntime struct {
4343 beforeResumed func(*subagents.Agent, string, bool) func()
4444 onStopRequested func(*subagents.Agent)
4545
46- requiredReadyMu sync.RWMutex
47- requiredReady <-chan struct{}
48- requiredReadyErr func() error
49- settingsMu sync.RWMutex
50- closeMu sync.Mutex
51- closed bool
46+ requiredReadyMu sync.RWMutex
47+ requiredReady <-chan struct{}
48+ requiredReadyErr func() error
49+ requiredContextMu sync.Mutex
50+ reportedRequiredUnmet map[string]struct{}
51+ lastRequiredBlock string
52+ settingsMu sync.RWMutex
53+ closeMu sync.Mutex
54+ closed bool
5255 }
5356
5457 type subagentRuntimeConfiguration struct {
@@ -43,12 +43,15 @@ type subagentRuntime struct {
43 beforeResumed func(*subagents.Agent, string, bool) func()43 beforeResumed func(*subagents.Agent, string, bool) func()
44 onStopRequested func(*subagents.Agent)44 onStopRequested func(*subagents.Agent)
45 45
46- requiredReadyMu sync.RWMutex46+ requiredReadyMu sync.RWMutex
47- requiredReady <-chan struct{}47+ requiredReady <-chan struct{}
48- requiredReadyErr func() error48+ requiredReadyErr func() error
49- settingsMu sync.RWMutex49+ requiredContextMu sync.Mutex
50- closeMu sync.Mutex50+ reportedRequiredUnmet map[string]struct{}
51- closed bool51+ lastRequiredBlock string
52+ settingsMu sync.RWMutex
53+ closeMu sync.Mutex
54+ closed bool
52 }55 }
53 56
54 type subagentRuntimeConfiguration struct {57 type subagentRuntimeConfiguration struct {
M packages/agent/subagents/persist.go +29 -5
@@ -1355,20 +1355,44 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
13551355 // scheduler can start the worker and emit a fast turn_end. A durable pending
13561356 // prompt is consumed before the newly requested prompt, so tracker
13571357 // registrations must preserve that scheduler order.
1358+ var beforeCleanups []func()
13581359 if before != nil {
1360+ register := func(prompt string) {
1361+ if cleanup := before(a, prompt); cleanup != nil {
1362+ beforeCleanups = append(beforeCleanups, cleanup)
1363+ }
1364+ }
13591365 if hadPendingResumePrompt {
1360- _ = before(a, pendingResumePrompt)
1366+ register(pendingResumePrompt)
13611367 }
13621368 if resumePrompt != "" {
1363- // No rejecting operation remains before scheduling, so there is no path
1364- // that needs the hook's rollback after this point.
1365- _ = before(a, resumePrompt)
1369+ register(resumePrompt)
13661370 }
13671371 if !hadPendingResumePrompt && resumePrompt == "" {
1368- _ = before(a, "")
1372+ register("")
13691373 }
13701374 if err := f.persistAgent(a); err != nil {
13711375 a.recordPersistenceError(err)
1376+ for idx := len(beforeCleanups) - 1; idx >= 0; idx-- {
1377+ beforeCleanups[idx]()
1378+ }
1379+ f.mu.Lock()
1380+ f.agents[a.ID] = existing
1381+ for idx, queued := range f.queue {
1382+ if queued == a {
1383+ f.queue = append(f.queue[:idx], f.queue[idx+1:]...)
1384+ break
1385+ }
1386+ }
1387+ f.mu.Unlock()
1388+ if restoreErr := f.persistAgent(existing); restoreErr != nil {
1389+ existing.recordPersistenceError(restoreErr)
1390+ }
1391+ if a.cancel != nil {
1392+ a.cancel()
1393+ }
1394+ _ = lease.Close()
1395+ return nil, err
13721396 }
13731397 }
13741398 f.armQueueTimeout(a)
@@ -1355,20 +1355,44 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1355 // scheduler can start the worker and emit a fast turn_end. A durable pending1355 // scheduler can start the worker and emit a fast turn_end. A durable pending
1356 // prompt is consumed before the newly requested prompt, so tracker1356 // prompt is consumed before the newly requested prompt, so tracker
1357 // registrations must preserve that scheduler order.1357 // registrations must preserve that scheduler order.
1358+ var beforeCleanups []func()
1358 if before != nil {1359 if before != nil {
1360+ register := func(prompt string) {
1361+ if cleanup := before(a, prompt); cleanup != nil {
1362+ beforeCleanups = append(beforeCleanups, cleanup)
1363+ }
1364+ }
1359 if hadPendingResumePrompt {1365 if hadPendingResumePrompt {
1360- _ = before(a, pendingResumePrompt)1366+ register(pendingResumePrompt)
1361 }1367 }
1362 if resumePrompt != "" {1368 if resumePrompt != "" {
1363- // No rejecting operation remains before scheduling, so there is no path1369+ register(resumePrompt)
1364- // that needs the hook's rollback after this point.
1365- _ = before(a, resumePrompt)
1366 }1370 }
1367 if !hadPendingResumePrompt && resumePrompt == "" {1371 if !hadPendingResumePrompt && resumePrompt == "" {
1368- _ = before(a, "")1372+ register("")
1369 }1373 }
1370 if err := f.persistAgent(a); err != nil {1374 if err := f.persistAgent(a); err != nil {
1371 a.recordPersistenceError(err)1375 a.recordPersistenceError(err)
1376+ for idx := len(beforeCleanups) - 1; idx >= 0; idx-- {
1377+ beforeCleanups[idx]()
1378+ }
1379+ f.mu.Lock()
1380+ f.agents[a.ID] = existing
1381+ for idx, queued := range f.queue {
1382+ if queued == a {
1383+ f.queue = append(f.queue[:idx], f.queue[idx+1:]...)
1384+ break
1385+ }
1386+ }
1387+ f.mu.Unlock()
1388+ if restoreErr := f.persistAgent(existing); restoreErr != nil {
1389+ existing.recordPersistenceError(restoreErr)
1390+ }
1391+ if a.cancel != nil {
1392+ a.cancel()
1393+ }
1394+ _ = lease.Close()
1395+ return nil, err
1372 }1396 }
1373 }1397 }
1374 f.armQueueTimeout(a)1398 f.armQueueTimeout(a)
M packages/agent/subagents/requirement.go +20 -3
@@ -154,11 +154,28 @@ func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string,
154154 if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) {
155155 durable, err := readTurnResult(a.stateDirectory(""))
156156 if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID {
157+ if err == nil {
158+ err = fmt.Errorf("durable result identity does not match agent %s turn %s", result.AgentID, result.TurnID)
159+ }
160+ persistenceErr := fmt.Errorf("persist required result: %w", err)
161+ a.requirement.State = RequirementIndeterminate
162+ a.requirement.ErrorCode = "required_outcome_unobserved"
163+ a.requirement.Notified = false
164+ a.requirement.UpdatedAt = time.Now().UTC()
165+ resolved := a.requirement
166+ a.requirementPersisting = true
157167 a.lifecycleMu.Unlock()
158- if err != nil {
159- a.recordPersistenceError(fmt.Errorf("persist required result: %w", err))
168+ a.recordPersistenceError(persistenceErr)
169+ if err := a.persistFn(a); err != nil {
170+ a.recordPersistenceError(err)
160171 }
161- return current
172+ a.lifecycleMu.Lock()
173+ if a.requirement == resolved {
174+ a.requirementPersisting = false
175+ a.signalRequirementLocked()
176+ }
177+ a.lifecycleMu.Unlock()
178+ return resolved
162179 }
163180 }
164181 state, errorCode := classifyRequirementOutcome(result, errMsg)
@@ -154,11 +154,28 @@ func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string,
154 if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) {154 if result != nil && a.persistFn != nil && !unobservedRequirementResult(result) {
155 durable, err := readTurnResult(a.stateDirectory(""))155 durable, err := readTurnResult(a.stateDirectory(""))
156 if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID {156 if err != nil || durable.TurnID != result.TurnID || durable.AgentID != result.AgentID {
157+ if err == nil {
158+ err = fmt.Errorf("durable result identity does not match agent %s turn %s", result.AgentID, result.TurnID)
159+ }
160+ persistenceErr := fmt.Errorf("persist required result: %w", err)
161+ a.requirement.State = RequirementIndeterminate
162+ a.requirement.ErrorCode = "required_outcome_unobserved"
163+ a.requirement.Notified = false
164+ a.requirement.UpdatedAt = time.Now().UTC()
165+ resolved := a.requirement
166+ a.requirementPersisting = true
157 a.lifecycleMu.Unlock()167 a.lifecycleMu.Unlock()
158- if err != nil {168+ a.recordPersistenceError(persistenceErr)
159- a.recordPersistenceError(fmt.Errorf("persist required result: %w", err))169+ if err := a.persistFn(a); err != nil {
170+ a.recordPersistenceError(err)
160 }171 }
161- return current172+ a.lifecycleMu.Lock()
173+ if a.requirement == resolved {
174+ a.requirementPersisting = false
175+ a.signalRequirementLocked()
176+ }
177+ a.lifecycleMu.Unlock()
178+ return resolved
162 }179 }
163 }180 }
164 state, errorCode := classifyRequirementOutcome(result, errMsg)181 state, errorCode := classifyRequirementOutcome(result, errMsg)
M packages/agent/subagents/requirement_test.go +7 -6
@@ -28,16 +28,17 @@ func TestRequiredWaitWakesOnlyAfterOutcomePersistence(t *testing.T) {
2828 close(resolved)
2929 }()
3030 <-persisting
31+ a.lifecycleMu.Lock()
32+ visible := a.visibleRequirementLocked()
33+ a.lifecycleMu.Unlock()
34+ if visible.State != RequirementPending {
35+ t.Fatalf("visible requirement while persistence is blocked = %+v, want pending", visible)
36+ }
3137 waited := make(chan RequirementSnapshot, 1)
3238 go func() {
3339 got, _ := a.waitRequirement(context.Background())
3440 waited <- got
3541 }()
36- select {
37- case got := <-waited:
38- t.Fatalf("wait woke before durable persistence: %+v", got)
39- default:
40- }
4142 close(release)
4243 <-resolved
4344 if got := <-waited; got.State != RequirementSatisfied {
@@ -353,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
353354 t.Fatalf("required retry: %v", err)
354355 }
355356 resumed.Wait()
356- got, err := f.WaitRequired(ctx, a.ID)
357+ got, err := f.WaitRequired(context.Background(), a.ID)
357358 if err != nil {
358359 t.Fatalf("wait for retry: %v", err)
359360 }
@@ -28,16 +28,17 @@ func TestRequiredWaitWakesOnlyAfterOutcomePersistence(t *testing.T) {
28 close(resolved)28 close(resolved)
29 }()29 }()
30 <-persisting30 <-persisting
31+ a.lifecycleMu.Lock()
32+ visible := a.visibleRequirementLocked()
33+ a.lifecycleMu.Unlock()
34+ if visible.State != RequirementPending {
35+ t.Fatalf("visible requirement while persistence is blocked = %+v, want pending", visible)
36+ }
31 waited := make(chan RequirementSnapshot, 1)37 waited := make(chan RequirementSnapshot, 1)
32 go func() {38 go func() {
33 got, _ := a.waitRequirement(context.Background())39 got, _ := a.waitRequirement(context.Background())
34 waited <- got40 waited <- got
35 }()41 }()
36- select {
37- case got := <-waited:
38- t.Fatalf("wait woke before durable persistence: %+v", got)
39- default:
40- }
41 close(release)42 close(release)
42 <-resolved43 <-resolved
43 if got := <-waited; got.State != RequirementSatisfied {44 if got := <-waited; got.State != RequirementSatisfied {
@@ -353,7 +354,7 @@ func TestWaitRequiredIsEventDrivenAndRetryCanSatisfy(t *testing.T) {
353 t.Fatalf("required retry: %v", err)354 t.Fatalf("required retry: %v", err)
354 }355 }
355 resumed.Wait()356 resumed.Wait()
356- got, err := f.WaitRequired(ctx, a.ID)357+ got, err := f.WaitRequired(context.Background(), a.ID)
357 if err != nil {358 if err != nil {
358 t.Fatalf("wait for retry: %v", err)359 t.Fatalf("wait for retry: %v", err)
359 }360 }
M packages/agent/subagents/result.go +7 -4
@@ -573,11 +573,14 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
573573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
574574 a.setResult(result)
575575 stateDir := a.stateDirectory(f.cfg.Root)
576- if err := writeTurnResult(stateDir, result); err == nil {
577- a.lifecycleMu.Lock()
578- a.resultRef = ResultRef(a.ID)
579- a.lifecycleMu.Unlock()
576+ if err := writeTurnResult(stateDir, result); err != nil {
577+ a.recordPersistenceError(fmt.Errorf("write turn result: %w", err))
578+ a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true)
579+ return
580580 }
581+ a.lifecycleMu.Lock()
582+ a.resultRef = ResultRef(a.ID)
583+ a.lifecycleMu.Unlock()
581584 a.resolveRequirement(0, result, "", true)
582585 }
583586
@@ -573,11 +573,14 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)573 result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
574 a.setResult(result)574 a.setResult(result)
575 stateDir := a.stateDirectory(f.cfg.Root)575 stateDir := a.stateDirectory(f.cfg.Root)
576- if err := writeTurnResult(stateDir, result); err == nil {576+ if err := writeTurnResult(stateDir, result); err != nil {
577- a.lifecycleMu.Lock()577+ a.recordPersistenceError(fmt.Errorf("write turn result: %w", err))
578- a.resultRef = ResultRef(a.ID)578+ a.resolveRequirement(0, nil, fmt.Sprintf("write turn result: %v", err), true)
579- a.lifecycleMu.Unlock()579+ return
580 }580 }
581+ a.lifecycleMu.Lock()
582+ a.resultRef = ResultRef(a.ID)
583+ a.lifecycleMu.Unlock()
581 a.resolveRequirement(0, result, "", true)584 a.resolveRequirement(0, result, "", true)
582 }585 }
583 586
M packages/agent/tools/subagent_required_test.go +1 -0
@@ -165,6 +165,7 @@ func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T)
165165 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
166166 },
167167 })
168+ t.Cleanup(second.StopAll)
168169 if _, errs := second.Reload(); len(errs) != 0 {
169170 t.Fatalf("reload errors: %v", errs)
170171 }
@@ -165,6 +165,7 @@ func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T)
165 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })165 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
166 },166 },
167 })167 })
168+ t.Cleanup(second.StopAll)
168 if _, errs := second.Reload(); len(errs) != 0 {169 if _, errs := second.Reload(); len(errs) != 0 {
169 t.Fatalf("reload errors: %v", errs)170 t.Fatalf("reload errors: %v", errs)
170 }171 }
M packages/agent/tools/subagent_resume.go +7 -7
@@ -57,7 +57,7 @@ const subagentResumeSchema = `{
5757 "required": ["agent_id", "prompt"]
5858 }`
5959
60-func (t *SubagentResumeTool) Name() string { return "subagent_resume" }
60+func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
6161
6262 func (t *SubagentResumeTool) Description() string {
6363 return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation."
@@ -67,7 +67,7 @@ func (t *SubagentResumeTool) Schema() json.RawMessage {
6767 return json.RawMessage(subagentResumeSchema)
6868 }
6969
70-func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
70+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
7171 if ctx != nil {
7272 select {
7373 case <-ctx.Done():
@@ -102,11 +102,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
102102 return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID))
103103 }
104104 required := args.Required || snapshot.Requirement.Unmet()
105- before := func(agent *subagents.Agent, prompt string) func() {
106- if t.BeforeResumed == nil {
107- return nil
105+ var before func(*subagents.Agent, string) func()
106+ if t.BeforeResumed != nil {
107+ before = func(agent *subagents.Agent, prompt string) func() {
108+ return t.BeforeResumed(agent, prompt, required)
108109 }
109- return t.BeforeResumed(agent, prompt, required)
110110 }
111111 var resumed *subagents.Agent
112112 var err error
@@ -122,7 +122,7 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
122122 t.OnResumed(resumed, args.Prompt, required)
123123 }
124124 if required {
125- return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt)
125+ return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress)
126126 }
127127
128128 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
@@ -57,7 +57,7 @@ const subagentResumeSchema = `{
57 "required": ["agent_id", "prompt"]57 "required": ["agent_id", "prompt"]
58 }`58 }`
59 59
60-func (t *SubagentResumeTool) Name() string { return "subagent_resume" }60+func (t *SubagentResumeTool) Name() string { return SubagentResumeToolName }
61 61
62 func (t *SubagentResumeTool) Description() string {62 func (t *SubagentResumeTool) Description() string {
63 return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation."63 return "Continue a sub-agent with a fresh turn budget and retained session. Set required=true for a mandatory follow-up; retries of failed, timed-out, or canceled required work stay required and wait event-driven for the new outcome. Indeterminate work requires explicit user reconciliation."
@@ -67,7 +67,7 @@ func (t *SubagentResumeTool) Schema() json.RawMessage {
67 return json.RawMessage(subagentResumeSchema)67 return json.RawMessage(subagentResumeSchema)
68 }68 }
69 69
70-func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {70+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
71 if ctx != nil {71 if ctx != nil {
72 select {72 select {
73 case <-ctx.Done():73 case <-ctx.Done():
@@ -102,11 +102,11 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
102 return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID))102 return protocolToolError(fmt.Sprintf("%s: required outcome for %s is indeterminate after host restart; the user must inspect the durable result or external side effect, then explicitly use /subagents resume-session, /subagents restart-task, or /subagents remove", prefix, snapshot.ID))
103 }103 }
104 required := args.Required || snapshot.Requirement.Unmet()104 required := args.Required || snapshot.Requirement.Unmet()
105- before := func(agent *subagents.Agent, prompt string) func() {105+ var before func(*subagents.Agent, string) func()
106- if t.BeforeResumed == nil {106+ if t.BeforeResumed != nil {
107- return nil107+ before = func(agent *subagents.Agent, prompt string) func() {
108+ return t.BeforeResumed(agent, prompt, required)
108 }109 }
109- return t.BeforeResumed(agent, prompt, required)
110 }110 }
111 var resumed *subagents.Agent111 var resumed *subagents.Agent
112 var err error112 var err error
@@ -122,7 +122,7 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
122 t.OnResumed(resumed, args.Prompt, required)122 t.OnResumed(resumed, args.Prompt, required)
123 }123 }
124 if required {124 if required {
125- return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt)125+ return waitRequiredSubagent(ctx, prefix, t.Supervisor, resumed, args.Prompt, progress)
126 }126 }
127 127
128 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)128 snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
M packages/agent/tools/subagent_spawn.go +13 -3
@@ -20,6 +20,13 @@ import (
2020 // Available when the host's launch-time tool policy permits delegation. The
2121 // primary-agent prompt decides whether delegation is proactive or only on an
2222 // explicit user request.
23+const (
24+ SubagentSpawnToolName = "subagent_spawn"
25+ SubagentStatusToolName = "subagent_status"
26+ SubagentStopToolName = "subagent_stop"
27+ SubagentResumeToolName = "subagent_resume"
28+)
29+
2330 type SubagentSpawnTool struct {
2431 // Supervisor is the supervisor used to spawn agents. Nil means subagents
2532 // are unavailable in this mode and the tool always errors.
@@ -117,7 +124,7 @@ const subagentSpawnSchemaTemplate = `{
117124 "required": ["task"]
118125 }`
119126
120-func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
127+func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
121128 func (t *SubagentSpawnTool) Description() string {
122129 return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
123130 }
@@ -266,7 +273,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
266273 t.OnSpawned(agent, task, a.Required)
267274 }
268275 if a.Required {
269- return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task)
276+ return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
270277 }
271278
272279 var sb strings.Builder
@@ -317,7 +324,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
317324 }, nil
318325 }
319326
320-func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string) (core.ToolResult, error) {
327+func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) {
328+ if progress != nil {
329+ progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID))
330+ }
321331 requirement, err := supervisor.WaitRequired(ctx, agent.ID)
322332 if err != nil {
323333 return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err)
@@ -20,6 +20,13 @@ import (
20 // Available when the host's launch-time tool policy permits delegation. The20 // Available when the host's launch-time tool policy permits delegation. The
21 // primary-agent prompt decides whether delegation is proactive or only on an21 // primary-agent prompt decides whether delegation is proactive or only on an
22 // explicit user request.22 // explicit user request.
23+const (
24+ SubagentSpawnToolName = "subagent_spawn"
25+ SubagentStatusToolName = "subagent_status"
26+ SubagentStopToolName = "subagent_stop"
27+ SubagentResumeToolName = "subagent_resume"
28+)
29+
23 type SubagentSpawnTool struct {30 type SubagentSpawnTool struct {
24 // Supervisor is the supervisor used to spawn agents. Nil means subagents31 // Supervisor is the supervisor used to spawn agents. Nil means subagents
25 // are unavailable in this mode and the tool always errors.32 // are unavailable in this mode and the tool always errors.
@@ -117,7 +124,7 @@ const subagentSpawnSchemaTemplate = `{
117 "required": ["task"]124 "required": ["task"]
118 }`125 }`
119 126
120-func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }127+func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
121 func (t *SubagentSpawnTool) Description() string {128 func (t *SubagentSpawnTool) Description() string {
122 return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."129 return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
123 }130 }
@@ -266,7 +273,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
266 t.OnSpawned(agent, task, a.Required)273 t.OnSpawned(agent, task, a.Required)
267 }274 }
268 if a.Required {275 if a.Required {
269- return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task)276+ return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
270 }277 }
271 278
272 var sb strings.Builder279 var sb strings.Builder
@@ -317,7 +324,10 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
317 }, nil324 }, nil
318 }325 }
319 326
320-func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string) (core.ToolResult, error) {327+func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) {
328+ if progress != nil {
329+ progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID))
330+ }
321 requirement, err := supervisor.WaitRequired(ctx, agent.ID)331 requirement, err := supervisor.WaitRequired(ctx, agent.ID)
322 if err != nil {332 if err != nil {
323 return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err)333 return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err)
M packages/agent/tools/subagent_status.go +1 -1
@@ -71,7 +71,7 @@ const subagentStatusSchema = `{
7171 }
7272 }`
7373
74-func (t *SubagentStatusTool) Name() string { return "subagent_status" }
74+func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
7575
7676 func (t *SubagentStatusTool) Description() string {
7777 return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
@@ -71,7 +71,7 @@ const subagentStatusSchema = `{
71 }71 }
72 }`72 }`
73 73
74-func (t *SubagentStatusTool) Name() string { return "subagent_status" }74+func (t *SubagentStatusTool) Name() string { return SubagentStatusToolName }
75 75
76 func (t *SubagentStatusTool) Description() string {76 func (t *SubagentStatusTool) Description() string {
77 return "Query live status for one background sub-agent or list all visible workers without waiting for completion."77 return "Query live status for one background sub-agent or list all visible workers without waiting for completion."
M packages/agent/tools/subagent_stop.go +1 -1
@@ -44,7 +44,7 @@ const subagentStopSchema = `{
4444 "required": ["agent_id"]
4545 }`
4646
47-func (t *SubagentStopTool) Name() string { return "subagent_stop" }
47+func (t *SubagentStopTool) Name() string { return SubagentStopToolName }
4848
4949 func (t *SubagentStopTool) Description() string {
5050 return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."
@@ -44,7 +44,7 @@ const subagentStopSchema = `{
44 "required": ["agent_id"]44 "required": ["agent_id"]
45 }`45 }`
46 46
47-func (t *SubagentStopTool) Name() string { return "subagent_stop" }47+func (t *SubagentStopTool) Name() string { return SubagentStopToolName }
48 48
49 func (t *SubagentStopTool) Description() string {49 func (t *SubagentStopTool) Description() string {
50 return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."50 return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close