detached 4a46e7b

fix(subagents): attribute shutdown cancellations

commit
4a46e7b7011a3576dcb67934f9dfec83639bb868
author
brice · · 2026-08-08 16:20 UTC
parent
1adbe2f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +156 -40
M docs/subagents.md +1 -1
@@ -115,7 +115,7 @@ During an active delegated turn, interactive mode shows compact rows beside the
115115
116116 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
117117
118-A completed worker emits a `turn.result` event and writes `result.json`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, inspect the full session through the stable references:
118+A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, inspect the full session through the stable references:
119119
120120 If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
121121
@@ -115,7 +115,7 @@ During an active delegated turn, interactive mode shows compact rows beside the
115 115
116 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.116 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
117 117
118-A completed worker emits a `turn.result` event and writes `result.json`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, inspect the full session through the stable references:118+A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. The host then injects `[auto-subagents update]`; that completion update, rather than polling or inspecting state, is the signal for the primary to continue. The inline output is bounded; after that update, inspect the full session through the stable references:
119 119
120 If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.120 If a provider rejects a child request because its payload or context window is too large, the worker compacts its persisted transcript and continues the same request once. If compaction or that retry cannot fit, the result uses error code `context_limit` and directs the caller to narrow the task or reduce gathered context; it does not include the provider's raw request error.
121 121
M packages/agent/modes/auto_subagents_test.go +5 -2
@@ -158,8 +158,11 @@ func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
158158 if !strings.Contains(update, "task: investigate the stuck worker") {
159159 t.Fatalf("stop update missing task: %q", update)
160160 }
161- if !strings.Contains(update, "status: killed") {
162- t.Fatalf("stop update missing killed status: %q", update)
161+ if !strings.Contains(update, "status: cancelled") {
162+ t.Fatalf("stop update missing cancellation status: %q", update)
163+ }
164+ if !strings.Contains(update, "subagent stopped by request") || strings.Contains(update, "context canceled") {
165+ t.Fatalf("stop update missing attributed cancellation: %q", update)
163166 }
164167 }
165168
@@ -158,8 +158,11 @@ func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
158 if !strings.Contains(update, "task: investigate the stuck worker") {158 if !strings.Contains(update, "task: investigate the stuck worker") {
159 t.Fatalf("stop update missing task: %q", update)159 t.Fatalf("stop update missing task: %q", update)
160 }160 }
161- if !strings.Contains(update, "status: killed") {161+ if !strings.Contains(update, "status: cancelled") {
162- t.Fatalf("stop update missing killed status: %q", update)162+ t.Fatalf("stop update missing cancellation status: %q", update)
163+ }
164+ if !strings.Contains(update, "subagent stopped by request") || strings.Contains(update, "context canceled") {
165+ t.Fatalf("stop update missing attributed cancellation: %q", update)
163 }166 }
164 }167 }
165 168
M packages/agent/modes/interactive.go +10 -1
@@ -8821,6 +8821,13 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
88218821 if status == "" {
88228822 status = string(snap.Status)
88238823 }
8824+ resultErr := ""
8825+ if snap.Result != nil && snap.Result.Status == subagents.ResultCanceled && snap.Result.ShutdownOrigin != "" {
8826+ status = "cancelled"
8827+ if snap.Result.Error != nil {
8828+ resultErr = snap.Result.Error.Message
8829+ }
8830+ }
88248831 task := e.task
88258832 if task == "" || !e.followUp {
88268833 task = snap.Task
@@ -8830,7 +8837,9 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
88308837 }
88318838 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
88328839 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
8833- if snap.Err != "" {
8840+ if resultErr != "" {
8841+ fmt.Fprintf(&sb, " cancellation: %s\n", truncateForSummary(resultErr, 240))
8842+ } else if snap.Err != "" {
88348843 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
88358844 } else if e.err != "" {
88368845 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
@@ -8821,6 +8821,13 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
8821 if status == "" {8821 if status == "" {
8822 status = string(snap.Status)8822 status = string(snap.Status)
8823 }8823 }
8824+ resultErr := ""
8825+ if snap.Result != nil && snap.Result.Status == subagents.ResultCanceled && snap.Result.ShutdownOrigin != "" {
8826+ status = "cancelled"
8827+ if snap.Result.Error != nil {
8828+ resultErr = snap.Result.Error.Message
8829+ }
8830+ }
8824 task := e.task8831 task := e.task
8825 if task == "" || !e.followUp {8832 if task == "" || !e.followUp {
8826 task = snap.Task8833 task = snap.Task
@@ -8830,7 +8837,9 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
8830 }8837 }
8831 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)8838 fmt.Fprintf(&sb, "%d. agent %s - status: %s\n", idx+1, snap.ID, status)
8832 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))8839 fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
8833- if snap.Err != "" {8840+ if resultErr != "" {
8841+ fmt.Fprintf(&sb, " cancellation: %s\n", truncateForSummary(resultErr, 240))
8842+ } else if snap.Err != "" {
8834 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))8843 fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
8835 } else if e.err != "" {8844 } else if e.err != "" {
8836 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))8845 fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
M packages/agent/subagent_worker.go +31 -9
@@ -354,21 +354,33 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
354354 resultStatus = "failed"
355355 }
356356 }
357+ mu.Lock()
358+ resultShutdownOrigin := shutdownOrigin.Sanitized()
359+ mu.Unlock()
360+ switch {
361+ case errors.Is(err, context.DeadlineExceeded):
362+ resultShutdownOrigin = subagents.ShutdownOriginDeadline
363+ case !errors.Is(err, context.Canceled):
364+ resultShutdownOrigin = ""
365+ }
357366 resultPayload := map[string]any{
358367 "status": resultStatus,
359368 "turn_id": turnID,
360369 "summary": boundedResultSummary(output),
361370 "output": boundedResultOutput(output, maxOutputBytes, maxOutputLines),
362- "error": resultErrorPayload(err),
371+ "error": resultErrorPayload(err, resultShutdownOrigin),
372+ }
373+ if resultShutdownOrigin != "" {
374+ resultPayload["shutdown_origin"] = resultShutdownOrigin
363375 }
364376 em.emit("turn.result", resultPayload)
365377 if err != nil {
366- em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err)})
378+ em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err, resultShutdownOrigin)})
367379 }
368380 em.emit("turn_end", map[string]any{
369381 "step": step,
370382 "turn_id": turnID,
371- "error": turnErrorMessage(err),
383+ "error": turnErrorMessage(err, resultShutdownOrigin),
372384 })
373385
374386 cancel()
@@ -413,11 +425,11 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
413425 }
414426
415427 requestShutdown := func(origin subagents.ShutdownOrigin) {
416- mu.Lock()
417- closing = true
418- mu.Unlock()
419428 shutdownOnce.Do(func() {
429+ mu.Lock()
430+ closing = true
420431 shutdownOrigin = origin.Sanitized()
432+ mu.Unlock()
421433 close(shutdown)
422434 })
423435 }
@@ -725,23 +737,33 @@ func truncateOutputUTF8(value string, maxBytes int) string {
725737 return string(data)
726738 }
727739
728-func resultErrorPayload(err error) map[string]any {
740+func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[string]any {
729741 if err == nil {
730742 return nil
731743 }
732744 if provider.IsContextOverflowError(err) {
733745 return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}
734746 }
735- if errors.Is(err, context.DeadlineExceeded) {
747+ if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {
736748 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}
737749 }
750+ if errors.Is(err, context.Canceled) {
751+ if resultErr := subagents.ShutdownResultError(shutdownOrigin); resultErr != nil {
752+ return map[string]any{"code": resultErr.Code, "message": resultErr.Message}
753+ }
754+ }
738755 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
739756 }
740757
741-func turnErrorMessage(err error) string {
758+func turnErrorMessage(err error, shutdownOrigin subagents.ShutdownOrigin) string {
742759 if provider.IsContextOverflowError(err) {
743760 return subagentContextLimitMessage
744761 }
762+ if err != nil {
763+ if resultErr := subagents.ShutdownResultError(shutdownOrigin); resultErr != nil {
764+ return resultErr.Message
765+ }
766+ }
745767 return errString(err)
746768 }
747769
@@ -354,21 +354,33 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
354 resultStatus = "failed"354 resultStatus = "failed"
355 }355 }
356 }356 }
357+ mu.Lock()
358+ resultShutdownOrigin := shutdownOrigin.Sanitized()
359+ mu.Unlock()
360+ switch {
361+ case errors.Is(err, context.DeadlineExceeded):
362+ resultShutdownOrigin = subagents.ShutdownOriginDeadline
363+ case !errors.Is(err, context.Canceled):
364+ resultShutdownOrigin = ""
365+ }
357 resultPayload := map[string]any{366 resultPayload := map[string]any{
358 "status": resultStatus,367 "status": resultStatus,
359 "turn_id": turnID,368 "turn_id": turnID,
360 "summary": boundedResultSummary(output),369 "summary": boundedResultSummary(output),
361 "output": boundedResultOutput(output, maxOutputBytes, maxOutputLines),370 "output": boundedResultOutput(output, maxOutputBytes, maxOutputLines),
362- "error": resultErrorPayload(err),371+ "error": resultErrorPayload(err, resultShutdownOrigin),
372+ }
373+ if resultShutdownOrigin != "" {
374+ resultPayload["shutdown_origin"] = resultShutdownOrigin
363 }375 }
364 em.emit("turn.result", resultPayload)376 em.emit("turn.result", resultPayload)
365 if err != nil {377 if err != nil {
366- em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err)})378+ em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err, resultShutdownOrigin)})
367 }379 }
368 em.emit("turn_end", map[string]any{380 em.emit("turn_end", map[string]any{
369 "step": step,381 "step": step,
370 "turn_id": turnID,382 "turn_id": turnID,
371- "error": turnErrorMessage(err),383+ "error": turnErrorMessage(err, resultShutdownOrigin),
372 })384 })
373 385
374 cancel()386 cancel()
@@ -413,11 +425,11 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
413 }425 }
414 426
415 requestShutdown := func(origin subagents.ShutdownOrigin) {427 requestShutdown := func(origin subagents.ShutdownOrigin) {
416- mu.Lock()
417- closing = true
418- mu.Unlock()
419 shutdownOnce.Do(func() {428 shutdownOnce.Do(func() {
429+ mu.Lock()
430+ closing = true
420 shutdownOrigin = origin.Sanitized()431 shutdownOrigin = origin.Sanitized()
432+ mu.Unlock()
421 close(shutdown)433 close(shutdown)
422 })434 })
423 }435 }
@@ -725,23 +737,33 @@ func truncateOutputUTF8(value string, maxBytes int) string {
725 return string(data)737 return string(data)
726 }738 }
727 739
728-func resultErrorPayload(err error) map[string]any {740+func resultErrorPayload(err error, shutdownOrigin subagents.ShutdownOrigin) map[string]any {
729 if err == nil {741 if err == nil {
730 return nil742 return nil
731 }743 }
732 if provider.IsContextOverflowError(err) {744 if provider.IsContextOverflowError(err) {
733 return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}745 return map[string]any{"code": "context_limit", "message": subagentContextLimitMessage}
734 }746 }
735- if errors.Is(err, context.DeadlineExceeded) {747+ if errors.Is(err, context.DeadlineExceeded) || shutdownOrigin == subagents.ShutdownOriginDeadline {
736 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}748 return map[string]any{"code": "deadline_exceeded", "message": "subagent turn deadline exceeded; partial output is preserved in the result and history"}
737 }749 }
750+ if errors.Is(err, context.Canceled) {
751+ if resultErr := subagents.ShutdownResultError(shutdownOrigin); resultErr != nil {
752+ return map[string]any{"code": resultErr.Code, "message": resultErr.Message}
753+ }
754+ }
738 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}755 return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
739 }756 }
740 757
741-func turnErrorMessage(err error) string {758+func turnErrorMessage(err error, shutdownOrigin subagents.ShutdownOrigin) string {
742 if provider.IsContextOverflowError(err) {759 if provider.IsContextOverflowError(err) {
743 return subagentContextLimitMessage760 return subagentContextLimitMessage
744 }761 }
762+ if err != nil {
763+ if resultErr := subagents.ShutdownResultError(shutdownOrigin); resultErr != nil {
764+ return resultErr.Message
765+ }
766+ }
745 return errString(err)767 return errString(err)
746 }768 }
747 769
M packages/agent/subagent_worker_test.go +10 -3
@@ -53,12 +53,19 @@ func TestSubagentTurnContextResetsDeadlineAndHonorsShutdown(t *testing.T) {
5353 }
5454
5555 func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {
56- payload := resultErrorPayload(context.DeadlineExceeded)
56+ payload := resultErrorPayload(context.DeadlineExceeded, "")
5757 if payload["code"] != "deadline_exceeded" {
5858 t.Fatalf("deadline error code = %v, want deadline_exceeded", payload["code"])
5959 }
6060 }
6161
62+func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {
63+ payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)
64+ if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {
65+ t.Fatalf("session shutdown error = %#v", payload)
66+ }
67+}
68+
6269 func TestShutdownExitPayloadIncludesOnlyKnownOrigin(t *testing.T) {
6370 payload := shutdownExitPayload(subagents.ShutdownOriginSession)
6471 if payload["reason"] != "shutdown" || payload["origin"] != subagents.ShutdownOriginSession {
@@ -257,7 +264,7 @@ func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
257264 if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
258265 t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
259266 }
260- payload := resultErrorPayload(err)
267+ payload := resultErrorPayload(err, "")
261268 if payload["code"] != "turn_failed" {
262269 t.Fatalf("result error payload = %#v, want turn_failed", payload)
263270 }
@@ -736,7 +743,7 @@ func workerRequestUserTextCount(req provider.Request, want string) int {
736743 }
737744
738745 func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {
739- payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"))
746+ payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"), "")
740747 if payload["code"] != "context_limit" {
741748 t.Fatalf("error code = %q, want context_limit", payload["code"])
742749 }
@@ -53,12 +53,19 @@ func TestSubagentTurnContextResetsDeadlineAndHonorsShutdown(t *testing.T) {
53 }53 }
54 54
55 func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {55 func TestResultErrorPayloadClassifiesTurnDeadline(t *testing.T) {
56- payload := resultErrorPayload(context.DeadlineExceeded)56+ payload := resultErrorPayload(context.DeadlineExceeded, "")
57 if payload["code"] != "deadline_exceeded" {57 if payload["code"] != "deadline_exceeded" {
58 t.Fatalf("deadline error code = %v, want deadline_exceeded", payload["code"])58 t.Fatalf("deadline error code = %v, want deadline_exceeded", payload["code"])
59 }59 }
60 }60 }
61 61
62+func TestResultErrorPayloadAttributesShutdownCancellation(t *testing.T) {
63+ payload := resultErrorPayload(context.Canceled, subagents.ShutdownOriginSession)
64+ if payload["code"] != "shutdown" || payload["message"] != "subagent stopped during session shutdown" {
65+ t.Fatalf("session shutdown error = %#v", payload)
66+ }
67+}
68+
62 func TestShutdownExitPayloadIncludesOnlyKnownOrigin(t *testing.T) {69 func TestShutdownExitPayloadIncludesOnlyKnownOrigin(t *testing.T) {
63 payload := shutdownExitPayload(subagents.ShutdownOriginSession)70 payload := shutdownExitPayload(subagents.ShutdownOriginSession)
64 if payload["reason"] != "shutdown" || payload["origin"] != subagents.ShutdownOriginSession {71 if payload["reason"] != "shutdown" || payload["origin"] != subagents.ShutdownOriginSession {
@@ -257,7 +264,7 @@ func TestPromptWithContextRecoveryPropagatesCompactionFailure(t *testing.T) {
257 if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {264 if err == nil || !strings.Contains(err.Error(), "compact transcript after initial overflow") {
258 t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)265 t.Fatalf("promptWithContextRecovery error = %v, want compaction failure", err)
259 }266 }
260- payload := resultErrorPayload(err)267+ payload := resultErrorPayload(err, "")
261 if payload["code"] != "turn_failed" {268 if payload["code"] != "turn_failed" {
262 t.Fatalf("result error payload = %#v, want turn_failed", payload)269 t.Fatalf("result error payload = %#v, want turn_failed", payload)
263 }270 }
@@ -736,7 +743,7 @@ func workerRequestUserTextCount(req provider.Request, want string) int {
736 }743 }
737 744
738 func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {745 func TestResultErrorPayloadSanitizesContextOverflow(t *testing.T) {
739- payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"))746+ payload := resultErrorPayload(errors.New("provider error: input exceeds the context window; echoed task text"), "")
740 if payload["code"] != "context_limit" {747 if payload["code"] != "context_limit" {
741 t.Fatalf("error code = %q, want context_limit", payload["code"])748 t.Fatalf("error code = %q, want context_limit", payload["code"])
742 }749 }
M packages/agent/subagents/agent.go +14 -0
@@ -163,6 +163,7 @@ type Agent struct {
163163 updatedAt time.Time
164164 lastActivity time.Time
165165 result *TurnResult
166+ shutdownOrigin ShutdownOrigin
166167 resultRef string
167168 patchRef string
168169 changedFiles []string
@@ -410,6 +411,19 @@ func (a *Agent) setResult(result *TurnResult) {
410411 a.lifecycleMu.Unlock()
411412 }
412413
414+func (a *Agent) setShutdownOrigin(origin ShutdownOrigin) {
415+ a.lifecycleMu.Lock()
416+ a.shutdownOrigin = origin.Sanitized()
417+ a.updatedAt = time.Now()
418+ a.lifecycleMu.Unlock()
419+}
420+
421+func (a *Agent) shutdownOriginValue() ShutdownOrigin {
422+ a.lifecycleMu.Lock()
423+ defer a.lifecycleMu.Unlock()
424+ return a.shutdownOrigin
425+}
426+
413427 // Transcript returns a copy of the running transcript. Detached agents
414428 // hydrate their durable event history the first time it is requested.
415429 func (a *Agent) Transcript() []string {
@@ -163,6 +163,7 @@ type Agent struct {
163 updatedAt time.Time163 updatedAt time.Time
164 lastActivity time.Time164 lastActivity time.Time
165 result *TurnResult165 result *TurnResult
166+ shutdownOrigin ShutdownOrigin
166 resultRef string167 resultRef string
167 patchRef string168 patchRef string
168 changedFiles []string169 changedFiles []string
@@ -410,6 +411,19 @@ func (a *Agent) setResult(result *TurnResult) {
410 a.lifecycleMu.Unlock()411 a.lifecycleMu.Unlock()
411 }412 }
412 413
414+func (a *Agent) setShutdownOrigin(origin ShutdownOrigin) {
415+ a.lifecycleMu.Lock()
416+ a.shutdownOrigin = origin.Sanitized()
417+ a.updatedAt = time.Now()
418+ a.lifecycleMu.Unlock()
419+}
420+
421+func (a *Agent) shutdownOriginValue() ShutdownOrigin {
422+ a.lifecycleMu.Lock()
423+ defer a.lifecycleMu.Unlock()
424+ return a.shutdownOrigin
425+}
426+
413 // Transcript returns a copy of the running transcript. Detached agents427 // Transcript returns a copy of the running transcript. Detached agents
414 // hydrate their durable event history the first time it is requested.428 // hydrate their durable event history the first time it is requested.
415 func (a *Agent) Transcript() []string {429 func (a *Agent) Transcript() []string {
M packages/agent/subagents/result.go +48 -12
@@ -37,22 +37,40 @@ type ResultError struct {
3737 Message string `json:"message,omitempty"`
3838 }
3939
40+// ShutdownResultError returns the stable, user-facing error associated with
41+// an intentional worker shutdown. Unknown origins are not attributed.
42+func ShutdownResultError(origin ShutdownOrigin) *ResultError {
43+ switch origin.Sanitized() {
44+ case ShutdownOriginTargeted:
45+ return &ResultError{Code: "shutdown", Message: "subagent stopped by request"}
46+ case ShutdownOriginSession:
47+ return &ResultError{Code: "shutdown", Message: "subagent stopped during session shutdown"}
48+ case ShutdownOriginDeadline:
49+ return &ResultError{Code: "deadline_exceeded", Message: "subagent turn deadline exceeded; partial output is preserved in the result and history"}
50+ case ShutdownOriginProcess:
51+ return &ResultError{Code: "shutdown", Message: "subagent stopped during process shutdown"}
52+ default:
53+ return nil
54+ }
55+}
56+
4057 // TurnResult is the protocol-level result for a delegated turn. Output is a
4158 // bounded inline preview; the full session/history remains available through
4259 // HistoryRef and the complete JSON result through ResultRef.
4360 type TurnResult struct {
44- Version int `json:"version"`
45- AgentID string `json:"agent_id,omitempty"`
46- TurnID string `json:"turn_id,omitempty"`
47- Status TurnStatus `json:"status"`
48- Summary string `json:"summary,omitempty"`
49- Output string `json:"output,omitempty"`
50- Structured json.RawMessage `json:"structured,omitempty"`
51- Artifacts []ArtifactRef `json:"artifacts,omitempty"`
52- ChangedFiles []string `json:"changed_files,omitempty"`
53- Usage map[string]any `json:"usage,omitempty"`
54- Error *ResultError `json:"error,omitempty"`
55- CreatedAt time.Time `json:"created_at"`
61+ Version int `json:"version"`
62+ AgentID string `json:"agent_id,omitempty"`
63+ TurnID string `json:"turn_id,omitempty"`
64+ Status TurnStatus `json:"status"`
65+ Summary string `json:"summary,omitempty"`
66+ Output string `json:"output,omitempty"`
67+ Structured json.RawMessage `json:"structured,omitempty"`
68+ Artifacts []ArtifactRef `json:"artifacts,omitempty"`
69+ ChangedFiles []string `json:"changed_files,omitempty"`
70+ Usage map[string]any `json:"usage,omitempty"`
71+ Error *ResultError `json:"error,omitempty"`
72+ ShutdownOrigin ShutdownOrigin `json:"shutdown_origin,omitempty"`
73+ CreatedAt time.Time `json:"created_at"`
5674 }
5775
5876 func (r *TurnResult) Validate(maxBytes, maxLines int) error {
@@ -71,6 +89,9 @@ func (r *TurnResult) Validate(maxBytes, maxLines int) error {
7189 if r.AgentID == "" {
7290 return errors.New("subagents: result missing agent id")
7391 }
92+ if r.ShutdownOrigin != "" && r.ShutdownOrigin.Sanitized() == "" {
93+ return errors.New("subagents: result has invalid shutdown origin")
94+ }
7495 if r.TurnID == "" {
7596 return errors.New("subagents: result missing turn id")
7697 }
@@ -443,6 +464,10 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
443464 return
444465 }
445466 attempt := a.AttemptValue()
467+ shutdownOrigin := a.shutdownOriginValue()
468+ if errors.Is(runErr, context.DeadlineExceeded) {
469+ shutdownOrigin = ShutdownOriginDeadline
470+ }
446471 a.mu.Lock()
447472 partialOutput := a.lastAssistant
448473 a.mu.Unlock()
@@ -515,6 +540,15 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
515540 if result.CreatedAt.IsZero() {
516541 result.CreatedAt = time.Now().UTC()
517542 }
543+ if shutdownOrigin != "" {
544+ result.ShutdownOrigin = shutdownOrigin
545+ result.Error = ShutdownResultError(shutdownOrigin)
546+ if shutdownOrigin == ShutdownOriginDeadline {
547+ result.Status = ResultFailed
548+ } else {
549+ result.Status = ResultCanceled
550+ }
551+ }
518552 if runErr != nil {
519553 if status == StatusKilled || errors.Is(runErr, context.Canceled) {
520554 result.Status = ResultCanceled
@@ -522,6 +556,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
522556 result.Status = ResultFailed
523557 }
524558 if errors.Is(runErr, context.DeadlineExceeded) {
559+ result.Status = ResultFailed
525560 // The parent deadline is authoritative for terminal categorization, so
526561 // it intentionally overrides any child result error, including
527562 // context_limit. The worker was first asked to stop cleanly, and any
@@ -534,6 +569,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
534569 Code: "deadline_exceeded",
535570 Message: message,
536571 }
572+ result.ShutdownOrigin = ShutdownOriginDeadline
537573 } else if result.Error == nil {
538574 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
539575 }
@@ -37,22 +37,40 @@ type ResultError struct {
37 Message string `json:"message,omitempty"`37 Message string `json:"message,omitempty"`
38 }38 }
39 39
40+// ShutdownResultError returns the stable, user-facing error associated with
41+// an intentional worker shutdown. Unknown origins are not attributed.
42+func ShutdownResultError(origin ShutdownOrigin) *ResultError {
43+ switch origin.Sanitized() {
44+ case ShutdownOriginTargeted:
45+ return &ResultError{Code: "shutdown", Message: "subagent stopped by request"}
46+ case ShutdownOriginSession:
47+ return &ResultError{Code: "shutdown", Message: "subagent stopped during session shutdown"}
48+ case ShutdownOriginDeadline:
49+ return &ResultError{Code: "deadline_exceeded", Message: "subagent turn deadline exceeded; partial output is preserved in the result and history"}
50+ case ShutdownOriginProcess:
51+ return &ResultError{Code: "shutdown", Message: "subagent stopped during process shutdown"}
52+ default:
53+ return nil
54+ }
55+}
56+
40 // TurnResult is the protocol-level result for a delegated turn. Output is a57 // TurnResult is the protocol-level result for a delegated turn. Output is a
41 // bounded inline preview; the full session/history remains available through58 // bounded inline preview; the full session/history remains available through
42 // HistoryRef and the complete JSON result through ResultRef.59 // HistoryRef and the complete JSON result through ResultRef.
43 type TurnResult struct {60 type TurnResult struct {
44- Version int `json:"version"`61+ Version int `json:"version"`
45- AgentID string `json:"agent_id,omitempty"`62+ AgentID string `json:"agent_id,omitempty"`
46- TurnID string `json:"turn_id,omitempty"`63+ TurnID string `json:"turn_id,omitempty"`
47- Status TurnStatus `json:"status"`64+ Status TurnStatus `json:"status"`
48- Summary string `json:"summary,omitempty"`65+ Summary string `json:"summary,omitempty"`
49- Output string `json:"output,omitempty"`66+ Output string `json:"output,omitempty"`
50- Structured json.RawMessage `json:"structured,omitempty"`67+ Structured json.RawMessage `json:"structured,omitempty"`
51- Artifacts []ArtifactRef `json:"artifacts,omitempty"`68+ Artifacts []ArtifactRef `json:"artifacts,omitempty"`
52- ChangedFiles []string `json:"changed_files,omitempty"`69+ ChangedFiles []string `json:"changed_files,omitempty"`
53- Usage map[string]any `json:"usage,omitempty"`70+ Usage map[string]any `json:"usage,omitempty"`
54- Error *ResultError `json:"error,omitempty"`71+ Error *ResultError `json:"error,omitempty"`
55- CreatedAt time.Time `json:"created_at"`72+ ShutdownOrigin ShutdownOrigin `json:"shutdown_origin,omitempty"`
73+ CreatedAt time.Time `json:"created_at"`
56 }74 }
57 75
58 func (r *TurnResult) Validate(maxBytes, maxLines int) error {76 func (r *TurnResult) Validate(maxBytes, maxLines int) error {
@@ -71,6 +89,9 @@ func (r *TurnResult) Validate(maxBytes, maxLines int) error {
71 if r.AgentID == "" {89 if r.AgentID == "" {
72 return errors.New("subagents: result missing agent id")90 return errors.New("subagents: result missing agent id")
73 }91 }
92+ if r.ShutdownOrigin != "" && r.ShutdownOrigin.Sanitized() == "" {
93+ return errors.New("subagents: result has invalid shutdown origin")
94+ }
74 if r.TurnID == "" {95 if r.TurnID == "" {
75 return errors.New("subagents: result missing turn id")96 return errors.New("subagents: result missing turn id")
76 }97 }
@@ -443,6 +464,10 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
443 return464 return
444 }465 }
445 attempt := a.AttemptValue()466 attempt := a.AttemptValue()
467+ shutdownOrigin := a.shutdownOriginValue()
468+ if errors.Is(runErr, context.DeadlineExceeded) {
469+ shutdownOrigin = ShutdownOriginDeadline
470+ }
446 a.mu.Lock()471 a.mu.Lock()
447 partialOutput := a.lastAssistant472 partialOutput := a.lastAssistant
448 a.mu.Unlock()473 a.mu.Unlock()
@@ -515,6 +540,15 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
515 if result.CreatedAt.IsZero() {540 if result.CreatedAt.IsZero() {
516 result.CreatedAt = time.Now().UTC()541 result.CreatedAt = time.Now().UTC()
517 }542 }
543+ if shutdownOrigin != "" {
544+ result.ShutdownOrigin = shutdownOrigin
545+ result.Error = ShutdownResultError(shutdownOrigin)
546+ if shutdownOrigin == ShutdownOriginDeadline {
547+ result.Status = ResultFailed
548+ } else {
549+ result.Status = ResultCanceled
550+ }
551+ }
518 if runErr != nil {552 if runErr != nil {
519 if status == StatusKilled || errors.Is(runErr, context.Canceled) {553 if status == StatusKilled || errors.Is(runErr, context.Canceled) {
520 result.Status = ResultCanceled554 result.Status = ResultCanceled
@@ -522,6 +556,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
522 result.Status = ResultFailed556 result.Status = ResultFailed
523 }557 }
524 if errors.Is(runErr, context.DeadlineExceeded) {558 if errors.Is(runErr, context.DeadlineExceeded) {
559+ result.Status = ResultFailed
525 // The parent deadline is authoritative for terminal categorization, so560 // The parent deadline is authoritative for terminal categorization, so
526 // it intentionally overrides any child result error, including561 // it intentionally overrides any child result error, including
527 // context_limit. The worker was first asked to stop cleanly, and any562 // context_limit. The worker was first asked to stop cleanly, and any
@@ -534,6 +569,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
534 Code: "deadline_exceeded",569 Code: "deadline_exceeded",
535 Message: message,570 Message: message,
536 }571 }
572+ result.ShutdownOrigin = ShutdownOriginDeadline
537 } else if result.Error == nil {573 } else if result.Error == nil {
538 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}574 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
539 }575 }
M packages/agent/subagents/result_test.go +35 -12
@@ -33,6 +33,20 @@ func TestTurnResultValidationAndBoundedOutput(t *testing.T) {
3333 }
3434 }
3535
36+func TestTurnResultValidatesShutdownOrigin(t *testing.T) {
37+ result := &TurnResult{
38+ Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultCanceled,
39+ ShutdownOrigin: ShutdownOriginSession,
40+ }
41+ if err := result.Validate(0, 0); err != nil {
42+ t.Fatalf("known shutdown origin was rejected: %v", err)
43+ }
44+ result.ShutdownOrigin = ShutdownOrigin("private detail")
45+ if err := result.Validate(0, 0); err == nil {
46+ t.Fatal("unknown shutdown origin was accepted")
47+ }
48+}
49+
3650 func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {
3751 result := &TurnResult{
3852 Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded,
@@ -108,11 +122,14 @@ func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {
108122 if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil {
109123 t.Fatal("mismatched result agent was accepted")
110124 }
111- valid := NewEvent(EventTurnResult, map[string]any{"status": string(ResultSucceeded)})
125+ valid := NewEvent(EventTurnResult, map[string]any{
126+ "status": string(ResultCanceled),
127+ "shutdown_origin": ShutdownOriginSession,
128+ })
112129 valid.AgentID = "agent-1"
113130 valid.TurnID = "turn-1"
114131 result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10)
115- if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" {
132+ if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" || result.ShutdownOrigin != ShutdownOriginSession {
116133 t.Fatalf("valid result = %#v, err=%v", result, err)
117134 }
118135 }
@@ -269,19 +286,21 @@ func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
269286
270287 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271288 tests := []struct {
272- name string
273- status Status
274- runErr error
275- childCode string
276- wantState TurnStatus
277- wantCode string
278- wantOutput string
289+ name string
290+ status Status
291+ runErr error
292+ childCode string
293+ shutdownOrigin ShutdownOrigin
294+ wantState TurnStatus
295+ wantCode string
296+ wantOutput string
279297 }{
280298 {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
281299 {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
282300 {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
283301 {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
284302 {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
303+ {name: "session shutdown", status: StatusKilled, runErr: context.Canceled, shutdownOrigin: ShutdownOriginSession, wantState: ResultCanceled, wantCode: "shutdown"},
285304 }
286305 for _, tt := range tests {
287306 t.Run(tt.name, func(t *testing.T) {
@@ -293,9 +312,10 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
293312 childError = &ResultError{Code: tt.childCode, Message: "child error"}
294313 }
295314 agent := &Agent{
296- ID: "agent-1",
297- stateDir: stateDir,
298- lastAssistant: tt.wantOutput,
315+ ID: "agent-1",
316+ stateDir: stateDir,
317+ lastAssistant: tt.wantOutput,
318+ shutdownOrigin: tt.shutdownOrigin,
299319 result: &TurnResult{
300320 AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
301321 },
@@ -328,6 +348,9 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
328348 if durable.Error == nil || durable.Error.Code != tt.wantCode {
329349 t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
330350 }
351+ if durable.ShutdownOrigin != tt.shutdownOrigin && tt.shutdownOrigin != "" {
352+ t.Fatalf("durable shutdown origin = %q, want %q", durable.ShutdownOrigin, tt.shutdownOrigin)
353+ }
331354 })
332355 }
333356 }
@@ -33,6 +33,20 @@ func TestTurnResultValidationAndBoundedOutput(t *testing.T) {
33 }33 }
34 }34 }
35 35
36+func TestTurnResultValidatesShutdownOrigin(t *testing.T) {
37+ result := &TurnResult{
38+ Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultCanceled,
39+ ShutdownOrigin: ShutdownOriginSession,
40+ }
41+ if err := result.Validate(0, 0); err != nil {
42+ t.Fatalf("known shutdown origin was rejected: %v", err)
43+ }
44+ result.ShutdownOrigin = ShutdownOrigin("private detail")
45+ if err := result.Validate(0, 0); err == nil {
46+ t.Fatal("unknown shutdown origin was accepted")
47+ }
48+}
49+
36 func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {50 func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {
37 result := &TurnResult{51 result := &TurnResult{
38 Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded,52 Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded,
@@ -108,11 +122,14 @@ func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {
108 if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil {122 if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil {
109 t.Fatal("mismatched result agent was accepted")123 t.Fatal("mismatched result agent was accepted")
110 }124 }
111- valid := NewEvent(EventTurnResult, map[string]any{"status": string(ResultSucceeded)})125+ valid := NewEvent(EventTurnResult, map[string]any{
126+ "status": string(ResultCanceled),
127+ "shutdown_origin": ShutdownOriginSession,
128+ })
112 valid.AgentID = "agent-1"129 valid.AgentID = "agent-1"
113 valid.TurnID = "turn-1"130 valid.TurnID = "turn-1"
114 result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10)131 result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10)
115- if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" {132+ if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" || result.ShutdownOrigin != ShutdownOriginSession {
116 t.Fatalf("valid result = %#v, err=%v", result, err)133 t.Fatalf("valid result = %#v, err=%v", result, err)
117 }134 }
118 }135 }
@@ -269,19 +286,21 @@ func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
269 286
270 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {287 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271 tests := []struct {288 tests := []struct {
272- name string289+ name string
273- status Status290+ status Status
274- runErr error291+ runErr error
275- childCode string292+ childCode string
276- wantState TurnStatus293+ shutdownOrigin ShutdownOrigin
277- wantCode string294+ wantState TurnStatus
278- wantOutput string295+ wantCode string
296+ wantOutput string
279 }{297 }{
280 {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},298 {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
281 {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},299 {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
282 {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},300 {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
283 {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},301 {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
284 {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},302 {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
303+ {name: "session shutdown", status: StatusKilled, runErr: context.Canceled, shutdownOrigin: ShutdownOriginSession, wantState: ResultCanceled, wantCode: "shutdown"},
285 }304 }
286 for _, tt := range tests {305 for _, tt := range tests {
287 t.Run(tt.name, func(t *testing.T) {306 t.Run(tt.name, func(t *testing.T) {
@@ -293,9 +312,10 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
293 childError = &ResultError{Code: tt.childCode, Message: "child error"}312 childError = &ResultError{Code: tt.childCode, Message: "child error"}
294 }313 }
295 agent := &Agent{314 agent := &Agent{
296- ID: "agent-1",315+ ID: "agent-1",
297- stateDir: stateDir,316+ stateDir: stateDir,
298- lastAssistant: tt.wantOutput,317+ lastAssistant: tt.wantOutput,
318+ shutdownOrigin: tt.shutdownOrigin,
299 result: &TurnResult{319 result: &TurnResult{
300 AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,320 AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
301 },321 },
@@ -328,6 +348,9 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
328 if durable.Error == nil || durable.Error.Code != tt.wantCode {348 if durable.Error == nil || durable.Error.Code != tt.wantCode {
329 t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)349 t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
330 }350 }
351+ if durable.ShutdownOrigin != tt.shutdownOrigin && tt.shutdownOrigin != "" {
352+ t.Fatalf("durable shutdown origin = %q, want %q", durable.ShutdownOrigin, tt.shutdownOrigin)
353+ }
331 })354 })
332 }355 }
333 }356 }
M packages/agent/subagents/supervisor.go +2 -0
@@ -814,6 +814,7 @@ func (f *Supervisor) stop(ctx context.Context, id string, origin ShutdownOrigin)
814814 pid := a.ProcessPIDValue()
815815 a.mu.Unlock()
816816 f.mu.Unlock()
817+ a.setShutdownOrigin(origin)
817818 // The detached worker may take the full grace period to exit. Do not
818819 // serialize unrelated supervisor operations for that entire wait.
819820 f.operationMu.Unlock()
@@ -832,6 +833,7 @@ func (f *Supervisor) stop(ctx context.Context, id string, origin ShutdownOrigin)
832833 }
833834 a.mu.Unlock()
834835 f.mu.Unlock()
836+ a.setShutdownOrigin(origin)
835837 f.operationMu.Unlock()
836838 a.setTurnState(TurnCanceling, a.CurrentTurnID())
837839
@@ -814,6 +814,7 @@ func (f *Supervisor) stop(ctx context.Context, id string, origin ShutdownOrigin)
814 pid := a.ProcessPIDValue()814 pid := a.ProcessPIDValue()
815 a.mu.Unlock()815 a.mu.Unlock()
816 f.mu.Unlock()816 f.mu.Unlock()
817+ a.setShutdownOrigin(origin)
817 // The detached worker may take the full grace period to exit. Do not818 // The detached worker may take the full grace period to exit. Do not
818 // serialize unrelated supervisor operations for that entire wait.819 // serialize unrelated supervisor operations for that entire wait.
819 f.operationMu.Unlock()820 f.operationMu.Unlock()
@@ -832,6 +833,7 @@ func (f *Supervisor) stop(ctx context.Context, id string, origin ShutdownOrigin)
832 }833 }
833 a.mu.Unlock()834 a.mu.Unlock()
834 f.mu.Unlock()835 f.mu.Unlock()
836+ a.setShutdownOrigin(origin)
835 f.operationMu.Unlock()837 f.operationMu.Unlock()
836 a.setTurnState(TurnCanceling, a.CurrentTurnID())838 a.setTurnState(TurnCanceling, a.CurrentTurnID())
837 839

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