From 6ffb23c18789a93fc4b6b97caab8423ea62f96aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 26 Jul 2026 14:18:35 +0100 Subject: [PATCH] fix: harden background work lifecycle tracking (#1955) --- .../adapter/transport/acp/adapter_tools.go | 140 +++++++++--- .../adapter/transport/acp/dialect_codex.go | 12 +- .../transport/acp/dialect_codex_test.go | 22 +- .../server/adapter/transport/acp/monitor.go | 11 +- .../transport/acp/monitor_contract_test.go | 24 +- .../adapter/transport/acp/monitor_test.go | 46 ++++ .../server/adapter/transport/acp/normalize.go | 71 +++++- .../adapter/transport/acp/subagent_test.go | 210 +++++++++++++++++- .../agentctl/types/streams/background_work.go | 64 ++++++ .../types/streams/background_work_test.go | 47 ++++ .../agentctl/types/streams/monitor_view.go | 1 + .../types/streams/monitor_view_test.go | 19 ++ .../agentctl/types/streams/tool_payload.go | 60 ++--- apps/backend/internal/backendapp/helpers.go | 3 +- .../background_work_accounting_test.go | 102 +++++++-- .../background_work_test_helpers_test.go | 19 ++ .../orchestrator/event_handlers_streaming.go | 37 ++- .../foreground_activity_freshload_test.go | 54 +++++ .../foreground_activity_prompt_cycle_test.go | 7 +- .../foreground_activity_signal_test.go | 111 +++++++-- .../foreground_busy_signal_test.go | 28 ++- .../orchestrator/foreground_claim_test.go | 3 +- .../prompt_background_acceptance_test.go | 2 +- .../internal/orchestrator/turn_activity.go | 116 ++++++---- .../dto/active_subagent_count_json_test.go | 28 +++ apps/backend/internal/task/dto/dto.go | 35 ++- .../internal/task/dto/task_activity.go | 26 ++- .../task/handlers/foreground_activity_test.go | 16 ++ .../task/handlers/process_handlers_test.go | 12 +- .../task/handlers/task_http_handlers.go | 6 +- .../task/handlers/workflow_handlers.go | 11 +- apps/backend/internal/task/service/service.go | 48 ++-- .../internal/task/service/service_events.go | 14 +- .../internal/task/service/task_activity.go | 84 ++++--- .../task/service/task_activity_test.go | 43 +++- apps/web/components/task/task-item.test.tsx | 7 +- apps/web/components/task/task-item.tsx | 33 ++- apps/web/e2e/tests/chat/busy-signal.spec.ts | 6 + apps/web/lib/kanban/map-task.test.ts | 7 + apps/web/lib/kanban/map-task.ts | 2 + apps/web/lib/ssr/mapper.test.ts | 13 ++ apps/web/lib/ssr/mapper.ts | 1 + apps/web/lib/state/slices/kanban/types.ts | 2 + apps/web/lib/types/activity.ts | 5 + apps/web/lib/types/backend.ts | 3 + apps/web/lib/types/http.ts | 23 +- .../web/lib/ws/handlers/agent-session.test.ts | 59 ++++- apps/web/lib/ws/handlers/agent-session.ts | 6 + .../lib/ws/handlers/tasks-activity.test.ts | 43 +++- apps/web/lib/ws/handlers/tasks.ts | 6 + ...ine-grained-foreground-idle-busy-signal.md | 68 +++++- docs/decisions/INDEX.md | 2 +- docs/plans/background-work-liveness/plan.md | 67 +++++- .../task-14-attest-background-lifecycles.md | 32 +++ .../task-15-publish-active-subagent-counts.md | 31 +++ .../task-16-sidebar-background-spinner.md | 33 +++ ...-17-verify-hardened-background-liveness.md | 36 +++ docs/public/coverage.json | 2 - docs/public/websocket-api.md | 10 + .../platform/background-work-liveness.md | 60 ++++- 60 files changed, 1778 insertions(+), 311 deletions(-) create mode 100644 apps/backend/internal/agentctl/types/streams/background_work.go create mode 100644 apps/backend/internal/agentctl/types/streams/background_work_test.go create mode 100644 apps/backend/internal/task/dto/active_subagent_count_json_test.go create mode 100644 apps/web/lib/types/activity.ts create mode 100644 docs/plans/background-work-liveness/task-14-attest-background-lifecycles.md create mode 100644 docs/plans/background-work-liveness/task-15-publish-active-subagent-counts.md create mode 100644 docs/plans/background-work-liveness/task-16-sidebar-background-spinner.md create mode 100644 docs/plans/background-work-liveness/task-17-verify-hardened-background-liveness.md diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go index c3fdd5ed27..99b2fe2111 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go @@ -3,6 +3,7 @@ package acp import ( "encoding/base64" "strconv" + "strings" "github.com/coder/acp-go-sdk" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" @@ -26,8 +27,7 @@ type codexSubagentCorrelation struct { emittedToolCallID string parentToolCallID string payload *streams.NormalizedPayload - collaborationSeen bool - activitySeen bool + terminalSeen bool lastSeen uint64 } @@ -130,13 +130,16 @@ func (a *Adapter) convertToolCallUpdate(sessionID string, tc *acp.SessionUpdateT normalizedPayload := a.normalizer.NormalizeToolCall(toolKind, args) toolCallID := string(tc.ToolCallId) - normalizedPayload, eventType, codexSignal, emittedToolCallID, codexParentToolCallID := a.trackToolCallPayload( + normalizedPayload, eventType, codexSignal, emittedToolCallID, codexParentToolCallID, suppress := a.trackToolCallPayload( sessionID, toolCallID, normalizedPayload, tc.Meta, string(tc.Status), ) + if suppress { + return nil + } // ScheduleWakeup tracking: meta carries `_meta.claudeCode.toolName` // on the initial tool_call; rawInput is usually empty here but record @@ -178,7 +181,7 @@ func (a *Adapter) trackToolCallPayload( payload *streams.NormalizedPayload, meta map[string]any, status string, -) (*streams.NormalizedPayload, string, codexSubagentSignal, string, string) { +) (*streams.NormalizedPayload, string, codexSubagentSignal, string, string, bool) { eventType := streams.EventTypeToolCall signal := codexSubagentSignalNone if a.agentID == codexAgentID { @@ -192,43 +195,59 @@ func (a *Adapter) trackToolCallPayload( defer a.mu.Unlock() if signal == codexSubagentSignalNone { a.activeToolCalls[toolCallID] = payload - return payload, eventType, signal, toolCallID, "" + return payload, eventType, signal, toolCallID, "", false } - correlation, duplicate := a.correlateCodexSubagentToolCallLocked( + correlation, duplicate, correlated := a.correlateCodexSubagentToolCallLocked( sessionID, toolCallID, payload, - signal, codexSenderThreadID(meta), ) + if !correlated { + return nil, "", signal, "", "", true + } if !duplicate { a.activeToolCalls[correlation.emittedToolCallID] = correlation.payload return cloneSubagentPayload(correlation.payload), eventType, signal, - correlation.emittedToolCallID, correlation.parentToolCallID + correlation.emittedToolCallID, correlation.parentToolCallID, false } - if active := a.activeToolCalls[correlation.emittedToolCallID]; sameCodexSubagentChild(active, correlation.payload) { + if correlation.terminalSeen { + delete(a.activeToolCalls, correlation.emittedToolCallID) + } else if active := a.activeToolCalls[correlation.emittedToolCallID]; sameCodexSubagentChild(active, correlation.payload) { a.activeToolCalls[correlation.emittedToolCallID] = correlation.payload } return cloneSubagentPayload(correlation.payload), streams.EventTypeToolUpdate, signal, - correlation.emittedToolCallID, correlation.parentToolCallID + correlation.emittedToolCallID, correlation.parentToolCallID, false } func (a *Adapter) correlateCodexSubagentToolCallLocked( sessionID string, toolCallID string, candidate *streams.NormalizedPayload, - signal codexSubagentSignal, parentThreadID string, -) (*codexSubagentCorrelation, bool) { +) (*codexSubagentCorrelation, bool, bool) { if a.codexSubagentCorrelations == nil { a.codexSubagentCorrelations = make(map[codexSubagentCorrelationKey]*codexSubagentCorrelation) } childSessionID := codexSubagentChildID(candidate) + if candidate != nil && candidate.BackgroundWork() != nil && !candidate.IsActiveBackgroundWork() { + correlation, found := a.findCodexSubagentByChildLocked(sessionID, childSessionID) + if !found { + return nil, false, false + } + mergeCodexSubagentPayload(correlation.payload, candidate) + stampSubagentBackgroundWork(correlation.payload, codexAgentID) + correlation.terminalSeen = true + a.touchCodexSubagentCorrelationLocked(correlation) + a.pruneCodexCompletedCorrelationsLocked() + return correlation, true, true + } key, correlation, found := a.findCodexSubagentCorrelationLocked(sessionID, toolCallID, childSessionID) if found { mergeCodexSubagentPayload(correlation.payload, candidate) - a.touchCodexSubagentCorrelationLocked(correlation, signal) + stampSubagentBackgroundWork(correlation.payload, codexAgentID) + a.touchCodexSubagentCorrelationLocked(correlation) if childSessionID != "" && key.childSessionID == "" { delete(a.codexSubagentCorrelations, key) key.childSessionID = childSessionID @@ -239,7 +258,7 @@ func (a *Adapter) correlateCodexSubagentToolCallLocked( correlation.parentToolCallID = a.codexParentToolCallIDLocked(sessionID, parentThreadID) } a.pruneCodexCompletedCorrelationsLocked() - return correlation, true + return correlation, true, true } key = codexSubagentCorrelationKey{ sessionID: sessionID, @@ -257,10 +276,42 @@ func (a *Adapter) correlateCodexSubagentToolCallLocked( correlation, ) correlation.parentToolCallID = a.codexParentToolCallIDLocked(sessionID, parentThreadID) - a.touchCodexSubagentCorrelationLocked(correlation, signal) + a.touchCodexSubagentCorrelationLocked(correlation) a.codexSubagentCorrelations[key] = correlation a.pruneCodexCompletedCorrelationsLocked() - return correlation, false + return correlation, false, true +} + +func (a *Adapter) findCodexSubagentByChildLocked( + sessionID string, + childSessionID string, +) (*codexSubagentCorrelation, bool) { + if childSessionID == "" { + return nil, false + } + var live *codexSubagentCorrelation + liveCount := 0 + var terminal *codexSubagentCorrelation + terminalCount := 0 + for key, correlation := range a.codexSubagentCorrelations { + if key.sessionID != sessionID || codexSubagentChildID(correlation.payload) != childSessionID { + continue + } + if correlation.terminalSeen { + terminal = correlation + terminalCount++ + continue + } + live = correlation + liveCount++ + } + if liveCount == 1 { + return live, true + } + if liveCount == 0 && terminalCount == 1 { + return terminal, true + } + return nil, false } func (a *Adapter) findCodexSubagentCorrelationLocked( @@ -322,7 +373,7 @@ func (a *Adapter) codexCorrelationSiblingCountLocked(sessionID, toolCallID strin } func codexCorrelationComplete(correlation *codexSubagentCorrelation) bool { - return correlation != nil && correlation.collaborationSeen && correlation.activitySeen + return correlation != nil && correlation.terminalSeen } func codexSubagentChildID(payload *streams.NormalizedPayload) string { @@ -337,18 +388,9 @@ func sameCodexSubagentChild(left, right *streams.NormalizedPayload) bool { return left != nil && right != nil && leftID == rightID } -func (a *Adapter) touchCodexSubagentCorrelationLocked( - correlation *codexSubagentCorrelation, - signal codexSubagentSignal, -) { +func (a *Adapter) touchCodexSubagentCorrelationLocked(correlation *codexSubagentCorrelation) { a.codexSubagentSequence++ correlation.lastSeen = a.codexSubagentSequence - switch signal { - case codexSubagentSignalCollaboration: - correlation.collaborationSeen = true - case codexSubagentSignalActivity: - correlation.activitySeen = true - } } func (a *Adapter) evictCodexSubagentCorrelationLocked() { @@ -376,6 +418,8 @@ func (a *Adapter) oldestCodexSubagentCorrelationLocked() (codexSubagentCorrelati } func (a *Adapter) pruneCodexCompletedCorrelationsLocked() { + // Completed correlations are capped at 256, so this simple scan remains + // bounded while keeping the correlation bookkeeping easy to audit. for a.codexCompletedCorrelationCountLocked() > maxCodexCompletedSubagentCorrelations { a.evictCodexSubagentCorrelationLocked() } @@ -482,7 +526,7 @@ func (a *Adapter) codexSubagentUpdateTargetLocked( // until it can be correlated coherently. return nil, "", "", true } - a.touchCodexSubagentCorrelationLocked(correlation, signal) + a.touchCodexSubagentCorrelationLocked(correlation) a.pruneCodexCompletedCorrelationsLocked() return correlation.payload, correlation.emittedToolCallID, correlation.parentToolCallID, false } @@ -524,6 +568,14 @@ func cloneSubagentPayload(payload *streams.NormalizedPayload) *streams.Normalize dst.OutputFile = src.OutputFile dst.CanReadOutputFile = src.CanReadOutputFile dst.SetIsAuggie(src.IsAuggie()) + if background := payload.BackgroundWork(); background != nil { + clone.SetBackgroundWorkIdentity( + background.Kind, + background.WorkID, + background.Detached, + background.Ended, + ) + } return clone } @@ -553,7 +605,8 @@ func fillCodexStatus(payload *streams.SubagentTaskPayload, status string) { func codexSubagentStatusRank(status string) int { switch status { - case toolStatusCompleted, toolStatusComplete, "errored", "error", "interrupted", "shutdown", "notFound", toolStatusCancelled: + case toolStatusCompleted, toolStatusComplete, toolStatusErrored, toolStatusError, + toolStatusInterrupted, toolStatusShutdown, toolStatusNotFound, toolStatusCancelled: return 3 case codexSubagentRunningStatus, "inProgress", toolStatusInProgress: return 2 @@ -696,6 +749,16 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session // rawOutput (OpenCode/Cursor); enrich the stored payload from both. if payload != nil && payload.Kind() == streams.ToolKindSubagentTask { a.normalizer.EnrichSubagentResult(payload, tcu.Meta, tcu.RawOutput) + if isTerminal && a.agentID == codexAgentID && + strings.TrimSpace(payload.SubagentTask().Status) == "" { + fillCodexStatus(payload.SubagentTask(), status) + } + if payload.BackgroundWork() != nil { + stampSubagentBackgroundWork(payload, a.agentID) + } + if isTerminal && payload.BackgroundWork() != nil && !payload.IsActiveBackgroundWork() { + a.markCodexSubagentTerminalLocked(sessionID, emittedToolCallID) + } } // Seed the Monitor view AFTER NormalizeToolResult so we overwrite the @@ -705,6 +768,9 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session // tool_call instead. if isMonitorRegistration && payload != nil { seedMonitorView(payload, monitorTaskID, monitorCommand) + if a.agentID == claudeAgentID || a.agentID == mockAgentID { + payload.SetMonitorIdentity(monitorTaskID, false) + } } // Preserve and mark-ended the Monitor view on tracked-Monitor terminal @@ -791,6 +857,22 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session } } +func (a *Adapter) markCodexSubagentTerminalLocked(sessionID, emittedToolCallID string) { + if a.agentID != codexAgentID || emittedToolCallID == "" { + return + } + for key, correlation := range a.codexSubagentCorrelations { + if key.sessionID != sessionID || correlation.emittedToolCallID != emittedToolCallID { + continue + } + correlation.terminalSeen = true + a.codexSubagentSequence++ + correlation.lastSeen = a.codexSubagentSequence + break + } + a.pruneCodexCompletedCorrelationsLocked() +} + // enrichModifyFileFromContents updates a ModifyFilePayload with data from // tool_call_contents. Claude ACP sends file path and content in tool_call_update // events rather than in the initial tool_call rawInput. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex.go index f09fd86f3a..379eb2c596 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex.go @@ -75,7 +75,17 @@ func codexCollaborationSignal(collaboration map[string]any) bool { func codexActivitySignal(activity map[string]any) bool { activityKind, _ := activity["activity"].(string) - return activityKind == codexSubagentStarted + return activityKind == codexSubagentStarted || codexActivityTerminal(activityKind) +} + +func codexActivityTerminal(activity string) bool { + switch activity { + case toolStatusCompleted, toolStatusComplete, toolStatusInterrupted, toolStatusCancelled, + toolStatusErrored, toolStatusError, toolStatusShutdown, toolStatusNotFound: + return true + default: + return false + } } func parseCodexCollaboration(collaboration map[string]any, rawInput any) (subagentFrame, bool) { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex_test.go index f7210ae1b8..49ac6e4c3c 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex_test.go @@ -80,6 +80,27 @@ func TestParseCodexSubagentFrameStartedActivity(t *testing.T) { } } +func TestParseCodexSubagentFrameTerminalActivity(t *testing.T) { + for _, status := range []string{ + toolStatusCompleted, + "interrupted", + toolStatusCancelled, + "errored", + "shutdown", + "notFound", + } { + t.Run(status, func(t *testing.T) { + frame, ok := parseCodexSubagentFrame(codexActivityMeta(status), "", nil) + if !ok { + t.Fatalf("expected %q activity to be recognized", status) + } + if frame.result.ChildSessionID != "thread-child" || frame.result.Status != status { + t.Fatalf("terminal frame = %+v", frame) + } + }) + } +} + func TestParseCodexSubagentFrameRejectsControlsAndMalformedMeta(t *testing.T) { tests := []struct { name string @@ -89,7 +110,6 @@ func TestParseCodexSubagentFrameRejectsControlsAndMalformedMeta(t *testing.T) { {"wait", codexCollaborationMeta("wait", []any{"thread-child"})}, {"close", codexCollaborationMeta("close", []any{"thread-child"})}, {"interacted activity", codexActivityMeta("interacted")}, - {"interrupted activity", codexActivityMeta("interrupted")}, {"missing codex", map[string]any{"other": map[string]any{}}}, {"wrong codex type", map[string]any{"codex": "invalid"}}, {"wrong collaboration type", map[string]any{"codex": map[string]any{"collaboration": 42}}}, diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go index 916e8a7bf3..0eda402e17 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go @@ -291,9 +291,6 @@ func seedMonitorView(payload *streams.NormalizedPayload, taskID, command string) view.Command = command } g.Output = monitorOutputWrapper(view) - // Attest, out of band of the agent-shaped Output map, that WE recognized this - // as a Monitor — this is what the background-work classifier trusts. - payload.SetMonitorIdentity(view.TaskID, view.Ended) } // updateMonitorPayloadView mutates the Monitor tool's NormalizedPayload to @@ -323,7 +320,9 @@ func appendMonitorEvent(payload *streams.NormalizedPayload, taskID, command, bod view.EventCount++ view.RecentEvents = appendCapped(view.RecentEvents, body, monitorPayloadCap) g.Output = monitorOutputWrapper(view) - payload.SetMonitorIdentity(view.TaskID, view.Ended) + if payload.Monitor() != nil { + payload.SetMonitorIdentity(view.TaskID, view.Ended) + } return payload } @@ -343,7 +342,9 @@ func markMonitorEnded(payload *streams.NormalizedPayload, reason string) *stream g.Output = monitorOutputWrapper(view) // Keep the attestation in step with the view: an ended Monitor no longer holds // the busy signal open. - payload.SetMonitorIdentity(view.TaskID, true) + if payload.Monitor() != nil { + payload.SetMonitorIdentity(view.TaskID, true) + } return payload } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go index a583fde785..11ea0f4958 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go @@ -28,9 +28,14 @@ func newMonitorGeneric() *streams.NormalizedPayload { return streams.NewGeneric("other", map[string]any{}) } +func seedTrustedMonitorView(payload *streams.NormalizedPayload) { + seedMonitorView(payload, "task-abc", "gh pr checks --watch") + payload.SetMonitorIdentity("task-abc", false) +} + func TestMonitorViewContract_SeededMonitorIsRecognizedAsActive(t *testing.T) { p := newMonitorGeneric() - seedMonitorView(p, "task-abc", "gh pr checks --watch") + seedTrustedMonitorView(p) if !p.IsActiveMonitor() { t.Fatal("a Monitor view written by seedMonitorView must be recognized as active background work") @@ -39,7 +44,7 @@ func TestMonitorViewContract_SeededMonitorIsRecognizedAsActive(t *testing.T) { func TestMonitorViewContract_EventsKeepMonitorActive(t *testing.T) { p := newMonitorGeneric() - seedMonitorView(p, "task-abc", "gh pr checks --watch") + seedTrustedMonitorView(p) appendMonitorEvent(p, "task-abc", "gh pr checks --watch", "all checks passing") if !p.IsActiveMonitor() { @@ -49,7 +54,7 @@ func TestMonitorViewContract_EventsKeepMonitorActive(t *testing.T) { func TestMonitorViewContract_EndedMonitorIsNotActive(t *testing.T) { p := newMonitorGeneric() - seedMonitorView(p, "task-abc", "gh pr checks --watch") + seedTrustedMonitorView(p) markMonitorEnded(p, "exited") if p.IsActiveMonitor() { @@ -62,7 +67,7 @@ func TestMonitorViewContract_EndedMonitorIsNotActive(t *testing.T) { // ever sees it. The contract has to survive that, not just hold in-process. func TestMonitorViewContract_SurvivesSerializationToOrchestrator(t *testing.T) { p := newMonitorGeneric() - seedMonitorView(p, "task-abc", "gh pr checks --watch") + seedTrustedMonitorView(p) data, err := p.MarshalJSON() if err != nil { @@ -113,16 +118,15 @@ func TestMonitorViewContract_ArbitraryToolOutputIsNotMistakenForAMonitor(t *test } } -// The attestation must actually be stamped by the real producer path — if -// seedMonitorView ever stopped calling SetMonitorIdentity, IsActiveMonitor would -// silently return false for every genuine Monitor and quietly un-ship the feature. -func TestMonitorViewContract_SeedStampsAdapterAttestation(t *testing.T) { +// Once the adapter has trusted a Monitor registration, later presentation +// updates must keep its out-of-band attestation in sync. +func TestMonitorViewContract_AttestationTracksPresentationUpdates(t *testing.T) { p := newMonitorGeneric() - seedMonitorView(p, "task-abc", "gh pr checks --watch") + seedTrustedMonitorView(p) m := p.Monitor() if m == nil { - t.Fatal("seedMonitorView must stamp the adapter's Monitor attestation") + t.Fatal("trusted Monitor must carry adapter attestation") } if m.TaskID != "task-abc" { t.Fatalf("attested task ID = %q, want task-abc", m.TaskID) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_test.go index d4383129de..c2bb480518 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_test.go @@ -279,6 +279,52 @@ func TestConvertToolCallResultUpdate_MonitorRegistrationOverridesCompleted(t *te } } +func TestConvertToolCallResultUpdate_MonitorAttestationIsAgentScoped(t *testing.T) { + tests := []struct { + name string + agentID string + attested bool + }{ + {name: "Claude", agentID: claudeAgentID, attested: true}, + {name: "controlled mock", agentID: mockAgentID, attested: true}, + {name: "Codex", agentID: codexAgentID, attested: false}, + {name: "unrecognized adapter", agentID: "other-acp", attested: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + a := newTestAdapter() + a.agentID = tc.agentID + a.normalizer = NewNormalizer(tc.agentID) + initial := &acp.SessionUpdateToolCall{ + ToolCallId: "tc-monitor", + Title: monitorToolName, + Kind: acp.ToolKind("other"), + Meta: monitorMeta(), + RawInput: map[string]any{"command": "gh pr checks --watch"}, + } + if event := a.convertToolCallUpdate("s1", initial); event == nil { + t.Fatal("initial Monitor tool call returned nil") + } + + completed := acp.ToolCallStatus(toolStatusCompleted) + event := a.convertToolCallResultUpdate("s1", &acp.SessionToolCallUpdate{ + ToolCallId: "tc-monitor", + Status: &completed, + Meta: monitorMeta(), + RawOutput: "Monitor started (task task-42, timeout 60000ms).", + }) + if event == nil || event.NormalizedPayload == nil { + t.Fatal("Monitor registration update returned no payload") + } + got := event.NormalizedPayload.BackgroundWork() != nil + if got != tc.attested { + t.Fatalf("background attestation present = %t, want %t", got, tc.attested) + } + }) + } +} + func TestConvertToolCallResultUpdate_RealCompletedStaysComplete(t *testing.T) { a := newTestAdapter() completed := acp.ToolCallStatus("completed") diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go index a8498d4954..df925f1609 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go @@ -23,11 +23,15 @@ const ( toolTypeSearch = "tool_search" toolTypeGeneric = "tool_call" - toolStatusComplete = "complete" - toolStatusCompleted = "completed" - toolStatusError = "error" - toolStatusInProgress = "in_progress" - toolStatusCancelled = "cancelled" + toolStatusComplete = "complete" + toolStatusCompleted = "completed" + toolStatusError = "error" + toolStatusErrored = "errored" + toolStatusInProgress = "in_progress" + toolStatusCancelled = "cancelled" + toolStatusInterrupted = "interrupted" + toolStatusNotFound = "notFound" + toolStatusShutdown = "shutdown" // args map keys the adapter stashes so the normalizer can detect subagent // (Task) tool calls without changing NormalizeToolCall's signature. @@ -39,6 +43,13 @@ const ( readTypeDirectory = "directory" genericLabelFile = "file" genericLabelFolder = "folder" + + claudeAgentID = "claude-acp" + + // mockAgentID is the controlled dev/E2E simulator. It emits captured + // Claude lifecycle shapes so product tests can exercise the same trusted + // path without credentials; the mock provider is disabled in production. + mockAgentID = "mock-agent" ) // DetectToolOperationType determines the specific tool operation type from ACP tool data. @@ -117,6 +128,7 @@ func (n *Normalizer) NormalizeToolCall(toolName string, args map[string]any) *st payload = n.normalizeGeneric(toolName, args) } applyAgentEnrichment(n.agentID, payload, enrichFrameFromArgs(args)) + stampBackgroundShellWork(n.agentID, payload) return payload } @@ -228,6 +240,7 @@ func (n *Normalizer) UpdatePayloadInput(payload *streams.NormalizedPayload, rawI if se := payload.ShellExec(); se != nil { updateShellExecInput(se, inputMap) + stampBackgroundShellWork(n.agentID, payload) } if gen := payload.Generic(); gen != nil { updateGenericInput(gen, inputMap, supplemental) @@ -288,6 +301,12 @@ func isEmptyGenericInputValue(v any) bool { } } +func stampBackgroundShellWork(agentID string, payload *streams.NormalizedPayload) { + if agentID == claudeAgentID && payload != nil && payload.ShellExec() != nil && payload.ShellExec().Background { + payload.SetBackgroundWorkIdentity(streams.BackgroundWorkKindShell, "", true, false) + } +} + func updateShellExecInput(se *streams.ShellExecPayload, inputMap map[string]any) { if cmd := shared.GetString(inputMap, "command"); cmd != "" && se.Command == "" { se.Command = cmd @@ -572,6 +591,7 @@ func (n *Normalizer) normalizeSubagent(args map[string]any) (*streams.Normalized if frame, ok := n.dialect.parseSubagentFrame(meta, title, rawInput); ok { payload := streams.NewSubagentTask(frame.description, frame.prompt, frame.subagentType) applySubagentResult(payload.SubagentTask(), frame.result) + stampSubagentBackgroundWork(payload, n.agentID) return payload, true } desc, prompt, subagentType, ok := recognizeSubagent(meta, title, rawInput) @@ -579,6 +599,9 @@ func (n *Normalizer) normalizeSubagent(args map[string]any) (*streams.Normalized return nil, false } payload := streams.NewSubagentTask(desc, prompt, subagentType) + if (n.agentID == claudeAgentID || n.agentID == mockAgentID) && isClaudeAgentMeta(meta) { + stampSubagentBackgroundWork(payload, n.agentID) + } // Mark Auggie subagents at recognition time so the result extractor // (which reads a generic `rawOutput.output` string) only runs for // payloads whose tool_call title actually carried the Auggie prefix. @@ -610,6 +633,44 @@ func (n *Normalizer) EnrichSubagentResult(payload *streams.NormalizedPayload, me return } applySubagentResult(sa, res) + if payload.BackgroundWork() != nil { + stampSubagentBackgroundWork(payload, n.agentID) + } +} + +func stampSubagentBackgroundWork(payload *streams.NormalizedPayload, agentID string) { + if payload == nil || payload.SubagentTask() == nil { + return + } + if agentID != claudeAgentID && agentID != codexAgentID && agentID != mockAgentID { + return + } + subagent := payload.SubagentTask() + workID := subagent.AgentID + if workID == "" { + workID = subagent.ChildSessionID + } + detached := subagent.IsAsync + ended := subagentStatusTerminal(subagent.Status) + if detached && subagent.Status == subagentAsyncLaunchedStatus { + ended = false + } + payload.SetBackgroundWorkIdentity( + streams.BackgroundWorkKindSubagent, + workID, + detached, + ended, + ) +} + +func subagentStatusTerminal(status string) bool { + switch status { + case toolStatusComplete, toolStatusCompleted, toolStatusError, toolStatusErrored, + "failed", toolStatusCancelled, toolStatusInterrupted, toolStatusShutdown, toolStatusNotFound: + return true + default: + return false + } } // --- Helper functions --- diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go index 7b9f3edbad..09ad6c8235 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go @@ -337,6 +337,91 @@ func TestNormalizeToolCall_CodexMetaIsDialectScoped(t *testing.T) { } } +func TestNormalizeSubagentBackgroundAttestationIsAdapterScoped(t *testing.T) { + tests := []struct { + name string + agentID string + args map[string]any + want bool + }{ + { + name: "Claude Agent metadata", + agentID: "claude-acp", + args: map[string]any{ + "kind": "other", + "meta": map[string]any{"claudeCode": map[string]any{"toolName": subagentClaudeToolName}}, + }, + want: true, + }, + { + name: "Codex collaboration metadata", + agentID: codexAgentID, + args: map[string]any{ + "kind": "other", + "meta": codexCollaborationMeta(codexCollaborationSpawnAgent, []any{"thread-child"}), + }, + want: true, + }, + { + name: "E2E mock Claude lifecycle metadata", + agentID: mockAgentID, + args: map[string]any{ + "kind": "other", + "meta": map[string]any{"claudeCode": map[string]any{"toolName": subagentClaudeToolName}}, + }, + want: true, + }, + { + name: "OpenCode presentation shape", + agentID: "opencode-acp", + args: map[string]any{"kind": "other", "title": subagentTaskName}, + want: false, + }, + { + name: "Cursor presentation shape", + agentID: "cursor-acp", + args: map[string]any{ + "kind": "other", + "raw_input": map[string]any{subagentKeyToolName: subagentTaskName}, + }, + want: false, + }, + { + name: "Auggie presentation shape", + agentID: "auggie-acp", + args: map[string]any{"kind": "other", "title": "sub-agent-review: audit"}, + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload := NewNormalizer(test.agentID).NormalizeToolCall("other", test.args) + if got := payload.IsActiveBackgroundWork(); got != test.want { + t.Fatalf("IsActiveBackgroundWork() = %v, want %v", got, test.want) + } + }) + } +} + +func TestNormalizeBackgroundShellAttestationIsClaudeScoped(t *testing.T) { + args := map[string]any{ + "kind": "execute", + "raw_input": map[string]any{ + "command": "sleep 30", + "run_in_background": true, + }, + } + claude := NewNormalizer("claude-acp").NormalizeToolCall("execute", args) + if !claude.IsActiveBackgroundWork() || !claude.IsDetachedBackgroundLaunch() { + t.Fatal("Claude background shell was not adapter-attested") + } + openCode := NewNormalizer("opencode-acp").NormalizeToolCall("execute", args) + if openCode.IsActiveBackgroundWork() { + t.Fatal("OpenCode presentation shape must not self-attest") + } +} + func TestCodexSubagentSequenceUsesImmutableSnapshots(t *testing.T) { a := newTestAdapter() a.agentID = codexAgentID @@ -385,6 +470,9 @@ func TestCodexSubagentSequenceUsesImmutableSnapshots(t *testing.T) { if got := startSnapshot.SubagentTask(); got.Status != "running" || got.Model != "" { t.Errorf("start event snapshot mutated after completion: status/model = %q/%q", got.Status, got.Model) } + if completeEvent.NormalizedPayload.IsActiveBackgroundWork() { + t.Error("terminal collaboration result must mark adapter-attested work ended") + } if _, active := a.activeToolCalls["call-spawn"]; active { t.Error("terminal completion must remove the active tool call") } @@ -423,6 +511,7 @@ func TestCloneSubagentPayloadDeepCopiesMutableFields(t *testing.T) { source.OutputFile = "/tmp/result" source.CanReadOutputFile = true source.SetIsAuggie(true) + payload.SetBackgroundWorkIdentity(streams.BackgroundWorkKindSubagent, "child-1", false, false) clone := cloneSubagentPayload(payload).SubagentTask() source.Description = "mutated" @@ -433,6 +522,9 @@ func TestCloneSubagentPayloadDeepCopiesMutableFields(t *testing.T) { if !clone.IsAuggie() || !clone.IsAsync || clone.OutputFile != "/tmp/result" || !clone.CanReadOutputFile { t.Fatalf("clone lost adapter fields: %+v", clone) } + if background := cloneSubagentPayload(payload).BackgroundWork(); background == nil || background.WorkID != "child-1" { + t.Fatalf("clone lost background attestation: %+v", background) + } } func TestCodexSubagentActivityDeduplicatesWhileRunning(t *testing.T) { @@ -472,6 +564,68 @@ func TestCodexSubagentActivityDeduplicatesWhileRunning(t *testing.T) { } } +func TestCodexSubagentTerminalActivityCorrelatesAcrossToolCallIDs(t *testing.T) { + for _, status := range []string{ + toolStatusCompleted, + "interrupted", + toolStatusCancelled, + "errored", + "shutdown", + "notFound", + } { + t.Run(status, func(t *testing.T) { + a := newTestAdapter() + a.agentID = codexAgentID + a.normalizer = NewNormalizer(codexAgentID) + + start := a.convertToolCallUpdate( + "session-1", + codexCollaborationToolCall("call-spawn", "thread-child", "running"), + ) + terminal := a.convertToolCallUpdate( + "session-1", + codexTerminalActivityToolCall("call-terminal", "thread-child", status), + ) + + if terminal == nil { + t.Fatal("terminal child activity was suppressed despite a matching launch") + } + if terminal.Type != streams.EventTypeToolUpdate { + t.Fatalf("terminal event type = %q, want tool_update", terminal.Type) + } + if terminal.ToolCallID != start.ToolCallID { + t.Fatalf("terminal tool call ID = %q, want original %q", terminal.ToolCallID, start.ToolCallID) + } + if terminal.ToolStatus != toolStatusComplete { + t.Fatalf("terminal tool status = %q, want complete", terminal.ToolStatus) + } + if got := terminal.NormalizedPayload.SubagentTask().Status; got != status { + t.Fatalf("terminal payload status = %q, want %q", got, status) + } + if terminal.NormalizedPayload.IsActiveBackgroundWork() { + t.Fatal("terminal child activity must mark adapter-attested work ended") + } + if _, active := a.activeToolCalls[start.ToolCallID]; active { + t.Fatal("terminal child activity must remove the original active tool call") + } + }) + } +} + +func TestCodexUnmatchedTerminalActivityDoesNotCreateSubagentCard(t *testing.T) { + a := newCodexCorrelationTestAdapter() + event := a.convertToolCallUpdate( + "session-1", + codexTerminalActivityToolCall("call-terminal", "unknown-child", "interrupted"), + ) + if event != nil { + t.Fatalf("unmatched terminal activity emitted a new card: %+v", event) + } + if len(a.codexSubagentCorrelations) != 0 || len(a.activeToolCalls) != 0 { + t.Fatal("unmatched terminal activity mutated subagent tracking") + } +} + func TestCodexSubagentCorrelationPressureRetainsDelayedMatch(t *testing.T) { a := newTestAdapter() a.agentID = codexAgentID @@ -498,8 +652,8 @@ func TestCodexSubagentCorrelationPressureRetainsDelayedMatch(t *testing.T) { childSessionID: "delayed-child", } correlation := a.codexSubagentCorrelations[key] - if correlation == nil || !correlation.collaborationSeen || !correlation.activitySeen { - t.Fatalf("delayed correlation signal state = %+v", correlation) + if correlation == nil || correlation.terminalSeen { + t.Fatalf("delayed live correlation state = %+v", correlation) } } @@ -511,6 +665,10 @@ func TestCodexCompletedTombstonesAreBoundedOldestFirst(t *testing.T) { childID := "child-" + strconv.Itoa(i) a.convertToolCallUpdate("session-1", codexCollaborationToolCall(id, childID, "running")) a.convertToolCallUpdate("session-1", codexStartedActivityToolCallForChild(id, childID)) + a.convertToolCallUpdate( + "session-1", + codexTerminalActivityToolCall("terminal-"+strconv.Itoa(i), childID, toolStatusCompleted), + ) } if got := a.codexCompletedCorrelationCountLocked(); got != maxCodexCompletedSubagentCorrelations { t.Fatalf("completed tombstones = %d, want %d", got, maxCodexCompletedSubagentCorrelations) @@ -592,6 +750,35 @@ func TestCodexCorrelationSameToolIDDifferentChildCreatesNewCard(t *testing.T) { } } +func TestCodexCollaborationCompletionPreservesExplicitRunningChildStatus(t *testing.T) { + a := newCodexCorrelationTestAdapter() + start := a.convertToolCallUpdate("session-1", codexCollaborationToolCall("spawn", "child-a", "running")) + completed := acp.ToolCallStatus(toolStatusCompleted) + result := a.convertToolCallResultUpdate("session-1", &acp.SessionToolCallUpdate{ + ToolCallId: acp.ToolCallId("spawn"), + Status: &completed, + RawInput: map[string]any{ + "agentsStates": map[string]any{ + "child-a": map[string]any{"status": "running"}, + }, + }, + Meta: codexCollaborationMeta(codexCollaborationSpawnAgent, []any{"child-a"}), + }) + if result == nil { + t.Fatal("expected collaboration result update") + } + if got := result.NormalizedPayload.SubagentTask().Status; got != "running" { + t.Fatalf("child status = %q, want running from agentsStates", got) + } + background := result.NormalizedPayload.BackgroundWork() + if background == nil || background.Ended { + t.Fatalf("running child background attestation = %+v, want active", background) + } + if got := start.NormalizedPayload.SubagentTask().Status; got != "running" { + t.Fatalf("persisted start snapshot mutated to %q", got) + } +} + func TestCodexAmbiguousChildlessResultsAreSuppressed(t *testing.T) { a := newCodexCorrelationTestAdapter() first := a.convertToolCallUpdate("session-1", codexCollaborationToolCall("reused", "child-a", "running")) @@ -927,6 +1114,25 @@ func codexStartedActivityToolCallForChild(toolCallID, childID string) *acp.Sessi } } +func codexTerminalActivityToolCall(toolCallID, childID, activity string) *acp.SessionUpdateToolCall { + return &acp.SessionUpdateToolCall{ + ToolCallId: acp.ToolCallId(toolCallID), + Kind: "other", + Title: "Subagent lifecycle", + Status: toolStatusCompleted, + RawInput: map[string]any{ + "agentThreadId": childID, + "agentPath": "/root/review_agent", + "activityKind": activity, + }, + Meta: map[string]any{"codex": map[string]any{"subagent": map[string]any{ + "threadId": childID, + "path": "/root/review_agent", + "activity": activity, + }}}, + } +} + func codexCollaborationToolCall(toolCallID, childID, status string) *acp.SessionUpdateToolCall { return codexCollaborationToolCallFrom(toolCallID, "thread-main", childID, status) } diff --git a/apps/backend/internal/agentctl/types/streams/background_work.go b/apps/backend/internal/agentctl/types/streams/background_work.go new file mode 100644 index 0000000000..51ac2b3e62 --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/background_work.go @@ -0,0 +1,64 @@ +package streams + +// BackgroundWorkKind identifies a workload whose lifecycle was recognized by +// an adapter. It is deliberately separate from ToolKind: ToolKind is shared +// presentation data, while this value is adapter-issued provenance used for +// prompt admission and runtime accounting. +type BackgroundWorkKind string + +const ( + BackgroundWorkKindSubagent BackgroundWorkKind = "subagent" + BackgroundWorkKindShell BackgroundWorkKind = "shell" + BackgroundWorkKindMonitor BackgroundWorkKind = "monitor" +) + +// BackgroundWorkPayload is typed adapter attestation that Kandev recognizes +// this workload's launch and terminal lifecycle. +type BackgroundWorkPayload struct { + Kind BackgroundWorkKind `json:"kind"` + WorkID string `json:"work_id,omitempty"` + Detached bool `json:"detached,omitempty"` + Ended bool `json:"ended,omitempty"` +} + +// BackgroundWork returns adapter-issued background-work provenance, or nil +// when the normalized payload is presentation-only. +func (p *NormalizedPayload) BackgroundWork() *BackgroundWorkPayload { + if p == nil { + return nil + } + return p.backgroundWork +} + +// SetBackgroundWorkIdentity records adapter-issued lifecycle provenance. +// Adapter recognizers are the only production callers. +func (p *NormalizedPayload) SetBackgroundWorkIdentity( + kind BackgroundWorkKind, + workID string, + detached bool, + ended bool, +) { + if p == nil || kind == "" { + return + } + if p.backgroundWork == nil { + p.backgroundWork = &BackgroundWorkPayload{} + } + p.backgroundWork.Kind = kind + if workID != "" { + p.backgroundWork.WorkID = workID + } + p.backgroundWork.Detached = detached + p.backgroundWork.Ended = ended +} + +// IsActiveBackgroundWork reports whether an adapter attested a live workload. +func (p *NormalizedPayload) IsActiveBackgroundWork() bool { + return p != nil && p.backgroundWork != nil && !p.backgroundWork.Ended +} + +// IsDetachedBackgroundLaunch distinguishes a terminal launch-tool result from +// terminal evidence for the launched workload itself. +func (p *NormalizedPayload) IsDetachedBackgroundLaunch() bool { + return p.IsActiveBackgroundWork() && p.backgroundWork.Detached +} diff --git a/apps/backend/internal/agentctl/types/streams/background_work_test.go b/apps/backend/internal/agentctl/types/streams/background_work_test.go new file mode 100644 index 0000000000..58493957e9 --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/background_work_test.go @@ -0,0 +1,47 @@ +package streams + +import ( + "encoding/json" + "testing" +) + +func TestBackgroundWorkAttestationRoundTrip(t *testing.T) { + payload := NewSubagentTask("audit", "inspect lifecycle", "reviewer") + payload.SetBackgroundWorkIdentity(BackgroundWorkKindSubagent, "child-1", false, false) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded NormalizedPayload + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + background := decoded.BackgroundWork() + if background == nil { + t.Fatal("background-work attestation was lost") + } + if background.Kind != BackgroundWorkKindSubagent || background.WorkID != "child-1" { + t.Fatalf("background-work attestation = %+v", background) + } + if !decoded.IsActiveBackgroundWork() { + t.Fatal("live attestation became inactive after round trip") + } +} + +func TestBackgroundWorkAttestationDistinguishesDetachedAndEnded(t *testing.T) { + payload := NewShellExec("sleep 30", "", "", 0, true) + payload.SetBackgroundWorkIdentity(BackgroundWorkKindShell, "job-1", true, false) + if !payload.IsActiveBackgroundWork() || !payload.IsDetachedBackgroundLaunch() { + t.Fatal("live detached shell was not classified as detached background work") + } + + payload.SetBackgroundWorkIdentity(BackgroundWorkKindShell, "", true, true) + if payload.IsActiveBackgroundWork() || payload.IsDetachedBackgroundLaunch() { + t.Fatal("ended shell remained active") + } + if got := payload.BackgroundWork().WorkID; got != "job-1" { + t.Fatalf("empty update erased work ID: %q", got) + } +} diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view.go b/apps/backend/internal/agentctl/types/streams/monitor_view.go index c416067cd6..22376c282e 100644 --- a/apps/backend/internal/agentctl/types/streams/monitor_view.go +++ b/apps/backend/internal/agentctl/types/streams/monitor_view.go @@ -73,6 +73,7 @@ func (p *NormalizedPayload) SetMonitorIdentity(taskID string, ended bool) { p.monitor.TaskID = taskID } p.monitor.Ended = ended + p.SetBackgroundWorkIdentity(BackgroundWorkKindMonitor, taskID, false, ended) } // IsActiveMonitor reports whether this payload is a live Claude Monitor watch. diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go index 0ebc2f91fb..f7555c2726 100644 --- a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go +++ b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go @@ -94,3 +94,22 @@ func TestIsActiveMonitor_AttestationSurvivesJSONRoundTrip(t *testing.T) { t.Fatalf("attested task ID must survive the round-trip, got %+v", got.Monitor()) } } + +func TestSetMonitorIdentityPropagatesBackgroundWork(t *testing.T) { + p := NewGeneric("other", map[string]any{}) + p.SetMonitorIdentity("task-42", true) + + background := p.BackgroundWork() + if background == nil { + t.Fatal("SetMonitorIdentity must stamp background-work provenance") + } + if background.Kind != BackgroundWorkKindMonitor { + t.Fatalf("background kind = %q, want %q", background.Kind, BackgroundWorkKindMonitor) + } + if background.WorkID != "task-42" { + t.Fatalf("background work ID = %q, want task-42", background.WorkID) + } + if !background.Ended { + t.Fatal("terminal monitor state must propagate to background-work provenance") + } +} diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload.go b/apps/backend/internal/agentctl/types/streams/tool_payload.go index a7cd9f1559..66db2ef8a3 100644 --- a/apps/backend/internal/agentctl/types/streams/tool_payload.go +++ b/apps/backend/internal/agentctl/types/streams/tool_payload.go @@ -54,6 +54,10 @@ type NormalizedPayload struct { showPlan *ShowPlanPayload manageTodos *ManageTodosPayload misc *MiscPayload + // backgroundWork is adapter-issued provenance. Tool kind and payload shape + // are presentation contracts and cannot relax prompt admission by + // themselves. + backgroundWork *BackgroundWorkPayload // monitor is adapter-only provenance, deliberately a sibling of generic // rather than a key inside GenericPayload.Output. Output is assigned the // agent's raw tool result verbatim (see NormalizeToolResult), so anything @@ -82,19 +86,20 @@ func (p *NormalizedPayload) Misc() *MiscPayload { return p.misc // MarshalJSON implements custom JSON marshaling for NormalizedPayload. func (p *NormalizedPayload) MarshalJSON() ([]byte, error) { type jsonPayload struct { - Kind ToolKind `json:"kind"` - ReadFile *ReadFilePayload `json:"read_file,omitempty"` - ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` - ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` - CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` - HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` - Generic *GenericPayload `json:"generic,omitempty"` - CreateTask *CreateTaskPayload `json:"create_task,omitempty"` - SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` - ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` - ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` - Misc *MiscPayload `json:"misc,omitempty"` - Monitor *MonitorPayload `json:"monitor,omitempty"` + Kind ToolKind `json:"kind"` + ReadFile *ReadFilePayload `json:"read_file,omitempty"` + ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` + ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` + CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` + HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` + Generic *GenericPayload `json:"generic,omitempty"` + CreateTask *CreateTaskPayload `json:"create_task,omitempty"` + SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` + ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` + ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` + Misc *MiscPayload `json:"misc,omitempty"` + Background *BackgroundWorkPayload `json:"background_work,omitempty"` + Monitor *MonitorPayload `json:"monitor,omitempty"` } return json.Marshal(jsonPayload{ Kind: p.kind, @@ -109,6 +114,7 @@ func (p *NormalizedPayload) MarshalJSON() ([]byte, error) { ShowPlan: p.showPlan, ManageTodos: p.manageTodos, Misc: p.misc, + Background: p.backgroundWork, Monitor: p.monitor, }) } @@ -117,19 +123,20 @@ func (p *NormalizedPayload) MarshalJSON() ([]byte, error) { // This is required because the struct has unexported fields. func (p *NormalizedPayload) UnmarshalJSON(data []byte) error { type jsonPayload struct { - Kind ToolKind `json:"kind"` - ReadFile *ReadFilePayload `json:"read_file,omitempty"` - ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` - ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` - CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` - HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` - Generic *GenericPayload `json:"generic,omitempty"` - CreateTask *CreateTaskPayload `json:"create_task,omitempty"` - SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` - ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` - ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` - Misc *MiscPayload `json:"misc,omitempty"` - Monitor *MonitorPayload `json:"monitor,omitempty"` + Kind ToolKind `json:"kind"` + ReadFile *ReadFilePayload `json:"read_file,omitempty"` + ModifyFile *ModifyFilePayload `json:"modify_file,omitempty"` + ShellExec *ShellExecPayload `json:"shell_exec,omitempty"` + CodeSearch *CodeSearchPayload `json:"code_search,omitempty"` + HttpRequest *HttpRequestPayload `json:"http_request,omitempty"` + Generic *GenericPayload `json:"generic,omitempty"` + CreateTask *CreateTaskPayload `json:"create_task,omitempty"` + SubagentTask *SubagentTaskPayload `json:"subagent_task,omitempty"` + ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"` + ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"` + Misc *MiscPayload `json:"misc,omitempty"` + Background *BackgroundWorkPayload `json:"background_work,omitempty"` + Monitor *MonitorPayload `json:"monitor,omitempty"` } var jp jsonPayload if err := json.Unmarshal(data, &jp); err != nil { @@ -148,6 +155,7 @@ func (p *NormalizedPayload) UnmarshalJSON(data []byte) error { p.showPlan = jp.ShowPlan p.manageTodos = jp.ManageTodos p.misc = jp.Misc + p.backgroundWork = jp.Background return nil } diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 6a5655e824..3e0bb1478f 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -871,7 +871,8 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han if p.services != nil { registerMentionRoutes(p.router, p.services.Mentions) } - taskhandlers.RegisterWorkflowRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.services.Workflow, p.log) + workflowH := taskhandlers.RegisterWorkflowRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.services.Workflow, p.log) + workflowH.SetForegroundActivityProvider(p.orchestratorSvc) taskH := taskhandlers.RegisterTaskRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.orchestratorSvc, p.taskRepo, planService, p.log) if p.services != nil && p.services.User != nil { taskH.SetTaskCreateLastUsedRecorder(p.services.User) diff --git a/apps/backend/internal/orchestrator/background_work_accounting_test.go b/apps/backend/internal/orchestrator/background_work_accounting_test.go index eccd465f9b..325d45637a 100644 --- a/apps/backend/internal/orchestrator/background_work_accounting_test.go +++ b/apps/backend/internal/orchestrator/background_work_accounting_test.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "slices" "testing" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" @@ -18,9 +19,10 @@ func registerAsyncWorkForExecution( taskID, sessionID, executionID, toolCallID, workID string, ) { t.Helper() - payload := streams.NewSubagentTask("background work", "do it", "general-purpose") + payload := attestedSubagentPayload("background work", "do it", "general-purpose") payload.SubagentTask().IsAsync = true payload.SubagentTask().AgentID = workID + payload.SetBackgroundWorkIdentity(streams.BackgroundWorkKindSubagent, workID, true, false) svc.handleAgentStreamEvent(t.Context(), &lifecycle.AgentStreamEventPayload{ TaskID: taskID, SessionID: sessionID, ExecutionID: executionID, Data: &lifecycle.AgentStreamEventData{ @@ -32,6 +34,68 @@ func registerAsyncWorkForExecution( }) } +func TestActiveSubagentCount_DerivesOnlyLiveSubagentRegistrations(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + const sessionID = "session-subagent-count" + + svc.registerBackgroundWorkKind( + sessionID, "subagent-one", "execution", "child-one", streams.BackgroundWorkKindSubagent, + ) + svc.registerBackgroundWorkKind( + sessionID, "subagent-two", "execution", "child-two", streams.BackgroundWorkKindSubagent, + ) + svc.registerBackgroundWorkKind( + sessionID, "shell", "execution", "shell-one", streams.BackgroundWorkKindShell, + ) + svc.registerBackgroundWorkKind( + sessionID, "monitor", "execution", "monitor-one", streams.BackgroundWorkKindMonitor, + ) + if got := svc.ActiveSubagentCount(sessionID); got != 2 { + t.Fatalf("active subagent count = %d, want 2", got) + } + + svc.completeBackgroundTaskForExecution(sessionID, "subagent-one", "execution") + svc.completeBackgroundTaskForExecution(sessionID, "subagent-one", "execution") + if got := svc.ActiveSubagentCount(sessionID); got != 1 { + t.Fatalf("count after duplicate terminal completion = %d, want 1", got) + } +} + +func TestBackgroundCompletion_IntermediateSubagentPublishesBackground(t *testing.T) { + repo := setupTestRepo(t) + const taskID, sessionID, executionID = "task-intermediate", "session-intermediate", "execution-intermediate" + seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + recorded := &recordingEventBus{} + svc.eventBus = recorded + svc.registerBackgroundWorkKind( + sessionID, "tool-one", executionID, "child-one", streams.BackgroundWorkKindSubagent, + ) + svc.registerBackgroundWorkKind( + sessionID, "tool-two", executionID, "child-two", streams.BackgroundWorkKindSubagent, + ) + svc.markForegroundIdle(sessionID) + + publication, changed := svc.completeBackgroundWorkSnapshot( + sessionID, executionID, "child-one", string(v1.ForegroundActivityGenerating), + ) + if !changed { + t.Fatal("intermediate subagent completion must publish the count-only transition") + } + svc.publishForegroundActivitySnapshot(t.Context(), taskID, sessionID, publication) + + if len(recorded.events) != 1 { + t.Fatalf("published events = %d, want 1", len(recorded.events)) + } + data, _ := recorded.events[0].event.Data.(map[string]interface{}) + if got := data["foreground_activity"]; got != string(v1.ForegroundActivityBackground) { + t.Fatalf("published foreground activity = %#v, want background", got) + } + if got := data["active_subagent_count"]; got != 1 { + t.Fatalf("published active subagent count = %#v, want 1", got) + } +} + func TestBackgroundCompletion_IdentifiedRetiresExactWorkAndDuplicateIsHarmless(t *testing.T) { svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) const taskID, sessionID, executionID = "task-accounting", "session-accounting", "execution-accounting" @@ -187,13 +251,13 @@ func TestBackgroundCompletion_UnidentifiedSuccessorCycleRetiresSoleSessionWork(t activityClears++ } } - if activityClears != 1 || len(taskEvents.activityTaskIDs) != 1 { + if activityClears != 1 || len(taskEvents.activityTaskIDs) != 2 { t.Fatalf("sole completion clear cardinality: session=%d task=%v", activityClears, taskEvents.activityTaskIDs) } // The same ID-less notification re-delivered after retirement is a no-op. svc.handleAgentStreamEvent(t.Context(), completion) - if len(taskEvents.activityTaskIDs) != 1 { + if len(taskEvents.activityTaskIDs) != 2 { t.Fatalf("duplicate completion republished task activity: %v", taskEvents.activityTaskIDs) } } @@ -273,23 +337,25 @@ func TestExecutionStop_RetiresOnlyOwnedBackgroundWorkAndPublishesFinalTransition t.Fatalf("final execution cleanup left stale background activity: %q", got) } - activityEvents := 0 + var activityValues []interface{} + var subagentCounts []int for _, record := range events.events { if record.subject != eventtypes.TaskSessionActivityChanged { continue } - activityEvents++ data, ok := record.event.Data.(map[string]interface{}) if !ok { t.Fatalf("terminal cleanup activity payload = %#v", record.event.Data) } - value, present := data["foreground_activity"] - if !present || value != nil { - t.Fatalf("terminal cleanup must explicitly clear foreground_activity, got %#v", data) - } + activityValues = append(activityValues, data["foreground_activity"]) + subagentCounts = append(subagentCounts, data["active_subagent_count"].(int)) } - if activityEvents != 1 { - t.Fatalf("terminal cleanup activity events = %d, want exactly one", activityEvents) + wantValues := []interface{}{"generating", "generating", "background", nil} + if !slices.Equal(activityValues, wantValues) { + t.Fatalf("execution cleanup activity values = %#v, want %#v", activityValues, wantValues) + } + if !slices.Equal(subagentCounts, []int{1, 2, 1, 0}) { + t.Fatalf("execution cleanup subagent counts = %v, want [1 2 1 0]", subagentCounts) } } @@ -667,8 +733,10 @@ func TestExecutionTerminalEvents_ReconcileMissingBackgroundCompletion(t *testing if got := countActivityClears(recorded); got != 1 { t.Fatalf("terminal session activity clears = %d, want exactly one", got) } - if len(taskEvents.activityTaskIDs) != 1 || taskEvents.activityTaskIDs[0] != taskID { - t.Fatalf("terminal task recomputes = %v, want [%s]", taskEvents.activityTaskIDs, taskID) + if len(taskEvents.activityTaskIDs) != 2 || + taskEvents.activityTaskIDs[0] != taskID || + taskEvents.activityTaskIDs[1] != taskID { + t.Fatalf("terminal task recomputes = %v, want [%s %s]", taskEvents.activityTaskIDs, taskID, taskID) } waitForStopCall(t, agentManager) }) @@ -858,9 +926,15 @@ func TestBackgroundWork_RegisteredFromInitialToolCallIsRetiredOnExecutionTeardow seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - payload := streams.NewSubagentTask("background work", "do it", "general-purpose") + payload := attestedSubagentPayload("background work", "do it", "general-purpose") payload.SubagentTask().IsAsync = true payload.SubagentTask().AgentID = "work-initial-call" + payload.SetBackgroundWorkIdentity( + streams.BackgroundWorkKindSubagent, + "work-initial-call", + true, + false, + ) svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{ TaskID: taskID, SessionID: sessionID, ExecutionID: executionID, Data: &lifecycle.AgentStreamEventData{ diff --git a/apps/backend/internal/orchestrator/background_work_test_helpers_test.go b/apps/backend/internal/orchestrator/background_work_test_helpers_test.go index 3743088ef4..60d85c4c7d 100644 --- a/apps/backend/internal/orchestrator/background_work_test_helpers_test.go +++ b/apps/backend/internal/orchestrator/background_work_test_helpers_test.go @@ -1,5 +1,24 @@ package orchestrator +import "github.com/kandev/kandev/internal/agentctl/types/streams" + +func attestedSubagentPayload(description, prompt, subagentType string) *streams.NormalizedPayload { + payload := streams.NewSubagentTask(description, prompt, subagentType) + payload.SetBackgroundWorkIdentity( + streams.BackgroundWorkKindSubagent, + "test-subagent", + false, + false, + ) + return payload +} + +func attestedBackgroundShellPayload(command string) *streams.NormalizedPayload { + payload := streams.NewShellExec(command, "", "", 0, true) + payload.SetBackgroundWorkIdentity(streams.BackgroundWorkKindShell, "", true, false) + return payload +} + // registerBackgroundTask is a test-only convenience wrapper around // registerBackgroundWork for tests that don't care about the execution/work // ID correlation (production call sites always pass those explicitly — see diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 0a3abe14a1..bb46b61780 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -335,12 +335,16 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag // clearExecutionBackgroundWorkSnapshot can never match this entry on // execution teardown, orphaning it if the execution dies before a // terminal tool frame arrives. - s.registerBackgroundWork( + kind := backgroundWorkKind(payload.Data.Normalized) + if s.registerBackgroundWorkKind( payload.SessionID, payload.Data.ToolCallID, payload.ExecutionID, backgroundWorkID(payload.Data.Normalized), - ) + kind, + ) && kind == streams.BackgroundWorkKindSubagent { + s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID) + } case toolOwnershipForeground: if s.markForegroundGenerating(payload.SessionID) { s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID) @@ -599,12 +603,16 @@ func (s *Service) trackBackgroundToolUpdate( // signal arrives. Monitor terminal payloads are no longer classified as // active, and synchronous subagents do not carry IsAsync. if normalizedIsDetachedLaunch(payload.Data.Normalized) { - s.registerBackgroundWork( + kind := backgroundWorkKind(payload.Data.Normalized) + if s.registerBackgroundWorkKind( payload.SessionID, payload.Data.ToolCallID, payload.ExecutionID, backgroundWorkID(payload.Data.Normalized), - ) + kind, + ) && kind == streams.BackgroundWorkKindSubagent { + s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID) + } return } // A finished top-level background task no longer holds the turn open. @@ -638,12 +646,16 @@ func (s *Service) trackBackgroundToolUpdate( // classifier can see them. Register only on that first recognition: // re-registering on later updates would re-set `yielded` and clobber a // foreground stream that meanwhile marked the turn generating again. - s.registerBackgroundWork( + kind := backgroundWorkKind(payload.Data.Normalized) + if s.registerBackgroundWorkKind( payload.SessionID, payload.Data.ToolCallID, payload.ExecutionID, backgroundWorkID(payload.Data.Normalized), - ) + kind, + ) && kind == streams.BackgroundWorkKindSubagent { + s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID) + } } // resolveToolUpdateOwnership preserves the ownership established by the @@ -668,6 +680,9 @@ func backgroundWorkID(payload *streams.NormalizedPayload) string { if payload == nil { return "" } + if background := payload.BackgroundWork(); background != nil { + return background.WorkID + } if subagent := payload.SubagentTask(); subagent != nil { return subagent.AgentID } @@ -677,6 +692,13 @@ func backgroundWorkID(payload *streams.NormalizedPayload) string { return "" } +func backgroundWorkKind(payload *streams.NormalizedPayload) streams.BackgroundWorkKind { + if payload == nil || payload.BackgroundWork() == nil { + return "" + } + return payload.BackgroundWork().Kind +} + // isTerminalToolStatus reports whether a tool_update status marks the tool call // as finished (successfully, in error, or cancelled). func isTerminalToolStatus(status string) bool { @@ -1045,7 +1067,8 @@ func (s *Service) publishTaskSessionStateChanged( // Carry activity only while the session can own foreground/background // work. Every other state gets an explicit null so partial client-store // merges clear a previously-live substate during session.stop/teardown. - "foreground_activity": foregroundActivity, + "foreground_activity": foregroundActivity, + "active_subagent_count": s.ActiveSubagentCount(sessionID), } if stateUpdatedAt != nil && !stateUpdatedAt.IsZero() { eventData[metaKeyUpdatedAt] = stateUpdatedAt.Format(time.RFC3339Nano) diff --git a/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go b/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go index 09892174f6..60971e0102 100644 --- a/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go +++ b/apps/backend/internal/orchestrator/foreground_activity_freshload_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" taskdto "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -84,6 +85,42 @@ func TestFreshLoad_SettledSessionWithDetachedWorkSerializesBackground(t *testing } } +func TestFreshLoad_SessionAndTaskSerializeActiveSubagentCounts(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + register := func(sessionID, toolCallID string) { + svc.handleAgentStreamEvent(t.Context(), &lifecycle.AgentStreamEventPayload{ + TaskID: "task-count", SessionID: sessionID, ExecutionID: "execution-count", + Data: &lifecycle.AgentStreamEventData{ + Type: agentEventToolCall, + ToolCallID: toolCallID, + ToolStatus: "in_progress", + Normalized: attestedSubagentPayload("audit", "inspect", "reviewer"), + }, + }) + } + register("session-one", "subagent-1") + register("session-one", "subagent-2") + register("session-two", "subagent-3") + + sessionDTO := &taskdto.TaskSessionDTO{ID: "session-one", State: models.TaskSessionStateRunning} + taskdto.EnrichForegroundActivity(sessionDTO, svc) + if got := marshalIntField(t, sessionDTO, "active_subagent_count"); got != 2 { + t.Fatalf("session active_subagent_count = %d, want 2", got) + } + + taskDTO := &taskdto.TaskDTO{ID: "task-count"} + sessions := []*models.TaskSession{ + {ID: "session-one", State: models.TaskSessionStateRunning}, + {ID: "session-two", State: models.TaskSessionStateWaitingForInput}, + } + taskdto.EnrichTaskForegroundActivity(taskDTO, sessions, svc) + if got := marshalIntField(t, taskDTO, "active_subagent_count"); got != 3 { + t.Fatalf("task active_subagent_count = %d, want 3", got) + } +} + // marshalField marshals the DTO and returns the string value of foreground_activity. func marshalField(t *testing.T, dto any) string { t.Helper() @@ -110,3 +147,20 @@ func marshalDTO(t *testing.T, dto any) string { } return string(body) } + +func marshalIntField(t *testing.T, dto any, field string) int { + t.Helper() + var wire map[string]json.RawMessage + if err := json.Unmarshal([]byte(marshalDTO(t, dto)), &wire); err != nil { + t.Fatalf("unmarshal wire: %v", err) + } + raw, ok := wire[field] + if !ok { + t.Fatalf("%s absent from wire: %v", field, wire) + } + var val int + if err := json.Unmarshal(raw, &val); err != nil { + t.Fatalf("decode %s: %v", field, err) + } + return val +} diff --git a/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go b/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go index cd61b232d5..131a3d5461 100644 --- a/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go +++ b/apps/backend/internal/orchestrator/foreground_activity_prompt_cycle_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" - "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -53,7 +52,7 @@ func TestForegroundActivitySignal_FollowUpPromptKeepsIndependentCompletionIdenti Type: agentEventToolCall, ToolCallID: "subagent-follow-up", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -80,6 +79,7 @@ func TestForegroundActivitySignal_FollowUpPromptKeepsIndependentCompletionIdenti } got := activityValues(eb) want := []string{ + string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground), @@ -127,7 +127,7 @@ func TestForegroundActivitySignal_PreDispatchEventsUseCurrentPromptCycle(t *test Type: agentEventToolCall, ToolCallID: "subagent-before-dispatch", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -153,6 +153,7 @@ func TestForegroundActivitySignal_PreDispatchEventsUseCurrentPromptCycle(t *test } got := activityValues(eb) want := []string{ + string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground), diff --git a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go index 331dc2f69c..416ee34190 100644 --- a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go +++ b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "errors" + "slices" "testing" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" @@ -70,6 +71,65 @@ func activityValues(eb *recordingEventBus) []string { return vals } +func activitySubagentCounts(eb *recordingEventBus) []int { + var vals []int + for _, rec := range eb.events { + if rec.subject != events.TaskSessionActivityChanged { + continue + } + if data, ok := rec.event.Data.(map[string]interface{}); ok { + if v, ok := data["active_subagent_count"].(int); ok { + vals = append(vals, v) + } + } + } + return vals +} + +func TestForegroundActivitySignal_PublishesCountOnlySubagentTransitions(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + eb := &recordingEventBus{} + svc.eventBus = eb + svc.messageCreator = &mockMessageCreator{} + + const taskID, sessionID = "task-count-only", "session-count-only" + for _, toolCallID := range []string{"subagent-1", "subagent-2"} { + svc.handleAgentStreamEvent(t.Context(), &lifecycle.AgentStreamEventPayload{ + TaskID: taskID, SessionID: sessionID, ExecutionID: "execution-count-only", + Data: &lifecycle.AgentStreamEventData{ + Type: agentEventToolCall, + ToolCallID: toolCallID, + ToolStatus: "in_progress", + Normalized: attestedSubagentPayload("audit", "inspect", "reviewer"), + }, + }) + } + terminal := attestedSubagentPayload("audit", "inspect", "reviewer") + terminal.SetBackgroundWorkIdentity( + streams.BackgroundWorkKindSubagent, + "test-subagent", + false, + true, + ) + svc.handleAgentStreamEvent(t.Context(), &lifecycle.AgentStreamEventPayload{ + TaskID: taskID, SessionID: sessionID, ExecutionID: "execution-count-only", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", + ToolCallID: "subagent-1", + ToolStatus: "completed", + Normalized: terminal, + }, + }) + + if got := activitySubagentCounts(eb); !slices.Equal(got, []int{1, 2, 1}) { + t.Fatalf("count-only activity updates = %v, want [1 2 1]", got) + } + if got := activityValues(eb); !slices.Equal(got, []string{"generating", "generating", "generating"}) { + t.Fatalf("count-only foreground activity = %v", got) + } +} + // TestForegroundActivitySignal_PublishesOnFlips proves the WS1 producer emits // the fine-grained busy signal exactly when the foreground/background substate // flips — background when the agent yields to a spawned task, generating again @@ -96,7 +156,7 @@ func TestForegroundActivitySignal_PublishesOnFlips(t *testing.T) { Type: agentEventToolCall, ToolCallID: "subagent-1", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -114,7 +174,11 @@ func TestForegroundActivitySignal_PublishesOnFlips(t *testing.T) { }) got := activityValues(eb) - want := []string{string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating)} + want := []string{ + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), + } if len(got) != len(want) { t.Fatalf("expected activity signal on each flip %v, got %v", want, got) } @@ -125,9 +189,8 @@ func TestForegroundActivitySignal_PublishesOnFlips(t *testing.T) { } } -// TestForegroundActivitySignal_NoPublishWithoutFlip proves the signal is emitted -// only on a real substate transition, never per background frame: a second -// concurrent background task, and completing all-but-the-last, must NOT publish. +// TestForegroundActivitySignal_NoPublishWithoutFlip proves count-only +// transitions publish even when foreground activity does not flip. func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) { repo := setupTestRepo(t) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -148,7 +211,7 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) { Type: agentEventToolCall, ToolCallID: id, ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, } } @@ -160,7 +223,7 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) { Type: "tool_update", ToolCallID: id, ToolStatus: agentEventComplete, - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, } } @@ -180,7 +243,13 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) { svc.handleAgentStreamEvent(context.Background(), terminal("subagent-2")) got := activityValues(eb) - want := []string{string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating)} + want := []string{ + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityBackground), + string(v1.ForegroundActivityGenerating), + } if len(got) != len(want) { t.Fatalf("expected exactly one publish per real flip %v, got %v", want, got) } @@ -342,7 +411,7 @@ func TestForegroundActivitySignal_PropagatesToTaskLevel(t *testing.T) { Type: agentEventToolCall, ToolCallID: "subagent-1", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -356,7 +425,7 @@ func TestForegroundActivitySignal_PropagatesToTaskLevel(t *testing.T) { }, }) - want := []string{taskID, taskID} + want := []string{taskID, taskID, taskID} if len(taskEvents.activityTaskIDs) != len(want) { t.Fatalf("expected task-level recompute on each flip %v, got %v", want, taskEvents.activityTaskIDs) } @@ -510,7 +579,7 @@ func TestForegroundActivitySignal_SamePromptOutputAfterIdleDoesNotInvalidateComp Type: agentEventToolCall, ToolCallID: "subagent-1", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) if tt.outputType == "tool_update" { @@ -541,6 +610,7 @@ func TestForegroundActivitySignal_SamePromptOutputAfterIdleDoesNotInvalidateComp } got := activityValues(eb) want := []string{ + string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating), string(v1.ForegroundActivityBackground), @@ -553,8 +623,8 @@ func TestForegroundActivitySignal_SamePromptOutputAfterIdleDoesNotInvalidateComp t.Fatalf("activity publication %d = %q, want %q (all %v)", i, got[i], want[i], got) } } - if len(taskEvents.activityTaskIDs) != 3 { - t.Fatalf("task activity publications = %v, want exactly three", taskEvents.activityTaskIDs) + if len(taskEvents.activityTaskIDs) != 4 { + t.Fatalf("task activity publications = %v, want exactly four", taskEvents.activityTaskIDs) } for _, publishedTaskID := range taskEvents.activityTaskIDs { if publishedTaskID != taskID { @@ -578,8 +648,14 @@ func TestForegroundActivitySignal_DetachedLaunchTerminalOutputStaysBackground(t toolID = "async-subagent-launch" ) seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning) - launch := streams.NewSubagentTask("explore", "find files", "general-purpose") + launch := attestedSubagentPayload("explore", "find files", "general-purpose") launch.SubagentTask().IsAsync = true + launch.SetBackgroundWorkIdentity( + streams.BackgroundWorkKindSubagent, + "test-subagent", + true, + false, + ) svc.handleAgentStreamEvent(t.Context(), &lifecycle.AgentStreamEventPayload{ TaskID: taskID, SessionID: sessionID, Data: &lifecycle.AgentStreamEventData{ @@ -606,8 +682,11 @@ func TestForegroundActivitySignal_DetachedLaunchTerminalOutputStaysBackground(t if !svc.hasBackgroundTask(sessionID, toolID) { t.Fatal("terminal launch card must not complete its detached workload") } - if got := activityValues(eb); len(got) != 1 || got[0] != string(v1.ForegroundActivityBackground) { - t.Fatalf("detached launch should publish only the foreground idle transition, got %v", got) + if got := activityValues(eb); !slices.Equal(got, []string{ + string(v1.ForegroundActivityGenerating), + string(v1.ForegroundActivityBackground), + }) { + t.Fatalf("detached launch activity sequence = %v", got) } } diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go index f0a3deb891..d87699b3a0 100644 --- a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go +++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go @@ -264,7 +264,7 @@ func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) { Type: agentEventToolCall, ToolCallID: "subagent-1", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -317,7 +317,7 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGate(t *testing.T) { Type: agentEventToolCall, ToolCallID: "subagent-1", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -333,7 +333,7 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGate(t *testing.T) { Type: "tool_update", ToolCallID: "subagent-1", ToolStatus: agentEventComplete, - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) @@ -368,7 +368,7 @@ func TestForegroundBusySignal_TerminalToolUpdateReclosesGateByIDNotKind(t *testi Type: agentEventToolCall, ToolCallID: "subagent-1", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -416,7 +416,7 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te Type: agentEventToolCall, ToolCallID: "subagent-1", ToolStatus: "running", - Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"), + Normalized: attestedSubagentPayload("explore", "find files", "general-purpose"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -477,8 +477,8 @@ func TestNormalizedIsBackgroundTask(t *testing.T) { want bool }{ {"nil", nil, false}, - {"subagent task", streams.NewSubagentTask("explore", "find files", "general-purpose"), true}, - {"background shell", streams.NewShellExec("sleep 30", "", "", 0, true), true}, + {"unattested subagent task", streams.NewSubagentTask("explore", "find files", "general-purpose"), false}, + {"unattested background shell", streams.NewShellExec("sleep 30", "", "", 0, true), false}, {"foreground shell", streams.NewShellExec("ls", "", "", 0, false), false}, {"active monitor", monitorGenericPayload(false), true}, {"ended monitor", monitorGenericPayload(true), false}, @@ -535,7 +535,7 @@ func TestForegroundBusySignal_BackgroundShellViaUpdate(t *testing.T) { Type: "tool_update", ToolCallID: "bash-1", ToolStatus: "in_progress", - Normalized: streams.NewShellExec("npm run dev", "", "", 0, true), + Normalized: attestedBackgroundShellPayload("npm run dev"), }, }) emitForegroundIdle(svc, taskID, sessionID) @@ -551,7 +551,7 @@ func TestForegroundBusySignal_BackgroundShellViaUpdate(t *testing.T) { Type: "tool_update", ToolCallID: "bash-1", ToolStatus: agentEventComplete, - Normalized: streams.NewShellExec("npm run dev", "", "", 0, true), + Normalized: attestedBackgroundShellPayload("npm run dev"), }, }) if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil { @@ -574,8 +574,14 @@ func TestForegroundBusySignal_AsyncSubagentLaunchCompletionPreservesWorkload(t * svc.messageCreator = &mockMessageCreator{} const sessionID = "session-async-subagent" - payload := streams.NewSubagentTask("background", "sleep", "general-purpose") + payload := attestedSubagentPayload("background", "sleep", "general-purpose") payload.SubagentTask().IsAsync = true + payload.SetBackgroundWorkIdentity( + streams.BackgroundWorkKindSubagent, + "test-subagent", + true, + false, + ) svc.registerBackgroundTask(sessionID, "subagent-1") svc.markForegroundIdle(sessionID) @@ -687,7 +693,7 @@ func TestForegroundBusySignal_UpdateDoesNotReYieldAfterForeground(t *testing.T) Type: "tool_update", ToolCallID: "bash-1", ToolStatus: "in_progress", - Normalized: streams.NewShellExec("npm run dev", "", "", 0, true), + Normalized: attestedBackgroundShellPayload("npm run dev"), }, }) } diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go index 7e862de5f0..d6903e77c4 100644 --- a/apps/backend/internal/orchestrator/foreground_claim_test.go +++ b/apps/backend/internal/orchestrator/foreground_claim_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" - "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -145,7 +144,7 @@ func TestPromptTask_ConcurrentPromptsIntoBackgroundIdleStartOneTurn(t *testing.T Type: "tool_update", ToolCallID: "bash-1", ToolStatus: "in_progress", - Normalized: streams.NewShellExec("npm run dev", "", "", 0, true), + Normalized: attestedBackgroundShellPayload("npm run dev"), }, }) emitForegroundIdle(svc, taskID, sessionID) diff --git a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go index d85cfb2163..5180ae88aa 100644 --- a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go +++ b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go @@ -57,7 +57,7 @@ func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) { toolCallID: "bash-1", burst: 1, normalized: func() *streams.NormalizedPayload { - return streams.NewShellExec("npm run dev", "", "", 0, true) + return attestedBackgroundShellPayload("npm run dev") }, }, { diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go index 79c7e0b359..e31e0e5dec 100644 --- a/apps/backend/internal/orchestrator/turn_activity.go +++ b/apps/backend/internal/orchestrator/turn_activity.go @@ -68,6 +68,7 @@ type turnActivity struct { type backgroundWork struct { executionID string workID string + kind streams.BackgroundWorkKind // seq is a monotonically increasing per-session registration order, // assigned only when a registration is first created. It lets an // uncorrelated (ID-less) completion retire the oldest outstanding @@ -301,8 +302,15 @@ func (s *Service) transitionForegroundToBackground(sessionID string, source fore // never be filled in afterward, and clearExecutionBackgroundWorkSnapshot can // then never retire the entry on execution teardown. func (s *Service) registerBackgroundWork(sessionID, toolCallID, executionID, workID string) { + s.registerBackgroundWorkKind(sessionID, toolCallID, executionID, workID, "") +} + +func (s *Service) registerBackgroundWorkKind( + sessionID, toolCallID, executionID, workID string, + kind streams.BackgroundWorkKind, +) bool { if sessionID == "" || toolCallID == "" { - return + return false } ta := s.turnActivityFor(sessionID, true) ta.mu.Lock() @@ -314,6 +322,9 @@ func (s *Service) registerBackgroundWork(sessionID, toolCallID, executionID, wor if workID != "" { updated.workID = workID } + if kind != "" { + updated.kind = kind + } if !exists { // Assign a sequence only for a brand-new entry: an update that merely // backfills executionID/workID on an existing registration must keep @@ -322,11 +333,32 @@ func (s *Service) registerBackgroundWork(sessionID, toolCallID, executionID, wor ta.backgroundSeq++ updated.seq = ta.backgroundSeq } - if !exists || current != updated { + changed := !exists || current != updated + if changed { ta.revision++ } ta.background[toolCallID] = updated ta.mu.Unlock() + return changed +} + +// ActiveSubagentCount derives the number of live adapter-attested subagents +// from the registration map. It is not maintained as an independent counter, +// so duplicate and out-of-order completion cannot make it drift. +func (s *Service) ActiveSubagentCount(sessionID string) int { + ta := s.turnActivityFor(sessionID, false) + if ta == nil { + return 0 + } + ta.mu.Lock() + defer ta.mu.Unlock() + count := 0 + for _, work := range ta.background { + if work.kind == streams.BackgroundWorkKindSubagent { + count++ + } + } + return count } // hasBackgroundTask reports whether toolCallID is already tracked as outstanding @@ -349,10 +381,8 @@ func (s *Service) hasBackgroundTask(sessionID, toolCallID string) bool { // task, scoped to the execution that owns it (an empty executionID matches any // owner — see the test-only completeBackgroundTask wrapper). When no // background task remains, the foreground turn is no longer "waiting on -// background". It returns true when clearing this task flipped the session -// back out of the background-idle substate (the last outstanding task -// finished), so the caller publishes the activity signal only on that final -// completion. +// background". It returns true whenever a registration was removed so the +// caller also publishes count-only transitions while other work remains. func (s *Service) completeBackgroundTaskForExecution(sessionID, toolCallID, executionID string) bool { if sessionID == "" || toolCallID == "" { return false @@ -369,13 +399,13 @@ func (s *Service) completeBackgroundTaskForExecution(sessionID, toolCallID, exec } delete(ta.background, toolCallID) ta.revision++ - changed := false + visibleChanged := false if len(ta.background) == 0 && ta.yielded { ta.yielded = false - changed = true + visibleChanged = true } ta.mu.Unlock() - return changed + return visibleChanged || work.kind == streams.BackgroundWorkKindSubagent } // completeBackgroundWork retires a provider completion. Identified completions @@ -427,18 +457,24 @@ func (s *Service) completeBackgroundWorkSnapshot( if matchedToolCallID == "" { return activityPublication{}, false } + removed := ta.background[matchedToolCallID] delete(ta.background, matchedToolCallID) ta.revision++ - if !ta.clearYieldWhenEmptyLocked() { + visibleChanged := ta.clearYieldWhenEmptyLocked() + if !visibleChanged && removed.kind != streams.BackgroundWorkKindSubagent { return activityPublication{}, false } - return activityPublication{activity: ta, revision: ta.revision, value: value}, true + publicationValue := value + if !visibleChanged && ta.yielded { + publicationValue = string(v1.ForegroundActivityBackground) + } + return activityPublication{activity: ta, revision: ta.revision, value: publicationValue}, true } // clearExecutionBackgroundWork removes every registration owned by one // terminal execution, preserving any successor execution already using the -// same task session. It returns true only for a visible background->default -// transition; callers publish after the tracker lock is released. +// same task session. It returns true whenever registrations were removed so +// callers publish count-only transitions too. func (s *Service) clearExecutionBackgroundWorkSnapshot( sessionID, executionID string, ) (activityPublication, bool) { @@ -452,21 +488,29 @@ func (s *Service) clearExecutionBackgroundWorkSnapshot( ta.mu.Lock() defer ta.mu.Unlock() removed := false + removedSubagent := false for toolCallID, work := range ta.background { if work.executionID == executionID { delete(ta.background, toolCallID) removed = true + removedSubagent = removedSubagent || work.kind == streams.BackgroundWorkKindSubagent } } if !removed { return activityPublication{}, false } ta.revision++ - changed := ta.clearYieldWhenEmptyLocked() - if !changed { + finalTransition := ta.clearYieldWhenEmptyLocked() + if !finalTransition && !removedSubagent { return activityPublication{}, false } - return activityPublication{activity: ta, revision: ta.revision, value: nil}, true + value := interface{}(string(v1.ForegroundActivityGenerating)) + if ta.yielded { + value = string(v1.ForegroundActivityBackground) + } else if finalTransition { + value = nil + } + return activityPublication{activity: ta, revision: ta.revision, value: value}, true } func (ta *turnActivity) clearYieldWhenEmptyLocked() bool { @@ -856,9 +900,10 @@ func (s *Service) publishForegroundActivityNow( return } eventData := map[string]interface{}{ - metaKeyTaskID: taskID, - metaKeySessionID: sessionID, - "foreground_activity": value, + metaKeyTaskID: taskID, + metaKeySessionID: sessionID, + "foreground_activity": value, + "active_subagent_count": s.ActiveSubagentCount(sessionID), } if err := s.eventBus.Publish(ctx, events.TaskSessionActivityChanged, bus.NewEvent(events.TaskSessionActivityChanged, "task-session", eventData)); err != nil { @@ -873,42 +918,17 @@ func (s *Service) publishForegroundActivityNow( s.publishTaskActivityIfChanged(ctx, taskID) } -// normalizedIsBackgroundTask reports whether a normalized tool payload represents -// spawned background work the foreground turn waits on: a subagent Task, a -// run-in-background shell command, or an active Claude Monitor watch. +// normalizedIsBackgroundTask reports whether the adapter attested a live +// workload whose complete lifecycle Kandev recognizes. // // A create_task tool is deliberately NOT background — it spawns an independent // task/session with its own lifecycle and does not hold the spawning turn open. func normalizedIsBackgroundTask(n *streams.NormalizedPayload) bool { - if n == nil { - return false - } - if n.Kind() == streams.ToolKindSubagentTask { - return true - } - if se := n.ShellExec(); se != nil && se.Background { - return true - } - // A Monitor is a long-running watch the foreground turn is not actively - // generating against. It normalizes to a Generic payload, so it is - // recognized via the shared streams predicate rather than a tool-name match. - if n.IsActiveMonitor() { - return true - } - return false + return n != nil && n.IsActiveBackgroundWork() } // normalizedIsDetachedLaunch distinguishes a launch tool completing from its // asynchronously-running workload completing. func normalizedIsDetachedLaunch(n *streams.NormalizedPayload) bool { - if n == nil { - return false - } - if subagent := n.SubagentTask(); subagent != nil { - return subagent.IsAsync - } - if shell := n.ShellExec(); shell != nil { - return shell.Background - } - return false + return n != nil && n.IsDetachedBackgroundLaunch() } diff --git a/apps/backend/internal/task/dto/active_subagent_count_json_test.go b/apps/backend/internal/task/dto/active_subagent_count_json_test.go new file mode 100644 index 0000000000..32f4ed73cf --- /dev/null +++ b/apps/backend/internal/task/dto/active_subagent_count_json_test.go @@ -0,0 +1,28 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActiveSubagentCountSerializesExplicitZero(t *testing.T) { + tests := map[string]any{ + "task": TaskDTO{}, + "session": TaskSessionDTO{}, + "session summary": TaskSessionSummaryDTO{}, + } + + for name, value := range tests { + t.Run(name, func(t *testing.T) { + encoded, err := json.Marshal(value) + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(encoded, &body)) + assert.Equal(t, float64(0), body["active_subagent_count"]) + }) + } +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 97e5b8b168..639bca8657 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -163,13 +163,14 @@ type TaskDTO struct { // surfaces fall through to the coarse task state (done / waiting / failed). // Computed on the backend and carried on the task record so every task-level // surface reads one authoritative value; stamped by EnrichTaskForegroundActivity. - ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"` - IsRemoteExecutor bool `json:"is_remote_executor,omitempty"` - ParentID string `json:"parent_id,omitempty"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"` + ActiveSubagentCount int `json:"active_subagent_count"` + IsRemoteExecutor bool `json:"is_remote_executor,omitempty"` + ParentID string `json:"parent_id,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Metadata map[string]interface{} `json:"metadata,omitempty"` // Office extensions AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"` @@ -258,7 +259,8 @@ type TaskSessionDTO struct { // background may remain present after the foreground turn settles. // Not persisted — populated at the serialization boundary by // EnrichForegroundActivity, never by FromTaskSession. - ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"` + ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"` + ActiveSubagentCount int `json:"active_subagent_count"` } // TaskSessionSummaryDTO is a lightweight version of TaskSessionDTO without snapshot fields. @@ -297,7 +299,8 @@ type TaskSessionSummaryDTO struct { TaskEnvironmentID string `json:"task_environment_id,omitempty"` // ForegroundActivity mirrors the in-memory fine-grained busy substate // (ADR-0049); see TaskSessionDTO. - ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"` + ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"` + ActiveSubagentCount int `json:"active_subagent_count"` // CommandCount is the number of tool_call messages on this session, // surfaced inline in the timeline entry header ("ran N commands"). // Populated by ListTaskSessions; defaults to 0 for callers that don't @@ -771,6 +774,10 @@ type ForegroundActivityProvider interface { ForegroundActivity(sessionID string) v1.ForegroundActivity } +type ActiveSubagentCountProvider interface { + ActiveSubagentCount(sessionID string) int +} + // EnrichForegroundActivity stamps the live fine-grained busy substate onto a full // session DTO. Generating is emitted only for RUNNING sessions; detached // background activity remains meaningful after the coarse state settles. @@ -781,6 +788,7 @@ func EnrichForegroundActivity(dto *TaskSessionDTO, provider ForegroundActivityPr if activity, ok := sessionForegroundActivity(dto.ID, dto.State, provider); ok { dto.ForegroundActivity = activity } + dto.ActiveSubagentCount = activeSubagentCount(dto.ID, provider) } // EnrichForegroundActivitySummary is EnrichForegroundActivity for the lightweight @@ -792,6 +800,15 @@ func EnrichForegroundActivitySummary(dto *TaskSessionSummaryDTO, provider Foregr if activity, ok := sessionForegroundActivity(dto.ID, dto.State, provider); ok { dto.ForegroundActivity = activity } + dto.ActiveSubagentCount = activeSubagentCount(dto.ID, provider) +} + +func activeSubagentCount(sessionID string, provider ForegroundActivityProvider) int { + countProvider, ok := provider.(ActiveSubagentCountProvider) + if !ok { + return 0 + } + return countProvider.ActiveSubagentCount(sessionID) } // WorkflowStepDTO represents a workflow step for API responses diff --git a/apps/backend/internal/task/dto/task_activity.go b/apps/backend/internal/task/dto/task_activity.go index 934c5e7ec3..f7a122ad93 100644 --- a/apps/backend/internal/task/dto/task_activity.go +++ b/apps/backend/internal/task/dto/task_activity.go @@ -7,15 +7,33 @@ import ( // EnrichTaskForegroundActivity stamps the task-level MOST-ACTIVE-WINS activity // aggregate onto a TaskDTO from the task's sessions. It is a no-op for a nil DTO -// or nil provider, so the field is emitted (via omitempty) only where a live -// activity tracker is wired and never fabricated otherwise. The provider is -// consulted for RUNNING sessions and for settled sessions that may still carry -// detached background liveness. +// or nil provider. ForegroundActivity is omitted when no activity is known; +// ActiveSubagentCount is always serialized as zero when no provider or live +// subagent is available. The provider is consulted for RUNNING sessions and for +// settled sessions that may still carry detached background liveness. func EnrichTaskForegroundActivity(dto *TaskDTO, sessions []*models.TaskSession, provider ForegroundActivityProvider) { if dto == nil || provider == nil { return } dto.ForegroundActivity = v1.AggregateForegroundActivity(sessionForegroundActivities(sessions, provider)) + dto.ActiveSubagentCount = taskActiveSubagentCount(sessions, provider) +} + +func taskActiveSubagentCount( + sessions []*models.TaskSession, + provider ForegroundActivityProvider, +) int { + countProvider, ok := provider.(ActiveSubagentCountProvider) + if !ok { + return 0 + } + total := 0 + for _, session := range sessions { + if session != nil { + total += countProvider.ActiveSubagentCount(session.ID) + } + } + return total } // sessionForegroundActivities resolves generating activity for RUNNING sessions diff --git a/apps/backend/internal/task/handlers/foreground_activity_test.go b/apps/backend/internal/task/handlers/foreground_activity_test.go index 0b75db8bcd..611e539c7c 100644 --- a/apps/backend/internal/task/handlers/foreground_activity_test.go +++ b/apps/backend/internal/task/handlers/foreground_activity_test.go @@ -21,12 +21,17 @@ import ( // tracker in handler tests. type fakeForegroundActivityProvider struct { value v1.ForegroundActivity + count int } func (f fakeForegroundActivityProvider) ForegroundActivity(string) v1.ForegroundActivity { return f.value } +func (f fakeForegroundActivityProvider) ActiveSubagentCount(string) int { + return f.count +} + // orchestratorWithActivity satisfies BOTH OrchestratorStarter and // dto.ForegroundActivityProvider so NewTaskHandlers derives the provider from it. type orchestratorWithActivity struct { @@ -103,6 +108,17 @@ func TestHTTPGetTaskSession_PreservesBackgroundActivityWhenNotRunning(t *testing assert.Contains(t, rec.Body.String(), `"foreground_activity":"background"`) } +func TestTaskDTOBuilderStampsForegroundActivityAndSubagentCount(t *testing.T) { + svc, _ := newSessionHandlerService(t, &models.TaskSession{ID: "sess-run", TaskID: "task-1", State: models.TaskSessionStateRunning}) + h := &TaskHandlers{service: svc, foregroundActivity: fakeForegroundActivityProvider{value: v1.ForegroundActivityBackground, count: 2}, logger: newTestLogger(t)} + + result, err := h.toTaskDTOsWithSessionInfo(context.Background(), []*models.Task{{ID: "task-1"}}) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, v1.ForegroundActivityBackground, result[0].ForegroundActivity) + assert.Equal(t, 2, result[0].ActiveSubagentCount) +} + func doGetTaskSession(t *testing.T, h *TaskHandlers, id string) dto.GetTaskSessionResponse { t.Helper() rec := getTaskSessionRecorder(t, h, id) diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 8db333e6d6..46fa4a06cb 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -525,7 +525,17 @@ func (m *mockRepository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, tas return make(map[string]*models.TaskSession), nil } func (m *mockRepository) BatchGetSessionsByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*models.TaskSession, error) { - return make(map[string][]*models.TaskSession), nil + result := make(map[string][]*models.TaskSession) + wanted := make(map[string]struct{}, len(taskIDs)) + for _, taskID := range taskIDs { + wanted[taskID] = struct{}{} + } + for _, session := range m.sessions { + if _, ok := wanted[session.TaskID]; ok { + result[session.TaskID] = append(result[session.TaskID], session) + } + } + return result, nil } func (m *mockRepository) SetSessionPrimary(ctx context.Context, sessionID string) error { return nil diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index dda0610234..5de787edde 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -201,6 +201,7 @@ func buildTaskDTOsWithSessionInfo( ctx context.Context, svc *service.Service, log *logger.Logger, + activityProvider dto.ForegroundActivityProvider, tasks []*models.Task, ) ([]dto.TaskDTO, error) { if len(tasks) == 0 { @@ -253,6 +254,7 @@ func buildTaskDTOsWithSessionInfo( pendingActionPtr(si.sessionID, pendingActionsBySession), ) taskDTO.TaskPendingAction = taskPendingActionPtr(sessions, pendingActionsBySession) + dto.EnrichTaskForegroundActivity(&taskDTO, sessions, activityProvider) result = append(result, taskDTO) } return result, nil @@ -368,7 +370,7 @@ func pendingActionPtr( } func (h *TaskHandlers) toTaskDTOsWithSessionInfo(ctx context.Context, tasks []*models.Task) ([]dto.TaskDTO, error) { - return buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, tasks) + return buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, h.foregroundActivity, tasks) } func (h *TaskHandlers) httpGetTask(c *gin.Context) { @@ -377,7 +379,7 @@ func (h *TaskHandlers) httpGetTask(c *gin.Context) { handleNotFound(c, h.logger, err, "task not found") return } - dtos, err := buildTaskDTOsWithSessionInfo(c.Request.Context(), h.service, h.logger, []*models.Task{task}) + dtos, err := buildTaskDTOsWithSessionInfo(c.Request.Context(), h.service, h.logger, h.foregroundActivity, []*models.Task{task}) if err != nil { h.logger.Error("failed to build task DTO with session info", zap.Error(err)) c.JSON(http.StatusOK, dto.FromTask(task)) diff --git a/apps/backend/internal/task/handlers/workflow_handlers.go b/apps/backend/internal/task/handlers/workflow_handlers.go index 40da6be4b7..5833136f1c 100644 --- a/apps/backend/internal/task/handlers/workflow_handlers.go +++ b/apps/backend/internal/task/handlers/workflow_handlers.go @@ -25,6 +25,7 @@ type WorkflowStepLister interface { type WorkflowHandlers struct { service *service.Service workflowStepLister WorkflowStepLister + foregroundActivity dto.ForegroundActivityProvider logger *logger.Logger } @@ -42,10 +43,16 @@ func RegisterWorkflowRoutes( svc *service.Service, stepLister WorkflowStepLister, log *logger.Logger, -) { +) *WorkflowHandlers { handlers := NewWorkflowHandlers(svc, stepLister, log) handlers.registerHTTP(router) handlers.registerWS(dispatcher) + return handlers +} + +// SetForegroundActivityProvider wires live session activity into task snapshots. +func (h *WorkflowHandlers) SetForegroundActivityProvider(provider dto.ForegroundActivityProvider) { + h.foregroundActivity = provider } func (h *WorkflowHandlers) registerHTTP(router *gin.Engine) { @@ -545,5 +552,5 @@ func (h *WorkflowHandlers) convertTasksWithPrimarySessions( ctx context.Context, tasks []*models.Task, ) ([]dto.TaskDTO, error) { - return buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, tasks) + return buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, h.foregroundActivity, tasks) } diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 9f5e663fb3..05cb2602a6 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -247,8 +247,9 @@ type Service struct { // taskActivityMu guards lastTaskActivity, the last task-level activity aggregate // emitted per task. It bounds live-propagation task.updated emissions to an // actual change of the aggregated three-state value. - taskActivityMu sync.Mutex - lastTaskActivity map[string]v1.ForegroundActivity + taskActivityMu sync.Mutex + lastTaskActivity map[string]v1.ForegroundActivity + lastTaskSubagentCount map[string]int // taskPublicationMu guards the per-task FIFO dispatchers. It is held only // while enqueueing/dequeueing; repository reads and synchronous EventBus // delivery always happen after it is released. @@ -275,27 +276,28 @@ type Service struct { // NewService creates a new task service func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discoveryConfig RepositoryDiscoveryConfig) *Service { return &Service{ - workspaces: repos.Workspaces, - tasks: repos.Tasks, - taskRepos: repos.TaskRepos, - workspaceFolders: repos.WorkspaceFolders, - workflows: repos.Workflows, - messages: repos.Messages, - turns: repos.Turns, - sessions: repos.Sessions, - gitSnapshots: repos.GitSnapshots, - repoEntities: repos.RepoEntities, - repositoryCleanup: repos.RepositoryCleanup, - executors: repos.Executors, - environments: repos.Environments, - taskEnvironments: repos.TaskEnvironments, - reviews: repos.Reviews, - resourceCleanups: repos.ResourceCleanups, - eventBus: eventBus, - logger: log, - discoveryConfig: discoveryConfig, - branchFetcher: newBranchFetcher(log.Zap()), - lastTaskActivity: make(map[string]v1.ForegroundActivity), + workspaces: repos.Workspaces, + tasks: repos.Tasks, + taskRepos: repos.TaskRepos, + workspaceFolders: repos.WorkspaceFolders, + workflows: repos.Workflows, + messages: repos.Messages, + turns: repos.Turns, + sessions: repos.Sessions, + gitSnapshots: repos.GitSnapshots, + repoEntities: repos.RepoEntities, + repositoryCleanup: repos.RepositoryCleanup, + executors: repos.Executors, + environments: repos.Environments, + taskEnvironments: repos.TaskEnvironments, + reviews: repos.Reviews, + resourceCleanups: repos.ResourceCleanups, + eventBus: eventBus, + logger: log, + discoveryConfig: discoveryConfig, + branchFetcher: newBranchFetcher(log.Zap()), + lastTaskActivity: make(map[string]v1.ForegroundActivity), + lastTaskSubagentCount: make(map[string]int), } } diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index c48fa8068c..32ab8cb933 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -34,8 +34,9 @@ type taskPublication struct { } type taskActivitySnapshot struct { - activity v1.ForegroundActivity - known bool + activity v1.ForegroundActivity + activeSubagentCount int + known bool } // PublishTaskUpdated publishes a task.updated event for the given task. @@ -396,7 +397,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas zap.String("task_id", task.ID), zap.Error(err)) } else if activity.known { - s.recordTaskActivity(task.ID, activity.activity) + s.recordTaskActivitySnapshot(task.ID, activity) } if eventType == events.TaskDeleted { s.forgetTaskActivity(task.ID) @@ -489,7 +490,11 @@ func (s *Service) addTaskForegroundActivityEventField(data map[string]interface{ case sessionsErr != nil: activity = &taskActivitySnapshot{known: false} default: - activity = &taskActivitySnapshot{activity: s.computeTaskForegroundActivityForSessions(sessions), known: true} + activity = &taskActivitySnapshot{ + activity: s.computeTaskForegroundActivityForSessions(sessions), + activeSubagentCount: s.computeTaskActiveSubagentCountForSessions(sessions), + known: true, + } } } if activity.known { @@ -498,6 +503,7 @@ func (s *Service) addTaskForegroundActivityEventField(data map[string]interface{ } else { data["foreground_activity"] = nil } + data["active_subagent_count"] = activity.activeSubagentCount } return activity } diff --git a/apps/backend/internal/task/service/task_activity.go b/apps/backend/internal/task/service/task_activity.go index a904372719..1682590787 100644 --- a/apps/backend/internal/task/service/task_activity.go +++ b/apps/backend/internal/task/service/task_activity.go @@ -18,6 +18,10 @@ type ForegroundActivityProvider interface { ForegroundActivity(sessionID string) v1.ForegroundActivity } +type activeSubagentCountProvider interface { + ActiveSubagentCount(sessionID string) int +} + // SetForegroundActivityProvider wires the live per-session activity tracker used // to compute the task-level MOST-ACTIVE-WINS aggregate. Optional; when unset the // aggregate is left empty and task-level surfaces fall through to the coarse @@ -26,29 +30,43 @@ func (s *Service) SetForegroundActivityProvider(provider ForegroundActivityProvi s.foregroundActivity = provider } -// computeTaskForegroundActivity resolves the task-level MOST-ACTIVE-WINS activity -// aggregate for a task from its active sessions. -// RUNNING sessions contribute foreground activity; settled sessions contribute -// only when their connected execution still reports detached background work. -// -// The second return value reports whether the aggregate is KNOWN. It is false only -// when the session set could not be loaded: the substate is then unavailable, and -// callers must PRESERVE the last-known reading rather than clear it, so a transient -// DB error never resolves a still-working task to a coarse "done" -// The safe fallback applies. A nil provider (feature not -// wired) is a known-empty result, not an error, and a running-but-empty aggregate -// returns ("", true) so it clears as usual. -func (s *Service) computeTaskForegroundActivity(ctx context.Context, taskID string) (v1.ForegroundActivity, bool) { +// computeTaskActivitySnapshot resolves the task-wide activity and subagent +// count from one active-session read. A load failure is unknown so callers +// preserve their last published snapshot; an unwired provider is known-empty. +func (s *Service) computeTaskActivitySnapshot( + ctx context.Context, + taskID string, +) (taskActivitySnapshot, bool) { if s.foregroundActivity == nil { - return "", true + return taskActivitySnapshot{known: true}, true } sessions, err := s.sessions.ListActiveTaskSessionsByTaskID(ctx, taskID) if err != nil { s.logger.Warn("failed to list sessions for task activity aggregate", zap.String("task_id", taskID), zap.Error(err)) - return "", false + return taskActivitySnapshot{}, false } - return s.computeTaskForegroundActivityForSessions(sessions), true + return taskActivitySnapshot{ + activity: s.computeTaskForegroundActivityForSessions(sessions), + activeSubagentCount: s.computeTaskActiveSubagentCountForSessions(sessions), + known: true, + }, true +} + +func (s *Service) computeTaskActiveSubagentCountForSessions( + sessions []*models.TaskSession, +) int { + countProvider, ok := s.foregroundActivity.(activeSubagentCountProvider) + if !ok { + return 0 + } + total := 0 + for _, session := range sessions { + if session != nil { + total += countProvider.ActiveSubagentCount(session.ID) + } + } + return total } // computeTaskForegroundActivityForSessions is computeTaskForegroundActivity's @@ -72,21 +90,15 @@ func (s *Service) computeTaskForegroundActivityForSessions(sessions []*models.Ta return v1.AggregateForegroundActivity(activities) } -// PublishTaskActivityIfChanged recomputes the task-level activity aggregate and -// emits a task.updated ONLY when the aggregated three-state value differs from the -// value last carried to the client — including a generating↔background flip that -// leaves the coarse task/session state unchanged. This bounds the added -// live-propagation traffic to an actual change of the aggregated value -// Safe to call on every per-session -// activity flip; it no-ops when the task-level reading is unaffected. The emitted -// task.updated re-records the value (via addTaskSessionEventFields → -// recordTaskActivity), keeping the dedup map in step with every task event. +// PublishTaskActivityIfChanged emits task.updated when either the task-level +// activity aggregate or live subagent count changes. It is safe to call on +// every session activity flip; unchanged snapshots are deduplicated. func (s *Service) PublishTaskActivityIfChanged(ctx context.Context, taskID string) { if taskID == "" || s.foregroundActivity == nil { return } s.enqueueTaskPublication(ctx, taskID, events.TaskUpdated, func(publicationCtx context.Context) { - current, known := s.computeTaskForegroundActivity(publicationCtx, taskID) + current, known := s.computeTaskActivitySnapshot(publicationCtx, taskID) if !known { // The session set could not be loaded: leave the last-known aggregate in // place instead of emitting a spurious clear that could momentarily read @@ -95,9 +107,12 @@ func (s *Service) PublishTaskActivityIfChanged(ctx context.Context, taskID strin } s.taskActivityMu.Lock() - previous, seen := s.lastTaskActivity[taskID] + previousActivity, activitySeen := s.lastTaskActivity[taskID] + previousCount, countSeen := s.lastTaskSubagentCount[taskID] s.taskActivityMu.Unlock() - if seen && previous == current { + if activitySeen && countSeen && + previousActivity == current.activity && + previousCount == current.activeSubagentCount { return } @@ -109,7 +124,7 @@ func (s *Service) PublishTaskActivityIfChanged(ctx context.Context, taskID strin } return } - s.publishTaskEventNow(publicationCtx, "task.updated", task, nil, nil, nil, &taskActivitySnapshot{activity: current, known: true}) + s.publishTaskEventNow(publicationCtx, "task.updated", task, nil, nil, nil, ¤t) }) } @@ -118,6 +133,10 @@ func (s *Service) PublishTaskActivityIfChanged(ctx context.Context, taskID strin // task.updated / task.state_changed / task.deleted carries the aggregate, so this // keeps the dedup baseline fresh regardless of which path emitted the event. func (s *Service) recordTaskActivity(taskID string, activity v1.ForegroundActivity) { + s.recordTaskActivitySnapshot(taskID, &taskActivitySnapshot{activity: activity, known: true}) +} + +func (s *Service) recordTaskActivitySnapshot(taskID string, snapshot *taskActivitySnapshot) { if taskID == "" { return } @@ -125,7 +144,11 @@ func (s *Service) recordTaskActivity(taskID string, activity v1.ForegroundActivi if s.lastTaskActivity == nil { s.lastTaskActivity = make(map[string]v1.ForegroundActivity) } - s.lastTaskActivity[taskID] = activity + if s.lastTaskSubagentCount == nil { + s.lastTaskSubagentCount = make(map[string]int) + } + s.lastTaskActivity[taskID] = snapshot.activity + s.lastTaskSubagentCount[taskID] = snapshot.activeSubagentCount s.taskActivityMu.Unlock() } @@ -137,5 +160,6 @@ func (s *Service) forgetTaskActivity(taskID string) { } s.taskActivityMu.Lock() delete(s.lastTaskActivity, taskID) + delete(s.lastTaskSubagentCount, taskID) s.taskActivityMu.Unlock() } diff --git a/apps/backend/internal/task/service/task_activity_test.go b/apps/backend/internal/task/service/task_activity_test.go index eaa3004b41..d0721a3e16 100644 --- a/apps/backend/internal/task/service/task_activity_test.go +++ b/apps/backend/internal/task/service/task_activity_test.go @@ -15,13 +15,18 @@ import ( // fakeActivityProvider resolves a per-session foreground activity so the // task-level aggregate can be exercised with distinct values per session. type fakeActivityProvider struct { - byID map[string]v1.ForegroundActivity + byID map[string]v1.ForegroundActivity + counts map[string]int } func (f *fakeActivityProvider) ForegroundActivity(sessionID string) v1.ForegroundActivity { return f.byID[sessionID] } +func (f *fakeActivityProvider) ActiveSubagentCount(sessionID string) int { + return f.counts[sessionID] +} + // blockingActivityEventBus pauses a generating publication until the test // releases it. type blockingActivityEventBus struct { @@ -172,6 +177,42 @@ func TestPublishTaskActivityIfChanged_EmitsOnlyOnAggregateChange(t *testing.T) { } } +func TestPublishTaskActivityIfChanged_EmitsOnCountOnlyChange(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + createTaskWithoutRepositories(t, ctx, repo) + createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning) + createRunningSession(t, ctx, repo, "s2", "task-1", models.TaskSessionStateRunning) + + provider := &fakeActivityProvider{ + byID: map[string]v1.ForegroundActivity{ + "s1": v1.ForegroundActivityGenerating, + "s2": v1.ForegroundActivityGenerating, + }, + counts: map[string]int{"s1": 1}, + } + svc.SetForegroundActivityProvider(provider) + + eventBus.ClearEvents() + svc.PublishTaskActivityIfChanged(ctx, "task-1") + if got := singlePublishedEventData(t, eventBus)["active_subagent_count"]; got != 1 { + t.Fatalf("initial active_subagent_count = %#v, want 1", got) + } + + provider.counts["s1"] = 2 + eventBus.ClearEvents() + svc.PublishTaskActivityIfChanged(ctx, "task-1") + if got := singlePublishedEventData(t, eventBus)["active_subagent_count"]; got != 2 { + t.Fatalf("count-only active_subagent_count = %#v, want 2", got) + } + + eventBus.ClearEvents() + svc.PublishTaskActivityIfChanged(ctx, "task-1") + if events := eventBus.GetPublishedEvents(); len(events) != 0 { + t.Fatalf("unchanged activity and count emitted %d events", len(events)) + } +} + func TestPublishTaskActivityIfChanged_OrdersConcurrentAggregatePublications(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() diff --git a/apps/web/components/task/task-item.test.tsx b/apps/web/components/task/task-item.test.tsx index eb1ac58368..558a29a28f 100644 --- a/apps/web/components/task/task-item.test.tsx +++ b/apps/web/components/task/task-item.test.tsx @@ -15,6 +15,7 @@ const PREPARING_PHASE = "preparing"; const DATA_LOADING_PHASE = "data-loading-phase"; const RUNNING_PHASE = "running"; const YELLOW_SPINNER_CLASS = "text-yellow-500"; +const VIOLET_SPINNER_CLASS = "text-violet-500"; const PREPARING_SPINNER_CLASS = "text-muted-foreground/40"; const SPIN_CLASS = "animate-spin"; const SLOW_SPIN_CLASS = "[animation-duration:2s]"; @@ -187,7 +188,11 @@ describe("TaskItem background-running indicator", () => { }); const icon = screen.getByTestId(BACKGROUND_ICON_TEST_ID); - expect(icon.querySelector(`.${SPIN_CLASS}`)).not.toBeNull(); + expect(icon.classList.contains(VIOLET_SPINNER_CLASS)).toBe(true); + expect(icon.classList.contains(SPIN_CLASS)).toBe(true); + expect(icon.parentElement?.getAttribute("tabindex")).toBe("0"); + expect(icon.parentElement?.classList.contains("focus-visible:ring-2")).toBe(true); + expect(screen.getByLabelText("Background work is running")).not.toBeNull(); expect(screen.queryByTestId(RUNNING_ICON_TEST_ID)).toBeNull(); expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).toBeNull(); }); diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 034365a49f..3d035b3c20 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -21,11 +21,7 @@ import { isDebugUI } from "@/lib/config"; import { useTaskColor } from "@/hooks/use-task-color"; import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; import type { ForegroundActivity, TaskState, TaskSessionState } from "@/lib/types/http"; -import { - getTaskStateIcon, - shouldUseQuestionTaskIcon, - shouldUsePermissionTaskIcon, -} from "@/lib/ui/state-icons"; +import { shouldUseQuestionTaskIcon, shouldUsePermissionTaskIcon } from "@/lib/ui/state-icons"; import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { RemoteCloudTooltip } from "./remote-cloud-tooltip"; @@ -170,6 +166,27 @@ function taskItemRowClick( return (e) => (onSelect ? onSelect(e) : onClick?.()); } +function BackgroundWorkTaskIcon() { + return ( + + + + + + Background work is running + + ); +} + function TaskStateIcon({ sessionState, state, @@ -211,11 +228,7 @@ function TaskStateIcon({ ); } if (foregroundActivity === "background") { - return ( - - {getTaskStateIcon(state, "h-3.5 w-3.5", false, "background")} - - ); + return ; } if (shouldUseQuestionTaskIcon(state)) { return ( diff --git a/apps/web/e2e/tests/chat/busy-signal.spec.ts b/apps/web/e2e/tests/chat/busy-signal.spec.ts index 2b70694892..5fd163147a 100644 --- a/apps/web/e2e/tests/chat/busy-signal.spec.ts +++ b/apps/web/e2e/tests/chat/busy-signal.spec.ts @@ -80,6 +80,12 @@ test.describe("Fine-grained busy signal — composer + status", () => { .sidebarTaskItem("Async subagent lifecycle") .getByTestId("task-state-background-running"); await expect(backgroundIndicator).toBeVisible(); + await expect(backgroundIndicator).toHaveClass(/text-violet-500/); + await expect(backgroundIndicator).toHaveClass(/animate-spin/); + await expect(backgroundIndicator.locator("..")).toHaveAttribute( + "aria-label", + "Background work is running", + ); // A prompt submitted while only the async child remains must be admitted // immediately. Foreground activity temporarily wins over the child. diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 90bfd6617e..9a84bab7c9 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -134,6 +134,13 @@ describe("toKanbanTask — HTTP DTO / WS payload parity", () => { }); describe("toKanbanTask — state normalization", () => { + it("maps the task-wide active subagent count for future status surfaces", () => { + const mapped = toKanbanTask( + wsPayload({ active_subagent_count: 3 } as Partial), + ) as ReturnType & { activeSubagentCount?: number }; + expect(mapped.activeSubagentCount).toBe(3); + }); + it("preserves explicit null pending and activity fields so snapshot updates clear stale values", () => { const mapped = toKanbanTask( wsPayload({ diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index c9b8baf473..2f999c988d 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -49,6 +49,7 @@ export type TaskLike = { primary_session_pending_action?: TaskPendingAction | null; task_pending_action?: TaskPendingAction | null; foreground_activity?: ForegroundActivity | null; + active_subagent_count?: number; session_count?: number | null; review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; primary_executor_id?: string | null; @@ -136,6 +137,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { primarySessionPendingAction: pickPendingAction(source.primary_session_pending_action), taskPendingAction: pickPendingAction(source.task_pending_action), foregroundActivity: pickForegroundActivity(source.foreground_activity), + activeSubagentCount: source.active_subagent_count ?? undefined, sessionCount: source.session_count ?? undefined, reviewStatus: source.review_status ?? undefined, primaryExecutorId: source.primary_executor_id ?? undefined, diff --git a/apps/web/lib/ssr/mapper.test.ts b/apps/web/lib/ssr/mapper.test.ts index c4b349da10..65ed1a5669 100644 --- a/apps/web/lib/ssr/mapper.test.ts +++ b/apps/web/lib/ssr/mapper.test.ts @@ -59,6 +59,19 @@ describe("snapshotToState", () => { expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBeUndefined(); }); + it.each([ + [0, 0], + [3, 3], + [undefined, undefined], + ])("maps active subagent count %s", (wireValue, expected) => { + const snapshot = snapshotWithPendingAction(undefined); + snapshot.tasks[0].active_subagent_count = wireValue; + + const state = snapshotToState(snapshot); + + expect(state.kanban?.tasks[0]?.activeSubagentCount).toBe(expected); + }); + it("preserves workflow step WIP fields", () => { const state = snapshotToState({ workflow: { diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 73a2dff1f0..68b4bd7a43 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -49,6 +49,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { primarySessionPendingAction: pickPendingAction(task.primary_session_pending_action), taskPendingAction: pickPendingAction(task.task_pending_action), foregroundActivity: task.foreground_activity ?? undefined, + activeSubagentCount: task.active_subagent_count ?? undefined, sessionCount: task.session_count ?? undefined, reviewStatus: task.review_status ?? undefined, parentTaskId: task.parent_id ?? undefined, diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index ad97275bd6..c3b6aa8de6 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -79,6 +79,8 @@ export type KanbanState = { * list background-running affordance. */ foregroundActivity?: ForegroundActivity | null; + /** Live subagents across this task's sessions; reserved for future UI. */ + activeSubagentCount?: number; sessionCount?: number | null; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; primaryExecutorId?: string | null; diff --git a/apps/web/lib/types/activity.ts b/apps/web/lib/types/activity.ts new file mode 100644 index 0000000000..ac31c14057 --- /dev/null +++ b/apps/web/lib/types/activity.ts @@ -0,0 +1,5 @@ +export type ForegroundActivity = "generating" | "background"; + +export type ActiveSubagentCountFields = { + active_subagent_count?: number; +}; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index ef4a996af8..a24121e71d 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -94,6 +94,7 @@ export type TaskEventPayload = { // Task-level MOST-ACTIVE-WINS activity aggregate across the task's sessions; // absent/null when no session is running. foreground_activity?: ForegroundActivity | null; + active_subagent_count?: number; session_count?: number | null; review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; archived_at?: string | null; @@ -258,6 +259,7 @@ export type TaskSessionStateChangedPayload = { // Fine-grained busy substate (see ADR-0049), carried on coarse transitions; // live flips arrive on session.activity_changed. foreground_activity?: ForegroundActivity | null; + active_subagent_count?: number; }; /** @@ -269,6 +271,7 @@ export type TaskSessionActivityChangedPayload = { task_id: string; session_id: string; foreground_activity: ForegroundActivity | null; + active_subagent_count: number; }; export type TaskSessionNotificationPayload = { diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index f42122e265..63834d14d9 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -1,4 +1,5 @@ import type { ExecutorType } from "./executor"; +import type { ActiveSubagentCountFields, ForegroundActivity } from "./activity"; import type { UserSettings } from "./http-user-settings"; import type { TaskRepository, WorkspaceFolder } from "./http-workspace-sources"; import type { @@ -13,6 +14,7 @@ import type { OnEnterActionType, StepEvents } from "./workflow-actions"; import type { EntityReference } from "./entity-reference"; export type { ExecutorType } from "./executor"; +export type { ActiveSubagentCountFields, ForegroundActivity } from "./activity"; export type { SavedLayout, SidebarViewApi, @@ -176,8 +178,6 @@ export type TaskPendingAction = "clarification" | "permission"; * `session.activity_changed` WS event and carried on `session.state_changed`; * absent/`null` is treated as "generating" for a RUNNING session. */ -export type ForegroundActivity = "generating" | "background"; - export type Workflow = { id: WorkflowId; workspace_id: WorkspaceId; @@ -297,7 +297,7 @@ export function primaryTaskRepository( return primary; } -export type Task = { +export type Task = ActiveSubagentCountFields & { id: TaskId; workspace_id: WorkspaceId; workflow_id: WorkflowId; @@ -314,13 +314,9 @@ export type Task = { primary_session_pending_action?: TaskPendingAction | null; task_pending_action?: TaskPendingAction | null; /** - * Task-level MOST-ACTIVE-WINS activity aggregate across the task's sessions - * The aggregate is "generating" when any session is generating, - * "background" when none is generating but at least one RUNNING session holds a - * turn open for background work, and absent/`null` when no session is running - * (task-level surfaces then fall through to the coarse task state). Computed on - * the backend and carried on the task record so every task-level surface reads - * one authoritative value. + * Task-level MOST-ACTIVE-WINS activity across sessions. "generating" wins, + * then "background"; null/absent means none is known. The count is the + * corresponding sum of live subagents. */ foreground_activity?: ForegroundActivity | null; session_count?: number | null; @@ -400,7 +396,7 @@ export type TaskSessionWorktree = { created_at?: string; }; -export type TaskSession = { +export type TaskSession = ActiveSubagentCountFields & { id: SessionId; task_id: TaskId; /** Optional user-supplied label shown on the session tab. */ @@ -418,10 +414,7 @@ export type TaskSession = { worktrees?: TaskSessionWorktree[]; task_environment_id?: string; state: TaskSessionState; - /** - * Fine-grained busy substate (ADR-0049). Pushed over - * `session.activity_changed`; background may outlive the foreground turn. - */ + /** Fine-grained busy substate; background may outlive the foreground turn (ADR-0049). */ foreground_activity?: ForegroundActivity | null; error_message?: string; metadata?: Record | null; diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index b0a1cd2af9..90ececadc0 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -58,12 +58,16 @@ function makeMessage(payload: TaskSessionStateChangedPayload) { }; } -function makeActivityMessage(payload: TaskSessionActivityChangedPayload) { +function makeActivityMessage( + payload: Omit & { + active_subagent_count?: number; + }, +) { return { id: "m", type: "notification" as const, action: "session.activity_changed" as const, - payload, + payload: { ...payload, active_subagent_count: payload.active_subagent_count ?? 0 }, }; } @@ -936,6 +940,39 @@ describe("session.state_changed → agentctl ready fallback", () => { }); }); +function applyActivityCount(existingCount: number, nextCount?: number) { + const upsert = vi.fn(); + const store = makeStore({ + taskSessions: { + items: { + "s-1": { + id: "s-1", + task_id: "t-1", + state: "RUNNING", + active_subagent_count: existingCount, + }, + }, + }, + upsertTaskSessionFromEvent: upsert, + }); + const handler = registerTaskSessionHandlers(store)[ACTIVITY_EVENT] as ( + msg: ReturnType, + ) => void; + const payload: Record = { + task_id: "t-1", + session_id: "s-1", + foreground_activity: "background", + }; + if (nextCount !== undefined) payload.active_subagent_count = nextCount; + handler({ + id: "m", + type: "notification", + action: "session.activity_changed", + payload, + } as ReturnType); + return upsert.mock.calls[0][1]; +} + describe("session.activity_changed handler — fine-grained busy signal", () => { beforeEach(() => { vi.clearAllMocks(); @@ -951,7 +988,12 @@ describe("session.activity_changed handler — fine-grained busy signal", () => const handler = registerTaskSessionHandlers(store)[ACTIVITY_EVENT] as (msg: any) => void; handler( - makeActivityMessage({ task_id: "t-1", session_id: "s-1", foreground_activity: "background" }), + makeActivityMessage({ + task_id: "t-1", + session_id: "s-1", + foreground_activity: "background", + active_subagent_count: 2, + }), ); expect(upsert).toHaveBeenCalledTimes(1); @@ -959,9 +1001,18 @@ describe("session.activity_changed handler — fine-grained busy signal", () => id: "s-1", state: "RUNNING", foreground_activity: "background", + active_subagent_count: 2, }); }); + it("preserves the known count when an older activity event omits it", () => { + expect(applyActivityCount(4)).toMatchObject({ active_subagent_count: 4 }); + }); + + it("clears the known count when an activity event explicitly sends zero", () => { + expect(applyActivityCount(4, 0)).toMatchObject({ active_subagent_count: 0 }); + }); + it("keeps accepting detached activity updates after the foreground settles", () => { const upsert = vi.fn(); const store = makeStore({ @@ -1051,12 +1102,14 @@ describe("session.state_changed carries and resets the busy substate", () => { new_state: "RUNNING", // eslint-disable-next-line @typescript-eslint/no-explicit-any foreground_activity: "generating" as any, + active_subagent_count: 2, }), ); expect(upsert.mock.calls[0][1]).toMatchObject({ state: "RUNNING", foreground_activity: "generating", + active_subagent_count: 2, }); }); diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 68741a28d8..0fd3ef9ae8 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -213,6 +213,8 @@ function buildSessionUpdate(payload: any): Record { // background (ADR-0049). if (payload.foreground_activity !== undefined) update.foreground_activity = payload.foreground_activity; + if (payload.active_subagent_count !== undefined) + update.active_subagent_count = payload.active_subagent_count; return update; } @@ -512,6 +514,10 @@ function applyForegroundActivity( started_at: existing.started_at ?? "", updated_at: existing.updated_at ?? "", foreground_activity: payload.foreground_activity ?? null, + active_subagent_count: + payload.active_subagent_count !== undefined + ? payload.active_subagent_count + : (existing.active_subagent_count ?? 0), }); } diff --git a/apps/web/lib/ws/handlers/tasks-activity.test.ts b/apps/web/lib/ws/handlers/tasks-activity.test.ts index 8f0e207dd9..cefa670ffa 100644 --- a/apps/web/lib/ws/handlers/tasks-activity.test.ts +++ b/apps/web/lib/ws/handlers/tasks-activity.test.ts @@ -73,7 +73,11 @@ function makeMessage(payload: Record) { } describe("task.updated task-level activity aggregate (live propagation + safe fallback)", () => { - type ExistingTask = { id: string; foregroundActivity?: "generating" | "background" }; + type ExistingTask = { + id: string; + foregroundActivity?: "generating" | "background"; + activeSubagentCount?: number; + }; function storeWithTask(existing: ExistingTask) { const task = { workflowStepId: "step1", title: "T", position: 0, ...existing }; @@ -145,4 +149,41 @@ describe("task.updated task-level activity aggregate (live propagation + safe fa expect(kanban).toBe("background"); expect(multi).toBe("background"); }); + + it("applies and clears the active subagent count, including count-only updates", () => { + const store = storeWithTask({ + id: "t1", + foregroundActivity: "generating", + activeSubagentCount: 2, + }); + const handlers = registerTasksHandlers(store); + + handlers["task.updated"]!(makeMessage({ ...makeTask("t1"), active_subagent_count: 0 })); + + expect(store.getState().kanban.tasks.find((task) => task.id === "t1")).toMatchObject({ + foregroundActivity: "generating", + activeSubagentCount: 0, + }); + expect( + store.getState().kanbanMulti.snapshots.wf1.tasks.find((task) => task.id === "t1"), + ).toMatchObject({ + foregroundActivity: "generating", + activeSubagentCount: 0, + }); + }); + + it("preserves the active subagent count when a partial update omits it", () => { + const store = storeWithTask({ id: "t1", activeSubagentCount: 3 }); + const handlers = registerTasksHandlers(store); + + handlers["task.updated"]!(makeMessage({ ...makeTask("t1"), title: "Renamed" })); + + expect( + store.getState().kanban.tasks.find((task) => task.id === "t1")?.activeSubagentCount, + ).toBe(3); + expect( + store.getState().kanbanMulti.snapshots.wf1.tasks.find((task) => task.id === "t1") + ?.activeSubagentCount, + ).toBe(3); + }); }); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 4801b32db2..a0e6cb8a07 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -86,6 +86,12 @@ function mergeTaskUpdate( ) { merged.foregroundActivity = existing.foregroundActivity; } + if ( + !hasPayloadField(payload, "active_subagent_count") && + nextTask.activeSubagentCount === undefined + ) { + merged.activeSubagentCount = existing.activeSubagentCount; + } return merged; } diff --git a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md index b18be8d40d..5846e6f24a 100644 --- a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md +++ b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md @@ -1,6 +1,6 @@ # 0049: Fine-grained foreground-idle busy signal -**Status:** accepted (amended 2026-07-24) +**Status:** accepted (amended 2026-07-25) **Date:** 2026-07-11 **Area:** backend, frontend, protocol **Related:** [Background work liveness spec](../specs/platform/background-work-liveness.md) @@ -50,9 +50,42 @@ to the foreground only: while it generates the completion summary. - A `RUNNING` session accepts a new prompt when its foreground turn is idle and at least one recognized background task is outstanding; otherwise it keeps rejecting input exactly as before. -- Recognition keys off the **normalized shape** of the work (subagent task / `run_in_background` shell / active Monitor), not tool-name string matching. The ACP normalizer is corrected to recognize Claude's `run_in_background:true` shell shape (a normalizer bug fix in its own right). -- Monitor recognition rests on **adapter attestation, not payload shape**. A Monitor normalizes to a Generic payload, and that payload's `Output` is assigned the agent's raw tool result verbatim — so *nothing carried inside `Output` can vouch for its own origin*, however many fields a classifier demands. The ACP adapter therefore stamps a typed `MonitorPayload` as a **sibling** of the Generic payload, on the path already gated by ACP `_meta.claudeCode.toolName` (metadata the claude-agent-acp wrapper sets, which model tool output cannot reach). `IsActiveMonitor` classifies on that attestation alone. `Output` keeps carrying the view the frontend card renders — it is a presentation contract, not a trust one. (The payload's `Name` is likewise unusable as a discriminator: it carries the ACP tool *kind*, which is `"other"` for Monitor.) -- The default is **busy**: any agent whose in-flight frames are not recognized as background work (Codex, OpenCode, and any future agent) preserves today's exact reject-while-`RUNNING` contract. The narrowing is capability-gated, not assumed — and, per the point above, an unrecognized agent cannot relax its own gate by shaping its tool output. +- Recognition rests on **adapter attestation, not normalized presentation + shape**. A normalized subagent, shell, or Generic card describes what the UI + renders; it does not prove that Kandev understands the provider's complete + workload lifecycle. The adapter stamps typed background-work provenance only + on a path whose launch, foreground-yield, and accountable terminal frames are + covered by protocol fixtures. Agent-shaped input or output is never allowed + to stamp that provenance. +- Initial capability support is deliberately narrow: Claude subagents, + `run_in_background` shells, and Monitor watches retain their tested support; + Codex subagents opt in once child terminal activity is correlated; OpenCode, + Cursor, Auggie, Copilot, Amp, generic ACP, and future agents remain + foreground-busy until their full lifecycle is covered. This is a typed + adapter capability, not a central string whitelist of agent names. +- The mock provider is a dev/E2E-only exception that replays captured Claude + lifecycle metadata through this trusted path; it does not expand production + agent support. +- Codex child activity may use a different ACP tool-call ID from the original + launch. Its child thread/session ID is therefore the workload correlation + identity. Completion, interruption, cancellation, error, shutdown, and + provider-reported disappearance retire the original registration exactly + once; generic collaboration control cards do not create new subagent + registrations. +- Monitor remains the motivating trust example. It normalizes to a Generic + payload whose `Output` is raw agent data, so the adapter-issued typed + provenance remains a sibling of presentation data. The same trust rule now + governs every background-work kind rather than Monitor alone. +- The default is **busy**: any agent whose in-flight frames are not attested as + recognized background work preserves the reject-while-`RUNNING` contract. An + unrecognized agent cannot relax its own gate by shaping a tool card or result. +- The live registry stores workload kind and provider-owned child identity per + registration. `active_subagent_count` is derived by counting live subagent + registrations, never maintained as an independent mutable counter. Session + DTOs and activity events expose that count; task DTOs sum it across sessions. + Shells and Monitor watches affect liveness but not the subagent count. This + preserves identities for a future subagent list without committing the + current UI to one. - Admission is **check-and-claim, not check-then-act**. The gate is a pure read, so `PromptTask` follows a passing read with an atomic `claimForegroundTurn` before it drives the turn: the check and the flip back to foreground-generating happen under one lock. Without this, two prompts landing in the background-idle window together (a double-send, two tabs) would both pass the read — the window spans a session reload, `ensureSessionRunning`, and a possibly network-bound model switch — and both reach `executor.Prompt`, starting overlapping turns on one ACP session. Exactly one prompt wins; the losers are rejected with `ErrAgentPromptInProgress` just as they were before this ADR. - The claim is **held, not merely taken**, and is tracked independently of background-idle activity. It survives until agentctl accepts the prompt, so a background tool call landing while the prompt is in preflight or queued cannot reopen the gate underneath it. The lifecycle adapter reports that exact dispatch boundary before waiting for turn completion. Claim tokens bind the activity record and a monotonically increasing admission generation, so a delayed completion or release cannot mutate a newer claim for the same session. A failed pre-dispatch prompt reopens the gate only if no newer foreground output invalidated its captured foreground epoch. Every transition that changes the substate is broadcast, including a release and background work that becomes visible when a claim completes, so clients cannot remain stranded on the admission-time value. - Lifecycle prompt delivery is serialized per agent execution. Agentctl acknowledges transport dispatch before the adapter's prompt RPC completes, so adapter-level queuing alone does not protect lifecycle's shared completion channel and response buffers from concurrent callers. An execution-scoped mutex gives each prompt one waiter and one buffer set; dispatch-only sends leave a pending-completion barrier that the next send must consume before resetting those buffers. @@ -79,6 +112,10 @@ The distinction is surfaced to the operator as a fine-grained substate (`foregro - The composer gates on foreground-generating rather than coarse `RUNNING`. - A tri-state status indicator distinguishes generating / working-in-background / done; the established "running" affordance is unchanged and a distinct indicator is *added* for the background-idle substate — it never reads as "done" while background work runs. +- The compact sidebar deliberately reuses the dashed running spinner: yellow + means foreground generation and violet means background work. A distinct + tooltip and accessible label carry the textual meaning; richer status + surfaces continue to show the background-work label. - The substate is delivered live over a `session.activity_changed` WS event and carried on `session.state_changed`. It is also read from the in-memory tracker into the boot payload and the session REST/WS DTOs. Generating is meaningful @@ -89,19 +126,28 @@ The distinction is surfaced to the operator as a fine-grained substate (`foregro The operator is no longer falsely locked out while background work runs, and the UI truthfully shows "working in the background" as distinct from both "generating" and "done". Accepting input earlier does not make delivery earlier for a held-open subagent turn — that message still waits behind the open exchange, as the queue does today — but out-of-turn work (a Monitor burst, a backgrounded shell after the main turn yielded) is forwarded promptly with no false "already running" rejection. -The signal is best-effort across a backend or agent-execution restart. Connected -executions retain background liveness across foreground turns, but detached -work that survives a restart cannot be reconstructed without an agent-side -liveness API. Recognition is deliberately Claude-shaped today (subagent task, -`run_in_background` shell, active Monitor are Claude features); other agents -keep today's behavior. +The signal and count are best-effort across a backend or agent-execution +restart. Connected executions retain background liveness and exact subagent +registrations across foreground turns, but work that survives a restart cannot +be reconstructed without an agent-side liveness API. Claude and Codex are the +initial attested adapters; all others keep the conservative busy behavior. ## Alternatives Considered - **Rely on upstream synthetic idle-turn completion alone.** Rejected: it structurally cannot arm while the foreground prompt exchange is open, and its debounce re-extends under event bursts, so the held-open and chained-burst windows remain. A falsifiable acceptance test drives a chatty Monitor and confirms a prompt sent during a burst is accepted only with the fine-grained gate. - **Drop the `RUNNING` gate / always accept input.** Rejected: it would let a new message race a genuinely-generating foreground turn, risking dropped or reordered messages and regressing non-steering agents. - **Persist the foreground/background distinction to the database (like the coarse states).** Rejected: persistence without an agent-side reconciliation API becomes a second source of truth and can survive restart as a false live value after the workload has died. Connected-execution tracking is kept in memory and read at serialization boundaries; turn close no longer destroys it, while execution teardown does. -- **Recognize background work by tool-name string matching.** Rejected as brittle across agents and updates; recognition keys on the normalized payload shape so producer and consumer share one contract. +- **Recognize background work by normalized payload shape.** Rejected: the shape + is shared presentation data and proves neither provider capability nor + terminal lifecycle support. It caused Codex starts to register globally even + though terminal child activities were not correlated. +- **Maintain a central agent-name whitelist.** Rejected: an agent identity does + not prove that the installed adapter/provider version emits the lifecycle + frames Kandev expects. Capability is stamped at the adapter recognition point + and locked by fixtures instead. +- **Maintain an increment/decrement subagent counter.** Rejected: duplicate or + out-of-order terminal events can drift an independent counter. Count is + derived from the idempotent live registration map. - **Treat every parentless tool update as foreground activity.** Rejected: incremental providers may omit lineage metadata on an update after supplying it on the initial call. Reclassifying from each update makes missing metadata diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 6f90c5fa10..624c4eac1d 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -56,7 +56,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0046 | [Settings route save coordinator](0046-settings-route-save-coordinator.md) | accepted | frontend | 2026-07-14 | | 0047 | [Plugins read conversation content via a capability-gated Host RPC](0047-plugin-host-conversation-reads.md) | accepted | backend, protocol | 2026-07-21 | | 0048 | [Plugins invoke a settings-selectable utility agent](0048-plugin-host-utility-agent-invoke.md) | accepted | backend, frontend, protocol | 2026-07-21 | -| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | accepted (amended 2026-07-24) | backend, frontend, protocol | 2026-07-11 | +| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | accepted (amended 2026-07-25) | backend, frontend, protocol | 2026-07-11 | | 2026-07-23-opencode-review-evidence-trust | [Trusted OpenCode Review Evidence](2026-07-23-opencode-review-evidence-trust.md) | accepted | workflow, infra | 2026-07-23 | | 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | accepted | workflow | 2026-07-23 | | 2026-07-23-post-commit-hook-aware-verification | [Post-Commit Hook-Aware Verification](2026-07-23-post-commit-hook-aware-verification.md) | accepted | workflow | 2026-07-23 | diff --git a/docs/plans/background-work-liveness/plan.md b/docs/plans/background-work-liveness/plan.md index 66d24d09a3..ed1ee9ce8a 100644 --- a/docs/plans/background-work-liveness/plan.md +++ b/docs/plans/background-work-liveness/plan.md @@ -1,7 +1,7 @@ --- spec: docs/specs/platform/background-work-liveness.md created: 2026-07-21 -status: done +status: in_progress --- # Implementation Plan: Background work liveness @@ -27,6 +27,21 @@ ends with `_meta._claude/origin.kind=task-notification`. A terminal launch-tool frame therefore cannot be treated as terminal evidence for either foreground idleness or detached-work completion. +## Lifecycle hardening amendment (2026-07-25) + +The next pass replaces global normalized-shape recognition with typed +adapter-issued background-work attestation. Claude retains its covered +subagent, background-shell, and Monitor paths; Codex subagents opt in after +terminal child activities are correlated by child thread ID across ACP +tool-call IDs. Other adapters remain conservatively foreground-busy. + +The orchestrator extends each live registration with workload kind and derives +`active_subagent_count` from the registration map. Session REST/boot/WS payloads +carry the per-session count, task payloads sum it across sessions, and count-only +changes publish even if `foreground_activity` stays generating or background. +The count is runtime-only and does not include background shells or Monitor +watches. This pass does not add a count badge or subagent list to the UI. + ## Backend ### ACP detached-work lifecycle @@ -94,6 +109,18 @@ idleness or detached-work completion. normalization, so desktop and mobile use the same view model with no new composition, touch, navigation, or responsive behavior. +### Sidebar background spinner + +- In `apps/web/components/task/task-item.tsx`, render background work with the + same `IconCircleDashed` spinner used for foreground running, using violet + instead of yellow. +- Preserve `task-state-background-running` and add the distinct + “Background work is running” tooltip/accessibility name. Do not change the + board, graph, header, or chat status icon vocabulary in this pass. +- Treat this as a shared compact-row styling change. The nearest mobile + exemplar is the existing mobile task switcher/sidebar row; verify the shared + component at mobile width without introducing desktop-only composition. + ## Tests - **Foreground precedence:** background + foreground output/claim reads @@ -115,6 +142,24 @@ idleness or detached-work completion. - **Frontend flags/store:** settled+background is working but not busy; `RUNNING`+generating is busy; foreground always wins; final completion is idle. Files: `use-session-state.test.ts` and relevant session WS slice tests. +- **Adapter trust:** presentation-only subagent/shell payloads do not relax the + gate; only Claude/Codex adapter fixtures stamp trusted lifecycle provenance. + Files: stream payload tests, ACP normalizer/dialect tests, and + `foreground_busy_signal_test.go`. +- **Codex cross-ID terminal correlation:** start under one tool-call ID followed + by completed/interrupted/cancelled/error/shutdown/not-found child activity + under another retires the original registration exactly once; controls do not + create a second card. Files: `dialect_codex_test.go`, `subagent_test.go`, and + focused orchestrator lifecycle tests. +- **Active subagent count:** one typed registration per live subagent; duplicate + terminal events are harmless; shells/Monitor do not count; execution teardown + clears registrations; session and task DTOs expose exact count on fresh load; + count-only WS/task updates publish. Files: stream payload tests, + `background_work_accounting_test.go`, foreground activity fresh-load/signal + tests, and task DTO/service tests. +- **Sidebar spinner:** background uses `IconCircleDashed`, violet spinning + classes, a distinct test ID, and an accessible background label while + generating stays yellow. File: `apps/web/components/task/task-item.test.tsx`. ## E2E Tests @@ -130,6 +175,10 @@ idleness or detached-work completion. foreground-ended/background-live outcome at Pixel 5 width. No mobile layout contract changes; this covers the shared state normalization already rendered by the existing mobile surface. +- Re-run desktop and Pixel 5 busy-signal scenarios to prove the composer accepts + input only for attested background work and the shared sidebar/task-switcher + renders the violet dashed spinner. Provider coverage itself stays at captured + ACP fixture level; CI does not need live credentials for every agent. ## Implementation Waves @@ -184,3 +233,19 @@ Wave 12: Wave 13: - [x] [Task 13: Async lifecycle review and verification](task-13-async-lifecycle-review-and-verification.md) + +Wave 14: + +- [x] [Task 14: Attest supported background lifecycles](task-14-attest-background-lifecycles.md) + +Wave 15: + +- [x] [Task 15: Publish active subagent counts](task-15-publish-active-subagent-counts.md) + +Wave 16: + +- [x] [Task 16: Align the sidebar background spinner](task-16-sidebar-background-spinner.md) + +Wave 17: + +- [ ] [Task 17: Verify hardened background liveness](task-17-verify-hardened-background-liveness.md) diff --git a/docs/plans/background-work-liveness/task-14-attest-background-lifecycles.md b/docs/plans/background-work-liveness/task-14-attest-background-lifecycles.md new file mode 100644 index 0000000000..b640a05236 --- /dev/null +++ b/docs/plans/background-work-liveness/task-14-attest-background-lifecycles.md @@ -0,0 +1,32 @@ +--- +id: "14-attest-background-lifecycles" +title: "Attest supported background lifecycles" +status: done +wave: 14 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/background-work-liveness.md" +--- + +# Task 14: Attest supported background lifecycles + +- **Acceptance:** Only tested Claude and Codex paths can stamp typed + background-work provenance; normalized presentation shape alone remains + foreground-busy. Codex terminal child activities correlate to the original + launch by child thread/session ID across tool-call IDs and retire it exactly + once without turning collaboration controls into subagent cards. +- **Verification:** `cd apps/backend && go test -race ./internal/agentctl/types/streams ./internal/agentctl/server/adapter/transport/acp ./internal/orchestrator`. +- **Files likely touched:** + `apps/backend/internal/agentctl/types/streams/tool_payload.go`, a focused + background provenance file and tests, + `apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_codex.go`, + `subagent.go`, their tests, and + `apps/backend/internal/orchestrator/turn_activity.go`. +- **Dependencies:** None. +- **Inputs:** Spec adapter-attestation and Codex cross-ID scenarios; ADR 0049 + adapter trust boundary; existing Monitor attestation pattern and Codex + correlation cache. +- **Worker:** Primary planner, direct implementation; standard model tier. +- **Output contract:** Report the typed provenance contract, supported adapter + matrix, captured RED/GREEN wire fixtures for every terminal status, exact + commands/results, risks, and update only this task status. diff --git a/docs/plans/background-work-liveness/task-15-publish-active-subagent-counts.md b/docs/plans/background-work-liveness/task-15-publish-active-subagent-counts.md new file mode 100644 index 0000000000..2931884022 --- /dev/null +++ b/docs/plans/background-work-liveness/task-15-publish-active-subagent-counts.md @@ -0,0 +1,31 @@ +--- +id: "15-publish-active-subagent-counts" +title: "Publish active subagent counts" +status: done +wave: 15 +depends_on: ["14-attest-background-lifecycles"] +plan: "plan.md" +spec: "../../specs/platform/background-work-liveness.md" +--- + +# Task 15: Publish active subagent counts + +- **Acceptance:** The live registry retains workload kind and provider child + identity and derives exact session counts without a separate mutable counter. + Session REST/boot/state/activity payloads expose `active_subagent_count`; + task payloads sum it across sessions; count-only transitions publish. + Duplicate/out-of-order terminal events and execution teardown cannot leave a + stale count, and shells/Monitor watches never inflate it. +- **Verification:** `cd apps/backend && go test -race ./internal/orchestrator ./internal/task/...`. +- **Files likely touched:** + `apps/backend/internal/orchestrator/turn_activity.go`, + `event_handlers_streaming.go`, foreground/background accounting tests, + `apps/backend/internal/task/dto/dto.go`, DTO enrichment and task aggregation + tests, plus API v1 activity types if required by the existing DTO pattern. +- **Dependencies:** Task 14 typed provenance and terminal correlation. +- **Inputs:** Spec API/persistence/count scenarios; ADR 0049 derived-count + invariant; existing foreground activity DTO/WS publication path. +- **Worker:** Primary planner, direct implementation; standard model tier. +- **Output contract:** Report registration schema, session/task aggregation + semantics, fresh-load and count-only publication evidence, exact + commands/results, risks, and update only this task status. diff --git a/docs/plans/background-work-liveness/task-16-sidebar-background-spinner.md b/docs/plans/background-work-liveness/task-16-sidebar-background-spinner.md new file mode 100644 index 0000000000..74603412a0 --- /dev/null +++ b/docs/plans/background-work-liveness/task-16-sidebar-background-spinner.md @@ -0,0 +1,33 @@ +--- +id: "16-sidebar-background-spinner" +title: "Align the sidebar background spinner" +status: done +wave: 16 +depends_on: + ["14-attest-background-lifecycles", "15-publish-active-subagent-counts"] +plan: "plan.md" +spec: "../../specs/platform/background-work-liveness.md" +--- + +# Task 16: Align the sidebar background spinner + +- **Acceptance:** The shared sidebar/task-switcher row renders foreground + generation with the existing yellow dashed spinner and background work with + the same dashed spinner in violet. The background element retains a distinct + test ID and exposes “Background work is running” through tooltip/accessibility + text. Other task-state icon surfaces remain unchanged. +- **Verification:** `cd apps && pnpm --filter @kandev/web test -- components/task/task-item.test.tsx`; focused rendered inspection at desktop and mobile width. +- **Files likely touched:** `apps/web/components/task/task-item.tsx` and + `apps/web/components/task/task-item.test.tsx`. +- **Dependencies:** Tasks 14-15 establish the trustworthy state feeding the + shared row. +- **Inputs:** Spec sidebar indicator behavior; existing + `IconCircleDashed` generating branch; nearest mobile exemplar in the shared + task switcher/sidebar row. +- **Worker:** Primary planner, direct localized UI implementation; standard + model tier. +- **Mobile parity:** Shared content/style only; no layout, navigation, touch, or + responsive contract changes. Verify the same component at mobile width. +- **Output contract:** Report exact DOM/accessibility contract, RED/GREEN unit + evidence, rendered desktop/mobile check, exact commands/results, and update + only this task status. diff --git a/docs/plans/background-work-liveness/task-17-verify-hardened-background-liveness.md b/docs/plans/background-work-liveness/task-17-verify-hardened-background-liveness.md new file mode 100644 index 0000000000..977540ad07 --- /dev/null +++ b/docs/plans/background-work-liveness/task-17-verify-hardened-background-liveness.md @@ -0,0 +1,36 @@ +--- +id: "17-verify-hardened-background-liveness" +title: "Verify hardened background liveness" +status: in_progress +wave: 17 +depends_on: + - "14-attest-background-lifecycles" + - "15-publish-active-subagent-counts" + - "16-sidebar-background-spinner" +plan: "plan.md" +spec: "../../specs/platform/background-work-liveness.md" +--- + +# Task 17: Verify hardened background liveness + +- **Acceptance:** A coverage matrix proves supported Claude/Codex lifecycle + admission, unsupported-adapter fail-closed behavior, exact session/task + counts, Codex terminal cleanup, prompt acceptance, fresh-load state, and + violet sidebar rendering. Backend race suites, frontend checks, and focused + desktop/Pixel 5 busy-signal E2E pass with no stale background state after + terminal activity or teardown. +- **Verification:** `make -C apps/backend fmt`; `make -C apps/backend test lint`; + `cd apps/web && pnpm run typecheck`; `cd apps && pnpm --filter @kandev/web test +-- components/task/task-item.test.tsx`; `cd apps/web && pnpm e2e --project=chromium +e2e/tests/chat/busy-signal.spec.ts`; `cd apps/web && pnpm e2e --project=mobile-chrome +e2e/tests/chat/mobile-busy-signal.spec.ts`. +- **Files likely touched:** Regression/E2E corrections only, plus this task + status. Public docs are reviewed for the new API fields; no count UI is added. +- **Dependencies:** Tasks 14-16. +- **Inputs:** All new spec scenarios, ADR 0049, task handoffs, and existing + desktop/mobile busy-signal fixtures. +- **Worker:** Primary planner coordinates; final verification must use the + required change-aware verifier after commit when implementation is approved. +- **Output contract:** Report the agent capability matrix, lifecycle/count/UI + evidence, exact full verification results, residual provider limitations, and + update only this task status. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index fcfa863296..6a4d770c21 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -150,13 +150,11 @@ "sources": [ "apps/backend/internal/profiles/profiles.go", "apps/web/app/settings/agents/page.tsx", - "apps/web/components/settings/agent-usage-section.tsx", "apps/web/hooks/domains/settings/use-dynamic-models.ts" ], "tests": [ "apps/backend/internal/profiles/profiles_test.go", "apps/web/components/settings/agent-profile-page.test.tsx", - "apps/web/components/settings/agent-usage-section.test.tsx", "apps/web/hooks/domains/settings/use-dynamic-models.test.ts" ], "settingsRoutes": ["/settings/agents", "/settings/prompts", "/settings/utility-agents"], diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index 16a6b5ad09..d880fec5d8 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -686,6 +686,7 @@ environment.created environment.updated environment.deleted session.state_changed +session.activity_changed session.turn.started session.turn.completed session.poll_mode_changed @@ -701,6 +702,15 @@ system.job.update Current lifecycle code broadcasts `session.turn.started` and `session.turn.completed` globally even though their payloads identify a session. `task.subscribe` does not change this global routing. +`session.activity_changed` carries `task_id`, `session_id`, `foreground_activity`, +and `active_subagent_count`. The activity is `generating`, `background`, or +`null`; the count is always an integer and is zero when no adapter-attested +subagent is live for that session. Task lifecycle notifications expose the same count +aggregated across +the task's sessions as `active_subagent_count`. Count-only changes may emit +`session.activity_changed` and `task.updated` even when the foreground activity +and coarse lifecycle state do not change. + Office workspace notifications are also global; clients filter on `workspace_id`: ```text diff --git a/docs/specs/platform/background-work-liveness.md b/docs/specs/platform/background-work-liveness.md index 82c0c700a6..2b4b7460f7 100644 --- a/docs/specs/platform/background-work-liveness.md +++ b/docs/specs/platform/background-work-liveness.md @@ -1,7 +1,7 @@ --- status: shipped created: 2026-07-21 -updated: 2026-07-24 +updated: 2026-07-25 owner: kandev --- @@ -21,8 +21,19 @@ and incorrectly prevents prompt delivery. may appear as done. - A foreground-idle session with recognized background work accepts a new prompt. A session with foreground activity continues to reject it. +- Only adapter-attested workloads relax prompt admission. An adapter opts in a + workload only when Kandev has fixture-tested both its launch and accountable + terminal lifecycle. Claude subagents, background shells, and Monitor watches, + plus Codex subagents, are initially supported; other ACP agents remain + conservatively foreground-busy. +- The runtime retains one registration per live subagent and derives an active + subagent count from those registrations. Background shells and Monitor + watches contribute to background liveness but not to the subagent count. - Task and session status surfaces distinguish generating, - background-running, and done without relying on color alone. A pending + background-running, and done with text or accessible labels in addition to + color. The compact sidebar uses the same dashed spinner for both running + states: yellow for foreground generation and violet for background work, with + a distinct background-work tooltip and accessible label. A pending permission request takes precedence over a pending clarification request, which in turn takes precedence over the work-state indicator for a current, input-capable session only; stale pending flags on starting or terminal @@ -41,11 +52,18 @@ and incorrectly prevents prompt delivery. - Session records and boot payloads expose `foreground_activity` as `generating`, `background`, or absent when no fine-grained activity is known. `background` can be present after the coarse session state settles. -- `session.activity_changed` publishes a changed fine-grained session value; - `session.state_changed` carries it with coarse state changes. +- Session records, boot payloads, activity/state notifications, task records, + and `task.updated` always expose `active_subagent_count` as an integer. It is + zero when no adapter-attested subagent is live and may be positive while + `foreground_activity` is either `generating` or `background`. +- `session.activity_changed` publishes the changed fine-grained session value + and active subagent count; a count-only transition is publishable even when + the activity tier does not change. `session.state_changed` carries both with + coarse state changes. - Task records and `task.updated` carry the most-active-wins - `foreground_activity` aggregate. A task update is emitted when that aggregate - changes, including a generating-to-background transition with no coarse state + `foreground_activity` aggregate and the sum of active subagents across its + sessions. A task update is emitted when either aggregate changes, including a + generating-to-background or count-only transition with no coarse state change. ## State machine @@ -65,6 +83,16 @@ execution so each prompt owns its completion wait and response buffers. - An unknown activity value for an in-flight `RUNNING` session is rendered as generating, not done. +- A normalized tool-card shape cannot attest to background capability. An + unsupported adapter that emits a subagent-looking card remains foreground-busy + until its launch, yield, and terminal lifecycle are explicitly supported. +- The development/E2E mock provider may replay captured Claude lifecycle + metadata through the same attested path. This is a deterministic test seam, + not an additional production-agent capability. +- A supported adapter must retire the original registration when a terminal + child event arrives under a different tool-call ID. Codex child thread/session + identity is the correlation key; interrupt, cancellation, failure, shutdown, + and normal completion are terminal. - Tool-call ownership is established by the initial call and retained across incremental updates. An update with unknown ownership preserves the current activity; missing parent metadata is not evidence that background-child work @@ -84,14 +112,29 @@ execution so each prompt owns its completion wait and response buffers. Fine-grained activity is in memory and is authoritative only while the owning agent execution remains connected. A backend or agent-execution restart does -not reconstruct detached work; it must not preserve a stale live reading. -Durable coarse state continues to survive as before. +not reconstruct detached work or active subagent counts; it must not preserve a +stale live reading. Counts are derived from the live registration map rather +than persisted or incremented independently. Durable coarse state continues to +survive as before. ## Scenarios - **GIVEN** a foreground-idle session with a recognized background workload, **WHEN** the operator sends a prompt, **THEN** the prompt is accepted while the status remains background-running until foreground activity begins. +- **GIVEN** two adapter-attested subagents and one background shell are live, + **WHEN** the session is serialized or publishes an activity update, **THEN** + `active_subagent_count` is two while all three workloads still contribute to + background liveness. +- **GIVEN** one generating session with one live subagent and another session + with two live subagents, **WHEN** the task is serialized, **THEN** its + `foreground_activity` is generating and its `active_subagent_count` is three. +- **GIVEN** a Codex subagent launch and a later terminal child activity under a + different tool-call ID, **WHEN** both identify the same child thread, **THEN** + the original registration is retired and the count decreases exactly once. +- **GIVEN** an unsupported ACP adapter emits a normalized subagent-shaped tool + card, **WHEN** its prompt remains in flight, **THEN** Kandev reports the + foreground as generating and the active subagent count remains zero. - **GIVEN** a task with one generating session and one background-running session, **WHEN** its aggregate is rendered, **THEN** it shows generating. - **GIVEN** a task with no generating session and one background-running @@ -123,6 +166,7 @@ Durable coarse state continues to survive as before. - Mid-turn steering for agents without concurrent-prompt capability. - Reconstructing detached-work liveness after backend or agent-execution restart. +- Rendering the active subagent count or individual subagent details in the UI. - Changing Office autonomous-agent status vocabulary. - Changing archive behavior when the operator has disabled archive confirmation.