From 1ae8f4cf9f9d81afcea2aa9e2574376e50a26455 Mon Sep 17 00:00:00 2001 From: nova28 Date: Sat, 15 Aug 2026 22:58:14 +0800 Subject: [PATCH] feat(backend): persist subagent tool-call context to task_session_subagents (#2671) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio --- apps/backend/internal/backendapp/adapters.go | 10 + .../internal/backendapp/orchestrator.go | 1 + apps/backend/internal/backendapp/services.go | 1 + .../internal/orchestrator/event_handlers.go | 11 +- .../orchestrator/event_handlers_streaming.go | 150 +- .../event_handlers_streaming_subagent_test.go | 561 ++++ apps/backend/internal/orchestrator/service.go | 24 + .../internal/task/models/subagent_context.go | 51 + .../internal/task/repository/interface.go | 12 + .../internal/task/repository/sqlite/base.go | 10 +- .../task/repository/sqlite/base_migrations.go | 47 + .../task/repository/sqlite/base_schema.go | 55 +- .../repository/sqlite/subagent_context.go | 160 ++ .../sqlite/subagent_context_activation.go | 35 + .../sqlite/subagent_context_backfill.go | 262 ++ .../sqlite/subagent_context_health.go | 156 ++ .../sqlite/subagent_context_health_test.go | 209 ++ .../sqlite/subagent_context_migration_test.go | 790 ++++++ .../sqlite/subagent_context_postgres_test.go | 422 +++ .../sqlite/subagent_context_test.go | 641 +++++ .../sqlite/subagent_context_timestamp.go | 57 + .../sqlite/subagent_context_timestamp_test.go | 74 + .../task/repository/sqlite/workspace_test.go | 10 + apps/backend/internal/task/service/service.go | 3 + .../internal/task/service/subagent_context.go | 171 ++ .../task/service/subagent_context_metrics.go | 12 + .../task/service/subagent_context_test.go | 475 ++++ ...-08-15-subagent-context-schema-boundary.md | 38 + docs/decisions/INDEX.md | 1 + .../subagent-context-persistence/plan.md | 506 ++++ .../task-01-schema-and-backfill.md | 88 + .../task-02-repository-upsert.md | 85 + .../task-03-service-writer.md | 87 + .../task-04-orchestrator-wiring.md | 89 + .../task-05-postgres-and-health.md | 86 + docs/specs/INDEX.md | 1 + .../subagent-context-persistence/spec.md | 2400 +++++++++++++++++ 37 files changed, 7751 insertions(+), 40 deletions(-) create mode 100644 apps/backend/internal/orchestrator/event_handlers_streaming_subagent_test.go create mode 100644 apps/backend/internal/task/models/subagent_context.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_activation.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_health.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_health_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_timestamp.go create mode 100644 apps/backend/internal/task/repository/sqlite/subagent_context_timestamp_test.go create mode 100644 apps/backend/internal/task/service/subagent_context.go create mode 100644 apps/backend/internal/task/service/subagent_context_metrics.go create mode 100644 apps/backend/internal/task/service/subagent_context_test.go create mode 100644 docs/decisions/2026-08-15-subagent-context-schema-boundary.md create mode 100644 docs/plans/subagent-context-persistence/plan.md create mode 100644 docs/plans/subagent-context-persistence/task-01-schema-and-backfill.md create mode 100644 docs/plans/subagent-context-persistence/task-02-repository-upsert.md create mode 100644 docs/plans/subagent-context-persistence/task-03-service-writer.md create mode 100644 docs/plans/subagent-context-persistence/task-04-orchestrator-wiring.md create mode 100644 docs/plans/subagent-context-persistence/task-05-postgres-and-health.md create mode 100644 docs/specs/subagent-context-persistence/spec.md diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index f9758e12c6..8577938cee 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -782,6 +782,16 @@ func (w *orchestratorWrapper) SteerTask(ctx context.Context, taskID, sessionID, return w.svc.SteerTask(ctx, taskID, sessionID, prompt, model, planMode, attachments) } +// subagentContextAdapter adapts the task service to the +// orchestrator.SubagentContextRecorder interface. +type subagentContextAdapter struct { + svc *taskservice.Service +} + +func (a *subagentContextAdapter) RecordSubagentContext(ctx context.Context, req taskservice.RecordSubagentContextRequest) { + a.svc.RecordSubagentContext(ctx, req) +} + // messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface type messageCreatorAdapter struct { svc *taskservice.Service diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index 7323217842..d40de52eab 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -143,6 +143,7 @@ func provideOrchestrator( msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log} orchestratorSvc.SetMessageCreator(msgCreator) + orchestratorSvc.SetSubagentContextRecorder(&subagentContextAdapter{svc: taskSvc}) orchestratorSvc.SetTurnService(newTurnServiceAdapter(taskSvc)) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 4eefd8d7f1..860671ea5d 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -99,6 +99,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Reviews: repos.Task, ResourceCleanups: repos.Task, StatusSummaries: repos.Task, + SubagentContexts: repos.Task, }, eventBus, log, diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index ca599ef0db..2589e890f6 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -14,11 +14,12 @@ import ( // Agent event type string constants. const ( - agentEventComplete = "complete" - agentEventCompleted = "completed" - agentEventError = "error" - agentEventToolCall = "tool_call" - agentEventFailed = "failed" + agentEventComplete = "complete" + agentEventCompleted = "completed" + agentEventError = "error" + agentEventToolCall = "tool_call" + agentEventToolUpdate = "tool_update" + agentEventFailed = "failed" ) // toolKindToMessageType maps the normalized tool kind to a frontend message type. diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 07c6d1a5aa..58688bf2ca 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/sessionstate" "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -74,6 +75,13 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle terminalCompleteStream = true } } else if s.shouldDropCompletedExecutionStreamEvent(payload) { + // Keep the message side suppressed for completed executions, but do not + // discard a late subagent frame before its durable context is recorded. + // This guard runs before the event-type switch below, so handler-level + // recording alone would not cover the production dispatch path. + if eventType == agentEventToolCall || eventType == agentEventToolUpdate { + s.recordSubagentContextFromFrame(ctx, payload, s.nonCreatingActiveTurnID(ctx, payload.SessionID)) + } return } @@ -101,7 +109,7 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle s.saveAgentTextIfPresent(ctx, payload) s.handleToolCallEvent(ctx, payload) - case "tool_update": + case agentEventToolUpdate: s.handleToolUpdateEvent(ctx, payload) case agentEventComplete: @@ -314,9 +322,20 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag s.logger.Warn("missing session_id for tool_call", zap.String("task_id", payload.TaskID), zap.String("tool_call_id", payload.Data.ToolCallID)) + // A recognized subagent_task frame with no session id is exactly the + // AC-2 identity-skip case (skipped_no_identity++): this guard predates + // the subagent-context feature and exists to protect message + // creation, so it must not silently swallow that counter too. + s.recordSubagentContextFromFrame(ctx, payload, "") return } if s.shouldDropCompletedExecutionStreamEvent(payload) { + // A late-arriving frame for an already-completed execution is + // correctly dropped for message purposes (see the guard's own + // contract), but it can be the ONLY frame that ever recognizes and + // settles this subagent — dropping it here too would permanently + // omit the row's status/token/duration data (AC-1, AC-11). + s.recordSubagentContextFromFrame(ctx, payload, s.nonCreatingActiveTurnID(ctx, payload.SessionID)) return } @@ -349,6 +368,11 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag // the task to REVIEW) leaves session=RUNNING with task=REVIEW. s.setSessionRunningForExecution(ctx, payload.TaskID, payload.SessionID, payload.ExecutionID) } + // Recording a subagent-context observation must never itself start a + // turn as a side effect (that would mutate durable state purely to label + // a telemetry row) — use the non-creating lookup here even though + // message creation above may have legitimately started one already. + s.recordSubagentContextFromFrame(ctx, payload, s.nonCreatingActiveTurnID(ctx, payload.SessionID)) ownership := toolOwnershipForeground if payload.Data.ParentToolCallID != "" { @@ -525,9 +549,17 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle. s.logger.Warn("missing session_id for tool_update", zap.String("task_id", payload.TaskID), zap.String("tool_call_id", payload.Data.ToolCallID)) + // See the matching comment in handleToolCallEvent: this guard + // predates the subagent-context feature and must not silently + // swallow the AC-2 skipped_no_identity counter. + s.recordSubagentContextFromFrame(ctx, payload, "") return } if s.shouldDropCompletedExecutionStreamEvent(payload) { + // See the matching guard in handleToolCallEvent: a dropped update can + // be the subagent's final terminal frame, and must still settle the + // durable row even though the message side is correctly ignored. + s.recordSubagentContextFromFrame(ctx, payload, s.nonCreatingActiveTurnID(ctx, payload.SessionID)) return } ownership := s.resolveToolUpdateOwnership(payload) @@ -564,10 +596,6 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle. // Split out of handleToolUpdateEvent to keep that function within the // package's function-length limits; no behavior change. func (s *Service) persistToolUpdateMessage(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { - if s.messageCreator == nil { - return - } - // Determine message type from normalized payload for fallback creation msgType := toolKindToMessageType(payload.Data.Normalized) status := payload.Data.ToolStatus @@ -591,34 +619,45 @@ func (s *Service) persistToolUpdateMessage(ctx context.Context, payload *lifecyc // update-only reconciliation and cannot wake a settled session. turnID = "" } - } else { + } else if s.messageCreator != nil { + // Only message creation needs a turn to attach a fallback-created + // card to, which is why this branch alone may lazily start one. + // Recording subagent context must never create a turn merely to + // label a row — see nonCreatingActiveTurnID. turnID = s.getActiveTurnID(payload.SessionID) - } - fallbackMsgType := msgType - if terminal && turnID == "" { - // A late terminal update can update its existing card, but must not - // create a message (and implicitly a turn) after the turn settled. - fallbackMsgType = "" + } else { + turnID = s.nonCreatingActiveTurnID(ctx, payload.SessionID) } - if err := s.messageCreator.UpdateToolCallMessage( - ctx, - payload.TaskID, - payload.Data.ToolCallID, - payload.Data.ParentToolCallID, // Pass parent for subagent nesting - status, - "", // result - no longer used, tool results in NormalizedPayload - payload.SessionID, - payload.Data.ToolTitle, // Include title from update event - turnID, // Turn ID for fallback creation - fallbackMsgType, // Empty for settled terminal reconciliations - payload.Data.Normalized, // Pass normalized tool data for message metadata - ); err != nil { - s.logger.Warn("failed to update tool call message", - zap.String("task_id", payload.TaskID), - zap.String("tool_call_id", payload.Data.ToolCallID), - zap.Error(err)) + // Message persistence is optional (see SetMessageCreator); turn-id + // resolution and subagent-context recording below must not depend on it. + if s.messageCreator != nil { + fallbackMsgType := msgType + if terminal && turnID == "" { + // A late terminal update can update its existing card, but must not + // create a message (and implicitly a turn) after the turn settled. + fallbackMsgType = "" + } + if err := s.messageCreator.UpdateToolCallMessage( + ctx, + payload.TaskID, + payload.Data.ToolCallID, + payload.Data.ParentToolCallID, // Pass parent for subagent nesting + status, + "", // result - no longer used, tool results in NormalizedPayload + payload.SessionID, + payload.Data.ToolTitle, // Include title from update event + turnID, // Turn ID for fallback creation + fallbackMsgType, // Empty for settled terminal reconciliations + payload.Data.Normalized, // Pass normalized tool data for message metadata + ); err != nil { + s.logger.Warn("failed to update tool call message", + zap.String("task_id", payload.TaskID), + zap.String("tool_call_id", payload.Data.ToolCallID), + zap.Error(err)) + } } + s.recordSubagentContextFromFrame(ctx, payload, turnID) // Terminal updates only wake an async turn that was established by prior // substantive output. A standalone terminal reconciliation belongs to the @@ -768,6 +807,59 @@ func isTerminalToolStatus(status string) bool { } } +// recordSubagentContextFromFrame persists a durable relational record of a +// subagent (Task tool) invocation when this frame's normalized payload is a +// recognized subagent_task. No-op when subagentContexts is unwired, when the +// frame isn't a subagent_task, or when the normalizer hasn't yet attached the +// typed payload (the initial tool_call for Claude/OpenCode carries none — +// recognition happens on a later tool_call_update, see AC-1a). turnID is +// whatever the call site already resolved; this helper never re-derives one. +// +// payload.SessionID is the Kandev task session id despite the +// agentSessionID-shaped naming downstream: messageCreatorAdapter passes the +// same value into CreateMessageRequest.TaskSessionID +// (internal/backendapp/adapters.go:879). +func (s *Service) recordSubagentContextFromFrame(ctx context.Context, payload *lifecycle.AgentStreamEventPayload, turnID string) { + if s.subagentContexts == nil || payload == nil || payload.Data == nil { + return + } + normalized := payload.Data.Normalized + if normalized == nil || normalized.Kind() != streams.ToolKindSubagentTask { + return + } + subagentTask := normalized.SubagentTask() + if subagentTask == nil { + return + } + s.subagentContexts.RecordSubagentContext(ctx, taskservice.RecordSubagentContextRequest{ + TaskSessionID: payload.SessionID, + TaskID: payload.TaskID, + TurnID: turnID, + ToolCallID: payload.Data.ToolCallID, + ParentToolCallID: payload.Data.ParentToolCallID, + ExecutionID: payload.ExecutionID, + ToolStatus: payload.Data.ToolStatus, + Payload: subagentTask, + ObservedAt: time.Now().UTC(), + }) +} + +// nonCreatingActiveTurnID resolves sessionID's active turn without ever +// starting one — unlike getActiveTurnID, whose doc comment explains it +// lazily starts a turn "even in edge cases like resumed sessions". Every +// caller here uses the result only to label a subagent-context row, never to +// attach a message, so recording an observation must never itself mutate +// state by creating a durable turn as a side effect. A lookup error is +// treated as "no turn known", matching the fail-closed pattern already used +// for terminal tool updates below. +func (s *Service) nonCreatingActiveTurnID(ctx context.Context, sessionID string) string { + turnID, err := s.peekActiveTurnID(ctx, sessionID) + if err != nil { + return "" + } + return turnID +} + func (s *Service) shouldDropCompletedExecutionStreamEvent(payload *lifecycle.AgentStreamEventPayload) bool { if payload == nil || payload.ExecutionID == "" || payload.SessionID == "" { return false diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_subagent_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_subagent_test.go new file mode 100644 index 0000000000..1ac02d2053 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_subagent_test.go @@ -0,0 +1,561 @@ +package orchestrator + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// fakeSubagentContextRecorder records every RecordSubagentContext call it +// receives, for unit-level assertions on what the orchestrator handlers +// build without going through a real repository. +type fakeSubagentContextRecorder struct { + mu sync.Mutex + requests []taskservice.RecordSubagentContextRequest +} + +func (r *fakeSubagentContextRecorder) RecordSubagentContext(_ context.Context, req taskservice.RecordSubagentContextRequest) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, req) +} + +func (r *fakeSubagentContextRecorder) all() []taskservice.RecordSubagentContextRequest { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]taskservice.RecordSubagentContextRequest, len(r.requests)) + copy(out, r.requests) + return out +} + +// serviceBackedSubagentContextRecorder adapts a real *taskservice.Service to +// orchestrator.SubagentContextRecorder, mirroring +// backendapp.subagentContextAdapter, so the integration test below exercises +// handler -> service -> repository end to end (same pattern as +// serviceBackedMessageCreator / newServiceBackedMessageCreator above). +type serviceBackedSubagentContextRecorder struct { + svc *taskservice.Service +} + +func (r *serviceBackedSubagentContextRecorder) RecordSubagentContext(ctx context.Context, req taskservice.RecordSubagentContextRequest) { + r.svc.RecordSubagentContext(ctx, req) +} + +// seedActiveTurn creates an open turn row and warms the in-memory active-turn +// cache getActiveTurnID reads first, so tests don't race a lazily-started +// turn with a different ID than the one they seeded. +func seedActiveTurn(t *testing.T, svc *Service, repo *sqliterepo.Repository, taskID, sessionID, turnID string) { + t.Helper() + now := time.Now().UTC() + require.NoError(t, repo.CreateTurn(context.Background(), &models.Turn{ + ID: turnID, TaskSessionID: sessionID, TaskID: taskID, + StartedAt: now, CreatedAt: now, UpdatedAt: now, + })) + svc.activeTurns.Store(sessionID, turnID) +} + +func newSubagentTaskToolCallPayload(taskID, sessionID, toolCallID, parentToolCallID, toolStatus string, normalized *streams.NormalizedPayload) *lifecycle.AgentStreamEventPayload { + return &lifecycle.AgentStreamEventPayload{ + TaskID: taskID, SessionID: sessionID, ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_call", ToolCallID: toolCallID, ParentToolCallID: parentToolCallID, + ToolStatus: toolStatus, ToolTitle: "Task", Normalized: normalized, + }, + } +} + +// TestHandleToolCallEventRecordsSubagentContext covers AC-1: a subagent_task +// frame on handleToolCallEvent records a context with the active turn id. +func TestHandleToolCallEventRecordsSubagentContext(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + normalized := streams.NewSubagentTask("review the diff", "prompt", "security-reviewer") + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload("task1", "session1", "tc-1", "", "pending", normalized)) + + requests := recorder.all() + require.Len(t, requests, 1) + req := requests[0] + require.Equal(t, "session1", req.TaskSessionID) + require.Equal(t, "task1", req.TaskID) + require.Equal(t, "turn1", req.TurnID) + require.Equal(t, "tc-1", req.ToolCallID) + require.Equal(t, "pending", req.ToolStatus) + require.NotNil(t, req.Payload) + require.Equal(t, "security-reviewer", req.Payload.SubagentType) +} + +// TestHandleToolUpdateEventRecordsSubagentContextAgain covers AC-3: a later +// frame for the same tool call on the update path records again, so the +// upsert's merge path runs. +func TestHandleToolUpdateEventRecordsSubagentContextAgain(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + launchPayload := streams.NewSubagentTask("review the diff", "prompt", "security-reviewer") + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload("task1", "session1", "tc-1", "", "pending", launchPayload)) + + updatePayload := streams.NewSubagentTask("review the diff", "prompt", "security-reviewer") + updatePayload.SubagentTask().Status = "completed" + svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", ToolCallID: "tc-1", ToolStatus: "in_progress", + Normalized: updatePayload, + }, + }) + + requests := recorder.all() + require.Len(t, requests, 2, "one recorded request per frame") + require.Equal(t, "turn1", requests[0].TurnID) + require.Equal(t, "turn1", requests[1].TurnID) +} + +// TestHandleToolUpdateEventCarriesTerminalToolStatus covers AC-11: a terminal +// update's ACP tool_status reaches the recorded request. +func TestHandleToolUpdateEventCarriesTerminalToolStatus(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload( + "task1", "session1", "tc-1", "", "pending", streams.NewSubagentTask("d", "p", "reviewer"))) + + terminalPayload := streams.NewSubagentTask("d", "p", "reviewer") + terminalPayload.SubagentTask().Status = "completed" + svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", ToolCallID: "tc-1", ToolStatus: agentEventComplete, + Normalized: terminalPayload, + }, + }) + + requests := recorder.all() + require.Len(t, requests, 2) + require.Equal(t, agentEventComplete, requests[1].ToolStatus) +} + +// TestHandleAgentStreamEventFromCompletedExecutionStillRecordsSubagentContext +// covers the public stream dispatcher. The completed-execution guard runs +// before the event-type switch, so handler-level tests alone would not catch +// a regression that drops the frame before the recorder can see it. +func TestHandleAgentStreamEventFromCompletedExecutionStillRecordsSubagentContext(t *testing.T) { + for _, eventType := range []string{"tool_call", "tool_update"} { + t.Run(eventType, func(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + messages := &mockMessageCreator{} + svc.messageCreator = messages + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + svc.markExecutionCompleted("session1", "exec-1") + + payload := streams.NewSubagentTask("d", "p", "reviewer") + payload.SubagentTask().Status = "completed" + event := &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: eventType, ToolCallID: "tc-late", ToolStatus: agentEventComplete, + Normalized: payload, + }, + } + + svc.handleAgentStreamEvent(ctx, event) + + require.Zero(t, messages.toolCallWrites+messages.toolUpdateWrites, + "the completed-execution guard must still drop the message side") + requests := recorder.all() + require.Len(t, requests, 1, + "the dispatcher must record a late subagent frame before dropping it") + require.Equal(t, "tc-late", requests[0].ToolCallID) + require.Equal(t, agentEventComplete, requests[0].ToolStatus) + }) + } +} + +// TestHandleToolCallEventIgnoresNonSubagentKind covers "a non-subagent_task +// normalized kind records nothing". +func TestHandleToolCallEventIgnoresNonSubagentKind(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + svc.handleToolCallEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_call", ToolCallID: "tc-read", ToolStatus: "pending", + Normalized: streams.NewReadFile("/tmp/file.txt", 0, 100), + }, + }) + + require.Empty(t, recorder.all()) +} + +// TestHandleToolCallEventRecordsSkipCounterEvenWithEmptySessionID covers +// AC-2: a subagent_task frame with an empty session id must still reach the +// subagent-context identity gate (which increments skipped_no_identity) — +// not be swallowed by the pre-existing "missing session_id" guard at the top +// of handleToolCallEvent, which predates this feature and exists to protect +// message creation, not subagent-context writer health. +func TestHandleToolCallEventRecordsSkipCounterEvenWithEmptySessionID(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload( + "task1", "", "tc-1", "", "pending", streams.NewSubagentTask("d", "p", "reviewer"))) + + requests := recorder.all() + require.Len(t, requests, 1, "an empty session id must still reach the identity gate so skipped_no_identity increments") + require.Empty(t, requests[0].TaskSessionID) +} + +// TestHandleToolUpdateEventRecordsSkipCounterEvenWithEmptySessionID is the +// tool_update-path counterpart of +// TestHandleToolCallEventRecordsSkipCounterEvenWithEmptySessionID. +func TestHandleToolUpdateEventRecordsSkipCounterEvenWithEmptySessionID(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", ToolCallID: "tc-1", ToolStatus: "in_progress", + Normalized: streams.NewSubagentTask("d", "p", "reviewer"), + }, + }) + + requests := recorder.all() + require.Len(t, requests, 1, "an empty session id must still reach the identity gate so skipped_no_identity increments") + require.Empty(t, requests[0].TaskSessionID) +} + +// TestHandleToolCallEventNilSubagentContextsRecorderIsSafe covers AC-27: a +// nil recorder must not panic, and the message write still happens. +func TestHandleToolCallEventNilSubagentContextsRecorderIsSafe(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + messages := &mockMessageCreator{} + svc.messageCreator = messages + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + require.Nil(t, svc.subagentContexts) + + require.NotPanics(t, func() { + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload( + "task1", "session1", "tc-1", "", "pending", streams.NewSubagentTask("d", "p", "reviewer"))) + }) + + require.Equal(t, 1, messages.toolCallWrites, "message write must proceed even without a subagent context recorder") +} + +// TestHandleToolUpdateEventNilMessageCreatorStillRecordsSubagentContext covers +// the reverse combination of TestHandleToolCallEventNilSubagentContextsRecorderIsSafe: +// messageCreator nil, subagentContexts recorder set, on the tool_update path. +// persistToolUpdateMessage must not let its message-persistence guard (nil is +// a supported SetMessageCreator configuration) block subagent-context +// recording, mirroring handleToolCallEvent's already-correct behavior for the +// insert path. +func TestHandleToolUpdateEventNilMessageCreatorStillRecordsSubagentContext(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = nil + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + updatePayload := streams.NewSubagentTask("review the diff", "prompt", "security-reviewer") + updatePayload.SubagentTask().Status = "completed" + require.NotPanics(t, func() { + svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", ToolCallID: "tc-1", ToolStatus: "in_progress", + Normalized: updatePayload, + }, + }) + }) + + requests := recorder.all() + require.Len(t, requests, 1, "subagent context recording must not depend on messageCreator being wired") + require.Equal(t, "turn1", requests[0].TurnID) +} + +// TestHandleToolCallEventRecordsNestedSubagent covers AC-6: a nested frame +// (ParentToolCallID set) is recorded, not dropped. +func TestHandleToolCallEventRecordsNestedSubagent(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload( + "task1", "session1", "tc-nested-child", "tc-nested-parent", "pending", + streams.NewSubagentTask("d", "p", "reviewer"))) + + requests := recorder.all() + require.Len(t, requests, 1) + require.Equal(t, "tc-nested-parent", requests[0].ParentToolCallID) +} + +// TestSubagentContextRecordedThroughRealServiceAndRepository is the +// integration case: handler -> real taskservice.Service -> real repository, +// mirroring newServiceBackedMessageCreator's pattern for messages. Confirms +// the wiring in backendapp.subagentContextAdapter is exercised end to end and +// that a terminal update settles the row. +func TestSubagentContextRecordedThroughRealServiceAndRepository(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + + taskSvc := taskservice.NewService(taskservice.Repos{ + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + Workflows: repo, + Messages: repo, + Turns: repo, + Sessions: repo, + GitSnapshots: repo, + RepoEntities: repo, + Executors: repo, + Environments: repo, + TaskEnvironments: repo, + Reviews: repo, + SubagentContexts: repo, + }, bus.NewMemoryEventBus(testLogger()), testLogger(), taskservice.RepositoryDiscoveryConfig{}) + svc.subagentContexts = &serviceBackedSubagentContextRecorder{svc: taskSvc} + + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload( + "task1", "session1", "tc-e2e", "", "pending", streams.NewSubagentTask("d", "p", "reviewer"))) + + terminalPayload := streams.NewSubagentTask("d", "p", "reviewer") + terminalPayload.SubagentTask().Status = "completed" + terminalPayload.SubagentTask().TotalTokens = 500 + svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", ToolCallID: "tc-e2e", ToolStatus: agentEventComplete, + Normalized: terminalPayload, + }, + }) + + rows, err := repo.ListSubagentContextsBySession(ctx, "session1") + require.NoError(t, err) + require.Len(t, rows, 1) + row := rows[0] + require.Equal(t, "tc-e2e", row.ToolCallID) + require.Equal(t, "live", row.Source) + require.NotNil(t, row.SettledAt) + require.NotNil(t, row.TotalTokens) + require.Equal(t, int64(500), *row.TotalTokens) +} + +// TestHandleToolUpdateEventFromCompletedExecutionStillRecordsSubagentContext +// covers AC-1/AC-11: shouldDropCompletedExecutionStreamEvent correctly drops +// a late frame for message purposes, but a subagent's final terminal +// tool_update can be exactly that late frame — dropping it there too would +// leave the durable row permanently unsettled with no status/token/duration +// data, even though this frame carries all of it. +func TestHandleToolUpdateEventFromCompletedExecutionStillRecordsSubagentContext(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + messages := &mockMessageCreator{} + svc.messageCreator = messages + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + svc.markExecutionCompleted("session1", "exec-1") + + terminalPayload := streams.NewSubagentTask("d", "p", "reviewer") + terminalPayload.SubagentTask().Status = "completed" + terminalPayload.SubagentTask().TotalTokens = 500 + svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", ToolCallID: "tc-late", ToolStatus: agentEventComplete, + Normalized: terminalPayload, + }, + }) + + require.Zero(t, messages.toolUpdateWrites, + "the completed-execution guard must still drop the message side") + requests := recorder.all() + require.Len(t, requests, 1, + "a subagent's terminal frame must still be recorded even after its execution is marked complete") + require.Equal(t, "tc-late", requests[0].ToolCallID) + require.Equal(t, agentEventComplete, requests[0].ToolStatus) +} + +// TestHandleToolCallEventFromCompletedExecutionStillRecordsSubagentContext is +// the tool_call-path counterpart: the very first recognizable frame for a +// subagent can itself arrive after its execution is marked complete, and +// dropping it there would mean the row never gets created at all (not just +// unsettled) — a stronger violation of AC-1. +func TestHandleToolCallEventFromCompletedExecutionStillRecordsSubagentContext(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + messages := &mockMessageCreator{} + svc.messageCreator = messages + seedActiveTurn(t, svc, repo, "task1", "session1", "turn1") + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + svc.markExecutionCompleted("session1", "exec-1") + + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload( + "task1", "session1", "tc-late-call", "", "pending", streams.NewSubagentTask("d", "p", "reviewer"))) + + require.Zero(t, messages.toolCallWrites, + "the completed-execution guard must still drop the message side") + requests := recorder.all() + require.Len(t, requests, 1, + "a subagent's first recognizable frame must still be recorded even after its execution is marked complete") + require.Equal(t, "tc-late-call", requests[0].ToolCallID) +} + +// TestHandleToolUpdateEventNilMessageCreatorDoesNotCreateTurn covers the +// coderabbitai/getActiveTurnID finding: recording a subagent-context +// observation must never itself start a turn as a side effect. With no +// active turn seeded and messageCreator nil, a non-terminal tool_update +// previously called the turn-CREATING getActiveTurnID purely to label the +// subagent row, silently starting a durable turn for a session that never +// asked for one. +func TestHandleToolUpdateEventNilMessageCreatorDoesNotCreateTurn(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = nil + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + updatePayload := streams.NewSubagentTask("review the diff", "prompt", "security-reviewer") + svc.handleToolUpdateEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", SessionID: "session1", ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: "tool_update", ToolCallID: "tc-1", ToolStatus: "in_progress", + Normalized: updatePayload, + }, + }) + + requests := recorder.all() + require.Len(t, requests, 1) + require.Empty(t, requests[0].TurnID, + "no turn existed and none should have been created merely to label this row") + turns, err := repo.ListTurnsBySession(ctx, "session1") + require.NoError(t, err) + require.Empty(t, turns, "recording subagent context alone must not create a durable turn") +} + +// TestHandleToolCallEventNilMessageCreatorDoesNotCreateTurn is the tool_call +// counterpart: the new unconditional recordSubagentContextFromFrame call in +// handleToolCallEvent must use the same non-creating turn lookup as the +// tool_update path. +func TestHandleToolCallEventNilMessageCreatorDoesNotCreateTurn(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = nil + recorder := &fakeSubagentContextRecorder{} + svc.subagentContexts = recorder + + svc.handleToolCallEvent(ctx, newSubagentTaskToolCallPayload( + "task1", "session1", "tc-1", "", "pending", streams.NewSubagentTask("d", "p", "reviewer"))) + + requests := recorder.all() + require.Len(t, requests, 1) + require.Empty(t, requests[0].TurnID, + "no turn existed and none should have been created merely to label this row") + turns, err := repo.ListTurnsBySession(ctx, "session1") + require.NoError(t, err) + require.Empty(t, turns, "recording subagent context alone must not create a durable turn") +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 6a729a30f5..0577ba7983 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -37,6 +37,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/workflow/engine" wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -111,6 +112,17 @@ type MessageCreator interface { InvalidateModelCache(sessionID string) } +// SubagentContextRecorder persists a durable relational record of a subagent +// (Task tool) invocation observed on a tool-call frame. It returns nothing — +// a repository failure never fails the enclosing message write, turn, or +// agent stream (AC-27 in +// docs/specs/subagent-context-persistence/spec.md). Implemented by +// taskservice.Service via an adapter; optional, so an installation that +// never wires it behaves exactly as before. +type SubagentContextRecorder interface { + RecordSubagentContext(ctx context.Context, req taskservice.RecordSubagentContextRequest) +} + // TurnService is an interface for managing session turns type TurnService interface { StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) @@ -358,6 +370,11 @@ type Service struct { // Message creator for saving agent responses messageCreator MessageCreator + // subagentContexts optionally persists a relational record of subagent + // (Task tool) invocations recognized on the tool-call frame paths. Nil is + // safe: both call sites guard on it. See SetSubagentContextRecorder. + subagentContexts SubagentContextRecorder + // Turn service for managing session turns turnService TurnService @@ -925,6 +942,13 @@ func (s *Service) SetMessageCreator(mc MessageCreator) { s.messageCreator = mc } +// SetSubagentContextRecorder wires the optional subagent-context writer. +// If not set, subagent tool-call frames are recognized and rendered exactly +// as before; only the durable relational record is skipped. +func (s *Service) SetSubagentContextRecorder(r SubagentContextRecorder) { + s.subagentContexts = r +} + // SetAttachmentReader wires the backend attachment store into passthrough // prompt delivery so claimed descriptors can be streamed into the workspace. func (s *Service) SetAttachmentReader(reader AttachmentReader) { diff --git a/apps/backend/internal/task/models/subagent_context.go b/apps/backend/internal/task/models/subagent_context.go new file mode 100644 index 0000000000..7c800d1553 --- /dev/null +++ b/apps/backend/internal/task/models/subagent_context.go @@ -0,0 +1,51 @@ +package models + +import "time" + +// SubagentContext is a durable, queryable row for one subagent (Task tool) +// invocation observed by the orchestrator. See +// docs/specs/subagent-context-persistence/spec.md. +// +// Every nullable column is a pointer. This is not style: ToolUseCount in +// particular must distinguish a reported 0 from "not reported", and a +// non-pointer int64 cannot carry that distinction. +type SubagentContext struct { + ID string + TaskSessionID string + TaskID string + ToolCallID string + // AgentExecutionID identifies the agent process execution that produced + // this observation. Part of the row's identity key alongside + // TaskSessionID/ToolCallID (AC-30) so a late frame from an earlier, + // already-completed execution never clobbers a later execution's row + // (AC-32). "unknown" is the reserved sentinel for "no execution identity + // available" (AC-31) — never empty, never a Go zero-value placeholder. + AgentExecutionID string + + TurnID *string + ParentToolCallID *string + SubagentType *string + Description *string + AgentID *string + ChildSessionID *string + Model *string + // AgentStatus is the child's own report (open, provider-defined vocabulary, + // e.g. "async_launched"). Stored verbatim, never consulted for terminality. + AgentStatus *string + // ToolStatus is the ACP tool-call status of the launching Task call + // (closed vocabulary). Terminality is decided from this field only. + ToolStatus *string + + IsAsync bool + + TotalTokens *int64 + ToolUseCount *int64 + DurationMs *int64 + + // Source is "live" or "backfill". Provenance, write-once. + Source string + + ObservedAt time.Time + SettledAt *time.Time + UpdatedAt time.Time +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index c8309b9018..dae300c636 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -481,3 +481,15 @@ type PlanRepository interface { // otherwise a new revision is appended with revision_number computed inside the tx. WritePlanRevision(ctx context.Context, head *models.TaskPlan, rev *models.TaskPlanRevision, coalesceLatestID *string) error } + +// SubagentContextRepository persists the durable, queryable record of a +// subagent (Task tool) invocation. See +// docs/specs/subagent-context-persistence/spec.md. +type SubagentContextRepository interface { + // UpsertSubagentContext inserts or merges one subagent invocation row, + // keyed on (task_session_id, tool_call_id). A single atomic statement — + // no read-then-write. + UpsertSubagentContext(ctx context.Context, sc *models.SubagentContext) error + ListSubagentContextsBySession(ctx context.Context, sessionID string) ([]*models.SubagentContext, error) + ListSubagentContextsByTurn(ctx context.Context, turnID string) ([]*models.SubagentContext, error) +} diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 375d81341b..b790b8e512 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -15,11 +15,11 @@ import ( // Repository provides SQLite-based task storage operations. type Repository struct { - db *sqlx.DB // writer - ro *sqlx.DB // reader (read-only pool) - ownsDB bool - log *logger.Logger - migrate *db.MigrateLogger + db *sqlx.DB // writer + ro *sqlx.DB // reader (read-only pool) + ownsDB bool + log *logger.Logger + migrate *db.MigrateLogger queuePurgeMu sync.RWMutex queuePurger func(context.Context, string) queuePurgeNotify func(context.Context, string) diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index de36e4ac58..558c95556c 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -355,6 +355,13 @@ func (r *Repository) runMigrations() error { return err } + // The execution-aware task_session_subagents schema is created directly by + // initSubagentContextSchema. The predecessor change that introduced the + // table is not part of the supported upgrade path, so there is no + // intermediate-shape rebuild to run here. Only the historical-message + // backfill belongs in the migration phase. + r.migrateSubagentContextBackfill() + return nil } @@ -461,6 +468,46 @@ func jsonRemoveKey(postgres bool, column, key string) string { return "json_remove(" + base + ", '$." + key + "')" } +// jsonKey extracts a text value at a JSON path from a TEXT-typed JSON column. +// key may itself be dot-separated ("normalized.kind") to reach a field nested +// several levels deep — jsonInt and jsonBoolToInt build on this by folding +// their parent/key pair into one such path before extracting. +func jsonKey(postgres bool, column, key string) string { + base := jsonColumn(postgres, column) + segments := strings.Join(strings.Split(key, "."), ",") + if postgres { + return "(" + base + " #>> '{" + segments + "}')" + } + return "json_extract(" + base + ", '$." + strings.ReplaceAll(segments, ",", ".") + "')" +} + +// jsonInt extracts a nested numeric field and normalizes it the way every +// unreported-or-invalid metric in this table normalizes: NULL, never a +// fabricated 0 for "not reported" and never a negative count (AC-7, AC-9, +// AC-23). Postgres casts to BIGINT, SQLite to INTEGER. +func jsonInt(postgres bool, column, parent, key string) string { + extracted := jsonKey(postgres, column, parent+"."+key) + castType := "INTEGER" + if postgres { + castType = "BIGINT" + } + cast := "CAST(NULLIF(" + extracted + ", '') AS " + castType + ")" + return "(CASE WHEN " + cast + " < 0 THEN NULL ELSE " + cast + " END)" +} + +// jsonBoolToInt normalizes is_async's dialect-inconsistent boolean spelling — +// Postgres's #>> text extraction yields 'true'/'false', SQLite's json_extract +// on a JSON boolean yields '1'/'0' — into the single 1/0 the column stores. +// An absent or false field yields 0, matching the column's own DEFAULT 0. +func jsonBoolToInt(postgres bool, column, parent, key string) string { + extracted := jsonKey(postgres, column, parent+"."+key) + // SQLite's json_extract returns a JSON boolean as the storage-class + // INTEGER 1/0, not the text '1'/'0' — comparing that INTEGER against a + // TEXT literal via IN never matches (SQLite orders INTEGER < TEXT by + // storage class), so the extracted value must be cast to TEXT first. + return "(CASE WHEN CAST(" + extracted + " AS TEXT) IN ('1', 'true') THEN 1 ELSE 0 END)" +} + // ensureImproveKandevWorkflowTemplateUniqueness removes the broad index from // the initial implementation, reconciles legacy bootstrap duplicates, then // enforces uniqueness only for the two hidden workflows created by this flow. diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index a8c8059389..95ee6779f3 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -610,7 +610,60 @@ func (r *Repository) initSessionSchema() error { if err := r.initSessionWorktreeSchema(); err != nil { return err } - return r.initMessageTurnSchema() + if err := r.initMessageTurnSchema(); err != nil { + return err + } + return r.initSubagentContextSchema() +} + +// initSubagentContextSchema creates task_session_subagents, the durable +// relational record of a subagent (Task tool) invocation. See +// docs/specs/subagent-context-persistence/spec.md. The three measurement +// columns (total_tokens, tool_use_count, duration_ms) deliberately carry no +// DEFAULT: an unreported value must store NULL, never 0 (75% of observed +// invocations report none of them). turn_id carries no FOREIGN KEY so a turn +// deletion never silently deletes the fan-out record it measured. +// +// agent_execution_id carries DEFAULT 'unknown' (AC-31's reserved sentinel for +// "no execution identity available") so every row has one, and is part of the +// UNIQUE key (task_session_id, agent_execution_id, tool_call_id) — a late +// frame from an earlier, already-completed execution creates/updates its own +// row instead of clobbering a later execution's (AC-32). Historical message +// rows are handled separately by the one-time backfill migration. +func (r *Repository) initSubagentContextSchema() error { + _, err := r.db.Exec(` + CREATE TABLE IF NOT EXISTS task_session_subagents ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + turn_id TEXT, + tool_call_id TEXT NOT NULL, + agent_execution_id TEXT NOT NULL DEFAULT 'unknown', + parent_tool_call_id TEXT, + subagent_type TEXT, + description TEXT, + agent_id TEXT, + child_session_id TEXT, + model TEXT, + agent_status TEXT, + tool_status TEXT, + is_async INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER, + tool_use_count INTEGER, + duration_ms INTEGER, + source TEXT NOT NULL DEFAULT 'live', + observed_at TIMESTAMP NOT NULL, + settled_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL, + UNIQUE (task_session_id, agent_execution_id, tool_call_id), + FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_subagents_session_id ON task_session_subagents(task_session_id); + CREATE INDEX IF NOT EXISTS idx_subagents_task_id ON task_session_subagents(task_id); + CREATE INDEX IF NOT EXISTS idx_subagents_turn_id ON task_session_subagents(turn_id); + `) + return err } func (r *Repository) initMessageTurnSchema() error { diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context.go b/apps/backend/internal/task/repository/sqlite/subagent_context.go new file mode 100644 index 0000000000..97412c4966 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context.go @@ -0,0 +1,160 @@ +// Package sqlite provides SQLite-based repository implementations. +package sqlite + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// subagentContextUnknownExecutionID is the reserved sentinel (AC-31) for "no +// execution identity available" — never NULL, never "", never omitted. +const subagentContextUnknownExecutionID = "unknown" + +// upsertSubagentContextSQL is a single atomic INSERT ... ON CONFLICT DO UPDATE. +// No read-then-write anywhere (AC-14): every merge, terminality, and +// write-once rule lives in the conflict clause. +// +// - turn_id: the *original* turn wins (AC-2a); a later turn never +// re-attributes the fan-out. +// - every other nullable column: fill-forward — an absent/empty reported +// value never blanks a value already learned (AC-4, AC-5). +// - tool_status: frozen once settled_at is set (AC-12); until then it fills +// forward like any other column. +// - is_async: sticky — a later frame reporting false never resets it. +// - settled_at: write-once (AC-11). +// - source, observed_at, agent_execution_id, id: absent from the SET list +// entirely — write-once, so a backfilled row is never relabelled live +// (AC-22), the first observation wins (AC-1a), and a row's execution +// identity never changes after creation (AC-30/AC-32). +const upsertSubagentContextSQL = ` + INSERT INTO task_session_subagents ( + id, task_session_id, task_id, turn_id, tool_call_id, agent_execution_id, parent_tool_call_id, + subagent_type, description, agent_id, child_session_id, model, + agent_status, tool_status, is_async, total_tokens, tool_use_count, + duration_ms, source, observed_at, settled_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (task_session_id, agent_execution_id, tool_call_id) DO UPDATE SET + turn_id = COALESCE(task_session_subagents.turn_id, excluded.turn_id), + parent_tool_call_id = COALESCE(excluded.parent_tool_call_id, task_session_subagents.parent_tool_call_id), + subagent_type = COALESCE(excluded.subagent_type, task_session_subagents.subagent_type), + description = COALESCE(excluded.description, task_session_subagents.description), + agent_id = COALESCE(excluded.agent_id, task_session_subagents.agent_id), + child_session_id = COALESCE(excluded.child_session_id, task_session_subagents.child_session_id), + model = COALESCE(excluded.model, task_session_subagents.model), + agent_status = COALESCE(excluded.agent_status, task_session_subagents.agent_status), + tool_status = CASE + WHEN task_session_subagents.settled_at IS NOT NULL THEN task_session_subagents.tool_status + ELSE COALESCE(excluded.tool_status, task_session_subagents.tool_status) + END, + is_async = CASE WHEN excluded.is_async = 1 THEN 1 ELSE task_session_subagents.is_async END, + total_tokens = COALESCE(excluded.total_tokens, task_session_subagents.total_tokens), + tool_use_count = COALESCE(excluded.tool_use_count, task_session_subagents.tool_use_count), + duration_ms = COALESCE(excluded.duration_ms, task_session_subagents.duration_ms), + settled_at = COALESCE(task_session_subagents.settled_at, excluded.settled_at), + updated_at = excluded.updated_at +` + +// subagentContextSourceLive is the default models.SubagentContext.Source for +// a row observed on the live orchestrator frame path, as opposed to "backfill". +const subagentContextSourceLive = "live" + +// UpsertSubagentContext inserts or merges one subagent invocation row, keyed +// on (task_session_id, agent_execution_id, tool_call_id). Safe under +// concurrent calls for the same key (AC-14) and for different keys in the +// same turn (AC-15): the merge is expressed entirely in the statement's +// conflict clause, never in Go-level read-then-write. +func (r *Repository) UpsertSubagentContext(ctx context.Context, sc *models.SubagentContext) error { + if sc.ID == "" { + sc.ID = uuid.New().String() + } + if sc.Source == "" { + sc.Source = subagentContextSourceLive + } + if sc.AgentExecutionID == "" { + sc.AgentExecutionID = subagentContextUnknownExecutionID + } + now := time.Now().UTC() + if sc.ObservedAt.IsZero() { + sc.ObservedAt = now + } + if sc.UpdatedAt.IsZero() { + sc.UpdatedAt = now + } + + _, err := r.db.ExecContext(ctx, r.db.Rebind(upsertSubagentContextSQL), + sc.ID, sc.TaskSessionID, sc.TaskID, sc.TurnID, sc.ToolCallID, sc.AgentExecutionID, sc.ParentToolCallID, + sc.SubagentType, sc.Description, sc.AgentID, sc.ChildSessionID, sc.Model, + sc.AgentStatus, sc.ToolStatus, dialect.BoolToInt(sc.IsAsync), sc.TotalTokens, sc.ToolUseCount, + sc.DurationMs, sc.Source, sc.ObservedAt, sc.SettledAt, sc.UpdatedAt, + ) + return err +} + +const selectSubagentContextColumns = ` + id, task_session_id, task_id, turn_id, tool_call_id, agent_execution_id, parent_tool_call_id, + subagent_type, description, agent_id, child_session_id, model, + agent_status, tool_status, is_async, total_tokens, tool_use_count, + duration_ms, source, observed_at, settled_at, updated_at +` + +// ListSubagentContextsBySession returns every subagent context for a session, +// ordered observed_at ASC, tool_call_id ASC, agent_execution_id ASC. This is +// AC-13's read order: tool_call_id is the primary tiebreak (unique within a +// session by construction, stable across replays, and total on its own once +// agent_execution_id is folded in for the cross-execution-collision case), +// agent_execution_id the final tiebreak — the generated id is deliberately +// never used. +func (r *Repository) ListSubagentContextsBySession(ctx context.Context, sessionID string) ([]*models.SubagentContext, error) { + return r.listSubagentContexts(ctx, "task_session_id", sessionID) +} + +// ListSubagentContextsByTurn returns every subagent context recorded under a +// turn, in the same observed_at ASC, tool_call_id ASC, agent_execution_id ASC +// order. A subagent whose tool_call_id was later re-attributed away from this +// turn (AC-2a preserves the original turn) never appears here for the newer +// turn. +func (r *Repository) ListSubagentContextsByTurn(ctx context.Context, turnID string) ([]*models.SubagentContext, error) { + return r.listSubagentContexts(ctx, "turn_id", turnID) +} + +// listSubagentContexts concatenates column directly into the query. Safe by +// construction ONLY because both call sites above pass a hardcoded literal +// ("task_session_id", "turn_id") — column must never be, or be derived from, +// caller/request input. +func (r *Repository) listSubagentContexts(ctx context.Context, column, value string) ([]*models.SubagentContext, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+selectSubagentContextColumns+` + FROM task_session_subagents + WHERE `+column+` = ? + ORDER BY observed_at ASC, tool_call_id ASC, agent_execution_id ASC + `), value) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var result []*models.SubagentContext + for rows.Next() { + sc := &models.SubagentContext{} + var isAsync int + if err := rows.Scan( + &sc.ID, &sc.TaskSessionID, &sc.TaskID, &sc.TurnID, &sc.ToolCallID, &sc.AgentExecutionID, &sc.ParentToolCallID, + &sc.SubagentType, &sc.Description, &sc.AgentID, &sc.ChildSessionID, &sc.Model, + &sc.AgentStatus, &sc.ToolStatus, &isAsync, &sc.TotalTokens, &sc.ToolUseCount, + &sc.DurationMs, &sc.Source, &sc.ObservedAt, &sc.SettledAt, &sc.UpdatedAt, + ); err != nil { + return nil, err + } + sc.IsAsync = isAsync == 1 + result = append(result, sc) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_activation.go b/apps/backend/internal/task/repository/sqlite/subagent_context_activation.go new file mode 100644 index 0000000000..60740c726f --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_activation.go @@ -0,0 +1,35 @@ +// Package sqlite provides SQLite-based repository implementations. +package sqlite + +import ( + "database/sql" + "errors" +) + +// subagentContextActivationKeyInsertSQL builds a write-once INSERT against +// kandev_meta for one activation key. ON CONFLICT DO NOTHING is what makes it +// write-once (AC-24, AC-24d, AC-24f): a key already present, including the +// other key of a pair repaired on a later boot, is left exactly as it is, +// never rewritten. r.migrate.Apply takes no bind args, +// so key and value are inlined; both are always produced by this package, +// never caller/request input, so this is not a SQL-injection surface. +func subagentContextActivationKeyInsertSQL(key, value string) string { + return "INSERT INTO kandev_meta (key, value) VALUES ('" + key + "', '" + value + "') ON CONFLICT (key) DO NOTHING" +} + +// subagentContextActivationKeyPresent reports whether key already has a row +// in kandev_meta, via a single indexed point lookup on its primary key. This +// is a row-existence test, not a value-non-empty test (SR45): an empty-string +// backfill_through row still counts as present, since AC-24f defines the +// empty string as this key's legitimate sentinel value, not "absent". +func (r *Repository) subagentContextActivationKeyPresent(key string) (bool, error) { + var value string + err := r.db.QueryRow(r.db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), key).Scan(&value) + if err == nil { + return true, nil + } + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go b/apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go new file mode 100644 index 0000000000..667ef6ab57 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go @@ -0,0 +1,262 @@ +// Package sqlite provides SQLite-based repository implementations. +package sqlite + +import ( + "database/sql" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" +) + +const ( + subagentContextCaptureSinceKey = "subagent_context_capture_since" + subagentContextBackfillThroughKey = "subagent_context_backfill_through" + subagentContextBackfillMigrationName = "task_session_subagents.backfill" +) + +// warnSubagentContextMigration logs a failure in the exact migration warning +// shape used by the repository's MigrateLogger. The backfill is telemetry-only: +// failures are visible at WARN and never abort backend startup. +func (r *Repository) warnSubagentContextMigration(name string, err error) { + if r.log == nil { + return + } + r.log.Warn("migration failed", zap.String("name", name), zap.Error(err)) +} + +// migrateSubagentContextBackfill recovers task_session_subagents history from +// existing task_session_messages rows whose metadata.normalized.kind is +// "subagent_task" (AC-21), and publishes the two write-once kandev_meta +// activation keys that let a consumer distinguish "zero fan-out" from +// "unmeasured, before capture began" (AC-24, AC-25). See +// docs/specs/subagent-context-persistence/spec.md § Backfill. +// +// runMigrations() (and therefore this function) runs on every boot, not just +// the first. AC-24d (revision 4) requires the guard to be "both backfill keys +// present", never `capture_since` alone: a pre-amendment build wrote only +// `capture_since` in an unguarded third statement, so guarding on it alone +// makes a `backfill_through`-missing installation permanently stuck. Checking +// both, and having each key's own INSERT use ON CONFLICT DO NOTHING, is what +// lets a partial pre-existing pair repair itself on the next boot without a +// second mechanism. +func (r *Repository) migrateSubagentContextBackfill() { + capturePresent, err := r.subagentContextActivationKeyPresent(subagentContextCaptureSinceKey) + if err != nil { + r.warnSubagentContextMigration(subagentContextBackfillMigrationName+".guard", err) + return + } + throughPresent, err := r.subagentContextActivationKeyPresent(subagentContextBackfillThroughKey) + if err != nil { + r.warnSubagentContextMigration(subagentContextBackfillMigrationName+".guard", err) + return + } + if capturePresent && throughPresent { + return + } + + if err := r.migrateSubagentContextBackfillUnsafe(); err != nil { + r.warnSubagentContextMigration(subagentContextBackfillMigrationName, err) + } +} + +// migrateSubagentContextBackfillUnsafe runs the AC-21 INSERT ... SELECT and +// both AC-24 key writes in one transaction (AC-24d): either everything here +// commits, or nothing does, so a failure at any point leaves neither key +// written and the backfill retries whole on the next boot (AC-24a). +// +// Ordering inside the transaction is deliberate: +// 1. The `backfill_through` high-water mark is computed from AC-21's SOURCE +// PREDICATE, over a snapshot no later than the INSERT's own (AC-24d +// revision 3) — computing it BEFORE the INSERT satisfies that on both +// SQLite and PostgreSQL's READ COMMITTED, since it can only under-claim, +// never over-claim, the window actually covered. +// 2. The INSERT ... SELECT runs, deduplicating rows that share +// (task_session_id, tool_call_id) by newest created_at then greatest id +// (AC-21b), and skipping rows missing any of the three required +// identities (AC-21a). ON CONFLICT DO NOTHING (AC-22) makes a retry a +// no-op against rows already backfilled or already captured live under +// the 'unknown' sentinel. +// 3. `capture_since` is sampled by Go, in-transaction, at or after step 2 +// completes and before the key writes (AC-24 point 4) — never at +// transaction start, which would over-claim the whole scan's duration. +// 4. Both keys are written with their own ON CONFLICT DO NOTHING, so a key +// already present — from this same transaction's retry guard failing to +// trigger, or from a pre-amendment partial pair — is preserved verbatim +// (AC-24 point 5, AC-24d revision 4). +func (r *Repository) migrateSubagentContextBackfillUnsafe() error { + postgres := dialect.IsPostgres(r.db.DriverName()) + predicate, selectCols := subagentContextBackfillPredicateAndColumns(postgres) + + tx, err := r.db.Beginx() + if err != nil { + return fmt.Errorf("begin subagent context backfill transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + backfillThrough, err := subagentContextBackfillHighWaterMark(tx, postgres, predicate) + if err != nil { + return err + } + + insertSQL := ` + WITH ranked AS ( + SELECT + m.*, + ` + selectCols.toolCallID + ` AS derived_tool_call_id, + ROW_NUMBER() OVER ( + PARTITION BY m.task_session_id, ` + selectCols.toolCallID + ` + ORDER BY m.created_at DESC, m.id DESC + ) AS rn + FROM task_session_messages m + WHERE ` + predicate + ` + ) + INSERT INTO task_session_subagents ( + id, task_session_id, task_id, turn_id, tool_call_id, agent_execution_id, parent_tool_call_id, + subagent_type, description, agent_id, child_session_id, model, + agent_status, tool_status, is_async, total_tokens, tool_use_count, + duration_ms, source, observed_at, settled_at, updated_at + ) + SELECT + m.id, + m.task_session_id, + m.task_id, + NULLIF(m.turn_id, ''), + m.derived_tool_call_id, + '` + subagentContextUnknownExecutionID + `', + ` + selectCols.parentToolCallID + `, + ` + selectCols.subagentType + `, + ` + selectCols.description + `, + ` + selectCols.agentID + `, + ` + selectCols.childSessionID + `, + ` + selectCols.model + `, + ` + selectCols.agentStatus + `, + ` + selectCols.toolStatus + `, + ` + selectCols.isAsync + `, + ` + selectCols.totalTokens + `, + ` + selectCols.toolUseCount + `, + ` + selectCols.durationMs + `, + 'backfill', + m.created_at, + (CASE WHEN ` + selectCols.terminal + ` THEN m.updated_at ELSE NULL END), + m.updated_at + FROM ranked m + WHERE m.rn = 1 + ON CONFLICT (task_session_id, agent_execution_id, tool_call_id) DO NOTHING + ` + if _, err := tx.Exec(insertSQL); err != nil { + return fmt.Errorf("subagent context backfill insert: %w", err) + } + + captureSince := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := tx.Exec(subagentContextActivationKeyInsertSQL(subagentContextCaptureSinceKey, captureSince)); err != nil { + return fmt.Errorf("write subagent_context_capture_since: %w", err) + } + if _, err := tx.Exec(subagentContextActivationKeyInsertSQL(subagentContextBackfillThroughKey, backfillThrough)); err != nil { + return fmt.Errorf("write subagent_context_backfill_through: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit subagent context backfill transaction: %w", err) + } + return nil +} + +// subagentContextBackfillHighWaterMark evaluates AC-21's source predicate's +// MAX(created_at) inside the caller's transaction, before the INSERT that +// shares the same predicate (AC-24d revision 3). AC-24f: the empty string is +// the sentinel for "no message matched", not a timestamp, and is exempt from +// the RFC3339Nano requirement the non-empty case observes. +// +// SQLite's driver only auto-converts a column value to time.Time when it can +// see the declared column type; wrapping it in MAX() loses that, so the scan +// target must be a string on that dialect (see parseLegacyTimestamp's own +// comment for the same fact). PostgreSQL's driver has no such gap. +func subagentContextBackfillHighWaterMark(tx *sqlx.Tx, postgres bool, predicate string) (string, error) { + query := `SELECT MAX(m.created_at) FROM task_session_messages m WHERE ` + predicate + if postgres { + var maxCreatedAt sql.NullTime + if err := tx.QueryRow(query).Scan(&maxCreatedAt); err != nil { + return "", fmt.Errorf("compute subagent context backfill high-water mark: %w", err) + } + if !maxCreatedAt.Valid { + return "", nil + } + return maxCreatedAt.Time.UTC().Format(time.RFC3339Nano), nil + } + var maxCreatedAt sql.NullString + if err := tx.QueryRow(query).Scan(&maxCreatedAt); err != nil { + return "", fmt.Errorf("compute subagent context backfill high-water mark: %w", err) + } + if !maxCreatedAt.Valid || maxCreatedAt.String == "" { + return "", nil + } + parsed := parseLegacyTimestamp(maxCreatedAt.String) + if parsed.IsZero() { + return "", fmt.Errorf("compute subagent context backfill high-water mark: unparsable timestamp %q", maxCreatedAt.String) + } + return parsed.Format(time.RFC3339Nano), nil +} + +// subagentContextBackfillColumns names every dialect-aware source expression +// the backfill INSERT's SELECT list needs, built alongside the predicate so +// the two stay in sync (both are derived from the same "meta" helper). +type subagentContextBackfillColumns struct { + toolCallID string + parentToolCallID string + subagentType string + description string + agentID string + childSessionID string + model string + agentStatus string + toolStatus string + isAsync string + totalTokens string + toolUseCount string + durationMs string + terminal string +} + +// subagentContextBackfillPredicateAndColumns builds AC-21's source predicate +// (which qualifying messages) and the dialect-aware column expressions the +// INSERT's SELECT list projects from each one. AC-21a: the predicate itself +// enforces the three required identities (task_id, tool_call_id, and the +// "subagent_task" kind marker), so a message missing any of them contributes +// no row rather than a row with a blank identity column. +func subagentContextBackfillPredicateAndColumns(postgres bool) (string, subagentContextBackfillColumns) { + meta := func(path string) string { return jsonKey(postgres, "m.metadata", path) } + nullable := func(expr string) string { return "NULLIF(" + expr + ", '')" } + subagentText := func(field string) string { return nullable(meta("normalized.subagent_task." + field)) } + subagentInt := func(field string) string { return jsonInt(postgres, "m.metadata", "normalized.subagent_task", field) } + + toolCallID := meta("tool_call_id") + toolStatusRaw := meta("status") + terminal := "(" + toolStatusRaw + " IN ('complete','completed','success','error','failed','cancelled'))" + predicate := ` + ` + meta("normalized.kind") + ` = 'subagent_task' + AND m.task_id IS NOT NULL AND m.task_id != '' + AND ` + toolCallID + ` IS NOT NULL AND ` + toolCallID + ` != ''` + + cols := subagentContextBackfillColumns{ + toolCallID: toolCallID, + parentToolCallID: nullable(meta("parent_tool_call_id")), + subagentType: subagentText("subagent_type"), + description: subagentText("description"), + agentID: subagentText("agent_id"), + childSessionID: subagentText("child_session_id"), + model: subagentText("model"), + agentStatus: subagentText("status"), + toolStatus: nullable(toolStatusRaw), + isAsync: jsonBoolToInt(postgres, "m.metadata", "normalized.subagent_task", "is_async"), + totalTokens: subagentInt("total_tokens"), + toolUseCount: subagentInt("tool_use_count"), + durationMs: subagentInt("duration_ms"), + terminal: terminal, + } + return predicate, cols +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_health.go b/apps/backend/internal/task/repository/sqlite/subagent_context_health.go new file mode 100644 index 0000000000..8b6de4528d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_health.go @@ -0,0 +1,156 @@ +// Package sqlite provides SQLite-based repository implementations. +package sqlite + +import ( + "database/sql" + "fmt" + "time" + + "github.com/kandev/kandev/internal/db/dialect" +) + +// These three functions are the package-private health surface for AC-28/AC-29. +// No production endpoint, scheduled check, or alerting integration is wired to +// them in this build — a deployment that wants one wires it separately. Tests +// exercise them on both dialects (SR44 / AC-28 scope note). They must live in +// a regular .go file (not _test.go) because a future caller will be production +// code that needs a real DB connection, not testing.TB. + +// subagentContextTimeLiteral renders t as a single-quoted RFC3339Nano SQL +// string literal. Every call site here passes a Go time.Time the caller +// controls (never request/caller input), so inlining is not a SQL-injection +// surface — the same convention subagentContextActivationKeyInsertSQL uses. +func subagentContextTimeLiteral(t time.Time) string { + return "'" + t.UTC().Format(time.RFC3339Nano) + "'" +} + +// subagentContextHealthCount runs a query whose single result column is a +// COUNT(*) and returns it as an int. +func (r *Repository) subagentContextHealthCount(query string) (int, error) { + var count int + if err := r.ro.QueryRow(query).Scan(&count); err != nil { + return 0, fmt.Errorf("subagent context health query: %w", err) + } + return count, nil +} + +// subagentContextShortfall answers AC-28's SHORTFALL anti-join: distinct +// (task_session_id, tool_call_id) keys that AC-2/AC-21a expect a subagent +// context row for, among messages at or after since, that have no matching +// row in task_session_subagents. This is the direction AC-29 requires to +// diverge (become non-zero) the moment the writer stops while message writes +// continue, and the only direction of AC-28's comparison that alarms. +// +// Every timestamp comparison goes through subagentPreciseTimestamp (AC-24e): +// a raw string comparison between the RFC3339Nano since value and this +// repository's own stored TIMESTAMP form silently snaps to midnight of +// since's date. Every metadata reference goes through jsonKey/jsonColumn +// (AC-23b), which normalizes NULL/”/'null' to '{}' so one legacy row with +// blank metadata cannot abort the whole comparison on either dialect. +func (r *Repository) subagentContextShortfall(since time.Time) (int, error) { + postgres := dialect.IsPostgres(r.db.DriverName()) + sinceExpr := subagentPreciseTimestamp(postgres, subagentContextTimeLiteral(since)) + toolCallID := jsonKey(postgres, "m.metadata", "tool_call_id") + kind := jsonKey(postgres, "m.metadata", "normalized.kind") + + query := ` + SELECT COUNT(*) FROM ( + SELECT m.task_session_id, ` + toolCallID + ` AS tool_call_id + FROM task_session_messages m + WHERE ` + kind + ` = 'subagent_task' + AND ` + subagentPreciseTimestamp(postgres, "m.created_at") + ` >= ` + sinceExpr + ` + AND COALESCE(m.task_session_id, '') <> '' + AND COALESCE(m.task_id, '') <> '' + AND COALESCE(` + toolCallID + `, '') <> '' + GROUP BY 1, 2 + EXCEPT + SELECT s.task_session_id, s.tool_call_id + FROM task_session_subagents s + WHERE ` + subagentPreciseTimestamp(postgres, "s.observed_at") + ` >= ` + sinceExpr + ` + ) AS shortfall + ` + return r.subagentContextHealthCount(query) +} + +// subagentContextExcess answers AC-28's EXCESS anti-join: distinct +// (task_session_id, tool_call_id) keys present in task_session_subagents at +// or after since with no accounting message. Attributed (a message-write +// failure with a successful context upsert, or a message straddling the +// activation boundary — see AC-1a) and does NOT alarm; it can never mask a +// shortfall because it is counted in the opposite direction. +// +// Deliberately does NOT carry the shortfall side's task_session_id/task_id/ +// tool_call_id non-empty predicates: the excess side asks "is there any +// message that accounts for this row", so it must subtract the widest +// message set available, not the AC-2/AC-21a-filtered one. Filtering it here +// would misreport excess for a row whose message legitimately has some other +// column empty. +func (r *Repository) subagentContextExcess(since time.Time) (int, error) { + postgres := dialect.IsPostgres(r.db.DriverName()) + sinceExpr := subagentPreciseTimestamp(postgres, subagentContextTimeLiteral(since)) + toolCallID := jsonKey(postgres, "m.metadata", "tool_call_id") + kind := jsonKey(postgres, "m.metadata", "normalized.kind") + + query := ` + SELECT COUNT(*) FROM ( + SELECT s.task_session_id, s.tool_call_id + FROM task_session_subagents s + WHERE ` + subagentPreciseTimestamp(postgres, "s.observed_at") + ` >= ` + sinceExpr + ` + GROUP BY 1, 2 + EXCEPT + SELECT m.task_session_id, ` + toolCallID + ` + FROM task_session_messages m + WHERE ` + kind + ` = 'subagent_task' + AND ` + subagentPreciseTimestamp(postgres, "m.created_at") + ` >= ` + sinceExpr + ` + ) AS excess + ` + return r.subagentContextHealthCount(query) +} + +// subagentContextMaxObservedSince answers AC-29's attribution half: the +// newest observed_at among rows at or after since (the same since and the +// same subagentPreciseTimestamp comparison the shortfall/excess queries +// use), or the zero time when no such row exists. A caller MUST treat the +// zero-time/false result as "the writer has produced nothing since this +// installation activated" — the strongest available divergence signal, not +// an absence of information — never as "no divergence", "unknown", or a +// healthy state (AC-29 revision 4). Scoping to since is required: an +// unscoped MAX(observed_at) reports the newest of whatever exists, which on +// an installation whose only rows are backfilled or legacy reports an +// instant that never established live-writer health at all. +func (r *Repository) subagentContextMaxObservedSince(since time.Time) (time.Time, bool, error) { + postgres := dialect.IsPostgres(r.db.DriverName()) + sinceExpr := subagentPreciseTimestamp(postgres, subagentContextTimeLiteral(since)) + + query := ` + SELECT MAX(s.observed_at) + FROM task_session_subagents s + WHERE ` + subagentPreciseTimestamp(postgres, "s.observed_at") + ` >= ` + sinceExpr + ` + ` + // SQLite's driver only auto-converts a column value to time.Time when it + // can see the declared column type; MAX() loses that (see + // parseLegacyTimestamp's comment), so the scan target must be a string on + // that dialect. PostgreSQL's driver has no such gap. + if postgres { + var maxObserved sql.NullTime + if err := r.ro.QueryRow(query).Scan(&maxObserved); err != nil { + return time.Time{}, false, fmt.Errorf("subagent context max observed since: %w", err) + } + if !maxObserved.Valid { + return time.Time{}, false, nil + } + return maxObserved.Time.UTC(), true, nil + } + var maxObservedRaw sql.NullString + if err := r.ro.QueryRow(query).Scan(&maxObservedRaw); err != nil { + return time.Time{}, false, fmt.Errorf("subagent context max observed since: %w", err) + } + if !maxObservedRaw.Valid || maxObservedRaw.String == "" { + return time.Time{}, false, nil + } + parsed := parseLegacyTimestamp(maxObservedRaw.String) + if parsed.IsZero() { + return time.Time{}, false, fmt.Errorf("subagent context max observed since: unparsable timestamp %q", maxObservedRaw.String) + } + return parsed, true, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_health_test.go b/apps/backend/internal/task/repository/sqlite/subagent_context_health_test.go new file mode 100644 index 0000000000..1d10089402 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_health_test.go @@ -0,0 +1,209 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestSubagentContextHealthCountsAgreeAfterLiveWrites covers AC-28: writing +// through the same path production uses (a subagent_task message plus a +// live UpsertSubagentContext call, exactly as the orchestrator's two frame +// handlers do) leaves both the shortfall and excess anti-joins at zero, and +// max-observed-since reports the latest write. +func TestSubagentContextHealthCountsAgreeAfterLiveWrites(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-health", "session-health", "turn-health") + ctx := context.Background() + since := time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC) + ts := since.Add(time.Hour) + + var latest time.Time + for i, toolCallID := range []string{"tc-health-1", "tc-health-2", "tc-health-3"} { + observedAt := ts.Add(time.Duration(i) * time.Minute) + latest = observedAt + seedSubagentMessage(t, repo, "msg-health-"+toolCallID, "session-health", "task-health", "turn-health", + toolCallID, "", "completed", map[string]interface{}{"status": "completed"}, + observedAt, observedAt) + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-health", TaskID: "task-health", ToolCallID: toolCallID, + Source: "live", ObservedAt: observedAt, UpdatedAt: observedAt, + }); err != nil { + t.Fatalf("UpsertSubagentContext %s: %v", toolCallID, err) + } + } + + shortfall, err := repo.subagentContextShortfall(since) + if err != nil { + t.Fatalf("subagentContextShortfall: %v", err) + } + if shortfall != 0 { + t.Fatalf("shortfall = %d, want 0 after every message got a live context row", shortfall) + } + + excess, err := repo.subagentContextExcess(since) + if err != nil { + t.Fatalf("subagentContextExcess: %v", err) + } + if excess != 0 { + t.Fatalf("excess = %d, want 0 when every context row is accounted for by a message", excess) + } + + maxObserved, ok, err := repo.subagentContextMaxObservedSince(since) + if err != nil { + t.Fatalf("subagentContextMaxObservedSince: %v", err) + } + if !ok { + t.Fatal("subagentContextMaxObservedSince: want a value, got none") + } + if !maxObserved.Equal(latest) { + t.Fatalf("subagentContextMaxObservedSince = %v, want %v (the last live write)", maxObserved, latest) + } +} + +// TestSubagentContextHealthShortfallDivergesWhenWriterMissesAMessage covers +// AC-28/AC-29's SHORTFALL direction: a subagent_task message the context +// writer never saw (simulating the writer having stopped while message +// writes continued) is the one divergence direction that must alarm, and +// max-observed-since stops advancing at the last moment the writer was +// known healthy — it does not silently track the missed message's time. +func TestSubagentContextHealthShortfallDivergesWhenWriterMissesAMessage(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-diverge", "session-diverge", "turn-diverge") + ctx := context.Background() + since := time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC) + writerAlive := since.Add(time.Hour) + writerDead := writerAlive.Add(time.Hour) + + // The writer was healthy for this one: both sides get a row. + seedSubagentMessage(t, repo, "msg-diverge-seen", "session-diverge", "task-diverge", "turn-diverge", + "tc-diverge-seen", "", "completed", map[string]interface{}{"status": "completed"}, writerAlive, writerAlive) + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-diverge", TaskID: "task-diverge", ToolCallID: "tc-diverge-seen", + Source: "live", ObservedAt: writerAlive, UpdatedAt: writerAlive, + }); err != nil { + t.Fatalf("UpsertSubagentContext: %v", err) + } + + if shortfall, err := repo.subagentContextShortfall(since); err != nil || shortfall != 0 { + t.Fatalf("shortfall before the simulated writer outage = %d, err = %v; want 0", shortfall, err) + } + + // The writer "stopped" here: the message write (load-bearing for the UI) + // still happens, but nothing calls UpsertSubagentContext for it. + seedSubagentMessage(t, repo, "msg-diverge-missed", "session-diverge", "task-diverge", "turn-diverge", + "tc-diverge-missed", "", "completed", map[string]interface{}{"status": "completed"}, writerDead, writerDead) + + shortfall, err := repo.subagentContextShortfall(since) + if err != nil { + t.Fatalf("subagentContextShortfall: %v", err) + } + if shortfall != 1 { + t.Fatalf("shortfall = %d, want exactly 1 (the message the writer missed)", shortfall) + } + + excess, err := repo.subagentContextExcess(since) + if err != nil { + t.Fatalf("subagentContextExcess: %v", err) + } + if excess != 0 { + t.Fatalf("excess = %d, want 0: a missed write is a shortfall, never an excess", excess) + } + + maxObserved, ok, err := repo.subagentContextMaxObservedSince(since) + if err != nil { + t.Fatalf("subagentContextMaxObservedSince: %v", err) + } + if !ok { + t.Fatal("subagentContextMaxObservedSince: want a value, got none") + } + if !maxObserved.Equal(writerAlive) { + t.Fatalf("subagentContextMaxObservedSince = %v, want %v (the last moment the writer was known healthy)", maxObserved, writerAlive) + } + if !maxObserved.Before(writerDead) { + t.Fatalf("subagentContextMaxObservedSince = %v, want it to predate the missed message at %v", maxObserved, writerDead) + } +} + +// TestSubagentContextHealthExcessDoesNotAlarm covers AC-28's EXCESS +// direction: a context row with no accounting message (a message-write +// failure alongside a successful context upsert) is attributed but must +// never alarm, and must never mask a genuine shortfall elsewhere in the +// same window. +func TestSubagentContextHealthExcessDoesNotAlarm(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-excess", "session-excess", "turn-excess") + ctx := context.Background() + since := time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC) + ts := since.Add(time.Hour) + + // A context row with no matching message: the message write "failed" + // while the context upsert "succeeded" (a straddling/attributed case). + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-excess", TaskID: "task-excess", ToolCallID: "tc-excess-orphan", + Source: "live", ObservedAt: ts, UpdatedAt: ts, + }); err != nil { + t.Fatalf("UpsertSubagentContext: %v", err) + } + + // A genuine shortfall elsewhere in the same window: a message with no + // context row at all. + seedSubagentMessage(t, repo, "msg-excess-shortfall", "session-excess", "task-excess", "turn-excess", + "tc-excess-shortfall", "", "completed", map[string]interface{}{"status": "completed"}, + ts.Add(time.Minute), ts.Add(time.Minute)) + + excess, err := repo.subagentContextExcess(since) + if err != nil { + t.Fatalf("subagentContextExcess: %v", err) + } + if excess != 1 { + t.Fatalf("excess = %d, want exactly 1 (the orphaned context row)", excess) + } + + shortfall, err := repo.subagentContextShortfall(since) + if err != nil { + t.Fatalf("subagentContextShortfall: %v", err) + } + if shortfall != 1 { + t.Fatalf("shortfall = %d, want exactly 1: the excess row must not net against or mask the real shortfall", shortfall) + } +} + +// TestSubagentContextHealthMaxObservedSinceUnscopedWouldOverclaim covers +// SR44 and AC-29 revision 4: a store whose only rows predate `since` (e.g. +// backfilled or legacy rows) must report "no value" when scoped to since, +// even though an unscoped MAX(observed_at) over the same table would return +// a non-NULL value. Reading that NULL/false result as "healthy" would hide +// the fact that this installation's live writer has never produced anything +// since it activated. +func TestSubagentContextHealthMaxObservedSinceUnscopedWouldOverclaim(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-old", "session-old", "turn-old") + old := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + since := time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC) + + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_subagents (id, task_session_id, task_id, tool_call_id, source, observed_at, updated_at) + VALUES (?, ?, ?, ?, 'backfill', ?, ?) + `), "row-old", "session-old", "task-old", "tc-old", old, old); err != nil { + t.Fatalf("seed pre-activation row: %v", err) + } + + var unscopedCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM task_session_subagents WHERE observed_at IS NOT NULL`).Scan(&unscopedCount); err != nil { + t.Fatalf("sanity count: %v", err) + } + if unscopedCount == 0 { + t.Fatal("sanity check failed: expected the seeded row to have a non-NULL observed_at") + } + + _, ok, err := repo.subagentContextMaxObservedSince(since) + if err != nil { + t.Fatalf("subagentContextMaxObservedSince: %v", err) + } + if ok { + t.Fatal("subagentContextMaxObservedSince: want no value (false) when every row predates since, even though an unscoped MAX would be non-NULL") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go b/apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go new file mode 100644 index 0000000000..08d721398a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go @@ -0,0 +1,790 @@ +package sqlite + +import ( + "database/sql" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" + dbutil "github.com/kandev/kandev/internal/db" +) + +// seedSubagentMessage inserts a task_session_messages row shaped like the +// orchestrator's real subagent_task tool-call message: tool_call_id and +// parent_tool_call_id are top-level metadata keys (matching +// messageCreatorAdapter.CreateToolCallMessage), and the agent-reported fields +// live under metadata.normalized.subagent_task. +func seedSubagentMessage( + t *testing.T, repo *Repository, + id, sessionID, taskID, turnID, toolCallID, parentToolCallID, toolStatus string, + subagentTask map[string]interface{}, + createdAt, updatedAt time.Time, +) { + t.Helper() + metadata := map[string]interface{}{ + "tool_call_id": toolCallID, + "status": toolStatus, + "normalized": map[string]interface{}{ + "kind": "subagent_task", + "subagent_task": subagentTask, + }, + } + if parentToolCallID != "" { + metadata["parent_tool_call_id"] = parentToolCallID + } + metadataJSON, err := json.Marshal(metadata) + if err != nil { + t.Fatalf("marshal metadata: %v", err) + } + seedRawSubagentMessage(t, repo, id, sessionID, taskID, turnID, string(metadataJSON), createdAt, updatedAt) +} + +func seedRawSubagentMessage( + t *testing.T, repo *Repository, + id, sessionID, taskID, turnID, metadataJSON string, + createdAt, updatedAt time.Time, +) { + t.Helper() + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_session_messages + (id, task_session_id, task_id, turn_id, author_type, content, type, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, 'agent', 'Subagent', 'tool_call', ?, ?, ?) + `), id, sessionID, taskID, turnID, metadataJSON, createdAt, updatedAt) + if err != nil { + t.Fatalf("seed subagent message %s: %v", id, err) + } +} + +type subagentContextRow struct { + id string + taskSessionID string + taskID string + turnID sql.NullString + agentExecutionID string + parentToolCallID sql.NullString + subagentType sql.NullString + description sql.NullString + agentID sql.NullString + childSessionID sql.NullString + model sql.NullString + agentStatus sql.NullString + toolStatus sql.NullString + isAsync int + totalTokens sql.NullInt64 + toolUseCount sql.NullInt64 + durationMs sql.NullInt64 + source string + observedAt time.Time + settledAt sql.NullTime + updatedAt time.Time +} + +func getSubagentContextRow(t *testing.T, repo *Repository, sessionID, toolCallID string) *subagentContextRow { + t.Helper() + row := &subagentContextRow{} + err := repo.db.QueryRow(repo.db.Rebind(` + SELECT id, task_session_id, task_id, turn_id, agent_execution_id, parent_tool_call_id, subagent_type, description, + agent_id, child_session_id, model, agent_status, tool_status, is_async, + total_tokens, tool_use_count, duration_ms, source, observed_at, settled_at, updated_at + FROM task_session_subagents WHERE task_session_id = ? AND tool_call_id = ? + `), sessionID, toolCallID).Scan( + &row.id, &row.taskSessionID, &row.taskID, &row.turnID, &row.agentExecutionID, &row.parentToolCallID, &row.subagentType, &row.description, + &row.agentID, &row.childSessionID, &row.model, &row.agentStatus, &row.toolStatus, &row.isAsync, + &row.totalTokens, &row.toolUseCount, &row.durationMs, &row.source, &row.observedAt, &row.settledAt, &row.updatedAt, + ) + if err != nil { + t.Fatalf("get subagent context row (%s,%s): %v", sessionID, toolCallID, err) + } + return row +} + +func countSubagentContextRows(t *testing.T, repo *Repository, sessionID, toolCallID string) int { + t.Helper() + var count int + if err := repo.db.QueryRow(repo.db.Rebind(` + SELECT COUNT(*) FROM task_session_subagents WHERE task_session_id = ? AND tool_call_id = ? + `), sessionID, toolCallID).Scan(&count); err != nil { + t.Fatalf("count subagent context rows (%s,%s): %v", sessionID, toolCallID, err) + } + return count +} + +func readMetaKey(t *testing.T, db *sqlx.DB, key string) (string, bool) { + t.Helper() + var value string + err := db.QueryRow(db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), key).Scan(&value) + if err == sql.ErrNoRows { + return "", false + } + if err != nil { + t.Fatalf("read kandev_meta %q: %v", key, err) + } + return value, true +} + +func newSubagentMigrationTestRepo(t *testing.T) (*Repository, *sqlx.DB) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "subagent-context-migration.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + // Production boot order (internal/persistence/provider.go) creates + // kandev_meta before the task repository; mirror that here since this + // package's repository never creates it itself. + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create kandev_meta: %v", err) + } + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize schema: %v", err) + } + // NewWithDB's own initSchema() already ran the one-time backfill once, + // against an empty database with no messages seeded yet, and claimed the + // two activation keys (AC-24) — correct for a genuinely fresh install, + // but not what this file's tests want: they seed subagent-shaped + // messages AFTER construction and then call repo.runMigrations() again, + // expecting THAT call to be the real (only) backfill run — mirroring a + // DB upgrade where historical messages predate this boot. Reset to + // "not yet activated" so every test in this file gets that semantics by + // default. See subagentContextBackfillActivated in base_migrations.go, + // which gates the backfill's full-table scan on these same two keys so + // it runs at most once per installation (AC-23a). + if _, err := db.Exec(`DELETE FROM kandev_meta WHERE key IN ('subagent_context_capture_since', 'subagent_context_backfill_through')`); err != nil { + t.Fatalf("reset subagent context activation keys: %v", err) + } + return repo, db +} + +// dropMessageMetadataIndex removes the expression index on +// task_session_messages(metadata) so a malformed-metadata row (used to prove +// AC-23b tolerates historical corruption) can be seeded at all: with the +// index present, SQLite evaluates json_extract on every insert and rejects +// invalid JSON outright, which real corrupt rows predate. +func dropMessageMetadataIndex(t *testing.T, db *sqlx.DB) { + t.Helper() + for _, index := range []string{"idx_messages_metadata_tool_call_id", "idx_messages_metadata_pending_id"} { + if _, err := db.Exec(`DROP INDEX IF EXISTS ` + index); err != nil { + t.Fatalf("drop %s: %v", index, err) + } + } +} + +// TestSubagentContextSchemaFreshAndReplay covers AC-17/AC-18: a fresh database +// gets the table, its three indexes, and the UNIQUE constraint, and +// runMigrations() replays cleanly (schema and row set unchanged). +func TestSubagentContextSchemaFreshAndReplay(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-schema", "session-schema", "turn-schema") + + for _, index := range []string{"idx_subagents_session_id", "idx_subagents_task_id", "idx_subagents_turn_id"} { + var name string + if err := db.Get(&name, `SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, index); err != nil { + t.Fatalf("index %s is missing: %v", index, err) + } + } + + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_subagents (id, task_session_id, task_id, tool_call_id, observed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `), "row-1", "session-schema", "task-schema", "tc-schema", now, now); err != nil { + t.Fatalf("first insert should succeed: %v", err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_subagents (id, task_session_id, task_id, tool_call_id, observed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `), "row-2", "session-schema", "task-schema", "tc-schema", now, now); err == nil { + t.Fatal("duplicate (task_session_id, tool_call_id) should violate UNIQUE constraint") + } + + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay migrations: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay migrations twice: %v", err) + } + if count := countSubagentContextRows(t, repo, "session-schema", "tc-schema"); count != 1 { + t.Fatalf("row count after replay = %d, want 1 (replay must not duplicate)", count) + } +} + +// TestSubagentContextBackfillAsyncLaunchedStoresNullNotZero is the spec's +// named must-not-get-wrong regression: 75% of real subagent invocations are +// async_launched with no reported usage, and a DEFAULT 0 anywhere would +// fabricate every one of them. (AC-7, AC-21) +// +// It also covers AC-21's per-column derivation for every text column the +// backfill's SELECT list populates: subagent_type, description, agent_id, +// child_session_id, and model are five adjacent same-typed columns in that +// SELECT with no other test giving each a distinct, individually-asserted +// value — a column-order mistake in the SELECT list (e.g. subagent_type and +// description swapped) would otherwise pass every existing test untouched. +func TestSubagentContextBackfillAsyncLaunchedStoresNullNotZero(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-async", "session-async", "turn-async") + createdAt := time.Date(2026, 8, 5, 10, 0, 0, 0, time.UTC) + updatedAt := createdAt.Add(time.Second) + + seedSubagentMessage(t, repo, "msg-async", "session-async", "task-async", "turn-async", + "tc-async", "", "complete", + map[string]interface{}{ + "description": "run the test suite", + "subagent_type": "test-runner", + "agent_id": "agent-xyz", + "child_session_id": "child-session-xyz", + "model": "claude-async-model", + "status": "async_launched", + "is_async": true, + }, + createdAt, updatedAt, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + row := getSubagentContextRow(t, repo, "session-async", "tc-async") + if row.taskID != "task-async" { + t.Errorf("task_id = %q, want task-async", row.taskID) + } + if !row.turnID.Valid || row.turnID.String != "turn-async" { + t.Errorf("turn_id = %v, want turn-async", row.turnID) + } + if !row.subagentType.Valid || row.subagentType.String != "test-runner" { + t.Errorf("subagent_type = %v, want test-runner", row.subagentType) + } + if !row.description.Valid || row.description.String != "run the test suite" { + t.Errorf("description = %v, want %q", row.description, "run the test suite") + } + if !row.agentID.Valid || row.agentID.String != "agent-xyz" { + t.Errorf("agent_id = %v, want agent-xyz", row.agentID) + } + if !row.childSessionID.Valid || row.childSessionID.String != "child-session-xyz" { + t.Errorf("child_session_id = %v, want child-session-xyz", row.childSessionID) + } + if !row.model.Valid || row.model.String != "claude-async-model" { + t.Errorf("model = %v, want claude-async-model", row.model) + } + if row.totalTokens.Valid { + t.Errorf("total_tokens = %v, want NULL", row.totalTokens) + } + if row.toolUseCount.Valid { + t.Errorf("tool_use_count = %v, want NULL", row.toolUseCount) + } + if row.durationMs.Valid { + t.Errorf("duration_ms = %v, want NULL", row.durationMs) + } + if row.source != "backfill" { + t.Errorf("source = %q, want backfill", row.source) + } + if !row.observedAt.Equal(createdAt) { + t.Errorf("observed_at = %v, want %v (message created_at)", row.observedAt, createdAt) + } + if row.isAsync != 1 { + t.Errorf("is_async = %d, want 1", row.isAsync) + } + if !row.agentStatus.Valid || row.agentStatus.String != "async_launched" { + t.Errorf("agent_status = %v, want async_launched", row.agentStatus) + } + // tool_status is the ACP status of the launching Task call, terminal here, + // so settled_at must be set from the message's updated_at. + if !row.toolStatus.Valid || row.toolStatus.String != "complete" { + t.Errorf("tool_status = %v, want complete", row.toolStatus) + } + if !row.settledAt.Valid || !row.settledAt.Time.Equal(updatedAt) { + t.Errorf("settled_at = %v, want %v", row.settledAt, updatedAt) + } +} + +// TestSubagentContextBackfillReportedZeroToolUseCountSurvives covers AC-8: a +// genuinely reported 0 must remain distinguishable from "not reported". +func TestSubagentContextBackfillReportedZeroToolUseCountSurvives(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-zero", "session-zero", "turn-zero") + ts := time.Date(2026, 8, 5, 11, 0, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-zero", "session-zero", "task-zero", "turn-zero", + "tc-zero", "", "completed", + map[string]interface{}{ + "status": "completed", + "tool_use_count": 0, + }, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + row := getSubagentContextRow(t, repo, "session-zero", "tc-zero") + if !row.toolUseCount.Valid || row.toolUseCount.Int64 != 0 { + t.Errorf("tool_use_count = %v, want 0 (reported, not absent)", row.toolUseCount) + } +} + +// TestSubagentContextBackfillNegativeMetricBecomesNull covers AC-9/AC-23: a +// negative reported metric is not a measurement and must not be stored. +func TestSubagentContextBackfillNegativeMetricBecomesNull(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-neg", "session-neg", "turn-neg") + ts := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-neg", "session-neg", "task-neg", "turn-neg", + "tc-neg", "", "completed", + map[string]interface{}{ + "status": "completed", + "total_tokens": -5, + "duration_ms": 1200, + }, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + row := getSubagentContextRow(t, repo, "session-neg", "tc-neg") + if row.totalTokens.Valid { + t.Errorf("total_tokens = %v, want NULL (negative reported value)", row.totalTokens) + } + if !row.durationMs.Valid || row.durationMs.Int64 != 1200 { + t.Errorf("duration_ms = %v, want 1200 (other fields unaffected)", row.durationMs) + } +} + +// TestSubagentContextBackfillEmptyStringBecomesNull covers the empty-string +// normalization rule: SubagentTaskPayload uses "" for absent; the column uses +// NULL so COUNT(model) counts models, not blanks. +func TestSubagentContextBackfillEmptyStringBecomesNull(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-empty", "session-empty", "turn-empty") + ts := time.Date(2026, 8, 5, 13, 0, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-empty", "session-empty", "task-empty", "turn-empty", + "tc-empty", "", "completed", + map[string]interface{}{ + "status": "completed", + "model": "", + }, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + row := getSubagentContextRow(t, repo, "session-empty", "tc-empty") + if row.model.Valid { + t.Errorf("model = %q, want NULL not empty string", row.model.String) + } +} + +// TestSubagentContextBackfillMalformedMetadataDoesNotAbort covers AC-23b: a +// single row with unparsable metadata (” or 'null') must not abort the +// whole statement, so a valid row alongside it still lands. +func TestSubagentContextBackfillMalformedMetadataDoesNotAbort(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-malformed", "session-malformed", "turn-malformed") + ts := time.Date(2026, 8, 5, 14, 0, 0, 0, time.UTC) + + // A literal empty-string metadata row can only exist as historical + // corruption predating idx_messages_metadata_tool_call_id (the index + // itself rejects an empty-string insert via its json_extract expression). + dropMessageMetadataIndex(t, db) + seedRawSubagentMessage(t, repo, "msg-blank", "session-malformed", "task-malformed", "turn-malformed", "", ts, ts) + seedRawSubagentMessage(t, repo, "msg-null", "session-malformed", "task-malformed", "turn-malformed", "null", ts, ts) + seedSubagentMessage(t, repo, "msg-valid", "session-malformed", "task-malformed", "turn-malformed", + "tc-malformed", "", "completed", + map[string]interface{}{"status": "completed"}, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations must not abort on malformed metadata rows: %v", err) + } + + if count := countSubagentContextRows(t, repo, "session-malformed", "tc-malformed"); count != 1 { + t.Fatalf("valid row alongside malformed metadata rows: count = %d, want 1", count) + } +} + +// TestSubagentContextBackfillNonTerminalSettledAtNull covers AC-23a's ELSE +// branch: a message whose derived ACP tool_status is NOT one of the terminal +// values must backfill with settled_at NULL, not a fabricated value from the +// message's updated_at. Every other backfill fixture in this file uses a +// terminal status, so nothing else exercises this branch — the spec notes 5 +// real rows are in exactly this "started" (non-terminal) state. +func TestSubagentContextBackfillNonTerminalSettledAtNull(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-started", "session-started", "turn-started") + ts := time.Date(2026, 8, 5, 14, 30, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-started", "session-started", "task-started", "turn-started", + "tc-started", "", "started", + map[string]interface{}{"status": "started"}, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + row := getSubagentContextRow(t, repo, "session-started", "tc-started") + if row.settledAt.Valid { + t.Errorf("settled_at = %v, want NULL (tool_status %q is not terminal)", row.settledAt, "started") + } + if !row.toolStatus.Valid || row.toolStatus.String != "started" { + t.Errorf("tool_status = %v, want started", row.toolStatus) + } +} + +// TestSubagentContextBackfillLiveRowWins covers AC-22: a live write always +// wins over a backfilled one, a replay of the backfill is a true no-op, AND +// the ON CONFLICT DO NOTHING clause that makes that possible does not abort +// the rest of the single unbounded INSERT...SELECT (AC-23a) — a second, +// unconflicted message in the same statement must still land. Without the +// clause, SQLite's INSERT...SELECT aborts the WHOLE statement on the first +// UNIQUE violation, silently dropping every other backfillable row too. +func TestSubagentContextBackfillLiveRowWins(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-live-wins", "session-live-wins", "turn-live-wins") + ts := time.Date(2026, 8, 5, 15, 0, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-live-wins", "session-live-wins", "task-live-wins", "turn-live-wins", + "tc-live-wins", "", "completed", + map[string]interface{}{"status": "completed", "model": "backfill-shape-model"}, + ts, ts, + ) + seedSubagentMessage(t, repo, "msg-live-wins-sibling", "session-live-wins", "task-live-wins", "turn-live-wins", + "tc-live-wins-sibling", "", "completed", + map[string]interface{}{"status": "completed", "model": "sibling-model"}, + ts.Add(time.Minute), ts.Add(time.Minute), + ) + + liveObservedAt := ts.Add(time.Hour) + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_subagents + (id, task_session_id, task_id, tool_call_id, model, source, observed_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'live', ?, ?) + `), "row-live-wins", "session-live-wins", "task-live-wins", "tc-live-wins", "live-model", liveObservedAt, liveObservedAt); err != nil { + t.Fatalf("seed pre-existing live row: %v", err) + } + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + row := getSubagentContextRow(t, repo, "session-live-wins", "tc-live-wins") + if row.source != "live" { + t.Errorf("source = %q, want live (backfill must not overwrite)", row.source) + } + if !row.model.Valid || row.model.String != "live-model" { + t.Errorf("model = %v, want live-model preserved", row.model) + } + if count := countSubagentContextRows(t, repo, "session-live-wins", "tc-live-wins"); count != 1 { + t.Fatalf("row count = %d, want exactly 1", count) + } + + sibling := getSubagentContextRow(t, repo, "session-live-wins", "tc-live-wins-sibling") + if sibling.source != "backfill" { + t.Errorf("sibling source = %q, want backfill (the UNIQUE violation on the OTHER row must not abort this insert)", sibling.source) + } + if !sibling.model.Valid || sibling.model.String != "sibling-model" { + t.Errorf("sibling model = %v, want sibling-model", sibling.model) + } +} + +// TestSubagentContextBackfillSkipsRowsWithoutIdentity covers AC-2: a message +// with no task_id or no tool_call_id must not backfill a row. +func TestSubagentContextBackfillSkipsRowsWithoutIdentity(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-identity", "session-identity", "turn-identity") + ts := time.Date(2026, 8, 5, 16, 0, 0, 0, time.UTC) + + // No task_id: seed the message row directly with task_id = ''. + seedRawSubagentMessage(t, repo, "msg-no-task", "session-identity", "", "turn-identity", + `{"tool_call_id":"tc-no-task","status":"completed","normalized":{"kind":"subagent_task","subagent_task":{"status":"completed"}}}`, + ts, ts, + ) + // No tool_call_id. + seedSubagentMessage(t, repo, "msg-no-toolcall", "session-identity", "task-identity", "turn-identity", + "", "", "completed", + map[string]interface{}{"status": "completed"}, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + if count := countSubagentContextRows(t, repo, "session-identity", "tc-no-task"); count != 0 { + t.Fatalf("row backfilled for message with no task_id: count = %d, want 0", count) + } + if count := countSubagentContextRows(t, repo, "session-identity", ""); count != 0 { + t.Fatalf("row backfilled for message with no tool_call_id: count = %d, want 0", count) + } +} + +// TestSubagentContextBackfillNestedParentToolCallID covers AC-6: a nested +// subagent (parent_tool_call_id set) must be recorded, not suppressed. +func TestSubagentContextBackfillNestedParentToolCallID(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-nested", "session-nested", "turn-nested") + ts := time.Date(2026, 8, 5, 17, 0, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-nested", "session-nested", "task-nested", "turn-nested", + "tc-nested-child", "tc-nested-parent", "completed", + map[string]interface{}{"status": "completed"}, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + row := getSubagentContextRow(t, repo, "session-nested", "tc-nested-child") + if !row.parentToolCallID.Valid || row.parentToolCallID.String != "tc-nested-parent" { + t.Errorf("parent_tool_call_id = %v, want tc-nested-parent", row.parentToolCallID) + } +} + +// TestSubagentContextActivationKeysWrittenOnce covers AC-24: both kandev_meta +// keys are written once, RFC3339-parseable (backfill_through may be empty), +// and never overwritten on replay. newSubagentMigrationTestRepo already +// resets both keys to "not yet activated" after construction (see its +// comment), so the first runMigrations() call below is the real backfill run +// this test exercises. +func TestSubagentContextActivationKeysWrittenOnce(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-activation", "session-activation", "turn-activation") + newest := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC) + older := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-activation-older", "session-activation", "task-activation", "turn-activation", + "tc-activation-older", "", "completed", map[string]interface{}{"status": "completed"}, older, older) + seedSubagentMessage(t, repo, "msg-activation-newest", "session-activation", "task-activation", "turn-activation", + "tc-activation-newest", "", "completed", map[string]interface{}{"status": "completed"}, newest, newest) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + captureSince, ok := readMetaKey(t, db, "subagent_context_capture_since") + if !ok { + t.Fatal("subagent_context_capture_since must be present") + } + if _, err := time.Parse(time.RFC3339, captureSince); err != nil { + t.Fatalf("subagent_context_capture_since = %q, not RFC3339: %v", captureSince, err) + } + + backfillThrough, ok := readMetaKey(t, db, "subagent_context_backfill_through") + if !ok { + t.Fatal("subagent_context_backfill_through must be present") + } + parsed, err := time.Parse(time.RFC3339, backfillThrough) + if err != nil { + t.Fatalf("subagent_context_backfill_through = %q, not RFC3339: %v", backfillThrough, err) + } + if !parsed.Equal(newest) { + t.Fatalf("subagent_context_backfill_through = %v, want newest message created_at %v", parsed, newest) + } + + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay runMigrations: %v", err) + } + captureSinceReplayed, _ := readMetaKey(t, db, "subagent_context_capture_since") + backfillThroughReplayed, _ := readMetaKey(t, db, "subagent_context_backfill_through") + if captureSinceReplayed != captureSince { + t.Fatalf("subagent_context_capture_since changed on replay: %q -> %q", captureSince, captureSinceReplayed) + } + if backfillThroughReplayed != backfillThrough { + t.Fatalf("subagent_context_backfill_through changed on replay: %q -> %q", backfillThrough, backfillThroughReplayed) + } +} + +// TestSubagentContextActivationBackfillThroughEmptyWithNoMessages covers the +// AC-24 edge case: a fresh DB with no subagent messages sets +// subagent_context_backfill_through to the empty string, not NULL and not a +// missing key. +func TestSubagentContextActivationBackfillThroughEmptyWithNoMessages(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + backfillThrough, ok := readMetaKey(t, db, "subagent_context_backfill_through") + if !ok { + t.Fatal("subagent_context_backfill_through must be present even with no subagent messages") + } + if backfillThrough != "" { + t.Fatalf("subagent_context_backfill_through = %q, want empty string", backfillThrough) + } +} + +// TestSubagentContextBackfillDoesNotRescanOnceActivated covers AC-23a: the +// backfill's full-table scan is "a real, accepted one-time cost ... taken +// once", not a per-boot cost. Once subagent_context_capture_since is set, a +// later runMigrations() call (a later boot) must skip the scan entirely — +// proven here by deleting an already-backfilled row and confirming a second +// runMigrations() call does not restore it. Under the pre-fix behaviour +// (migrateSubagentContextBackfill ran its INSERT...SELECT unconditionally on +// every call, relying only on ON CONFLICT DO NOTHING for row-level +// idempotence) nothing would have stopped that second call from re-scanning +// task_session_messages and re-inserting the deleted row, since deleting it +// removes the only thing ON CONFLICT DO NOTHING keys off. +func TestSubagentContextBackfillDoesNotRescanOnceActivated(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-once", "session-once", "turn-once") + ts := time.Date(2026, 8, 5, 18, 0, 0, 0, time.UTC) + + seedSubagentMessage(t, repo, "msg-once", "session-once", "task-once", "turn-once", + "tc-once", "", "completed", + map[string]interface{}{"status": "completed"}, + ts, ts, + ) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("first runMigrations (the real, one-time backfill): %v", err) + } + if count := countSubagentContextRows(t, repo, "session-once", "tc-once"); count != 1 { + t.Fatalf("row count after first backfill = %d, want 1", count) + } + + if _, err := db.Exec(db.Rebind(`DELETE FROM task_session_subagents WHERE task_session_id = ? AND tool_call_id = ?`), + "session-once", "tc-once"); err != nil { + t.Fatalf("delete backfilled row: %v", err) + } + + if err := repo.runMigrations(); err != nil { + t.Fatalf("second runMigrations (a later boot): %v", err) + } + if count := countSubagentContextRows(t, repo, "session-once", "tc-once"); count != 0 { + t.Fatalf("row count after second boot = %d, want 0 (backfill must not re-scan once activated)", count) + } +} + +// TestSubagentContextBackfillStatementFailureLogsWarnWithMigrationName covers +// AC-20: because MigrateLogger.Apply swallows a migration statement's error +// (internal/db/migratelog.go), the only way a failure is observable is a WARN +// log carrying the migration's name. Forces the backfill INSERT to fail (its +// target table dropped out from under it) and asserts that WARN, using the +// same zaptest/observer pattern as +// TestCutover_RolledBackCutoverEmitsNoSuccessLog in +// worktree_ownership_election_test.go. +func TestSubagentContextBackfillStatementFailureLogsWarnWithMigrationName(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "subagent-context-migration-failure.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create kandev_meta: %v", err) + } + + core, logs := observer.New(zapcore.WarnLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("create observer logger: %v", err) + } + repo := &Repository{db: db, ro: db, log: log, migrate: dbutil.NewMigrateLogger(db, log)} + if err := repo.initSchema(); err != nil { + t.Fatalf("initSchema: %v", err) + } + + seedForMsgTest(t, repo, "task-migfail", "session-migfail", "turn-migfail") + seedSubagentMessage(t, repo, "msg-migfail", "session-migfail", "task-migfail", "turn-migfail", + "tc-migfail", "", "completed", + map[string]interface{}{"status": "completed"}, + time.Date(2026, 8, 5, 19, 0, 0, 0, time.UTC), time.Date(2026, 8, 5, 19, 0, 0, 0, time.UTC), + ) + + // Reset to "not yet activated" (see newSubagentMigrationTestRepo's + // comment) so the next runMigrations() call actually attempts the + // backfill, then break its INSERT statement so that attempt fails. Drop a + // column the INSERT's column list needs instead of the whole table, so the + // failure is isolated to the backfill statement. + if _, err := db.Exec(`DELETE FROM kandev_meta WHERE key IN ('subagent_context_capture_since', 'subagent_context_backfill_through')`); err != nil { + t.Fatalf("reset activation keys: %v", err) + } + if _, err := db.Exec(`ALTER TABLE task_session_subagents DROP COLUMN updated_at`); err != nil { + t.Fatalf("drop updated_at column: %v", err) + } + + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations must swallow the failure, not return it: %v", err) + } + + entries := logs.FilterMessage("migration failed").All() + var found bool + for _, entry := range entries { + if entry.ContextMap()["name"] == "task_session_subagents.backfill" { + found = true + break + } + } + if !found { + t.Fatalf("no WARN log carrying migration name %q found among %d entries", "task_session_subagents.backfill", len(entries)) + } + + // AC-24d: the INSERT and both activation-key writes share one + // transaction, so a failed INSERT must leave neither key written — + // never just the backfill row-set incomplete while activation still + // reports "done". Confirms this stays true rather than one write + // slipping outside the transaction in a future edit. + if _, ok := readMetaKey(t, db, subagentContextCaptureSinceKey); ok { + t.Fatalf("%s must not be written when the backfill INSERT fails", subagentContextCaptureSinceKey) + } + if _, ok := readMetaKey(t, db, subagentContextBackfillThroughKey); ok { + t.Fatalf("%s must not be written when the backfill INSERT fails", subagentContextBackfillThroughKey) + } +} + +// TestSubagentContextBackfillDoesNotRescanWhenBackfillThroughEmpty is SR45's +// named discriminating test: the activation guard must be a row-EXISTENCE +// check on both kandev_meta keys, not a value-non-empty check. AC-24f makes +// the empty string a legitimate, present value for backfill_through (no +// message matched the predicate at activation time) — a value-non-empty +// guard would misread that row as "not yet activated" and re-scan on every +// later boot, exactly the per-boot cost AC-23a forbids. This test activates +// with zero messages present (so backfill_through is written as ”) and +// then proves a second runMigrations() call, after a message now exists, +// still does not backfill it. +func TestSubagentContextBackfillDoesNotRescanWhenBackfillThroughEmpty(t *testing.T) { + repo, db := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, "task-empty-guard", "session-empty-guard", "turn-empty-guard") + + if err := repo.runMigrations(); err != nil { + t.Fatalf("first runMigrations (activates with no messages): %v", err) + } + backfillThrough, ok := readMetaKey(t, db, "subagent_context_backfill_through") + if !ok { + t.Fatal("subagent_context_backfill_through must be present after activation") + } + if backfillThrough != "" { + t.Fatalf("subagent_context_backfill_through = %q, want empty string (no messages existed at activation)", backfillThrough) + } + + ts := time.Date(2026, 8, 5, 20, 0, 0, 0, time.UTC) + seedSubagentMessage(t, repo, "msg-empty-guard", "session-empty-guard", "task-empty-guard", "turn-empty-guard", + "tc-empty-guard", "", "completed", map[string]interface{}{"status": "completed"}, ts, ts) + + if err := repo.runMigrations(); err != nil { + t.Fatalf("second runMigrations (a later boot, message now exists): %v", err) + } + if count := countSubagentContextRows(t, repo, "session-empty-guard", "tc-empty-guard"); count != 0 { + t.Fatalf("row count after second boot = %d, want 0: an empty-string backfill_through is a PRESENT row and must not re-trigger the scan", count) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go b/apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go new file mode 100644 index 0000000000..d5da1fe3f8 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go @@ -0,0 +1,422 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func seedPostgresForMsgTest(t *testing.T, db *sqlx.DB, taskID, sessionID, turnID string) { + t.Helper() + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, '', 'test task', ?, ?) + `), taskID, now, now); err != nil { + t.Fatalf("seed task %s: %v", taskID, err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_sessions (id, task_id, started_at, updated_at) + VALUES (?, ?, ?, ?) + `), sessionID, taskID, now, now); err != nil { + t.Fatalf("seed session %s: %v", sessionID, err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `), turnID, sessionID, taskID, now, now, now); err != nil { + t.Fatalf("seed turn %s: %v", turnID, err) + } +} + +// TestPostgresSubagentContextSchemaAndBackfill is the Postgres counterpart to +// TestSubagentContextSchemaFreshAndReplay / TestSubagentContextBackfill*: a +// fresh schema plus a migration replay produces the table, its indexes, the +// backfilled rows, and both activation keys, matching SQLite (AC-19). Skips +// unless KANDEV_TEST_POSTGRES_DSN is set. +func TestPostgresSubagentContextSchemaAndBackfill(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + // Production boot order (internal/persistence/provider.go) creates + // kandev_meta before the task repository; mirror that here since this + // package's repository never creates it itself. + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create kandev_meta: %v", err) + } + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + + for _, index := range []string{"idx_subagents_session_id", "idx_subagents_task_id", "idx_subagents_turn_id"} { + var name string + if err := db.Get(&name, `SELECT indexname FROM pg_indexes WHERE indexname = $1`, index); err != nil { + t.Fatalf("index %s is missing on postgres: %v", index, err) + } + } + + seedPostgresForMsgTest(t, db, "task-pg-schema", "session-pg-schema", "turn-pg-schema") + createdAt := time.Date(2026, 8, 5, 10, 0, 0, 0, time.UTC) + updatedAt := createdAt.Add(time.Second) + metadata := `{"tool_call_id":"tc-pg-backfill","status":"complete","normalized":{"kind":"subagent_task","subagent_task":{"status":"async_launched","is_async":true}}}` + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_messages + (id, task_session_id, task_id, turn_id, author_type, content, type, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, 'agent', 'Subagent', 'tool_call', ?, ?, ?) + `), "msg-pg-backfill", "session-pg-schema", "task-pg-schema", "turn-pg-schema", metadata, createdAt, updatedAt); err != nil { + t.Fatalf("seed subagent message: %v", err) + } + + // Reset the write-once activation keys to simulate this being the + // database's first boot with historical data present — mirrors + // TestSubagentContextActivationKeysWrittenOnce's SQLite technique, needed + // because NewWithDB above already claimed both keys against the + // then-empty database in this same boot. + if _, err := db.Exec(`DELETE FROM kandev_meta WHERE key IN ('subagent_context_capture_since', 'subagent_context_backfill_through')`); err != nil { + t.Fatalf("reset activation keys: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay runMigrations: %v", err) + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-pg-schema") + if err != nil { + t.Fatalf("ListSubagentContextsBySession: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1", len(rows)) + } + row := rows[0] + if row.Source != "backfill" { + t.Errorf("source = %q, want backfill", row.Source) + } + if row.TotalTokens != nil || row.ToolUseCount != nil || row.DurationMs != nil { + t.Errorf("metrics = tokens=%v count=%v duration=%v, want all NULL (async_launched)", row.TotalTokens, row.ToolUseCount, row.DurationMs) + } + if !row.IsAsync { + t.Error("is_async = false, want true") + } + if row.SettledAt == nil || !row.SettledAt.Equal(updatedAt) { + t.Errorf("settled_at = %v, want %v (tool_status complete is terminal)", row.SettledAt, updatedAt) + } + + var captureSince, backfillThrough string + if err := db.Get(&captureSince, db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), "subagent_context_capture_since"); err != nil { + t.Fatalf("read subagent_context_capture_since: %v", err) + } + if _, err := time.Parse(time.RFC3339, captureSince); err != nil { + t.Fatalf("subagent_context_capture_since = %q, not RFC3339: %v", captureSince, err) + } + if err := db.Get(&backfillThrough, db.Rebind(`SELECT value FROM kandev_meta WHERE key = ?`), "subagent_context_backfill_through"); err != nil { + t.Fatalf("read subagent_context_backfill_through: %v", err) + } + parsed, err := time.Parse(time.RFC3339, backfillThrough) + if err != nil { + t.Fatalf("subagent_context_backfill_through = %q, not RFC3339: %v", backfillThrough, err) + } + if !parsed.Equal(createdAt) { + t.Errorf("subagent_context_backfill_through = %v, want %v", parsed, createdAt) + } + +} + +// TestPostgresUpsertSubagentContextConflictBehavior verifies the upsert's +// conflict clause behaviorally on Postgres — schema replay alone would not +// exercise ON CONFLICT DO UPDATE semantics (ADR 0027). Covers fill-forward, +// write-once settled_at, sticky is_async, and preserved original turn_id. +func TestPostgresUpsertSubagentContextConflictBehavior(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + seedPostgresForMsgTest(t, db, "task-pg-upsert", "session-pg-upsert", "turn-pg-upsert-a") + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `), "turn-pg-upsert-b", "session-pg-upsert", "task-pg-upsert", time.Now().UTC(), time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatalf("seed second turn: %v", err) + } + + firstSettle := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + turnA := "turn-pg-upsert-a" + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-pg-upsert", TaskID: "task-pg-upsert", TurnID: &turnA, + ToolCallID: "tc-pg-upsert", Source: "live", ObservedAt: firstSettle, UpdatedAt: firstSettle, + SubagentType: sp("security-reviewer"), IsAsync: true, + ToolStatus: sp("completed"), SettledAt: &firstSettle, + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + + turnB := "turn-pg-upsert-b" + laterSettle := firstSettle.Add(time.Hour) + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-pg-upsert", TaskID: "task-pg-upsert", TurnID: &turnB, + ToolCallID: "tc-pg-upsert", Source: "live", ObservedAt: firstSettle, UpdatedAt: laterSettle, + IsAsync: false, ToolStatus: sp("completed"), SettledAt: &laterSettle, + Model: sp("claude-opus-5"), + }); err != nil { + t.Fatalf("second upsert: %v", err) + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-pg-upsert") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1", len(rows)) + } + row := rows[0] + if row.TurnID == nil || *row.TurnID != "turn-pg-upsert-a" { + t.Errorf("turn_id = %v, want turn-pg-upsert-a (original turn preserved)", row.TurnID) + } + if !row.IsAsync { + t.Error("is_async = false, want sticky true") + } + if row.SettledAt == nil || !row.SettledAt.Equal(firstSettle) { + t.Errorf("settled_at = %v, want %v (write-once)", row.SettledAt, firstSettle) + } + if row.SubagentType == nil || *row.SubagentType != "security-reviewer" { + t.Errorf("subagent_type = %v, want preserved (fill-forward)", row.SubagentType) + } + if row.Model == nil || *row.Model != "claude-opus-5" { + t.Errorf("model = %v, want claude-opus-5 (filled from second frame)", row.Model) + } +} + +// TestPostgresSubagentContextBackfillJSONHelpers verifies the backfill's +// dialect-aware JSON helpers over jsonb (AC-23b): malformed metadata (” and +// 'null') do not abort the migration, and the boolean spelling difference +// (#>> yields 'true'/'false' text on Postgres vs SQLite's 1/0) normalizes +// correctly. +func TestPostgresSubagentContextBackfillJSONHelpers(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + // Production boot order (internal/persistence/provider.go) creates + // kandev_meta before the task repository; mirror that here since this + // package's repository never creates it itself. Without this, the + // backfill transaction's capture_since key write fails and rolls back + // the whole migration, silently discarding the valid row along with the + // malformed ones this test means to exercise. + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create kandev_meta: %v", err) + } + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresForMsgTest(t, db, "task-pg-malformed", "session-pg-malformed", "turn-pg-malformed") + ts := time.Date(2026, 8, 5, 14, 0, 0, 0, time.UTC) + + // idx_messages_metadata_tool_call_id / idx_messages_metadata_pending_id + // are expression indexes over metadata::jsonb; Postgres evaluates them on + // every insert, so a literal empty-string metadata row (simulating + // historical corruption that predates the index) can only be seeded with + // the index dropped first — matching subagent_context_migration_test.go's + // SQLite technique. + for _, index := range []string{"idx_messages_metadata_tool_call_id", "idx_messages_metadata_pending_id"} { + if _, err := db.Exec(`DROP INDEX IF EXISTS ` + index); err != nil { + t.Fatalf("drop %s: %v", index, err) + } + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_messages + (id, task_session_id, task_id, turn_id, author_type, content, type, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, 'agent', 'Subagent', 'tool_call', ?, ?, ?) + `), "msg-pg-blank", "session-pg-malformed", "task-pg-malformed", "turn-pg-malformed", "", ts, ts); err != nil { + t.Fatalf("seed blank-metadata message: %v", err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_messages + (id, task_session_id, task_id, turn_id, author_type, content, type, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, 'agent', 'Subagent', 'tool_call', ?, ?, ?) + `), "msg-pg-null", "session-pg-malformed", "task-pg-malformed", "turn-pg-malformed", "null", ts, ts); err != nil { + t.Fatalf("seed null-metadata message: %v", err) + } + validMetadata := `{"tool_call_id":"tc-pg-valid","status":"completed","normalized":{"kind":"subagent_task","subagent_task":{"status":"completed","is_async":true}}}` + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_messages + (id, task_session_id, task_id, turn_id, author_type, content, type, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, 'agent', 'Subagent', 'tool_call', ?, ?, ?) + `), "msg-pg-valid", "session-pg-malformed", "task-pg-malformed", "turn-pg-malformed", validMetadata, ts, ts); err != nil { + t.Fatalf("seed valid message: %v", err) + } + + // Reset the write-once activation keys to simulate this being the + // database's first boot with historical data present — mirrors + // TestPostgresSubagentContextSchemaAndBackfill's technique, needed + // because NewWithDB above already claimed both keys against the + // then-empty database in this same boot. + if _, err := db.Exec(`DELETE FROM kandev_meta WHERE key IN ('subagent_context_capture_since', 'subagent_context_backfill_through')`); err != nil { + t.Fatalf("reset activation keys: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations must not abort on malformed metadata rows: %v", err) + } + + rows, err := repo.ListSubagentContextsBySession(context.Background(), "session-pg-malformed") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1 (only the valid row backfills)", len(rows)) + } + row := rows[0] + if row.ToolCallID != "tc-pg-valid" { + t.Errorf("tool_call_id = %q, want tc-pg-valid", row.ToolCallID) + } + if !row.IsAsync { + t.Error("is_async = false, want true (Postgres #>> 'true' spelling must normalize to 1)") + } +} + +// TestPostgresSubagentContextHealthQueriesIgnoreSessionTimezone proves the +// health queries' timestamp comparisons produce the same result regardless +// of the Postgres session's `timezone` GUC. observed_at/created_at are naive +// TIMESTAMP columns storing a UTC wall clock (see subagentPreciseTimestamp's +// doc comment); casting one via ::timestamptz reinterprets its digits using +// whatever timezone the current session happens to have set, silently +// shifting the comparison by that session's UTC offset. A production server +// whose default timezone is not UTC — the common case outside test +// environments — would see the shortfall/excess anti-joins undercount and +// max-observed-since go blind exactly like a stalled writer would, which is +// what AC-29's "ok=false must mean genuinely nothing observed" guarantee +// depends on. TestPostgresSubagentContextHealthCountsAgreeAfterLiveWrites +// above never varies the session timezone, so it could not have caught this. +func TestPostgresSubagentContextHealthQueriesIgnoreSessionTimezone(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create kandev_meta: %v", err) + } + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresForMsgTest(t, db, "task-pg-tz", "session-pg-tz", "turn-pg-tz") + + // OpenIsolatedPostgres pins the pool to a single connection, so this SET + // holds for every query the test and the repository issue afterward. + // Kiritimati (UTC+14) is far enough from UTC that the naive-TIMESTAMP- + // as-timestamptz bug shifts a comparison by far more than the hour of + // slack seeded below, regardless of the host running the test. + if _, err := db.Exec(`SET TIME ZONE 'Pacific/Kiritimati'`); err != nil { + t.Fatalf("set session timezone: %v", err) + } + + since := time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC) + after := since.Add(time.Hour) + + // A message with no accounting context row: shortfall must count it. + seedSubagentMessage(t, repo, "msg-pg-tz-shortfall", "session-pg-tz", "task-pg-tz", "turn-pg-tz", + "tc-pg-tz-shortfall", "", "completed", map[string]interface{}{"status": "completed"}, + after, after) + + // A context row with no accounting message: excess must count it, and + // max-observed-since must report it. + if err := repo.UpsertSubagentContext(context.Background(), &models.SubagentContext{ + TaskSessionID: "session-pg-tz", TaskID: "task-pg-tz", ToolCallID: "tc-pg-tz-excess", + Source: "live", ObservedAt: after, UpdatedAt: after, + }); err != nil { + t.Fatalf("UpsertSubagentContext: %v", err) + } + + shortfall, err := repo.subagentContextShortfall(since) + if err != nil { + t.Fatalf("subagentContextShortfall: %v", err) + } + if shortfall != 1 { + t.Errorf("shortfall = %d, want 1 (a non-UTC session timezone must not hide an unmatched message)", shortfall) + } + + excess, err := repo.subagentContextExcess(since) + if err != nil { + t.Fatalf("subagentContextExcess: %v", err) + } + if excess != 1 { + t.Errorf("excess = %d, want 1 (a non-UTC session timezone must not hide an unmatched context row)", excess) + } + + maxObserved, ok, err := repo.subagentContextMaxObservedSince(since) + if err != nil { + t.Fatalf("subagentContextMaxObservedSince: %v", err) + } + if !ok { + t.Fatal("subagentContextMaxObservedSince: want a value, got none (a non-UTC session timezone must not make a live write invisible)") + } + if !maxObserved.Equal(after) { + t.Errorf("subagentContextMaxObservedSince = %v, want %v", maxObserved, after) + } +} + +// TestPostgresSubagentContextHealthCountsAgreeAfterLiveWrites is the +// PostgreSQL counterpart to TestSubagentContextHealthCountsAgreeAfterLiveWrites: +// AC-28's shortfall/excess anti-joins and AC-29's max-observed-since use +// SQLite's json_extract in the spec's illustrative form, which does not exist +// on PostgreSQL — jsonKey builds the dialect-appropriate expression instead, +// and this test proves the resulting queries actually agree on PostgreSQL, +// not just on SQLite (AC-19). +func TestPostgresSubagentContextHealthCountsAgreeAfterLiveWrites(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create kandev_meta: %v", err) + } + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresForMsgTest(t, db, "task-pg-health", "session-pg-health", "turn-pg-health") + ctx := context.Background() + since := time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC) + ts := since.Add(time.Hour) + + var latest time.Time + for i, toolCallID := range []string{"tc-pg-health-1", "tc-pg-health-2", "tc-pg-health-3"} { + observedAt := ts.Add(time.Duration(i) * time.Minute) + latest = observedAt + seedSubagentMessage(t, repo, "msg-pg-health-"+toolCallID, "session-pg-health", "task-pg-health", "turn-pg-health", + toolCallID, "", "completed", map[string]interface{}{"status": "completed"}, + observedAt, observedAt) + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-pg-health", TaskID: "task-pg-health", ToolCallID: toolCallID, + Source: "live", ObservedAt: observedAt, UpdatedAt: observedAt, + }); err != nil { + t.Fatalf("UpsertSubagentContext %s: %v", toolCallID, err) + } + } + + shortfall, err := repo.subagentContextShortfall(since) + if err != nil { + t.Fatalf("subagentContextShortfall: %v", err) + } + if shortfall != 0 { + t.Fatalf("shortfall = %d, want 0 after every message got a live context row", shortfall) + } + + excess, err := repo.subagentContextExcess(since) + if err != nil { + t.Fatalf("subagentContextExcess: %v", err) + } + if excess != 0 { + t.Fatalf("excess = %d, want 0 when every context row is accounted for by a message", excess) + } + + maxObserved, ok, err := repo.subagentContextMaxObservedSince(since) + if err != nil { + t.Fatalf("subagentContextMaxObservedSince: %v", err) + } + if !ok { + t.Fatal("subagentContextMaxObservedSince: want a value, got none") + } + if !maxObserved.Equal(latest) { + t.Fatalf("subagentContextMaxObservedSince = %v, want %v (the last live write)", maxObserved, latest) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_test.go b/apps/backend/internal/task/repository/sqlite/subagent_context_test.go new file mode 100644 index 0000000000..609490b6ba --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_test.go @@ -0,0 +1,641 @@ +package sqlite + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func sp(s string) *string { return &s } +func i64p(n int64) *int64 { return &n } +func tp(t time.Time) *time.Time { return &t } + +func newSubagentContextTestRepo(t *testing.T, taskID, sessionID, turnID string) *Repository { + t.Helper() + repo, _ := newSubagentMigrationTestRepo(t) + seedForMsgTest(t, repo, taskID, sessionID, turnID) + return repo +} + +func mustGetSubagentContext(t *testing.T, repo *Repository, sessionID, toolCallID string) *models.SubagentContext { + t.Helper() + rows, err := repo.ListSubagentContextsBySession(context.Background(), sessionID) + if err != nil { + t.Fatalf("ListSubagentContextsBySession: %v", err) + } + for _, row := range rows { + if row.ToolCallID == toolCallID { + return row + } + } + t.Fatalf("no subagent context row for (%s, %s) among %d rows", sessionID, toolCallID, len(rows)) + return nil +} + +// TestUpsertSubagentContextInsertsRow covers AC-1: a first observation +// creates a row. +func TestUpsertSubagentContextInsertsRow(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-1", "session-1", "turn-1") + ctx := context.Background() + observedAt := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-1", + TaskID: "task-1", + TurnID: sp("turn-1"), + ToolCallID: "tc-1", + Source: "live", + ObservedAt: observedAt, + UpdatedAt: observedAt, + }) + if err != nil { + t.Fatalf("UpsertSubagentContext: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-1", "tc-1") + if row.Source != "live" { + t.Errorf("source = %q, want live", row.Source) + } + if !row.ObservedAt.Equal(observedAt) { + t.Errorf("observed_at = %v, want %v", row.ObservedAt, observedAt) + } + if row.TurnID == nil || *row.TurnID != "turn-1" { + t.Errorf("turn_id = %v, want turn-1", row.TurnID) + } +} + +// TestUpsertSubagentContextSecondCallUpdatesInPlace covers AC-3: a later +// frame for an already-recorded key updates the same row, never a second one. +func TestUpsertSubagentContextSecondCallUpdatesInPlace(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-2", "session-2", "turn-2") + ctx := context.Background() + first := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + second := first.Add(time.Minute) + + base := models.SubagentContext{ + TaskSessionID: "session-2", TaskID: "task-2", TurnID: sp("turn-2"), + ToolCallID: "tc-2", Source: "live", ObservedAt: first, UpdatedAt: first, + } + if err := repo.UpsertSubagentContext(ctx, &base); err != nil { + t.Fatalf("first upsert: %v", err) + } + second_ := base + second_.Model = sp("claude-opus-5") + second_.UpdatedAt = second + if err := repo.UpsertSubagentContext(ctx, &second_); err != nil { + t.Fatalf("second upsert: %v", err) + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-2") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1", len(rows)) + } + if rows[0].Model == nil || *rows[0].Model != "claude-opus-5" { + t.Errorf("model = %v, want claude-opus-5", rows[0].Model) + } +} + +// TestUpsertSubagentContextFillForward covers AC-4: a frame with an +// empty/absent field never blanks a value already learned. +func TestUpsertSubagentContextFillForward(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-3", "session-3", "turn-3") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-3", TaskID: "task-3", TurnID: sp("turn-3"), + ToolCallID: "tc-3", Source: "live", ObservedAt: ts, UpdatedAt: ts, + SubagentType: sp("security-reviewer"), Description: sp("review the diff"), + Model: sp("claude-opus-5"), TotalTokens: i64p(500), + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + + // A later frame reports nothing new — every already-learned field must + // survive untouched. + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-3", TaskID: "task-3", + ToolCallID: "tc-3", Source: "live", ObservedAt: ts, UpdatedAt: ts.Add(time.Second), + }); err != nil { + t.Fatalf("second upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-3", "tc-3") + if row.SubagentType == nil || *row.SubagentType != "security-reviewer" { + t.Errorf("subagent_type = %v, want preserved", row.SubagentType) + } + if row.Description == nil || *row.Description != "review the diff" { + t.Errorf("description = %v, want preserved", row.Description) + } + if row.Model == nil || *row.Model != "claude-opus-5" { + t.Errorf("model = %v, want preserved", row.Model) + } + if row.TotalTokens == nil || *row.TotalTokens != 500 { + t.Errorf("total_tokens = %v, want preserved", row.TotalTokens) + } +} + +// TestUpsertSubagentContextReplacesWithNonEmptyValue covers AC-5: a +// non-empty reported value replaces the stored value. +func TestUpsertSubagentContextReplacesWithNonEmptyValue(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-4", "session-4", "turn-4") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-4", TaskID: "task-4", ToolCallID: "tc-4", + Source: "live", ObservedAt: ts, UpdatedAt: ts, TotalTokens: i64p(100), + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-4", TaskID: "task-4", ToolCallID: "tc-4", + Source: "live", ObservedAt: ts, UpdatedAt: ts.Add(time.Second), TotalTokens: i64p(250), + }); err != nil { + t.Fatalf("second upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-4", "tc-4") + if row.TotalTokens == nil || *row.TotalTokens != 250 { + t.Errorf("total_tokens = %v, want 250 (replaced)", row.TotalTokens) + } +} + +// TestUpsertSubagentContextSettledAtWriteOnce covers AC-11: a second terminal +// frame with a later timestamp leaves the first settled_at value. +func TestUpsertSubagentContextSettledAtWriteOnce(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-5", "session-5", "turn-5") + ctx := context.Background() + firstSettle := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + laterSettle := firstSettle.Add(time.Hour) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-5", TaskID: "task-5", ToolCallID: "tc-5", + Source: "live", ObservedAt: firstSettle, UpdatedAt: firstSettle, + ToolStatus: sp("completed"), SettledAt: tp(firstSettle), + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-5", TaskID: "task-5", ToolCallID: "tc-5", + Source: "live", ObservedAt: firstSettle, UpdatedAt: laterSettle, + ToolStatus: sp("completed"), SettledAt: tp(laterSettle), + }); err != nil { + t.Fatalf("second upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-5", "tc-5") + if row.SettledAt == nil || !row.SettledAt.Equal(firstSettle) { + t.Errorf("settled_at = %v, want %v (write-once)", row.SettledAt, firstSettle) + } +} + +// TestUpsertSubagentContextPostSettleFillsNullsButFreezesStatus covers AC-12: +// a non-terminal frame after settling leaves settled_at and tool_status +// alone but still fills columns that are currently NULL. +func TestUpsertSubagentContextPostSettleFillsNullsButFreezesStatus(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-6", "session-6", "turn-6") + ctx := context.Background() + settleTime := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-6", TaskID: "task-6", ToolCallID: "tc-6", + Source: "live", ObservedAt: settleTime, UpdatedAt: settleTime, + ToolStatus: sp("completed"), SettledAt: tp(settleTime), + }); err != nil { + t.Fatalf("first (terminal) upsert: %v", err) + } + + // A stray non-terminal/duplicate frame arrives after settling — it must + // not resurrect tool_status or settled_at, but new information (model) + // still fills the NULL column. + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-6", TaskID: "task-6", ToolCallID: "tc-6", + Source: "live", ObservedAt: settleTime, UpdatedAt: settleTime.Add(time.Second), + ToolStatus: sp("running"), Model: sp("claude-opus-5"), + }); err != nil { + t.Fatalf("second (post-settle) upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-6", "tc-6") + if row.ToolStatus == nil || *row.ToolStatus != "completed" { + t.Errorf("tool_status = %v, want frozen at completed", row.ToolStatus) + } + if row.SettledAt == nil || !row.SettledAt.Equal(settleTime) { + t.Errorf("settled_at = %v, want frozen at %v", row.SettledAt, settleTime) + } + if row.Model == nil || *row.Model != "claude-opus-5" { + t.Errorf("model = %v, want filled from post-settle frame", row.Model) + } +} + +// TestUpsertSubagentContextNeverTerminalRowSettledAtNull covers AC-10: a row +// that never reaches terminal keeps settled_at NULL and still exists. +func TestUpsertSubagentContextNeverTerminalRowSettledAtNull(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-7", "session-7", "turn-7") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-7", TaskID: "task-7", ToolCallID: "tc-7", + Source: "live", ObservedAt: ts, UpdatedAt: ts, ToolStatus: sp("in_progress"), + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-7", "tc-7") + if row.SettledAt != nil { + t.Errorf("settled_at = %v, want NULL", row.SettledAt) + } +} + +// TestUpsertSubagentContextTurnIDPreservedFromFirstFrame covers AC-2a: the +// original turn_id survives a later frame carrying a different turn. +func TestUpsertSubagentContextTurnIDPreservedFromFirstFrame(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-8", "session-8", "turn-8a") + ctx := context.Background() + if err := seedTurn(t, repo, "turn-8b", "session-8", "task-8"); err != nil { + t.Fatalf("seed second turn: %v", err) + } + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-8", TaskID: "task-8", TurnID: sp("turn-8a"), + ToolCallID: "tc-8", Source: "live", ObservedAt: ts, UpdatedAt: ts, + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-8", TaskID: "task-8", TurnID: sp("turn-8b"), + ToolCallID: "tc-8", Source: "live", ObservedAt: ts, UpdatedAt: ts.Add(time.Second), + }); err != nil { + t.Fatalf("second upsert under a different turn: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-8", "tc-8") + if row.TurnID == nil || *row.TurnID != "turn-8a" { + t.Errorf("turn_id = %v, want turn-8a (original turn preserved)", row.TurnID) + } +} + +// TestUpsertSubagentContextIsAsyncSticky covers "is_async is sticky": once +// set true, a later frame reporting false must not reset it. +func TestUpsertSubagentContextIsAsyncSticky(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-9", "session-9", "turn-9") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-9", TaskID: "task-9", ToolCallID: "tc-9", + Source: "live", ObservedAt: ts, UpdatedAt: ts, IsAsync: true, + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-9", TaskID: "task-9", ToolCallID: "tc-9", + Source: "live", ObservedAt: ts, UpdatedAt: ts.Add(time.Second), IsAsync: false, + }); err != nil { + t.Fatalf("second upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-9", "tc-9") + if !row.IsAsync { + t.Error("is_async = false, want sticky true") + } +} + +// TestUpsertSubagentContextSourceAndObservedAtNotOverwritten covers write-once +// source/observed_at (AC-1a, AC-22): neither is present in the conflict SET +// list, so a later upsert (even one that claims a different source or a +// different observed_at) must not change them. +func TestUpsertSubagentContextSourceAndObservedAtNotOverwritten(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-10", "session-10", "turn-10") + ctx := context.Background() + firstObserved := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + laterObserved := firstObserved.Add(time.Hour) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-10", TaskID: "task-10", ToolCallID: "tc-10", + Source: "backfill", ObservedAt: firstObserved, UpdatedAt: firstObserved, + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-10", TaskID: "task-10", ToolCallID: "tc-10", + Source: "live", ObservedAt: laterObserved, UpdatedAt: laterObserved, + }); err != nil { + t.Fatalf("second (live) upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-10", "tc-10") + if row.Source != "backfill" { + t.Errorf("source = %q, want backfill preserved (a live write must not relabel a backfilled row)", row.Source) + } + if !row.ObservedAt.Equal(firstObserved) { + t.Errorf("observed_at = %v, want %v (first observation wins)", row.ObservedAt, firstObserved) + } +} + +// TestUpsertSubagentContextNestedParentToolCallID covers AC-6: a nested +// subagent is stored, not suppressed. +func TestUpsertSubagentContextNestedParentToolCallID(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-11", "session-11", "turn-11") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-11", TaskID: "task-11", ToolCallID: "tc-11-child", + ParentToolCallID: sp("tc-11-parent"), Source: "live", ObservedAt: ts, UpdatedAt: ts, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-11", "tc-11-child") + if row.ParentToolCallID == nil || *row.ParentToolCallID != "tc-11-parent" { + t.Errorf("parent_tool_call_id = %v, want tc-11-parent", row.ParentToolCallID) + } +} + +// TestUpsertSubagentContextDefaultsExecutionIDToUnknown covers AC-31: an +// empty ExecutionID is not stored verbatim as "", it stores the reserved +// 'unknown' sentinel so the column is never NULL and never blank. +func TestUpsertSubagentContextDefaultsExecutionIDToUnknown(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-exec-default", "session-exec-default", "turn-exec-default") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-exec-default", TaskID: "task-exec-default", ToolCallID: "tc-exec-default", + Source: "live", ObservedAt: ts, UpdatedAt: ts, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-exec-default", "tc-exec-default") + if row.AgentExecutionID != "unknown" { + t.Errorf("agent_execution_id = %q, want unknown", row.AgentExecutionID) + } +} + +// TestUpsertSubagentContextStoresExecutionIDVerbatim covers AC-30: a +// non-empty ExecutionID is stored exactly as given, not normalized or +// truncated. +func TestUpsertSubagentContextStoresExecutionIDVerbatim(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-exec-verbatim", "session-exec-verbatim", "turn-exec-verbatim") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-exec-verbatim", TaskID: "task-exec-verbatim", ToolCallID: "tc-exec-verbatim", + AgentExecutionID: "exec-abc-123", Source: "live", ObservedAt: ts, UpdatedAt: ts, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + + row := mustGetSubagentContext(t, repo, "session-exec-verbatim", "tc-exec-verbatim") + if row.AgentExecutionID != "exec-abc-123" { + t.Errorf("agent_execution_id = %q, want exec-abc-123", row.AgentExecutionID) + } +} + +// TestUpsertSubagentContextCrossExecutionToolCallIDDoesNotClobber covers +// AC-32, the maintainer-found defect Amendment 1 fixes: two different agent +// process executions can legitimately reuse the same tool_call_id (a fresh +// ACP session numbering its tool calls from 1 again). Before +// agent_execution_id joined the upsert/unique key, the second execution's +// frame would silently overwrite the first execution's row instead of +// creating its own. +func TestUpsertSubagentContextCrossExecutionToolCallIDDoesNotClobber(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-cross-exec", "session-cross-exec", "turn-cross-exec") + ctx := context.Background() + firstObserved := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + secondObserved := firstObserved.Add(time.Hour) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-cross-exec", TaskID: "task-cross-exec", ToolCallID: "tc-shared", + AgentExecutionID: "exec-first", Model: sp("model-first"), + Source: "live", ObservedAt: firstObserved, UpdatedAt: firstObserved, + }); err != nil { + t.Fatalf("first execution upsert: %v", err) + } + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-cross-exec", TaskID: "task-cross-exec", ToolCallID: "tc-shared", + AgentExecutionID: "exec-second", Model: sp("model-second"), + Source: "live", ObservedAt: secondObserved, UpdatedAt: secondObserved, + }); err != nil { + t.Fatalf("second execution upsert: %v", err) + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-cross-exec") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 2 { + t.Fatalf("row count = %d, want 2 (one per execution, same tool_call_id must not clobber)", len(rows)) + } + + byExecution := map[string]*models.SubagentContext{} + for _, row := range rows { + byExecution[row.AgentExecutionID] = row + } + first, ok := byExecution["exec-first"] + if !ok || first.Model == nil || *first.Model != "model-first" { + t.Errorf("exec-first row = %+v, want model-first preserved", first) + } + second, ok := byExecution["exec-second"] + if !ok || second.Model == nil || *second.Model != "model-second" { + t.Errorf("exec-second row = %+v, want model-second preserved", second) + } +} + +// TestUpsertSubagentContextCascadeDeletesWithSession covers AC-16: deleting +// the parent session removes its subagent context rows. +func TestUpsertSubagentContextCascadeDeletesWithSession(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-12", "session-12", "turn-12") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-12", TaskID: "task-12", ToolCallID: "tc-12", + Source: "live", ObservedAt: ts, UpdatedAt: ts, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + if err := repo.DeleteTaskSession(ctx, "session-12"); err != nil { + t.Fatalf("DeleteTaskSession: %v", err) + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-12") + if err != nil { + t.Fatalf("list after cascade: %v", err) + } + if len(rows) != 0 { + t.Fatalf("row count after session delete = %d, want 0 (FK cascade)", len(rows)) + } +} + +// TestListSubagentContextsOrderingUsesToolCallIDTiebreak covers AC-13: rows +// sharing an observed_at order by tool_call_id, never by the generated id. +func TestListSubagentContextsOrderingUsesToolCallIDTiebreak(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-13", "session-13", "turn-13") + ctx := context.Background() + sharedObservedAt := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + // Insert in an order that would produce the wrong result if id (insertion + // order / generated UUID) were used as the tiebreak instead of tool_call_id. + for _, toolCallID := range []string{"tc-z", "tc-a", "tc-m"} { + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-13", TaskID: "task-13", ToolCallID: toolCallID, + Source: "live", ObservedAt: sharedObservedAt, UpdatedAt: sharedObservedAt, + }); err != nil { + t.Fatalf("upsert %s: %v", toolCallID, err) + } + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-13") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 3 { + t.Fatalf("row count = %d, want 3", len(rows)) + } + got := []string{rows[0].ToolCallID, rows[1].ToolCallID, rows[2].ToolCallID} + want := []string{"tc-a", "tc-m", "tc-z"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("order = %v, want %v (tool_call_id tiebreak)", got, want) + } + } +} + +// TestListSubagentContextsByTurn covers the turn-scoped read seam. +func TestListSubagentContextsByTurn(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-14", "session-14", "turn-14a") + ctx := context.Background() + if err := seedTurn(t, repo, "turn-14b", "session-14", "task-14"); err != nil { + t.Fatalf("seed second turn: %v", err) + } + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-14", TaskID: "task-14", TurnID: sp("turn-14a"), + ToolCallID: "tc-14a", Source: "live", ObservedAt: ts, UpdatedAt: ts, + }); err != nil { + t.Fatalf("upsert turn-14a: %v", err) + } + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-14", TaskID: "task-14", TurnID: sp("turn-14b"), + ToolCallID: "tc-14b", Source: "live", ObservedAt: ts.Add(time.Second), UpdatedAt: ts.Add(time.Second), + }); err != nil { + t.Fatalf("upsert turn-14b: %v", err) + } + + rows, err := repo.ListSubagentContextsByTurn(ctx, "turn-14a") + if err != nil { + t.Fatalf("ListSubagentContextsByTurn: %v", err) + } + if len(rows) != 1 || rows[0].ToolCallID != "tc-14a" { + t.Fatalf("turn-14a rows = %+v, want exactly tc-14a", rows) + } +} + +// TestUpsertSubagentContextConcurrentSameKey covers AC-14: concurrent frames +// for the same key produce exactly one row via the atomic upsert statement, +// with no read-then-write race. Run with -race. +func TestUpsertSubagentContextConcurrentSameKey(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-15", "session-15", "turn-15") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + const concurrency = 8 + var wg sync.WaitGroup + errs := make([]error, concurrency) + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-15", TaskID: "task-15", ToolCallID: "tc-15", + Source: "live", ObservedAt: ts, UpdatedAt: ts.Add(time.Duration(i) * time.Millisecond), + TotalTokens: i64p(int64(i)), + }) + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("concurrent upsert %d: %v", i, err) + } + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-15") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want exactly 1 for concurrent same-key upserts", len(rows)) + } +} + +// TestUpsertSubagentContextConcurrentDifferentKeys covers AC-15: concurrent +// frames for different tool_call_ids in one turn (the observed 3-, 4-, 5-, +// and 8-way fan-outs) all land, and neither blocks the other. +func TestUpsertSubagentContextConcurrentDifferentKeys(t *testing.T) { + for _, fanOut := range []int{3, 4, 5, 8} { + t.Run(fmt.Sprintf("fanout-%d", fanOut), func(t *testing.T) { + repo := newSubagentContextTestRepo(t, "task-16", "session-16", "turn-16") + ctx := context.Background() + ts := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + + var wg sync.WaitGroup + errs := make([]error, fanOut) + for i := 0; i < fanOut; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-16", TaskID: "task-16", TurnID: sp("turn-16"), + ToolCallID: sprintfToolCallID(i), Source: "live", ObservedAt: ts, UpdatedAt: ts, + }) + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("concurrent upsert %d: %v", i, err) + } + } + + rows, err := repo.ListSubagentContextsBySession(ctx, "session-16") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != fanOut { + t.Fatalf("row count = %d, want %d for a %d-way fan-out", len(rows), fanOut, fanOut) + } + }) + } +} + +func sprintfToolCallID(i int) string { + return fmt.Sprintf("tc-fanout-%d", i) +} + +// seedTurn inserts an additional turn row for a session already seeded by +// seedForMsgTest (which creates exactly one turn). +func seedTurn(t *testing.T, repo *Repository, turnID, sessionID, taskID string) error { + t.Helper() + now := time.Now().UTC() + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `), turnID, sessionID, taskID, now, now, now) + return err +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_timestamp.go b/apps/backend/internal/task/repository/sqlite/subagent_context_timestamp.go new file mode 100644 index 0000000000..b4a532c0ee --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_timestamp.go @@ -0,0 +1,57 @@ +// Package sqlite provides SQLite-based repository implementations. +package sqlite + +// subagentPreciseTimestamp builds a SQL expression that evaluates to a value +// safe to compare with plain >= / < against another such expression, at no +// coarser than microsecond precision and monotonically across the whole +// range (AC-24e in docs/specs/subagent-context-persistence/spec.md). expr may +// be a TIMESTAMP column reference or a bind-parameter placeholder carrying an +// RFC3339(Nano) string (an activation key from kandev_meta). +// +// Deliberately not internal/db's existing timestampColumn/julianday(): its +// ~40-microsecond ULP at 2026-scale epoch distances is coarser than this +// comparison's required floor. +// +// On Postgres the column is a naive TIMESTAMP (no time zone), storing the +// same UTC wall clock SQLite does — NOT a native timestamptz. A straight +// ::timestamptz cast would reinterpret those digits using the session's +// `timezone` GUC rather than as literal UTC, silently shifting every +// comparison by whatever offset that session happens to have (pinned by +// TestPostgresSubagentContextHealthQueriesIgnoreSessionTimezone). AT TIME +// ZONE 'UTC' instead reinterprets the naive value as already being UTC, +// producing a timestamptz whose comparisons are session-timezone-independent. +// +// On SQLite, this repository's own stored TIMESTAMP text (verified against +// the mattn/go-sqlite3 driver's on-disk bytes, not the reformatted string a +// *string Scan target sees) is space-separated with a "+00:00" offset, e.g. +// "2026-08-10 09:00:00.123456+00:00", while the activation keys this package +// writes are RFC3339Nano, e.g. "2026-08-10T09:00:00.123456789Z". A raw >= +// across those two shapes silently snaps to midnight of the key's date: the +// space/offset column sorts before any 'T'-shaped key sharing its date +// prefix, because ' ' (0x20) < 'T' (0x54). Every timestamp this repository +// writes is already a UTC wall-clock instant (see rfc3339Timestamp's doc +// comment), so a blind ' '->'T' / '+00:00'->'Z' substitution is safe and +// turns both shapes into the same lexical form. +// +// That alone is not sufficient: Go's ".999999999" format (and this +// repository's own storage of it) trims trailing zero fractional digits, +// omitting the '.' entirely at a whole second. Two same-shape strings that +// differ only in fraction width do not compare correctly against each other +// as raw text — e.g. "...00Z" (an exact whole second) sorts AFTER +// "...00.123456Z" (a later fraction of that same second) under plain byte +// comparison, because 'Z' (0x5A) > '.' (0x2E). Padding every fraction to a +// fixed 6-digit (microsecond) width before comparing removes that failure +// mode; TestSubagentPreciseTimestampOrdersWholeSecondBeforeFraction pins it. +func subagentPreciseTimestamp(postgres bool, expr string) string { + if postgres { + return "((" + expr + ") AT TIME ZONE 'UTC')" + } + + normalized := "REPLACE(REPLACE(" + expr + ", ' ', 'T'), '+00:00', 'Z')" + datePart := "substr(" + normalized + ", 1, 19)" + rest := "substr(" + normalized + ", 20)" + fracDigits := "(CASE WHEN substr(" + rest + ", 1, 1) = '.' " + + "THEN substr(" + rest + ", 2, length(" + rest + ") - 2) ELSE '' END)" + fracPadded := "substr(" + fracDigits + " || '000000', 1, 6)" + return "(" + datePart + " || '.' || " + fracPadded + " || 'Z')" +} diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_timestamp_test.go b/apps/backend/internal/task/repository/sqlite/subagent_context_timestamp_test.go new file mode 100644 index 0000000000..a6967567e6 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_timestamp_test.go @@ -0,0 +1,74 @@ +package sqlite + +import ( + "testing" + "time" +) + +// TestSubagentPreciseTimestampOrdersWholeSecondBeforeFraction covers AC-24e: +// a raw >= between this repository's stored TIMESTAMP text and an RFC3339Nano +// activation key silently snaps to midnight of the key's date (' ' sorts +// before 'T'), and even after fixing that shape mismatch, a whole-second +// value with no fractional digits sorts AFTER a later fractional value in the +// same second under plain byte comparison ('Z' > '.'). subagentPreciseTimestamp +// must get both directions right. +func TestSubagentPreciseTimestampOrdersWholeSecondBeforeFraction(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + if _, err := repo.db.Exec(`CREATE TABLE ts_probe (label TEXT, ts TIMESTAMP)`); err != nil { + t.Fatalf("create probe table: %v", err) + } + whole := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) + frac := time.Date(2026, 8, 10, 9, 0, 0, 123456000, time.UTC) + if _, err := repo.db.Exec(`INSERT INTO ts_probe (label, ts) VALUES (?, ?), (?, ?)`, + "whole", whole, "frac", frac); err != nil { + t.Fatalf("seed probe rows: %v", err) + } + + expr := subagentPreciseTimestamp(false, "ts") + + var wholeLTfrac int + if err := repo.db.QueryRow(` + SELECT (SELECT ` + expr + ` FROM ts_probe WHERE label='whole') + < (SELECT ` + expr + ` FROM ts_probe WHERE label='frac') + `).Scan(&wholeLTfrac); err != nil { + t.Fatalf("compare whole < frac: %v", err) + } + if wholeLTfrac != 1 { + t.Errorf("whole (09:00:00.000000) < frac (09:00:00.123456) = %d, want 1 (true)", wholeLTfrac) + } + + // The activation key comparison AC-24e actually governs: an RFC3339Nano + // key bound as a parameter must compare correctly against the raw + // space+offset stored column, for a key on the same date but a later + // time-of-day than a stored row. The key is bound once via a subquery + // alias so subagentPreciseTimestamp can reference it as a column + // expression (repeated internally) without needing repeated bind args. + key := time.Date(2026, 8, 10, 9, 0, 0, 500000000, time.UTC).Format(time.RFC3339Nano) + keyExpr := subagentPreciseTimestamp(false, "k.key_raw") + keySubquery := `(SELECT ? AS key_raw) k` + + var rowGEKey int + if err := repo.db.QueryRow(` + SELECT (SELECT `+expr+` FROM ts_probe WHERE label='frac') >= `+keyExpr+` + FROM `+keySubquery, + key, + ).Scan(&rowGEKey); err != nil { + t.Fatalf("compare frac row >= key: %v", err) + } + if rowGEKey != 0 { + t.Errorf("frac row (09:00:00.123456) >= key (09:00:00.5) = %d, want 0 (false)", rowGEKey) + } + + var wholeGEKey int + if err := repo.db.QueryRow(` + SELECT (SELECT `+expr+` FROM ts_probe WHERE label='whole') >= `+keyExpr+` + FROM `+keySubquery, + key, + ).Scan(&wholeGEKey); err != nil { + t.Fatalf("compare whole row >= key: %v", err) + } + if wholeGEKey != 0 { + t.Errorf("whole row (09:00:00.0, same date) >= key (09:00:00.5) = %d, want 0 (false) — "+ + "a naive raw comparison would incorrectly snap this to true", wholeGEKey) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/workspace_test.go b/apps/backend/internal/task/repository/sqlite/workspace_test.go index 67aaaed544..b93a2b7205 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace_test.go +++ b/apps/backend/internal/task/repository/sqlite/workspace_test.go @@ -291,6 +291,15 @@ func seedWorkspaceCascadeRows(t *testing.T, repo *Repository, workspaceID string }); err != nil { t.Fatalf("CreateMessage: %v", err) } + turnDeleteID := "turn-delete" + if err := repo.UpsertSubagentContext(ctx, &models.SubagentContext{ + TaskSessionID: "session-delete", + TaskID: "task-delete", + TurnID: &turnDeleteID, + ToolCallID: "tc-delete", + }); err != nil { + t.Fatalf("UpsertSubagentContext: %v", err) + } if err := repo.CreateTaskPlan(ctx, &models.TaskPlan{ ID: "plan-delete", TaskID: "task-delete", @@ -323,6 +332,7 @@ func assertNoWorkspaceCascadeDependents(t *testing.T, repo *Repository) { "task_environment_repos", "task_session_turns", "task_session_messages", + "task_session_subagents", "task_plans", "task_plan_revisions", } { diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 3573b40811..93cd66d762 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -260,6 +260,7 @@ type Repos struct { Reviews repository.ReviewRepository ResourceCleanups repository.TaskResourceCleanupRepository StatusSummaries repository.TaskStatusSummaryRepository + SubagentContexts repository.SubagentContextRepository } // Service provides task business logic @@ -282,6 +283,7 @@ type Service struct { reviews repository.ReviewRepository resourceCleanups repository.TaskResourceCleanupRepository statusSummaries repository.TaskStatusSummaryRepository + subagentContexts repository.SubagentContextRepository attachmentSvc *AttachmentService statusSummaryPRs TaskStatusSummaryPRReader queuedPromptCounter QueuedPromptCounter @@ -411,6 +413,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover reviews: repos.Reviews, resourceCleanups: repos.ResourceCleanups, statusSummaries: repos.StatusSummaries, + subagentContexts: repos.SubagentContexts, eventBus: eventBus, logger: log, discoveryConfig: discoveryConfig, diff --git a/apps/backend/internal/task/service/subagent_context.go b/apps/backend/internal/task/service/subagent_context.go new file mode 100644 index 0000000000..497c6c6baf --- /dev/null +++ b/apps/backend/internal/task/service/subagent_context.go @@ -0,0 +1,171 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/task/models" +) + +// RecordSubagentContextRequest carries the orchestrator's already-resolved +// identity and the parsed subagent_task payload for one frame. The +// orchestrator owns turn-id resolution and the ToolStatus/normalized-kind +// guard; this request is the seam between that decision and persistence. +type RecordSubagentContextRequest struct { + TaskSessionID string + TaskID string + TurnID string + ToolCallID string + ParentToolCallID string + // ExecutionID identifies the agent process execution that emitted this + // frame (Amendment 1). Empty means no execution identity was available; + // the repository stores the 'unknown' sentinel (AC-31) for it, and this + // service increments unknown_execution when that happens on a persisted + // write (AC-26). + ExecutionID string + // ToolStatus is the ACP tool-call status of the launching Task call. + // Terminality (AC-11) keys off this field only; Payload.Status + // ("agent_status" — the child's own report) is stored verbatim and never + // consulted for terminality. + ToolStatus string + Payload *streams.SubagentTaskPayload + // ObservedAt is Kandev's observation time for this frame (AC-1a) — not a + // subagent start time. + ObservedAt time.Time +} + +// subagentTerminalToolStatuses mirrors internal/orchestrator's unexported +// isTerminalToolStatus (event_handlers_streaming.go, function +// isTerminalToolStatus — deliberately not pinned to a line number here, +// since TestSubagentContextTerminalStatusesMatchOrchestrator re-parses the +// function by name and a hardcoded line drifts on every unrelated edit to +// that file). The two lists are pinned together by that test so a future +// edit to one fails the other. +var subagentTerminalToolStatuses = map[string]bool{ + "complete": true, "completed": true, "success": true, + "error": true, "failed": true, "cancelled": true, +} + +func isTerminalToolStatus(status string) bool { + return subagentTerminalToolStatuses[status] +} + +// subagentContextSourceLive is the models.SubagentContext.Source value for +// a row observed on the live orchestrator frame path, as opposed to +// "backfill" (written only by the one-time migration). +const subagentContextSourceLive = "live" + +// RecordSubagentContext persists one observation of a subagent (Task tool) +// invocation. It returns nothing: that is the mechanism by which AC-27 +// holds — a writer with no error return cannot fail the enclosing message +// write, turn, or agent stream, and no future caller can accidentally +// propagate one. See docs/specs/subagent-context-persistence/spec.md. +func (s *Service) RecordSubagentContext(ctx context.Context, req RecordSubagentContextRequest) { + if s.subagentContexts == nil { + return + } + if req.Payload == nil { + return + } + // attempted counts every recognized-payload frame that reaches this + // point, before the identity check below — skipped_no_identity is a + // subset counted WITHIN attempted (AC-26: attempted = + // skipped_no_identity + persisted + failed), not a separate bucket. + subagentContextTotal.Add("attempted", 1) + if req.TaskSessionID == "" || req.TaskID == "" || req.ToolCallID == "" { + subagentContextTotal.Add("skipped_no_identity", 1) + return + } + + totalTokens, totalTokensAnomalous := normalizeOmitEmptyMetric(req.Payload.TotalTokens) + durationMs, durationAnomalous := normalizeOmitEmptyMetric(req.Payload.DurationMs) + toolUseCount, toolUseCountAnomalous := normalizeToolUseCount(req.Payload.ToolUseCount) + // AC-26 counts anomalous_value once per frame carrying at least one + // negative metric, not once per negative field. + if totalTokensAnomalous || durationAnomalous || toolUseCountAnomalous { + subagentContextTotal.Add("anomalous_value", 1) + } + + sc := &models.SubagentContext{ + TaskSessionID: req.TaskSessionID, + TaskID: req.TaskID, + ToolCallID: req.ToolCallID, + AgentExecutionID: req.ExecutionID, + TurnID: nilIfEmpty(req.TurnID), + ParentToolCallID: nilIfEmpty(req.ParentToolCallID), + SubagentType: nilIfEmpty(req.Payload.SubagentType), + Description: nilIfEmpty(req.Payload.Description), + AgentID: nilIfEmpty(req.Payload.AgentID), + ChildSessionID: nilIfEmpty(req.Payload.ChildSessionID), + Model: nilIfEmpty(req.Payload.Model), + AgentStatus: nilIfEmpty(req.Payload.Status), + ToolStatus: nilIfEmpty(req.ToolStatus), + IsAsync: req.Payload.IsAsync, + TotalTokens: totalTokens, + ToolUseCount: toolUseCount, + DurationMs: durationMs, + Source: subagentContextSourceLive, + ObservedAt: req.ObservedAt, + UpdatedAt: req.ObservedAt, + } + if isTerminalToolStatus(req.ToolStatus) { + settledAt := req.ObservedAt + sc.SettledAt = &settledAt + } + + if err := s.subagentContexts.UpsertSubagentContext(ctx, sc); err != nil { + if s.logger != nil { + s.logger.Warn("failed to record subagent context", + zap.String("session_id", req.TaskSessionID), + zap.String("tool_call_id", req.ToolCallID), + zap.Error(err)) + } + subagentContextTotal.Add("failed", 1) + return + } + subagentContextTotal.Add("persisted", 1) + if req.ExecutionID == "" { + subagentContextTotal.Add("unknown_execution", 1) + } +} + +// nilIfEmpty turns the payload's "" (absent) into NULL, so COUNT(model) +// counts models rather than blanks. +func nilIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + +// normalizeOmitEmptyMetric handles TotalTokens/DurationMs, which are plain +// int64 with `omitempty` on the wire payload: a genuine 0 and "not reported" +// are indistinguishable at this layer, so both normalize to NULL, matching +// AC-7. A negative value normalizes to NULL and reports anomalous (AC-9). +func normalizeOmitEmptyMetric(value int64) (*int64, bool) { + if value == 0 { + return nil, false + } + if value < 0 { + return nil, true + } + v := value + return &v, false +} + +// normalizeToolUseCount handles ToolUseCount, which is a *int specifically so +// a reported 0 survives (AC-8) while "not reported" stays nil (AC-7). A +// negative reported value normalizes to NULL and reports anomalous (AC-9). +func normalizeToolUseCount(reported *int) (*int64, bool) { + if reported == nil { + return nil, false + } + v := int64(*reported) + if v < 0 { + return nil, true + } + return &v, false +} diff --git a/apps/backend/internal/task/service/subagent_context_metrics.go b/apps/backend/internal/task/service/subagent_context_metrics.go new file mode 100644 index 0000000000..0368e33e26 --- /dev/null +++ b/apps/backend/internal/task/service/subagent_context_metrics.go @@ -0,0 +1,12 @@ +package service + +import "expvar" + +// subagentContextTotal exposes writer-health counters for +// RecordSubagentContext, under the existing expvar convention used by +// routing_* (internal/office/scheduler) and subproc_*. Counters only — see +// AC-26 in docs/specs/subagent-context-persistence/spec.md. +// +// Keys: attempted, persisted, skipped_no_identity, anomalous_value, failed, +// unknown_execution (Amendment 1, AC-31). +var subagentContextTotal = expvar.NewMap("subagent_context_total") diff --git a/apps/backend/internal/task/service/subagent_context_test.go b/apps/backend/internal/task/service/subagent_context_test.go new file mode 100644 index 0000000000..3e42413248 --- /dev/null +++ b/apps/backend/internal/task/service/subagent_context_test.go @@ -0,0 +1,475 @@ +package service + +import ( + "context" + "errors" + "expvar" + "os" + "regexp" + "strings" + "sync" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/agentctl/types/streams" + commonlogger "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" +) + +// stubSubagentContextRepo is a minimal repository.SubagentContextRepository +// double: it records every upsert it receives (or returns a fixed error), +// with no other behavior. +type stubSubagentContextRepo struct { + mu sync.Mutex + upserts []*models.SubagentContext + err error +} + +func (s *stubSubagentContextRepo) UpsertSubagentContext(_ context.Context, sc *models.SubagentContext) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return s.err + } + s.upserts = append(s.upserts, sc) + return nil +} + +func (s *stubSubagentContextRepo) ListSubagentContextsBySession(context.Context, string) ([]*models.SubagentContext, error) { + return nil, nil +} + +func (s *stubSubagentContextRepo) ListSubagentContextsByTurn(context.Context, string) ([]*models.SubagentContext, error) { + return nil, nil +} + +func (s *stubSubagentContextRepo) last(t *testing.T) *models.SubagentContext { + t.Helper() + s.mu.Lock() + defer s.mu.Unlock() + if len(s.upserts) == 0 { + t.Fatal("no subagent context was upserted") + } + return s.upserts[len(s.upserts)-1] +} + +func (s *stubSubagentContextRepo) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.upserts) +} + +func newSubagentContextTestService(t *testing.T, repo *stubSubagentContextRepo) *Service { + t.Helper() + log, err := commonlogger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("create logger: %v", err) + } + return &Service{subagentContexts: repo, logger: log} +} + +func readSubagentCounter(t *testing.T, key string) int64 { + t.Helper() + v := subagentContextTotal.Get(key) + if v == nil { + return 0 + } + iv, ok := v.(*expvar.Int) + if !ok { + t.Fatalf("counter %q is %T, want *expvar.Int", key, v) + } + return iv.Value() +} + +// counterDelta runs fn and returns how much the named subagentContextTotal +// counter moved. Safe against cross-test accumulation on the shared +// package-level expvar.Map because it snapshots before and after. +func counterDelta(t *testing.T, key string, fn func()) int64 { + t.Helper() + before := readSubagentCounter(t, key) + fn() + return readSubagentCounter(t, key) - before +} + +func intptr(n int) *int { return &n } + +// TestRecordSubagentContextIdentityGate covers AC-2: each of an empty +// session id, task id, or tool_call_id skips the write entirely and +// increments skipped_no_identity by exactly one. It also covers AC-26's +// invariant (attempted = skipped_no_identity + persisted + failed): +// skipped_no_identity is a subset counted WITHIN attempted, so a recognized +// payload that fails the identity gate must still move attempted by one. +func TestRecordSubagentContextIdentityGate(t *testing.T) { + cases := []struct { + name string + req RecordSubagentContextRequest + }{ + {"missing_session_id", RecordSubagentContextRequest{ + TaskSessionID: "", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{}, + }}, + {"missing_task_id", RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{}, + }}, + {"missing_tool_call_id", RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "", + Payload: &streams.SubagentTaskPayload{}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + + var attemptedDelta int64 + skippedDelta := counterDelta(t, "skipped_no_identity", func() { + attemptedDelta = counterDelta(t, "attempted", func() { + svc.RecordSubagentContext(context.Background(), tc.req) + }) + }) + if skippedDelta != 1 { + t.Errorf("skipped_no_identity delta = %d, want 1", skippedDelta) + } + if attemptedDelta != 1 { + t.Errorf("attempted delta = %d, want 1 (AC-26 invariant)", attemptedDelta) + } + if repo.count() != 0 { + t.Errorf("repository call count = %d, want 0 (no write)", repo.count()) + } + }) + } +} + +// TestRecordSubagentContextNilPayloadIsNoop is a defensive guard: the +// orchestrator only calls this after confirming Normalized.SubagentTask() is +// non-nil, but a nil payload must still be handled without a panic or a +// write. +func TestRecordSubagentContextNilPayloadIsNoop(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", Payload: nil, + }) + if repo.count() != 0 { + t.Errorf("repository call count = %d, want 0", repo.count()) + } +} + +// TestRecordSubagentContextEmptyStringsNormalizeToNil covers the +// empty-string-to-NULL normalization rule for every nullable TEXT field. +func TestRecordSubagentContextEmptyStringsNormalizeToNil(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + TurnID: "", ParentToolCallID: "", ToolStatus: "", + Payload: &streams.SubagentTaskPayload{ + SubagentType: "", Description: "", AgentID: "", ChildSessionID: "", Model: "", Status: "", + }, + ObservedAt: time.Now().UTC(), + }) + + sc := repo.last(t) + for name, field := range map[string]*string{ + "turn_id": sc.TurnID, "parent_tool_call_id": sc.ParentToolCallID, + "subagent_type": sc.SubagentType, "description": sc.Description, + "agent_id": sc.AgentID, "child_session_id": sc.ChildSessionID, + "model": sc.Model, "agent_status": sc.AgentStatus, "tool_status": sc.ToolStatus, + } { + if field != nil { + t.Errorf("%s = %q, want NULL for an empty string", name, *field) + } + } +} + +// TestRecordSubagentContextMetricNormalization covers AC-7, AC-8, AC-9: an +// absent metric is nil, a reported zero ToolUseCount survives as 0, and a +// negative value becomes nil while incrementing anomalous_value and leaving +// the rest of the frame intact. +func TestRecordSubagentContextMetricNormalization(t *testing.T) { + t.Run("absent_metrics_are_nil", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{TotalTokens: 0, DurationMs: 0, ToolUseCount: nil}, + ObservedAt: time.Now().UTC(), + }) + sc := repo.last(t) + if sc.TotalTokens != nil || sc.DurationMs != nil || sc.ToolUseCount != nil { + t.Errorf("metrics = tokens=%v duration=%v count=%v, want all nil", sc.TotalTokens, sc.DurationMs, sc.ToolUseCount) + } + }) + + t.Run("reported_zero_tool_use_count_survives", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{ToolUseCount: intptr(0)}, + ObservedAt: time.Now().UTC(), + }) + sc := repo.last(t) + if sc.ToolUseCount == nil || *sc.ToolUseCount != 0 { + t.Errorf("tool_use_count = %v, want *0", sc.ToolUseCount) + } + }) + + t.Run("negative_total_tokens_becomes_nil_others_intact", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + delta := counterDelta(t, "anomalous_value", func() { + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{ + TotalTokens: -5, DurationMs: 1200, Description: "kept", + }, + ObservedAt: time.Now().UTC(), + }) + }) + if delta != 1 { + t.Errorf("anomalous_value delta = %d, want 1", delta) + } + sc := repo.last(t) + if sc.TotalTokens != nil { + t.Errorf("total_tokens = %v, want nil", sc.TotalTokens) + } + if sc.DurationMs == nil || *sc.DurationMs != 1200 { + t.Errorf("duration_ms = %v, want 1200 (unaffected)", sc.DurationMs) + } + if sc.Description == nil || *sc.Description != "kept" { + t.Errorf("description = %v, want kept (unaffected)", sc.Description) + } + }) + + t.Run("negative_duration_ms_becomes_nil", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + delta := counterDelta(t, "anomalous_value", func() { + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{DurationMs: -1}, + ObservedAt: time.Now().UTC(), + }) + }) + if delta != 1 { + t.Errorf("anomalous_value delta = %d, want 1", delta) + } + if repo.last(t).DurationMs != nil { + t.Errorf("duration_ms = %v, want nil", repo.last(t).DurationMs) + } + }) + + t.Run("negative_tool_use_count_becomes_nil", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + delta := counterDelta(t, "anomalous_value", func() { + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{ToolUseCount: intptr(-3)}, + ObservedAt: time.Now().UTC(), + }) + }) + if delta != 1 { + t.Errorf("anomalous_value delta = %d, want 1", delta) + } + if repo.last(t).ToolUseCount != nil { + t.Errorf("tool_use_count = %v, want nil", repo.last(t).ToolUseCount) + } + }) + + t.Run("multiple_negative_metrics_in_one_frame_count_once", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + delta := counterDelta(t, "anomalous_value", func() { + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{ + TotalTokens: -5, DurationMs: -1, ToolUseCount: intptr(-3), + }, + ObservedAt: time.Now().UTC(), + }) + }) + if delta != 1 { + t.Errorf("anomalous_value delta = %d, want 1 (one frame, not one per negative field)", delta) + } + }) +} + +// TestRecordSubagentContextTerminality covers AC-10a/AC-11: settled_at is +// set only from a terminal ACP tool_status, never from agent_status — +// including the Claude-specific "async_launched" agent status, whose own +// tool call is nonetheless terminal in the ACP sense. +func TestRecordSubagentContextTerminality(t *testing.T) { + observedAt := time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC) + + t.Run("terminal_tool_status_sets_settled_at", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + ToolStatus: "completed", Payload: &streams.SubagentTaskPayload{Status: "async_launched"}, + ObservedAt: observedAt, + }) + sc := repo.last(t) + if sc.SettledAt == nil || !sc.SettledAt.Equal(observedAt) { + t.Errorf("settled_at = %v, want %v", sc.SettledAt, observedAt) + } + if sc.AgentStatus == nil || *sc.AgentStatus != "async_launched" { + t.Errorf("agent_status = %v, want async_launched stored verbatim", sc.AgentStatus) + } + }) + + t.Run("async_launched_agent_status_alone_does_not_settle", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + ToolStatus: "in_progress", Payload: &streams.SubagentTaskPayload{Status: "async_launched"}, + ObservedAt: observedAt, + }) + if repo.last(t).SettledAt != nil { + t.Errorf("settled_at = %v, want nil (tool_status is non-terminal)", repo.last(t).SettledAt) + } + }) + + t.Run("unrecognised_agent_status_stored_verbatim_and_does_not_settle", func(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + ToolStatus: "pending", Payload: &streams.SubagentTaskPayload{Status: "a-status-kandev-has-never-seen"}, + ObservedAt: observedAt, + }) + sc := repo.last(t) + if sc.AgentStatus == nil || *sc.AgentStatus != "a-status-kandev-has-never-seen" { + t.Errorf("agent_status = %v, want stored verbatim", sc.AgentStatus) + } + if sc.SettledAt != nil { + t.Errorf("settled_at = %v, want nil", sc.SettledAt) + } + }) +} + +// TestRecordSubagentContextRepositoryFailure covers AC-27: a repository +// error logs WARN with the session and tool-call id, increments failed, and +// the call still returns normally (no panic, no error surfaced to caller). +func TestRecordSubagentContextRepositoryFailure(t *testing.T) { + repo := &stubSubagentContextRepo{err: errors.New("disk full")} + core, logs := observer.New(zapcore.WarnLevel) + log, err := commonlogger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("create logger: %v", err) + } + svc := &Service{subagentContexts: repo, logger: log} + + delta := counterDelta(t, "failed", func() { + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-fail", TaskID: "task-1", ToolCallID: "tc-fail", + Payload: &streams.SubagentTaskPayload{}, ObservedAt: time.Now().UTC(), + }) + }) + if delta != 1 { + t.Errorf("failed delta = %d, want 1", delta) + } + entries := logs.FilterMessage("failed to record subagent context").All() + if len(entries) != 1 { + t.Fatalf("WARN log count = %d, want 1", len(entries)) + } + fields := entries[0].ContextMap() + if fields["session_id"] != "session-fail" || fields["tool_call_id"] != "tc-fail" { + t.Errorf("log fields = %#v, want session_id=session-fail tool_call_id=tc-fail", fields) + } +} + +// TestRecordSubagentContextHappyPathCounters covers AC-26: attempted and +// persisted both move by one on a successful write. +func TestRecordSubagentContextHappyPathCounters(t *testing.T) { + repo := &stubSubagentContextRepo{} + svc := newSubagentContextTestService(t, repo) + + var attemptedDelta, persistedDelta int64 + attemptedDelta = counterDelta(t, "attempted", func() { + persistedDelta = counterDelta(t, "persisted", func() { + svc.RecordSubagentContext(context.Background(), RecordSubagentContextRequest{ + TaskSessionID: "session-1", TaskID: "task-1", ToolCallID: "tc-1", + Payload: &streams.SubagentTaskPayload{}, ObservedAt: time.Now().UTC(), + }) + }) + }) + if attemptedDelta != 1 { + t.Errorf("attempted delta = %d, want 1", attemptedDelta) + } + if persistedDelta != 1 { + t.Errorf("persisted delta = %d, want 1", persistedDelta) + } + if repo.count() != 1 { + t.Errorf("repository upsert count = %d, want 1", repo.count()) + } +} + +// TestSubagentContextTerminalStatusesMatchOrchestrator pins +// subagentTerminalToolStatuses against the orchestrator's unexported peer +// isTerminalToolStatus (event_handlers_streaming.go:762). This package +// cannot import internal/orchestrator directly — orchestrator already +// imports internal/task/service for RecordSubagentContext, so that would be +// a cycle — so the peer's source is read and parsed instead. +func TestSubagentContextTerminalStatusesMatchOrchestrator(t *testing.T) { + constsSrc, err := os.ReadFile("../../orchestrator/event_handlers.go") + if err != nil { + t.Fatalf("read orchestrator event_handlers.go: %v", err) + } + switchSrc, err := os.ReadFile("../../orchestrator/event_handlers_streaming.go") + if err != nil { + t.Fatalf("read orchestrator event_handlers_streaming.go: %v", err) + } + + // Captures only the first "case ...:" clause, i.e. isTerminalToolStatus's + // switch must stay a single comma-separated case (not one "case" per + // line) for this regex to see every status. A refactor that splits it + // shrinks orchestratorSet below the size check at line ~435 and fails + // loudly — if you land here from that failure, this is why. + caseRe := regexp.MustCompile(`(?s)func isTerminalToolStatus\(status string\) bool \{.*?case (.*?):`) + match := caseRe.FindSubmatch(switchSrc) + if match == nil { + t.Fatal("could not find isTerminalToolStatus's case clause in event_handlers_streaming.go") + } + tokens := strings.Split(string(match[1]), ",") + + constRe := regexp.MustCompile(`(\w+)\s*=\s*"([^"]+)"`) + constValues := map[string]string{} + for _, m := range constRe.FindAllSubmatch(constsSrc, -1) { + constValues[string(m[1])] = string(m[2]) + } + + orchestratorSet := map[string]bool{} + for _, tok := range tokens { + tok = strings.TrimSpace(tok) + if strings.HasPrefix(tok, `"`) { + orchestratorSet[strings.Trim(tok, `"`)] = true + continue + } + value, ok := constValues[tok] + if !ok { + t.Fatalf("orchestrator case token %q resolves to no known string constant", tok) + } + orchestratorSet[value] = true + } + + if len(orchestratorSet) != len(subagentTerminalToolStatuses) { + t.Fatalf("orchestrator terminal set = %v (%d), service set = %v (%d)", + orchestratorSet, len(orchestratorSet), subagentTerminalToolStatuses, len(subagentTerminalToolStatuses)) + } + for status := range subagentTerminalToolStatuses { + if !orchestratorSet[status] { + t.Errorf("service terminal set has %q, orchestrator's does not: %v", status, orchestratorSet) + } + } +} diff --git a/docs/decisions/2026-08-15-subagent-context-schema-boundary.md b/docs/decisions/2026-08-15-subagent-context-schema-boundary.md new file mode 100644 index 0000000000..e52eebdf91 --- /dev/null +++ b/docs/decisions/2026-08-15-subagent-context-schema-boundary.md @@ -0,0 +1,38 @@ +# ADR-2026-08-15-subagent-context-schema-boundary: Start with the execution-aware subagent schema + +**Status:** accepted +**Date:** 2026-08-15 +**Area:** backend + +## Context + +The subagent context feature stores `agent_execution_id` in its uniqueness key. +The feature branch also contained a dialect-specific rebuild for an earlier +two-column table shape. That earlier shape is not present on the supported base +branch, so the rebuild and its activation marker would add upgrade paths for a +database state that Kandev has never shipped. + +## Decision + +Create `task_session_subagents` directly in its final execution-aware shape from +`initSubagentContextSchema`. Keep `runMigrations()` responsible only for the +historical-message backfill and its two backfill activation keys. Do not add a +schema-shape probe, a legacy-table rebuild, or a separate execution-schema +activation key until a shipped predecessor creates a real compatibility need. + +## Consequences + +Startup has one schema definition and one backfill path for this feature. The +SQLite and PostgreSQL implementations do not need parallel cutover logic or +failpoint-only test infrastructure. A database created by an unreleased +intermediate build is outside the supported upgrade contract and would need a +separate, evidence-based migration if that build is ever shipped. + +## Alternatives Considered + +- **Keep the legacy rebuild and execution activation key.** Rejected because it + preserves substantial dialect-specific code for an unshipped schema and makes + the migration order harder to reason about. +- **Ship the two-column table first, then migrate it later.** Rejected because it + creates avoidable production state and a compatibility obligation for a + feature whose final key is already known. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 8e6534d9cc..19853089bc 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -91,6 +91,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-27-legacy-add-branch-live-rescan | [Preserve Live Rescan for Legacy Add Branch](2026-07-27-legacy-add-branch-live-rescan.md) | accepted | backend, protocol | 2026-07-27 | | 2026-07-28-visible-wip-overflow-queues | [Separate Visible Queueing From WIP Admission](2026-07-28-visible-wip-overflow-queues.md) | proposed | backend, frontend, protocol, workflow | 2026-07-28 | | 2026-08-12-queue-task-moves-at-wip-capacity | [Queue Task Moves at WIP Capacity](2026-08-12-queue-task-moves-at-wip-capacity.md) | accepted | backend, frontend, protocol, workflow | 2026-08-12 | +| 2026-08-15-subagent-context-schema-boundary | [Start with the execution-aware subagent schema](2026-08-15-subagent-context-schema-boundary.md) | accepted | backend | 2026-08-15 | | 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 | | 2026-07-29-agent-stall-user-controlled-recovery | [Keep Agent Stall Recovery User Controlled](2026-07-29-agent-stall-user-controlled-recovery.md) | accepted | backend, frontend, protocol | 2026-07-29 | | 2026-07-29-quarantine-retention-override | [Make Quarantine Retention Overridable but Visible](2026-07-29-quarantine-retention-override.md) | accepted | backend, frontend | 2026-07-29 | diff --git a/docs/plans/subagent-context-persistence/plan.md b/docs/plans/subagent-context-persistence/plan.md new file mode 100644 index 0000000000..11ebbd476f --- /dev/null +++ b/docs/plans/subagent-context-persistence/plan.md @@ -0,0 +1,506 @@ +--- +spec: docs/specs/subagent-context-persistence/spec.md +created: 2026-08-13 +status: draft +--- + +# Implementation Plan: Subagent context persistence + +> **Current implementation boundary (2026-08-15).** The execution-aware +> uniqueness key is the initial schema contract. This plan retains the original +> design notes below, but the supported implementation does not include a +> compatibility migration for the unshipped two-column table shape. See +> [ADR-2026-08-15-subagent-context-schema-boundary](../../decisions/2026-08-15-subagent-context-schema-boundary.md). + +> The load-bearing differences from the original draft are: +> +> - **Uniqueness key** is `(task_session_id, agent_execution_id, tool_call_id)`, +> not `(task_session_id, tool_call_id)`. `task_session_subagents` carries a +> new `agent_execution_id TEXT NOT NULL DEFAULT 'unknown'` column (AC-31). +> A live write with no execution identity available stores the `'unknown'` +> sentinel and the service increments `unknown_execution`. +> - The final table is created by `initSubagentContextSchema`; `runMigrations()` +> only performs the historical-message backfill and writes its two activation +> keys. There is no execution-schema activation key or shape probe. +> - **The backfill and the activation-key writes are one transaction**, not +> three independent `r.migrate.Apply(...)` calls: `tx.Beginx()` / +> `defer tx.Rollback()` wraps the `INSERT ... SELECT`, the `capture_since` +> write, and the `backfill_through` write, then `tx.Commit()`. A key is +> therefore never written after a swallowed backfill failure — the whole +> transaction rolls back together, so this is already the atomic-with-success +> behavior a docs-only staleness report once flagged as missing. +> - **`RecordSubagentContextRequest` carries `ExecutionID string`**, propagated +> from the orchestrator's frame handling through to the repository upsert's +> conflict key. +> +> The "Schema (task 01)" through "Orchestrator wiring (task 04)" sections below +> are retained as the historical design record; the boundary above is +> authoritative for the current implementation. + +## Overview + +Add one new table, `task_session_subagents`, and a write path that fills it from +the two orchestrator frame handlers that already parse the subagent payload. The +order is forced by dependency: table DDL and the one-time backfill first, then +the repository upsert that encodes every merge rule, then the service-level +writer that owns normalization and the `expvar` counters, then the orchestrator +call sites, and finally the Postgres parity and health-query coverage. Nothing +user-facing changes, so there is no frontend and no E2E work. + +The whole feature is additive telemetry. The governing constraint from the spec +is that it must be structurally impossible for this writer to break the product +path (AC-27), and that an unreported measurement must never be stored as `0` +(AC-7, and the regression named in the spec's *The one shape a builder must not +get wrong*). + +--- + +## Backend + +### Schema (task 01) + +New table created in the schema-init DDL, in +`apps/backend/internal/task/repository/sqlite/base_schema.go`, as a new +`initSubagentContextSchema()` called from `initSessionSchema()` immediately after +`initMessageTurnSchema()`. + +**On the spec's "DDL *and* migration" wording.** `apps/backend/AGENTS.md` +§ *Schema & migrations* is explicit that `initSchema()` runs every +`CREATE TABLE IF NOT EXISTS` step **before** `runMigrations()`, on every boot. +A *new table* is therefore fully created on existing databases by the schema-init +DDL alone, which satisfies AC-17. The "add it only via a migration" rule in that +document governs **new columns on existing tables**, which this feature has none +of. Do **not** duplicate the `CREATE TABLE` into `base_migrations.go` — the +migration entry for this feature is the backfill and the activation keys, and +nothing else. + +Columns exactly as the spec's *Data model* table. The three measurement columns +carry **no `DEFAULT`**: + +```sql +CREATE TABLE IF NOT EXISTS task_session_subagents ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + turn_id TEXT, + tool_call_id TEXT NOT NULL, + parent_tool_call_id TEXT, + subagent_type TEXT, + description TEXT, + agent_id TEXT, + child_session_id TEXT, + model TEXT, + agent_status TEXT, + tool_status TEXT, + is_async INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER, + tool_use_count INTEGER, + duration_ms INTEGER, + source TEXT NOT NULL DEFAULT 'live', + observed_at TIMESTAMP NOT NULL, + settled_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL, + UNIQUE (task_session_id, tool_call_id), + FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_subagents_session_id ON task_session_subagents(task_session_id); +CREATE INDEX IF NOT EXISTS idx_subagents_task_id ON task_session_subagents(task_id); +CREATE INDEX IF NOT EXISTS idx_subagents_turn_id ON task_session_subagents(turn_id); +``` + +No `FOREIGN KEY` on `turn_id` — deliberate, per the spec: a turn deletion must +not delete the fan-out record. + +### Migration: backfill and activation keys (task 01) + +`apps/backend/internal/task/repository/sqlite/base_migrations.go`, a new +`migrateSubagentContextBackfill()` called from `runMigrations()` **after** the +existing statements. Three `r.migrate.Apply(...)` calls, all argument-free SQL +(`MigrateLogger.Apply` takes no bind args, and its swallow-plus-`WARN` +contract at `internal/db/migratelog.go:33` is what AC-20 is written against): + +1. `task_session_subagents.backfill` — one unbounded + `INSERT INTO task_session_subagents (...) SELECT ... FROM task_session_messages m + WHERE = 'subagent_task' AND ... ON CONFLICT (task_session_id, tool_call_id) DO NOTHING`. + No `LIMIT`, no batching (AC-23a). +2. `kandev_meta.subagent_context_capture_since` — write-once + `INSERT ... ON CONFLICT(key) DO NOTHING` with an `fmt.Sprintf`-inlined + `time.Now().UTC().Format(time.RFC3339)` literal. +3. `kandev_meta.subagent_context_backfill_through` — write-once, value from a + subselect `COALESCE(MAX(m.created_at), '')` over the same predicate as (1). + +`kandev_meta` is guaranteed to exist: `persistence.Provide` calls +`ensureMetaTable` (`internal/persistence/provider.go:72`) before +`repository.Provide` builds the task repo (`internal/backendapp/storage.go:41`). + +Backfill derivations, all via dialect-aware helpers in the same file: + +| Target | Source | Notes | +|---|---|---| +| `id` | `m.id` | The message's own UUID. Deterministic, so a replay maps to the same row and `DO NOTHING` is a true no-op. | +| `task_session_id`, `task_id` | `m.task_session_id`, `m.task_id` | Rows with `task_id = ''` are excluded by the `WHERE` (AC-2). | +| `turn_id` | `NULLIF(m.turn_id, '')` | | +| `tool_call_id` | `metadata.tool_call_id` | Excluded by `WHERE` when empty (AC-2). | +| `parent_tool_call_id` | `metadata.parent_tool_call_id` | Top-level metadata key, not inside `normalized`. | +| `agent_status` | `metadata.normalized.subagent_task.status` | | +| `tool_status` | `metadata.status` | The ACP status the message row already stores. | +| `observed_at` | `m.created_at` | | +| `settled_at` | `m.updated_at` when the derived `tool_status` is terminal, else NULL | Terminal set mirrors `isTerminalToolStatus`. | +| `source` | literal `'backfill'` | | + +Three new helpers beside `jsonColumn` / `jsonText` +(`base_migrations.go:319-335`), each reusing `jsonColumn`'s empty/`'null'` +normalization so one malformed metadata document cannot abort the statement +(AC-23b): + +- `jsonKey(postgres, column, key string) string` — a **top-level** text extract + (`#>> '{key}'` / `json_extract(..., '$.key')`), needed for `tool_call_id`, + `parent_tool_call_id`, `status`, and `normalized.kind`. +- `jsonInt(postgres, column, parent, key string) string` — `CAST` of the text + extract to `BIGINT` (Postgres) / `INTEGER` (SQLite), wrapped so an empty + extract and a **negative** value both yield NULL (AC-9, AC-23). +- `jsonBoolToInt(postgres, column, parent, key string) string` — `1` / `0`. + Postgres `#>>` yields `'true'`; SQLite `json_extract` yields `1`. The helper + must accept both spellings, which is the whole reason it exists. + +`total_tokens` and `duration_ms` are `omitempty int64` on the payload, so a +reported `0` never reaches the JSON and absent-means-NULL falls out for free. +`tool_use_count` is `*int` with `omitempty`, so it is present in the JSON exactly +when the agent reported it — including a genuine `0` (AC-8). + +### Model and repository upsert (task 02) + +`apps/backend/internal/task/models/subagent_context.go`: + +```go +type SubagentContext struct { + ID, TaskSessionID, TaskID, ToolCallID string + TurnID, ParentToolCallID, SubagentType, Description *string + AgentID, ChildSessionID, Model, AgentStatus, ToolStatus *string + IsAsync bool + TotalTokens *int64 + ToolUseCount *int64 + DurationMs *int64 + Source string + ObservedAt time.Time + SettledAt *time.Time + UpdatedAt time.Time +} +``` + +Every nullable column is a pointer. This is not style: `ToolUseCount` must +distinguish a reported `0` from "not reported", and a non-pointer `int64` cannot. + +`apps/backend/internal/task/repository/sqlite/subagent_context.go`: + +- `UpsertSubagentContext(ctx context.Context, sc *models.SubagentContext) error` + — a **single** `INSERT ... ON CONFLICT (task_session_id, tool_call_id) DO UPDATE SET ...`. + No read-then-write anywhere (AC-14). Every merge rule lives in the conflict + clause: + + | Column | Conflict expression | AC | + |---|---|---| + | `turn_id` | `COALESCE(task_session_subagents.turn_id, excluded.turn_id)` | AC-2a — the *original* turn wins; a later turn never re-attributes | + | text fields | `COALESCE(excluded., task_session_subagents.)` | AC-4, AC-5 — fill-forward, never blank | + | `tool_status` | `CASE WHEN task_session_subagents.settled_at IS NOT NULL THEN task_session_subagents.tool_status ELSE COALESCE(excluded.tool_status, ...) END` | AC-12 — frozen at the terminal value | + | `is_async` | `CASE WHEN excluded.is_async = 1 THEN 1 ELSE task_session_subagents.is_async END` | sticky, never reset to 0 | + | metrics | `COALESCE(excluded., task_session_subagents.)` | AC-4, AC-7 | + | `settled_at` | `COALESCE(task_session_subagents.settled_at, excluded.settled_at)` | AC-11 — write-once | + | `updated_at` | `excluded.updated_at` | | + | `source`, `observed_at`, `id` | **absent from the SET list** | write-once: a backfilled row is never relabelled `live` (AC-22), and first observation wins (AC-1a) | + + Both dialects accept `excluded.` on the right and unqualified names on the + left; the self-qualified `task_session_subagents.` reads are valid on both. + Statement built once as a package-level `const` and passed through + `r.db.Rebind`, matching `review.go:27` and `document.go:291`. + +- `ListSubagentContextsBySession(ctx, sessionID string) ([]*models.SubagentContext, error)` + and `ListSubagentContextsByTurn(ctx, turnID string)` — both + `ORDER BY observed_at ASC, tool_call_id ASC` (AC-13). These are the read seam + AC-13 is written against and the seam the tests assert ordering through. They + are repository-level only: no service method, no DTO, no route. The spec's + *Out of scope* forbids a read API, not a repository accessor. + +### Service writer and counters (task 03) + +`apps/backend/internal/task/service/subagent_context.go`, one entry point: + +```go +type RecordSubagentContextRequest struct { + TaskSessionID, TaskID, TurnID string + ToolCallID, ParentToolCallID string + ToolStatus string + Payload *streams.SubagentTaskPayload + ObservedAt time.Time +} + +func (s *Service) RecordSubagentContext(ctx context.Context, req RecordSubagentContextRequest) +``` + +**It returns nothing.** That is the mechanism by which AC-27 holds: a writer +with no error return cannot fail the enclosing message write, turn, or stream, +and no future caller can accidentally propagate one. It owns: + +- Identity gate — empty `TaskSessionID`, `TaskID`, or `ToolCallID` ⇒ no write, + `skipped_no_identity`++, return (AC-2). +- `nilIfEmpty(string) *string` applied to every nullable TEXT field, so `""` + becomes NULL and `COUNT(model)` counts models (spec § *Column-level + normalization rules*). +- Metric normalization: `DurationMs`/`TotalTokens` map to `*int64` and are left + nil when zero-or-absent; `ToolUseCount` maps to `*int64` **only** when the + payload pointer is non-nil, preserving a reported `0` (AC-7, AC-8). Any + negative value ⇒ that field nil **and** `anomalous_value`++, other fields + unaffected (AC-9). +- Terminality: `settled_at = &req.ObservedAt` only when + `orchestrator`-equivalent terminal classification of `req.ToolStatus` is true. + `agent_status` is stored verbatim and never consulted (AC-10a, AC-11). The + classifier is unexported in `internal/orchestrator`, so the service gets its + own `isTerminalToolStatus` over the identical set + (`complete`, `completed`, `success`, `error`, `failed`, `cancelled`) with a + comment naming `event_handlers_streaming.go:762` as the peer, plus a test that + pins the two lists together. +- Failure handling: repository error ⇒ `WARN` with session id and tool call id, + `failed`++, return (AC-27). +- `attempted`++ on entry past the nil-payload check; `persisted`++ on success. + +`apps/backend/internal/task/service/subagent_context_metrics.go` — one +`expvar.NewMap("subagent_context_total")` with the five keys AC-26 names +(`attempted`, `persisted`, `skipped_no_identity`, `anomalous_value`, `failed`), +following `internal/office/scheduler/metrics_vars.go` (package-level `NewMap`, +counters only, no gauges). + +### Orchestrator wiring (task 04) + +`apps/backend/internal/orchestrator/service.go` — a narrow interface beside +`MessageCreator`: + +```go +type SubagentContextRecorder interface { + RecordSubagentContext(ctx context.Context, req taskservice.RecordSubagentContextRequest) +} +``` + +held as an optional `subagentContexts` field on `Service`, nil-checked at both +call sites exactly as `messageCreator` is. + +Two call sites in `event_handlers_streaming.go`, each already holding every +input: + +- `handleToolCallEvent` (~line 312) — after the `CreateToolCallMessage` block, + using `s.getActiveTurnID(payload.SessionID)`. +- `persistToolUpdateMessage` (~line 565) — after `UpdateToolCallMessage`, using + the `turnID` that function already resolved (`peekActiveTurnID` for terminal + frames, `getActiveTurnID` otherwise). + +Both guarded by `payload.Data.Normalized.Kind() == streams.ToolKindSubagentTask` +and a non-nil `Normalized.SubagentTask()`. A new +`recordSubagentContextFromFrame(ctx, payload, turnID)` helper holds the guard and +the request build so neither handler grows past the package's `funlen` limit of +80 lines / 50 statements. + +`payload.SessionID` is the Kandev task session id: `messageCreatorAdapter` +passes the same value straight into `CreateMessageRequest.TaskSessionID` +(`internal/backendapp/adapters.go:879`), despite the `agentSessionID` parameter +name. + +`apps/backend/internal/backendapp/adapters.go` — a `subagentContextAdapter` over +`*taskservice.Service`, wired where `messageCreatorAdapter` is wired. + +--- + +## Frontend + +None. The spec's *What this feature is, and is not* rules out any frontend +surface: the subagent card keeps rendering from message metadata, unchanged. No +DTO field, no WebSocket event, no store slice. + +--- + +## Tests + +### Migration and backfill — `subagent_context_migration_test.go` (task 01) + +Patterned on `task_external_id_migration_test.go`. + +- **Fresh DB creates the table, indexes, and UNIQUE constraint.** File: + `apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go`. + How: `NewWithDB` over a temp SQLite file, then assert + `idx_subagents_session_id`, `idx_subagents_task_id`, `idx_subagents_turn_id` + exist in `sqlite_master` and that a duplicate + `(task_session_id, tool_call_id)` insert is rejected. (AC-17) +- **Replay is a no-op.** How: `repo.runMigrations()` twice; assert no error and + an unchanged row count. (AC-18) +- **Backfill inserts one row per subagent message.** How: seed a session, a + turn, and message rows whose `metadata.normalized.kind = 'subagent_task'`, + then `runMigrations()`; assert one row each with `source = 'backfill'` and + `observed_at = m.created_at`. (AC-21) +- **`async_launched` shape stores NULL, not `0`** — the spec's named + must-not-get-wrong regression. How: seed a message whose + `subagent_task` object has `status: "async_launched"` and no + `total_tokens` / `tool_use_count` / `duration_ms`; assert all three columns + `IS NULL` and that none is `0`. (AC-7) +- **A reported `tool_use_count` of `0` survives as `0`.** (AC-8) +- **A negative reported metric backfills as NULL.** (AC-9, AC-23) +- **Empty JSON strings become NULL.** How: a payload with `model: ""`; assert + `model IS NULL`, not `''`. +- **Malformed metadata does not abort the statement.** How: seed one message row + with `metadata = ''` and another with `metadata = 'null'` alongside a valid + subagent row; assert the valid row still lands. (AC-23b) +- **A live row wins over the backfill.** How: pre-insert a `source = 'live'` row + for a `(session, tool_call_id)` that also exists as a message, run + `runMigrations()`, assert `source` is still `'live'` and there is exactly one + row. (AC-22) +- **Rows with no `task_id` or no `tool_call_id` are not backfilled.** (AC-2) +- **Activation keys are written once.** How: assert both `kandev_meta` keys are + present and RFC3339-parseable (`backfill_through` may be `''`), capture their + values, `runMigrations()` again, assert both unchanged. (AC-24) +- **Fresh DB with no subagent messages sets `backfill_through` to `''`.** (AC-24) + +### Repository upsert — `subagent_context_test.go` (task 02) + +File: `apps/backend/internal/task/repository/sqlite/subagent_context_test.go`. + +- **First upsert inserts.** (AC-1) +- **Second upsert for the same key updates in place, one row.** (AC-3) +- **Fill-forward: a frame with empty fields does not blank learned values.** + Table-driven over every nullable column. (AC-4) +- **A non-empty value replaces the stored value.** (AC-5) +- **`settled_at` is write-once**: a second terminal frame with a later timestamp + leaves the first value. (AC-11) +- **A non-terminal frame after settling leaves `settled_at` and `tool_status` + alone but still fills NULL columns.** (AC-12) +- **Never-terminal row exists with `settled_at IS NULL`.** (AC-10) +- **`turn_id` from the first frame survives a later frame carrying a different + turn.** (AC-2a) +- **`is_async` is sticky** across a later frame reporting false. +- **`source` and `observed_at` are not overwritten** by a later upsert. +- **Nested `parent_tool_call_id` is stored and the row is not suppressed.** + (AC-6) +- **Concurrent upserts of the same key** — N goroutines, `errgroup`; assert + exactly one row and that the invariants above hold regardless of commit order. + (AC-14) +- **Concurrent upserts of different keys in one turn** — the observed 3-, 4-, + 5- and 8-way fan-outs; assert every row lands. (AC-15) +- **Deleting the parent session cascades the rows away.** (AC-16) Also add + `task_session_subagents` to `assertNoWorkspaceCascadeDependents` in + `workspace_test.go:317`, so the workspace cascade test covers the new table. +- **Read ordering is `observed_at ASC, tool_call_id ASC`** — seed rows sharing an + `observed_at` and assert the `tool_call_id` tiebreak, per AC-13's explicit + rejection of `id` as a tiebreak. + +### Service writer — `subagent_context_test.go` (task 03) + +File: `apps/backend/internal/task/service/subagent_context_test.go`. + +- **Table-driven identity gate**: each of empty session id, empty task id, empty + tool call id ⇒ no repository call, `skipped_no_identity` incremented by one. + (AC-2) +- **Table-driven normalization**: `""` ⇒ NULL for every nullable TEXT column; + absent metric ⇒ nil; reported `0` `ToolUseCount` ⇒ `*int64(0)`; negative + ⇒ nil plus `anomalous_value`++ with the other fields of the same frame intact. + (AC-7, AC-8, AC-9) +- **Terminal `tool_status` sets `settled_at`; `agent_status = "async_launched"` + alone does not.** (AC-10a, AC-11) +- **An unrecognised `agent_status` is stored verbatim and does not settle.** + (AC-10a) +- **A repository error logs `WARN`, increments `failed`, and the call still + returns normally.** How: a stub repo returning an error plus + `zaptest`/observed-logs. (AC-27) +- **`attempted` and `persisted` move as specified** on the happy path. (AC-26) +- **The service's terminal-status set equals the orchestrator's** — pin both + lists so a future edit to one breaks the test. (AC-11) + +### Orchestrator integration — `event_handlers_streaming_subagent_test.go` (task 04) + +File: +`apps/backend/internal/orchestrator/event_handlers_streaming_subagent_test.go`. + +- **`handleToolCallEvent` with a `subagent_task` payload records a context** with + the active turn id. (AC-1) +- **`handleToolUpdateEvent` for the same tool call records again** so the merge + path runs; assert one recorded request per frame with the right turn id. + (AC-3) +- **A terminal update carries the terminal `tool_status` through.** (AC-11) +- **A non-`subagent_task` normalized kind records nothing.** +- **A nil `subagentContexts` field is safe** — no panic, message write still + happens. (AC-27) +- **A nested frame (`ParentToolCallID` set) is recorded, not dropped.** (AC-6) +- **Integration through the real service and repository**, using + `newServiceBackedMessageCreator(repo)` as `event_handlers_streaming_test.go:906` + does, so one test exercises handler → service → repo end to end. + +### Postgres parity and the health query (task 05) + +- **Fresh Postgres schema plus migration replay**, including the backfill and + both activation keys. File: + `apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go`, + gated on `KANDEV_TEST_POSTGRES_DSN` via `testutil.OpenIsolatedPostgres`. + (AC-19) +- **The upsert's conflict clause behaves identically on Postgres** — fill-forward, + `settled_at` write-once, `is_async` sticky, `turn_id` preserved. This is the + ADR-0027 requirement that schema replay alone is insufficient for a + dialect-sensitive method. (AC-19) +- **The backfill's JSON helpers work on `jsonb`**, including the `''`/`'null'` + metadata rows and the boolean spelling difference. (AC-23b) +- **AC-28 health query** on a seeded store: assert the + `task_session_messages` `subagent_task` count equals + `COUNT(*) FROM task_session_subagents` after live writes, and that seeding a + message the writer never saw makes the two diverge. File: + `apps/backend/internal/task/repository/sqlite/subagent_context_health_test.go`. + (AC-28, AC-29) + +--- + +## E2E Tests + +None. The spec's *Out of scope* states no UI surface changes, therefore "no E2E +coverage is implied". Adding one would test nothing this feature changes. + +--- + +## Verification Results + +Pending. On completion, synchronize this section with each task's `## Results`: +record exact commands and outcomes/counts, generated artifact paths, and +cleanup/teardown evidence. + +--- + +## Implementation Waves And Parallel Candidates + +Every task shares the `internal/task` subtree and each depends on the previous +one's schema or seam, so the chain is sequential. Nothing here is +parallel-safe: tasks 01 and 02 both edit the same package and task 01 owns +shared schema DDL. + +``` +Wave 1: +- [ ] [task-01-schema-and-backfill](task-01-schema-and-backfill.md) + +Wave 2: +- [ ] [task-02-repository-upsert](task-02-repository-upsert.md) + +Wave 3: +- [ ] [task-03-service-writer](task-03-service-writer.md) + +Wave 4: +- [ ] [task-04-orchestrator-wiring](task-04-orchestrator-wiring.md) + +Wave 5: +- [ ] [task-05-postgres-and-health](task-05-postgres-and-health.md) +``` + +## Open Questions + +None. Two spec ambiguities were resolved against repository convention rather +than left open, and both are recorded above so a builder does not re-litigate +them: + +1. **"Schema-init DDL *and* an idempotent migration"** for a new table would be + a duplicated `CREATE TABLE`. `apps/backend/AGENTS.md` settles it: schema init + runs before `runMigrations()` on every boot, so the DDL alone satisfies + AC-17. See *Schema (task 01)*. +2. **AC-13 mandates a read ordering** while *Out of scope* forbids a read API. + Resolved as two repository-level list methods and no service, DTO, or route. + See *Model and repository upsert (task 02)*. diff --git a/docs/plans/subagent-context-persistence/task-01-schema-and-backfill.md b/docs/plans/subagent-context-persistence/task-01-schema-and-backfill.md new file mode 100644 index 0000000000..2f617413ad --- /dev/null +++ b/docs/plans/subagent-context-persistence/task-01-schema-and-backfill.md @@ -0,0 +1,88 @@ +--- +id: "01-schema-and-backfill" +title: "Schema, backfill migration, activation keys" +status: pending +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/subagent-context-persistence/spec.md" +--- + +> **Amendment 1 update:** the shipped schema and migration differ from this +> task as originally drafted — see the "Amendment 1 update" note near the top +> of `plan.md` for the current uniqueness key, the execution-identity +> migration, and the atomic backfill transaction. + +# Task 01: Schema, backfill migration, activation keys + +Create the `task_session_subagents` table, the one-time backfill from existing +message metadata, and the two write-once `kandev_meta` activation keys. + +## Acceptance + +1. A fresh database and an existing database both end up with the table, its + three indexes, the `UNIQUE (task_session_id, tool_call_id)` constraint, and + the `task_sessions` cascade FK — and no FK on `turn_id`. `runMigrations()` + replays cleanly twice with no schema or row-set change. (AC-17, AC-18) +2. The backfill inserts one `source = 'backfill'` row per existing + `subagent_task` message, deriving NULL (never `0`, never `''`) for every + unreported or empty field, skipping rows with no `task_id` or no + `tool_call_id`, and leaving any pre-existing row untouched. (AC-21, AC-22, + AC-23, AC-23a, AC-2) +3. `subagent_context_capture_since` and `subagent_context_backfill_through` are + written once and never overwritten on replay. (AC-24) + +## Files likely touched + +- `apps/backend/internal/task/repository/sqlite/base_schema.go` — new + `initSubagentContextSchema()`, called from `initSessionSchema()` right after + `initMessageTurnSchema()`. +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` — new + `migrateSubagentContextBackfill()` called from `runMigrations()` after the + existing statements; new `jsonKey`, `jsonInt`, `jsonBoolToInt` helpers beside + `jsonColumn` / `jsonText` (currently lines 319-335). +- `apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go` + — new. + +## Dependencies + +None. + +## Parallelism + +`sequential` — owns shared schema DDL and the migration runner. + +## Inputs + +- Spec § *Data model*, § *Column-level normalization rules*, § *Migration*, + § *Backfill*, § *Activation point and provenance*. +- Plan § *Schema (task 01)* and § *Migration: backfill and activation keys*, + which carry the full DDL, the derivation table, and the resolution of the + spec's "DDL *and* migration" wording. **Do not duplicate the `CREATE TABLE` + into `base_migrations.go`** — read that section before starting. +- Pattern to copy: `task_external_id_migration_test.go` (seed a pre-migration + row, call `runMigrations` twice, assert idempotence). +- `internal/db/migratelog.go:33` — `MigrateLogger.Apply` swallows errors and logs + `WARN` with the migration name. It takes **no bind args**, so every statement + in this task must be argument-free SQL; inline the `capture_since` timestamp + with `fmt.Sprintf` over `time.Now().UTC().Format(time.RFC3339)`. +- `internal/persistence/provider.go:72` — `ensureMetaTable` runs before the task + repo is built, so `kandev_meta` is guaranteed present. +- ADR `docs/decisions/0027-replayable-schema-migrations.md` — use `internal/db` + error classification, never local error-string matching. + +## Verification + +``` +cd apps/backend && gofmt -l internal/task/repository/sqlite && make lint && go test -run 'TestSubagentContext' ./internal/task/repository/sqlite/... && go test ./internal/task/repository/sqlite/... +``` + +The final unscoped run is required: it re-runs `TestSQLiteSchemaReinitializes` +and the existing migration suite against the new DDL. + +## Results + +Pending. Before marking the task done, replace this with every exact command +actually run and its outcome/count, generated artifact paths, and cleanup or +teardown evidence. Record security/trust and external side-effect boundaries +when applicable, or explicitly state `None`. diff --git a/docs/plans/subagent-context-persistence/task-02-repository-upsert.md b/docs/plans/subagent-context-persistence/task-02-repository-upsert.md new file mode 100644 index 0000000000..4ddbf8c232 --- /dev/null +++ b/docs/plans/subagent-context-persistence/task-02-repository-upsert.md @@ -0,0 +1,85 @@ +--- +id: "02-repository-upsert" +title: "Model and repository upsert" +status: pending +wave: 2 +depends_on: ["01-schema-and-backfill"] +plan: "plan.md" +spec: "../../specs/subagent-context-persistence/spec.md" +--- + +> **Amendment 1 update:** the shipped conflict key is +> `(task_session_id, agent_execution_id, tool_call_id)`, and `SubagentContext` +> carries `AgentExecutionID` — see the "Amendment 1 update" note near the top +> of `plan.md`. + +# Task 02: Model and repository upsert + +Add the `SubagentContext` model and the single atomic upsert that encodes every +merge, terminality, and ordering rule in SQL. + +## Acceptance + +1. `UpsertSubagentContext` is one `INSERT ... ON CONFLICT DO UPDATE` statement + with no read-then-write anywhere. Repeated calls for one + `(task_session_id, tool_call_id)` produce exactly one row; concurrent calls + for the same key and for different keys in one turn both hold. (AC-3, AC-14, + AC-15) +2. The conflict clause satisfies fill-forward (AC-4, AC-5), write-once + `settled_at` (AC-11), post-settle immutability of `tool_status` with + continued NULL-filling (AC-12), original-`turn_id` preservation (AC-2a), + sticky `is_async`, and write-once `source` / `observed_at` (AC-1a, AC-22). +3. Reads are ordered `observed_at ASC, tool_call_id ASC`, and deleting the + parent session cascades the rows away. (AC-13, AC-16) + +## Files likely touched + +- `apps/backend/internal/task/models/subagent_context.go` — new. Every nullable + column is a pointer; `ToolUseCount *int64` specifically so a reported `0` is + distinguishable from "not reported". +- `apps/backend/internal/task/repository/sqlite/subagent_context.go` — new: + `UpsertSubagentContext`, `ListSubagentContextsBySession`, + `ListSubagentContextsByTurn`. +- `apps/backend/internal/task/repository/sqlite/subagent_context_test.go` — new. +- `apps/backend/internal/task/repository/sqlite/workspace_test.go` — add + `task_session_subagents` to `assertNoWorkspaceCascadeDependents` (line ~317). +- The task repository interface in `apps/backend/internal/task/repository/`, if + the service consumes the repo through an interface rather than the concrete + type — check before assuming either way. + +## Dependencies + +Task 01 — the table must exist. + +## Parallelism + +`sequential` — same package as task 01. + +## Inputs + +- Plan § *Model and repository upsert (task 02)* — carries the full + column-by-column conflict-expression table with its AC mapping. Implement from + that table; do not re-derive it. +- Spec § *Data model*, AC-2a, AC-3 through AC-6, AC-10 through AC-16. +- Upsert patterns already in the package: `review.go:27`, `document.go:291`, + `executor.go:171`. Build the statement as a package-level `const` and pass it + through `r.db.Rebind`. +- `git_snapshots.go` — the sibling read/scan shape for a session-scoped child + table. +- Concurrency test shape: `messagequeue/repository_reorder_test.go:212`. + +## Verification + +``` +cd apps/backend && gofmt -l internal/task/models internal/task/repository/sqlite && make lint && go test -run 'TestSubagentContext' ./internal/task/repository/sqlite/... && go test -race -run 'TestSubagentContext' ./internal/task/repository/sqlite/... && go test ./internal/task/repository/sqlite/... +``` + +The `-race` run is not optional: AC-14 and AC-15 are concurrency claims and a +non-race pass is not evidence for them. + +## Results + +Pending. Before marking the task done, replace this with every exact command +actually run and its outcome/count, generated artifact paths, and cleanup or +teardown evidence. Record security/trust and external side-effect boundaries +when applicable, or explicitly state `None`. diff --git a/docs/plans/subagent-context-persistence/task-03-service-writer.md b/docs/plans/subagent-context-persistence/task-03-service-writer.md new file mode 100644 index 0000000000..3ee55e8952 --- /dev/null +++ b/docs/plans/subagent-context-persistence/task-03-service-writer.md @@ -0,0 +1,87 @@ +--- +id: "03-service-writer" +title: "Service writer and expvar counters" +status: pending +wave: 3 +depends_on: ["02-repository-upsert"] +plan: "plan.md" +spec: "../../specs/subagent-context-persistence/spec.md" +--- + +> **Amendment 1 update:** the writer's request/identity-gate shape carries an +> `ExecutionID` and the attempted/anomalous counters aggregate as documented in +> the "Amendment 1 update" note near the top of `plan.md` — read that note for +> the current writer-health invariants before implementing from this file. + +# Task 03: Service writer and expvar counters + +Add the single service entry point that owns identity gating, value +normalization, terminality, and the five writer-health counters. + +## Acceptance + +1. `RecordSubagentContext` has **no error return**. A repository failure logs + `WARN` with the session id and tool call id, increments `failed`, and returns + normally — so it is structurally impossible for this writer to fail the + enclosing message write, turn, or agent stream. (AC-27) +2. Normalization is exact: every empty nullable TEXT field becomes NULL, never + `''`; an unreported `total_tokens` / `tool_use_count` / `duration_ms` becomes + NULL, never `0`; a reported `tool_use_count` of `0` stays `0`; a negative + value becomes NULL and increments `anomalous_value` while leaving the frame's + other fields intact. (AC-7, AC-8, AC-9) +3. `settled_at` is set only from a terminal ACP `tool_status`. `agent_status` is + stored verbatim and never consulted for terminality — including + `async_launched` and any value Kandev does not recognise. (AC-10a, AC-11) +4. `expvar` exposes `attempted`, `persisted`, `skipped_no_identity`, + `anomalous_value`, `failed`. Missing session id, task id, or tool call id + writes nothing and increments `skipped_no_identity`. (AC-2, AC-26) + +## Files likely touched + +- `apps/backend/internal/task/service/subagent_context.go` — new: + `RecordSubagentContextRequest`, `RecordSubagentContext`, `nilIfEmpty`, the + metric normalizer, and a local `isTerminalToolStatus`. +- `apps/backend/internal/task/service/subagent_context_metrics.go` — new: one + `expvar.NewMap("subagent_context_total")`. +- `apps/backend/internal/task/service/subagent_context_test.go` — new. + +## Dependencies + +Task 02 — the repository upsert is the collaborator under test. + +## Parallelism + +`sequential`. + +## Inputs + +- Plan § *Service writer and counters (task 03)* — carries the request struct, + the no-error-return rationale, and the counter names. +- Spec § *Column-level normalization rules*, § *Two statuses, deliberately + separate*, AC-2, AC-7 through AC-11, AC-26, AC-27. +- `streams.SubagentTaskPayload` at + `internal/agentctl/types/streams/tool_payload.go` — note `ToolUseCount *int` + is a pointer *specifically* to carry a reported `0`, and that `DurationMs` / + `TotalTokens` are `omitempty int64` so a reported `0` is indistinguishable from + absent for those two. Preserve that asymmetry rather than smoothing it. +- `internal/orchestrator/event_handlers_streaming.go:762` — the peer + `isTerminalToolStatus`. It is unexported, so copy the set + (`complete`, `completed`, `success`, `error`, `failed`, `cancelled`), name the + peer in a comment, and add a test that pins the two lists together so a future + edit to one fails. +- Counter convention: `internal/office/scheduler/metrics_vars.go` (package-level + `expvar.NewMap`, counters only) and its test + `metrics_vars_test.go` for the name-assertion shape. + +## Verification + +``` +cd apps/backend && gofmt -l internal/task/service && make lint && go test -run 'TestRecordSubagentContext|TestSubagentContext' ./internal/task/service/... && go test ./internal/task/service/... +``` + +## Results + +Pending. Before marking the task done, replace this with every exact command +actually run and its outcome/count, generated artifact paths, and cleanup or +teardown evidence. Record security/trust and external side-effect boundaries +when applicable, or explicitly state `None`. diff --git a/docs/plans/subagent-context-persistence/task-04-orchestrator-wiring.md b/docs/plans/subagent-context-persistence/task-04-orchestrator-wiring.md new file mode 100644 index 0000000000..95e218fff8 --- /dev/null +++ b/docs/plans/subagent-context-persistence/task-04-orchestrator-wiring.md @@ -0,0 +1,89 @@ +--- +id: "04-orchestrator-wiring" +title: "Orchestrator frame wiring" +status: pending +wave: 4 +depends_on: ["03-service-writer"] +plan: "plan.md" +spec: "../../specs/subagent-context-persistence/spec.md" +--- + +> **Amendment 1 update:** the orchestrator must supply the execution identity +> (`agent_execution_id`) alongside the existing frame fields — see the +> "Amendment 1 update" note near the top of `plan.md` for the current wiring +> shape. + +# Task 04: Orchestrator frame wiring + +Call the writer from the two frame handlers that already parse the subagent +payload, and wire the adapter in `backendapp`. + +## Acceptance + +1. A `subagent_task` frame on `handleToolCallEvent` records a context with the + active turn id; a later frame for the same tool call on + `persistToolUpdateMessage` records again so the upsert's merge path runs. A + nested frame (`ParentToolCallID` set) is recorded, not dropped. (AC-1, AC-3, + AC-6) +2. A non-`subagent_task` normalized kind records nothing, and a nil recorder is + safe — no panic, and the message write still happens. (AC-27) +3. Neither handler exceeds the package's `funlen` limit (80 lines / 50 + statements); the guard and request build live in one + `recordSubagentContextFromFrame` helper. + +## Files likely touched + +- `apps/backend/internal/orchestrator/service.go` — new + `SubagentContextRecorder` interface beside `MessageCreator` (line ~86) and an + optional `subagentContexts` field on `Service`. +- `apps/backend/internal/orchestrator/event_handlers_streaming.go` — new + `recordSubagentContextFromFrame` helper; call it from `handleToolCallEvent` + (~line 312, after the `CreateToolCallMessage` block) and from + `persistToolUpdateMessage` (~line 565, after `UpdateToolCallMessage`). +- `apps/backend/internal/backendapp/adapters.go` — new `subagentContextAdapter` + over `*taskservice.Service`, wired where `messageCreatorAdapter` is wired. +- `apps/backend/internal/orchestrator/event_handlers_streaming_subagent_test.go` + — new. + +## Dependencies + +Task 03 — the service method is what the interface abstracts. + +## Parallelism + +`sequential`. + +## Inputs + +- Plan § *Orchestrator wiring (task 04)* — carries the interface shape, both call + sites, and the turn-id sourcing rule. +- Spec AC-1, AC-1a, AC-2a, AC-3, AC-6, AC-11, AC-27. +- **Turn id must come from what each call site already resolved**, not from a + fresh lookup: `handleToolCallEvent` uses `s.getActiveTurnID(payload.SessionID)` + and `persistToolUpdateMessage` uses its local `turnID`, which is + `peekActiveTurnID` for terminal frames and `getActiveTurnID` otherwise. A + terminal frame on a settled turn deliberately resolves to `""`, and AC-2a's + original-turn preservation lives in the upsert, not here — do not re-derive a + turn to "fix" an empty one. +- `payload.SessionID` **is** the Kandev task session id, despite the + `agentSessionID` parameter name downstream: `messageCreatorAdapter` passes the + same value into `CreateMessageRequest.TaskSessionID` + (`internal/backendapp/adapters.go:879`). +- Guard on `payload.Data.Normalized.Kind() == streams.ToolKindSubagentTask` and a + non-nil `Normalized.SubagentTask()`. +- Test patterns: `event_handlers_streaming_test.go:906` + (`newServiceBackedMessageCreator(repo)`) for the end-to-end + handler → service → repo case, and `mockMessageCreator` for the unit cases. + +## Verification + +``` +cd apps/backend && gofmt -l internal/orchestrator internal/backendapp && make lint && go test -run 'Subagent' ./internal/orchestrator/... && go test ./internal/orchestrator/... ./internal/backendapp/... +``` + +## Results + +Pending. Before marking the task done, replace this with every exact command +actually run and its outcome/count, generated artifact paths, and cleanup or +teardown evidence. Record security/trust and external side-effect boundaries +when applicable, or explicitly state `None`. diff --git a/docs/plans/subagent-context-persistence/task-05-postgres-and-health.md b/docs/plans/subagent-context-persistence/task-05-postgres-and-health.md new file mode 100644 index 0000000000..e30b9c7760 --- /dev/null +++ b/docs/plans/subagent-context-persistence/task-05-postgres-and-health.md @@ -0,0 +1,86 @@ +--- +id: "05-postgres-and-health" +title: "Postgres parity and health-query coverage" +status: pending +wave: 5 +depends_on: ["04-orchestrator-wiring"] +plan: "plan.md" +spec: "../../specs/subagent-context-persistence/spec.md" +--- + +> **Current implementation boundary:** Postgres parity covers the final +> execution-aware schema and the historical-message backfill. The feature does +> not ship a compatibility rebuild for an earlier table shape; see +> `docs/decisions/2026-08-15-subagent-context-schema-boundary.md`. + +# Task 05: Postgres parity and health-query coverage + +Prove the dialect-sensitive statements behave identically on PostgreSQL, and pin +the AC-28 expected-versus-observed health comparison. + +## Acceptance + +1. Against `KANDEV_TEST_POSTGRES_DSN`, a fresh schema plus a migration replay + produces the same table, the same backfilled rows, and the same two + activation keys as SQLite. (AC-19) +2. The upsert's conflict clause is verified *behaviorally* on Postgres — + fill-forward, write-once `settled_at`, sticky `is_async`, preserved original + `turn_id` — not merely replayed. The backfill's JSON helpers are verified over + `jsonb`, including `''` and `'null'` metadata rows and the boolean spelling + difference (`'true'` vs `1`). (AC-19, AC-23b) +3. The AC-28 comparison is asserted both ways: the two counts agree after live + writes, and seeding a `subagent_task` message the writer never saw makes them + diverge, with `MAX(observed_at)` locating the divergence in time. (AC-28, + AC-29) + +## Files likely touched + +- `apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go` + — new. +- `apps/backend/internal/task/repository/sqlite/subagent_context_health_test.go` + — new. + +## Dependencies + +Task 04 — the health check compares rows written by the live path. + +## Parallelism + +`sequential`. + +## Inputs + +- Spec AC-19, AC-23b, AC-28, AC-29, and § *Writer health* — in particular *why* + the message count is a valid independent expectation: the message write is + load-bearing for the UI and a break is noticed within one turn, whereas a + broken context write is noticed by nobody. Keep that asymmetry in the test + comment; it is the reason the check is real. +- ADR `docs/decisions/0027-replayable-schema-migrations.md` and + `apps/backend/AGENTS.md` § *Schema & migrations*: "add an environment-gated + PostgreSQL behavior test for every changed dialect-sensitive method (schema + replay is insufficient)". +- Patterns: `postgres_schema_test.go` (env-gated fresh-schema + replay), + `document_plan_postgres_test.go` (asserting `ON CONFLICT` updates rather than + inserts on Postgres), `testutil.OpenIsolatedPostgres` / + `testutil.PostgresDSNFromEnv` (`internal/testutil/postgres.go:45`). +- The AC-28 query is dialect-specific (`json_extract` vs `#>>`); reuse the + helpers from task 01 rather than hand-writing it twice. + +## Verification + +``` +cd apps/backend && gofmt -l internal/task/repository/sqlite && make lint && go test -run 'TestSubagentContextHealth' ./internal/task/repository/sqlite/... && KANDEV_TEST_POSTGRES_DSN="$KANDEV_TEST_POSTGRES_DSN" go test -run 'Postgres.*Subagent|Subagent.*Postgres' ./internal/task/repository/sqlite/... +``` + +If `KANDEV_TEST_POSTGRES_DSN` is unset the Postgres run **skips rather than +fails**. A skip is not evidence for AC-19: record it explicitly as a skip in +`## Results` and state that AC-19 remains unverified locally, rather than +reporting the task fully verified. + +## Results + +Pending. Before marking the task done, replace this with every exact command +actually run and its outcome/count, generated artifact paths, and cleanup or +teardown evidence. Record whether the Postgres suite ran or skipped. Record +security/trust and external side-effect boundaries when applicable, or explicitly +state `None`. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 069df38595..e385698d18 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -260,6 +260,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, | [agent-resume-runtime-recovery](agent-resume-runtime-recovery/spec.md) | shipped | | [agent-stall-recovery](agent-stall-recovery/spec.md) | approved | | [mcp-session-observability](mcp-session-observability/spec.md) | approved | +| [subagent-context-persistence](subagent-context-persistence/spec.md) | draft | | [auth](auth/spec.md) | building | | [create-local-repository](create-local-repository/spec.md) | shipped | | [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building | diff --git a/docs/specs/subagent-context-persistence/spec.md b/docs/specs/subagent-context-persistence/spec.md new file mode 100644 index 0000000000..a57fd5a3be --- /dev/null +++ b/docs/specs/subagent-context-persistence/spec.md @@ -0,0 +1,2400 @@ +--- +status: draft +created: 2026-08-12 +updated: 2026-08-14 +owner: nova28 +--- + +# Subagent context persistence + +> **Current implementation boundary (2026-08-15).** The execution-aware +> `task_session_subagents` shape is created directly by schema initialization. +> The predecessor two-column shape and the `subagent_context_execution_since` +> activation key were not shipped on the supported base branch, so the current +> implementation does not include a compatibility rebuild or shape-probing +> migration for them. Historical amendment text below records the earlier +> design discussion; the supported migration work is the historical-message +> backfill and its two activation keys. See +> [ADR-2026-08-15-subagent-context-schema-boundary](../../decisions/2026-08-15-subagent-context-schema-boundary.md). + +## Amendment log + +**Amendment 1 (2026-08-14) — row identity gains an execution dimension.** + +The original contract keyed the upsert on `(task_session_id, tool_call_id)` and +justified that as "session-scoped by design". Code review of PR +[#2617](https://github.com/kdlbs/kandev/pull/2617) by a repository maintainer +established that this key is **wrong, not merely narrow**: `tool_call_id` is +unique only within one *agent execution*, so a late frame from a finished +execution can overwrite a different, newer execution's row. + +This is not hypothetical, and the amendment does not rest on the reviewer's +authority. It was confirmed from the code: + +- `allocateCodexEmittedToolCallIDLocked` + (`internal/agentctl/server/adapter/transport/acp/adapter_tools.go:448-475`) + de-duplicates emitted tool-call IDs against `a.codexEmittedToolCallIDs`, an + **in-memory map owned by one adapter instance**. A new execution constructs a + new adapter with an empty map, so a wire `tool_call_id` the agent reuses is + re-emitted verbatim with no disambiguating suffix. Uniqueness is therefore + guaranteed **per execution**, never per session. +- Each execution is a fresh `uuid.New().String()` + (`internal/agent/runtime/lifecycle/manager_execution.go:572`, + `manager_launch.go:526`), so executions rotate on every launch and resume + within one long-lived session. +- The exposure widened when the PR's own fix made late frames from + already-completed executions *recorded* rather than dropped + (`internal/orchestrator/event_handlers_streaming.go:325-333`). Before that + fix such a frame was discarded and could not clobber anything; after it, it + can. The root defect (no execution identity in the key) predates that fix. + +What this amendment changes: `agent_execution_id` is added to the data model and +to the upsert key (AC-30 … AC-34), and the ACs that named the two-column key are +restated — **AC-2a, AC-3, AC-13, AC-14, AC-22, AC-28**. Sections carrying an +amended contract are marked *(amended by Amendment 1)*. Everything else in this +document is unchanged and still binding. + +### Amendment 1, revision 1 (2026-08-14) — failure and detection semantics + +Spec Review returned **FIX FIRST** on Amendment 1 with eleven findings, two +independent outside-voice legs concurring on three of them. The key change itself +was confirmed sound and is unaltered. What was missing was **failure and detection +semantics**: under a migration runner that swallows errors, the spec did not say +what "the migration succeeded" means, what happens when it does not, or what +durable datum marks the point from which each guarantee holds. + +Revision 1 adds only that, plus five smaller corrections. It changes no existing +AC's intent: + +- **A third activation key, `subagent_context_execution_since`** (AC-24), because + `subagent_context_capture_since` is write-once and was written by the + *pre-amendment* build — it marks the start of capture, not the amendment + boundary, so it cannot separate a legacy `'unknown'` row from a live frame that + arrived without an execution id. The old claim that it could was false and is + removed. +- **A failure contract for the backfill** (AC-24a) — an activation key is never + written after a failed backfill. +- **A failure contract for the AC-33 rebuild** (AC-33a), and AC-33 scoped to both + dialects, because `recreateTable` returns `(false, nil)` on PostgreSQL by + construction; the precedent it cites covers only the SQLite half. +- **AC-5's exception list enumerated** (it claimed AC-9 was its only exception; at + least six other criteria override it), and AC-1's `source` requirement reconciled + with `source`'s write-once guarantee. +- **AC-7 given a detection rule** for `total_tokens` and `duration_ms`, which are + plain `int64` and cannot express "not reported" the way `ToolUseCount *int` can. +- Corrections to AC-8, AC-11, AC-15, AC-21, AC-26, AC-28 and the index rationale. + +Passages carrying revision 1 are marked *(revision 1)*. + +### Amendment 1, revision 2 (2026-08-14) — activation semantics and the two unnamed backfill sources + +Spec Review returned **FIX FIRST** on revision 1 with twelve findings, from a +cross-vendor leg (codex) and a cross-model leg (fable) plus the reviewer's own +pass. Three of them were found independently by all three voices. No existing +AC's intent changes; every fix below is additive. + +Revision 1 added failure semantics. What it did not do was say **when each +activation key gets written on each kind of installation**, and it left two +backfill column sources unnamed. Revision 2 fixes exactly that, plus six smaller +corrections: + +- **The activation keys are now written off the OBSERVED END STATE, not off + whether a migration fired** (AC-24, AC-33b). Revision 1 tied + `subagent_context_execution_since` to AC-33, whose trigger is a table that + "predates Amendment 1" — so a fresh database, and any database that predates + the feature entirely, never wrote it, and AC-24b then permanently declared + those installations un-amended. That was the single worst defect in revision 1: + it inverted the detection signal on the majority of future installations. +- **All four key states are now defined** (AC-24c), not just one. +- **`subagent_context_backfill_through` is derived from the source predicate, not + from what one attempt inserted** (AC-24). Revision 1's tightening was wrong on a + retry, where `ON CONFLICT DO NOTHING` inserts nothing and the literal reading + published the empty string. +- **The keys carry nanosecond precision and comparisons are defined at equality** + (AC-24), because they were being compared against a microsecond-precision column. +- **Migration ordering is stated** (AC-33c): the AC-33 shape change runs before the + AC-21 backfill, and the backfill does not run against a stale shape. +- **AC-28 defines what to do when `capture_since` is absent** — the state AC-24a + creates and AC-20 points at — and **AC-28/AC-29 now compare an execution-agnostic + distinct-key count**, so the extra rows Amendment 1 deliberately creates can no + longer bank headroom that hides a stopped writer. +- **The backfill's `tool_status` and `turn_id` sources are named** (AC-21). Both were + verified at the writer: `metadata.status` is written from + `payload.Data.ToolStatus`, and the message's `turn_id` column is written from the + orchestrator's resolved turn id. +- Plus a duplicate-source tiebreak (AC-21), `turn_id` NULL fill-forward (AC-2a), the + later-reported-zero rule (AC-7a), `task_id` added to AC-5's exception table, and a + narrowed observability claim in the acceptance-criteria preamble. + +Passages carrying revision 2 are marked *(revision 2)*. + +### Amendment 1, revision 3 (2026-08-14) — the activation keys as instants, and the excess direction + +Spec Review returned **FIX FIRST** on revision 2 with nine findings, from a +cross-vendor leg (codex), a cross-model leg (fable) and the reviewer's own pass. +No existing AC's intent changes; every fix below is additive. + +Revisions 1 and 2 built the activation keys up as a *detection story* — when each +is written, what each absence means. What neither reached is the layer underneath: +**the keys are strings in a text column, and nothing said how a string becomes a +comparison against a timestamp column.** That gap is not theoretical. It is live in +the shipped pre-amendment writer, and it was measured rather than argued: + +- **The comparison is broken today, and the boundary snaps to midnight.** + `task_session_messages.created_at` stores `2026-07-30 01:39:37.271235+00:00` + (space separator, microseconds, `+00:00` offset — sampled from the reference + store). `subagent_context_capture_since` is written + `time.Now().UTC().Format(time.RFC3339)` and `subagent_context_backfill_through` + via `rfc3339Timestamp`, both producing `2026-07-30T01:39:37Z`. Compared as text, + `' '` (0x20) sorts before `'T'` (0x54), so **every row on the key's own calendar + day reads as "before" the key regardless of its actual time.** Measured in SQLite + with the real formats: same instant → `0`; a row 23h59m *after* the key, same day + → `0`; a key from the previous day → `1`. Only the date part is doing any work. + AC-24 now requires a timestamp comparison, not a string comparison (AC-24e). +- **Second precision, not nanosecond.** Both shipped key writes emit seconds — + `time.RFC3339` and `strftime('%Y-%m-%dT%H:%M:%SZ', …)`. `rfc3339Timestamp` + (`base_migrations.go`) is the helper a builder naturally reaches for and it + **cannot** produce the precision AC-24 mandates. Named so it is not reached for. +- **`backfill_through = ''` is the fresh-install default and had no defined + meaning** (AC-24f). The shipped statement already `COALESCE(…, '')`s it, AC-24 + mandates it, and AC-24's own RFC3339Nano requirement contradicted it. +- **The `MAX` runs as a separate statement after the INSERT** — three independent + `r.migrate.Apply` calls, no transaction — so "at that same instant" was asserted + and not mechanised. AC-24d now fixes the evaluation order (SR30). +- **AC-28's excess direction was undefined**, and closing it naively would have + reintroduced the banking hole revision 2 removed: if excess and shortfall net + against each other in one signed difference, banked excess still absorbs missed + writes. AC-28 now measures the two directions as **separate anti-joins**, so + neither can cancel the other and AC-29 holds unconditionally. +- Plus: a negative metric no longer blanks a learned measurement (AC-9, the same + rule revision 2 gave the reported zero), AC-12 defers to AC-5 after settlement, + AC-1a is scoped to commit order, AC-33b's key write is gated on the clauses that + are actually schema-observable, and a stale revision-1 row is removed from + *Failure modes*. + +Passages carrying revision 3 are marked *(revision 3)*. + +### Amendment 1, revision 4 (2026-08-14) — the keys as written values, and two queries that did not run + +Spec Review returned **FIX FIRST** on revision 3 with ten findings, from a cross-vendor leg +(codex), a cross-model leg (fable) and the reviewer's own pass. No existing AC's intent +changes, and **revision 4 adds no new acceptance criterion** — every fix is a clause inside +a criterion that already existed. + +Revisions 1–3 built the activation keys up as a *detection story* (when each is written), +then as *comparable instants* (AC-24e). What none of them reached is the layer underneath +both: **the keys are values that something has to produce, on installations that already +exist.** Four of the ten findings are that one theme: + +- **`capture_since` was defined as an instant nothing can observe.** AC-24 called it "the + instant the backfill statement committed successfully" while AC-24d requires the key write + to be atomic with that insert — and the commit instant is knowable only *after* commit, + when an atomic write is no longer possible. A builder had to invent transaction-start, + pre-commit sample, or a database clock, and those publish measurably different boundaries. + AC-24 point 4 now fixes the sample point and states which direction of error is safe. +- **Keys the shipped build already wrote had no disposition.** Revision 3 measured them at + second precision and mandated RFC3339Nano, while AC-24 makes the keys write-once — two + requirements that cannot both hold on any already-activated installation. AC-24 point 5 + settles it: write-once wins, the key stands, and the residual second is disclosed. +- **A partial key pair is already reachable on the installed base.** The pre-amendment build + applies the insert and both key writes as three independent `r.migrate.Apply` calls and + guards re-runs on `capture_since` alone, so a database can hold `capture_since` without + `backfill_through` — permanently, because the guard then suppresses every retry. AC-24d + now requires the guard to be **both** keys, which repairs the state with machinery the + spec already had. +- **AC-24e mandated a precision it named no mechanism for.** The repository's own + `timestampColumn` renders SQLite's side as `julianday()`, a double whose ULP here is + roughly 40 µs — coarser than the microsecond column — and, worse, revision 3's mandated + same-day test *passes* against it. AC-24e now sets the floor at the column's own + precision, forbids `julianday`, and *Verification* adds the one-microsecond boundary test + that actually discriminates. + +The other six: AC-28's two normative anti-joins **did not parse on PostgreSQL** (no +derived-table alias) and **bypassed the JSON normalization AC-23b exists to mandate**, so a +single legacy `''` metadata row would disable the health check on either dialect; AC-25's +"unknown execution identity **by construction**" is falsified by the self-heal state +revision 3 itself introduced, where real UUIDs precede the key; AC-29's `MAX(observed_at)` +had no window and no empty-result rule; AC-28's excess anti-join and its prose described +different sets; and the observability taxonomy's AC-25 exclusion was never extended to the +consumer clauses revisions 1–3 added. + +Passages carrying revision 4 are marked *(revision 4)*. + +## Why + +When an agent fans out to subagents (Claude's `Task` tool, OpenCode's `task`, +Cursor's `Task`, Auggie's `sub-agent-:`), Kandev already recognises the +fan-out and already receives the child's identity and usage on the completion +frame. It renders them, counts them in memory, and then keeps no queryable +record of them. + +The consequence is a specific, measured distortion. In the reference store +(`~/.kandev/data/kandev.db`, snapshotted 2026-08-12), **sessions per card is +exactly 1.0 for every step**, while 253 subagent invocations exist across 38 +sessions and 127 turns — including **42 turns that fanned out to exactly three +subagents**. A step that spawned three parallel reviewers is, in the store, +indistinguishable from one long conversation. Every per-context cost figure is +therefore wrong in the same direction, and the Ops Cost analysis of the Review +step's cache cost had to *infer* a three-way fan-out by reading the step's +prompt, because nothing in the store recorded it. + +## What this feature is, and is not + +**It is persistence and a parent link.** The data already arrives on a frame +Kandev parses (`internal/agentctl/server/adapter/transport/acp/subagent.go`) and +is already carried in a typed payload +(`streams.SubagentTaskPayload`). This spec adds a durable, queryable row per +subagent invocation, linked to the session and turn that spawned it. It adds no +new instrumentation, no new agent-side capability, and no new wire fields. + +**It is not a session.** A subagent context is deliberately *not* a +`task_sessions` row. See *The design question*, below — that decision is part of +the contract, not an implementation detail. + +**It is not a UI feature.** No frontend surface changes. The existing subagent +card already renders from message metadata and continues to do so, unchanged. + +**It is not cost attribution.** Token counts are stored exactly as the agent +reported them, with provenance. No pricing, no dollars, no rollup into +`task_sessions.cost_subcents`. + +### One correction to the framing + +The originating analysis states the data is "never persisted". That is +imprecise, and the imprecision matters to the design. The normalized payload +*is* written today — as opaque JSON at +`task_session_messages.metadata.normalized.subagent_task` (253 such rows exist +in the reference store). What does not exist is a **relational fact**: no +stable row identity, no first-class parent link, no column a query can group by, +and no independent record — the JSON lives inside a row that is cascade-deleted +with its session. + +This matters twice over: + +1. It makes a **backfill possible**, which this spec takes (see *Backfill*). +2. It makes the message table an **independent expected-count source** for + writer health (see *Writer health*). + +--- + +## The design question: a row in `task_sessions`, or its own table? + +**Answer: its own table, `task_session_subagents`.** The parent link is a +foreign key from that table to `task_sessions`, not a `parent_session_id` column +added to `task_sessions`. + +The reasoning, from the code as it stands: + +**A subagent has none of the things a `task_sessions` row asserts.** It has no +workspace and no `workspace_path`; no executor, `executor_profile_id`, or +`task_environment_id`; no worktree or `base_branch`; no `agent_profile_id`; no +`executors_running` row; no ACP session Kandev can `session/load` or resume; no +`state` machine (`CREATED` → `RUNNING` → …); no launch, stop, or recover path. +A row in `task_sessions` implies all of those, and every one would be a lie. + +**`task_sessions` is read by paths that would be actively harmed.** Session +rows feed lifecycle recovery (`RecoverInstances`), the orchestrator, session-tab +listings, WebSocket subscription scoping, the auth scoper +(`AuthorizeSessionAccess` resolves session → task → workspace → owner), the boot +payload, and cost rollups. Inserting non-launchable rows there forces a new +"is this real" predicate into every one of them, and any path that forgets it +becomes a defect. + +**It would silently break the very series this feature exists to fix.** The +downstream extract's `dim_session` is `SELECT … FROM task_sessions`. Adding +subagent rows to that table would change what "sessions per card" *means* +mid-series, with no schema versioning to signal it — which is precisely the +discontinuity the cross-cutting rule for this work forbids. A separate table is +purely additive: the existing series stays comparable, and the new fact is +opt-in for any consumer that wants it. + +The name follows the existing sibling convention: `task_session_messages`, +`task_session_turns`, `task_session_commits`, `task_session_subagents`. + +--- + +## Sampled input shapes + +Every shape below was read from the live store on 2026-08-12, not assumed. + +### Population by terminal status (n = 253) + +| status | n | agent_id | child_session_id | model | total_tokens | tool_use_count | duration_ms | +|---|---|---|---|---|---|---|---| +| `async_launched` | 190 | 190 | 0 | 190 | **0** | **0** | **0** | +| `completed` | 57 | 57 | 0 | 49 | 57 | 57 | 57 | +| `started` | 5 | 0 | 5 | 0 | 0 | 0 | 0 | +| *(absent)* | 1 | 0 | 0 | 0 | 0 | 0 | 0 | + +Three facts drive the whole contract: + +1. **75% of subagent invocations report no usage at all.** `async_launched` is + Claude's `run_in_background` dispatch; the dispatch is terminal for the + `Task` tool, the subagent runs out-of-band and writes to an `outputFile` + Kandev never reads. These rows will never gain tokens, duration, or a tool + count. Storing `0` for them would fabricate 190 measurements. +2. **`child_session_id` is empty for every Claude row** and is populated only by + OpenCode (5 rows, all non-terminal). It cannot be the identity. +3. **`tool_call_id` is present on all 253 rows and unique across all 253.** + `agent_id` is unique among the 247 rows that carry one, but is absent for + three of the four supported agents. `tool_call_id` is the identity — + *(amended by Amendment 1)* **paired with the execution that emitted it**. + The observed uniqueness across 253 rows is a property of this sample, not a + guarantee: see *Execution identity, as sampled* below for why it does not + hold in general. + +### Other measured facts + +- **Fan-out distribution per turn:** 1→65 turns, 2→12, **3→42**, 4→5, 5→2, 8→1. +- **Nesting:** exactly 1 of 253 rows carries a `parent_tool_call_id` — a + subagent that spawned a subagent. Rare, real, and must be specified. +- `turn_id` and `task_session_id` are non-empty on all 253 rows. +- `subagent_type` is missing on 3 rows. +- No two subagent rows within a turn share a `created_at` (microsecond + precision), so ties are not observed — but they are not prevented either. +- Observed window: 2026-08-03 15:32:20Z to 2026-08-12 13:24:58Z. + +### The payload as parsed + +`streams.SubagentTaskPayload` carries `Description`, `Prompt`, `SubagentType`, +`Status`, `AgentID`, `Model`, `ChildSessionID`, `DurationMs`, `TotalTokens`, +`ToolUseCount *int`, `ResultText`, `IsAsync`, `OutputFile`, +`CanReadOutputFile`. `ToolUseCount` is already a pointer specifically so a +reported `0` is distinguishable from "not reported" — that distinction is +load-bearing here and must survive into the column. + +### Execution identity, as sampled *(added by Amendment 1)* + +Read from the code on 2026-08-14, because the amendment adds a column and a +spec may not assume a field exists. + +**It is already on the frame.** `lifecycle.AgentStreamEventPayload` carries +`ExecutionID string \`json:"execution_id"\`` — documented in place as +"Lifecycle execution ID; stable across the payload's lifetime" +(`internal/agent/runtime/lifecycle/event_types.go:231`). Both publish sites set +it from `execution.ID` +(`lifecycle/events.go:226`, `lifecycle/manager_streaming.go:287`), and the +orchestrator already reads it on this exact path: `recordSubagentContextFromFrame` +is called from handlers that pass the same `payload`, and +`shouldDropCompletedExecutionStreamEvent` logs it as `agent_execution_id` +(`internal/orchestrator/event_handlers_streaming.go:855-867`). **No new wire +field, no new instrumentation** — this amendment stays inside the original +scope note. + +**Its shape.** `execution.ID` is a `uuid.New().String()` +(`lifecycle/manager_execution.go:572`, `manager_launch.go:526`). A reserved +sentinel that is not a UUID therefore cannot collide with a real value. + +**It can be empty.** `shouldDropCompletedExecutionStreamEvent` returns early +when `payload.ExecutionID == ""`, so the empty case is reachable in code rather +than merely conceivable. AC-31 defines it rather than leaving it to a builder. + +**Historical messages do not carry it.** `task_session_messages` +(`internal/task/repository/sqlite/base_schema.go:679-694`) has columns +`id, task_session_id, task_id, turn_id, author_type, author_id, content, +requests_input, type, metadata, created_at, updated_at` — and no execution +identity, in the row or in the JSON the backfill reads. The backfill therefore +*cannot* derive a real value; AC-31's sentinel is a necessity, not a +convenience. + +**Resume does not split a subagent across executions.** A subagent's frames +reaching two different executions would turn execution-scoping into a +row-splitting defect. It cannot happen on the replay path: `handleACPUpdate` +suppresses `ToolCall` and `ToolCallUpdate` (among others) while +`isLoadingSession` is set during `session/load` +(`adapter/transport/acp/adapter_updates.go:136-160`), so replayed history never +reaches the orchestrator. A live execution's own Task tool call dies with its +process and emits nothing into its successor. One subagent invocation therefore +belongs to exactly one execution, which is what makes the three-column key safe. + +**The existing constraint is an inline table constraint, not an index.** +`UNIQUE (task_session_id, tool_call_id)` is declared inside `CREATE TABLE` +(`base_schema.go:650`). SQLite cannot alter a table constraint in place, so +changing the key on a database that already has the table requires a table +rebuild; the repository already owns that pattern (`recreateTableNamed`, +`base_migrations.go:857`, `:911`). AC-33 states the required end state and +leaves the mechanism to Build. + +## Data model + +A new table, created on fresh databases in the schema-init DDL **and** +introduced on existing databases by an idempotent migration in +`runMigrations()`, per the repository's schema rules. + +| Column | Type | Null? | Meaning | +|---|---|---|---| +| `id` | TEXT PK | no | Kandev-generated UUID. Not an ordering key. | +| `task_session_id` | TEXT | no | Parent session. FK → `task_sessions(id)` ON DELETE CASCADE. | +| `task_id` | TEXT | no | Denormalised parent task, mirroring `task_session_turns`. | +| `turn_id` | TEXT | yes | Turn that spawned it. NULL when no turn was active. | +| `agent_execution_id` | TEXT | no | *(added by Amendment 1)* The lifecycle execution that emitted the frame (`AgentStreamEventPayload.ExecutionID`). Part of the upsert key. Never NULL: the reserved sentinel `'unknown'` is stored when no execution identity is available (AC-31). | +| `tool_call_id` | TEXT | no | Agent-supplied invocation identity. Unique only **within one execution** — see *Constraints and indexes*. | +| `parent_tool_call_id` | TEXT | yes | Set when a subagent spawned this subagent. NULL for top-level. | +| `subagent_type` | TEXT | yes | e.g. `security-reviewer`. NULL when not reported. | +| `description` | TEXT | yes | Short label from `rawInput.description`. | +| `agent_id` | TEXT | yes | Provider-supplied child agent id. NULL for providers that omit it. | +| `child_session_id` | TEXT | yes | OpenCode child ACP session. NULL elsewhere. | +| `model` | TEXT | yes | Resolved child model, verbatim (e.g. `claude-opus-5[1m]`). | +| `agent_status` | TEXT | yes | The **agent-reported subagent** status, verbatim (`completed`, `async_launched`, `started`, …). NULL when never reported. | +| `tool_status` | TEXT | yes | The **ACP tool-call** status of the launching `Task` call, stored verbatim. Non-terminal values include `pending` and `in_progress`; the six terminal values are enumerated in AC-11. NULL when never reported. | +| `is_async` | INTEGER | no | 1 for a detached dispatch, else 0. Default 0. | +| `total_tokens` | INTEGER | **yes** | As reported. **NULL when not reported.** | +| `tool_use_count` | INTEGER | **yes** | As reported. **NULL when not reported; 0 only when the agent reported 0.** | +| `duration_ms` | INTEGER | **yes** | As reported. **NULL when not reported.** | +| `source` | TEXT | no | `live` or `backfill`. Provenance. Default `live`. | +| `observed_at` | TIMESTAMP | no | When Kandev first observed the launch frame. | +| `settled_at` | TIMESTAMP | yes | When a terminal status was first observed. NULL while outstanding. | +| `updated_at` | TIMESTAMP | no | Last write to this row. | + +Constraints and indexes *(amended by Amendment 1)*: + +- `UNIQUE(task_session_id, agent_execution_id, tool_call_id)` — the upsert key. + Uniqueness is **execution-scoped**, because that is the widest scope in which + a `tool_call_id` is actually unique. The original two-column key assumed + session scope; *Execution identity, as sampled* shows why that assumption is + false, and the *Amendment log* records the review that caught it. + + The column order is the key's own contract, not incidental: `task_session_id` + leads so the constraint's backing index is usable as a leftmost-prefix scan for + session-scoped reads (AC-13), rather than being usable only for full-key + lookups. *(revision 1)* An earlier draft went further and said this meant the + key needed no second index — which contradicted the index list two bullets + below, where `INDEX(task_session_id)` is still declared. The claim is narrowed + to what is true: **the key order means no NEW index is required for AC-13's + reads.** The pre-existing single-column index is retained, in both the + fresh-database DDL and the AC-33 rebuild, so that a migrated database and a + fresh one have byte-identical index sets (AC-33 clause 4) — dropping a + now-redundant index is a separate, behaviour-neutral change with its own + measurement, and silently doing it inside a correctness migration would make + the AC-33 replay test encode an arbitrary choice. + + Two rows that differ **only** in `agent_execution_id` are the intended + outcome, not a duplicate. They record two genuinely different invocations + that happened to reuse an ID, and collapsing them is precisely the data loss + this key prevents. +- FK `task_session_id` → `task_sessions(id)` ON DELETE CASCADE. +- **`agent_execution_id` carries no foreign key**, for the same reason + `turn_id` does not. Execution rows live in `executors_running` and are + removed when the executor stops (`agent_execution_id` was itself dropped from + `task_sessions` by an earlier migration, `base_migrations.go:857`), so an FK + would delete the measurement when the thing it measured finished — the exact + failure the `turn_id` decision already rejected. It is a plain value column + that happens to be part of the key. +- **`turn_id` carries no foreign key.** `task_session_messages.turn_id` + cascades from `task_session_turns`, which would mean deleting a turn silently + deletes the fan-out record for that turn. The measurement must outlive the + turn row, so `turn_id` is a plain nullable column. +- `INDEX(task_session_id)`, `INDEX(task_id)`, `INDEX(turn_id)`. + +Deliberately **not** stored: `prompt`, `result_text`, `output_file`. The prompt +and result are free text already retained on the message row; duplicating them +here doubles the store's largest text payload and adds a second copy of +prompt content to any extract. This table is structure, not content. + +### Two statuses, deliberately separate + +`agent_status` and `tool_status` are different measurements from different +layers and are **not** merged into one column: + +- `agent_status` is the child's own report, read from + `_meta.claudeCode.toolResponse.status` (or the provider's equivalent). Its + vocabulary is provider-defined and open — `async_launched` is a value only + Claude produces, and it means *dispatched, will never report usage*. +- `tool_status` is the ACP status of the launching `Task` tool call, which + Kandev already classifies via `isTerminalToolStatus`. Its vocabulary is + closed and provider-neutral. + +Terminality (AC-11) keys off `tool_status`, because that is the signal the +orchestrator already treats as authoritative everywhere else. `agent_status` is +stored verbatim and is never used to decide whether the row has settled. + +### Column-level normalization rules + +These apply to every write path, live and backfill: + +- **An empty string is stored as NULL**, never as `''`, for every nullable TEXT + column. `SubagentTaskPayload` uses `""` for "absent"; the column uses NULL, so + that `COUNT(model)` counts models rather than blanks. +- `is_async` defaults to `0` and is set to `1` only on a positive report; it is + never set back to `0` by a later frame. +- `source` defaults to `'live'`. +- *(added by Amendment 1)* `agent_execution_id` is **NOT NULL and never + empty**. It is the reserved literal `'unknown'` whenever no execution + identity is available, and the frame's `ExecutionID` otherwise. It is + write-once: once a row exists, no later frame changes its + `agent_execution_id`, because doing so would move the row to a different + identity. `'unknown'` cannot collide with a real value, which is always a + UUID. + + **There is exactly one sentinel, deliberately shared** by all three sources + of "no execution identity" — the backfill, rows written before this amendment, + and a live frame whose `ExecutionID` was empty. A second sentinel would look + tidier and would be a bug: the backfill's `ON CONFLICT DO NOTHING` (AC-22) + only suppresses a duplicate when the backfilled row lands on the *same* key + as the row already there, so a backfill sentinel distinct from the + pre-amendment live sentinel would insert a second row for a subagent already + recorded. + + *(revision 1)* **What sharing the sentinel does and does not preserve.** An + earlier draft claimed `observed_at` against `subagent_context_capture_since` + separates pre-amendment rows from a live frame that genuinely arrived without + an execution id. **That claim was false and is withdrawn.** + `subagent_context_capture_since` is write-once (AC-24) and was written by the + build that shipped *before* this amendment, so it marks the start of capture, + not the amendment boundary; after AC-33 re-sentinels every legacy row, a + pre-amendment row and a post-amendment empty-`ExecutionID` row are + indistinguishable — both `source = 'live'`, both `observed_at` after + `capture_since`, both `'unknown'`. + + What actually separates them is the third activation key + `subagent_context_execution_since` (AC-24), written by the AC-33 migration. A + `'unknown'` row whose `observed_at` precedes it is a legacy row that never had + an execution dimension; a `'unknown'` row whose `observed_at` follows it is a + live frame that genuinely arrived without an execution id, and that case also + increments the `unknown_execution` counter (AC-31). `source` continues to + separate backfilled from live. Without that third key the distinction is not + recoverable from the database at all, and AC-31's whole purpose — making a + regression that silently stops propagating execution identity *observable* — + would hold only for the lifetime of one process's expvar counters. +- `observed_at`, `settled_at`, `updated_at` are UTC wall-clock instants from the + backend process (`time.Now().UTC()`), at the same precision as the sibling + session tables. +- `task_id` is required (NOT NULL). WHEN a frame carries no task id, the row is + not written and AC-2's `skipped_no_identity` path applies — a session-scoped + row with no card cannot be joined to anything the feature exists to answer. +- Reported metrics are stored verbatim within `int64`; there is no upper clamp. + The largest observed `total_tokens` is 275,717. + +--- + +## Acceptance criteria + +EARS-style. *(observability claim narrowed in revision 2.)* Each criterion is +verifiable, on one of three surfaces, named here so a builder does not hunt for a +database-observable outcome that a given criterion does not have: + +- **Data** — most criteria: the outcome is a query against + `task_session_subagents`, `task_session_messages`, or `kandev_meta`, or the value + of an exported counter. +- **Process** — AC-1a, AC-20, AC-27, AC-33a: the outcome is a `WARN` log line, boot + completing rather than aborting, or the enclosing message write surviving. A test + asserts these with a log observer or by completing a boot; they are not queries. +- **Structural** — AC-14, AC-19, AC-23a, AC-23b, and *(revision 3)* AC-24d's + evaluation-order clause, AC-24e, and AC-33b's second paragraph: these constrain *how* + the implementation is built (a single atomic upsert with no read-then-write; + `internal/db` error classification rather than string matching; one unbounded + `INSERT … SELECT`; the dialect-aware JSON helpers; the `MAX` evaluated no later than + the insert; a key parsed and bound as a timestamp rather than compared as a string; an + end state observed from the live schema rather than inferred from a `fired` boolean) + rather than what the resulting + data looks like. Two implementations can produce identical rows and counters while + only one satisfies them, which is the point: each rules out a mechanism that is + correct today and fragile under change. + +Revision 1 claimed every criterion was "observable from the database or from a +logged/exported counter without reading source", which was not true of the eight +above — four of them cannot be observed from data at all. **AC-25 is deliberately +excluded from this taxonomy**: it is published consumer semantics, a rule for +readers of this store rather than a behaviour of it, and the database can expose +provenance but cannot prove a consumer disclosed it. + +*(revision 4)* **The same exclusion covers the consumer-facing clauses revisions 1–3 +added, which were left inside the taxonomy by oversight.** Specifically: **AC-24b** in its +entirety; the *"Meaning, and what a consumer SHALL do"* column of **AC-24c**'s four-state +table; and **AC-24f**'s consumer clauses ("a consumer SHALL NOT read it as 'no boundary is +known'", and SHALL NOT treat post-`capture_since` history as unknown fan-out). Each is a +rule for a reader of this store, and this spec ships no reader — § *Out of scope* excludes +every read surface — so none of them is verifiable on any of the three surfaces above, and +a builder hunting a database-observable outcome for them is hunting the thing the taxonomy +exists to prevent. + +What IS observable, and what *Verification* accordingly asserts, is the **state each +criterion classifies**: that the key is present or absent, and that the value written is +the instant or the `''` sentinel. The reading laid on that state is contract for +consumers, exactly as AC-25 is. AC-24's own SHALL-write clauses, AC-24d's atomicity and +AC-24f's "SHALL hold the empty string" remain squarely **Data** and are unaffected. + +### Capture + +**AC-1** *(amended by Amendment 1)* — WHEN the orchestrator observes a tool-call +frame whose normalized kind is `subagent_task`, and a non-empty session id, task +id and `tool_call_id` are all present, THEN a `task_session_subagents` row SHALL +exist for `(task_session_id, agent_execution_id, tool_call_id)` — where +`agent_execution_id` is the frame's execution id or `'unknown'` per AC-31. + +*(revision 1)* WHEN that frame **creates** the row, THEN `source` SHALL be +`'live'` and `observed_at` SHALL be Kandev's observation time for that frame. +WHEN that frame **merges onto a row that already exists** — including a row the +backfill wrote — THEN `source` and `observed_at` SHALL both be left unchanged, +because both are write-once (see *Persistence guarantees*) and record where and +when the row was **first** established, not what most recently touched it. + +This is stated because the two readings are observably different and the earlier +wording implied the wrong one. A live frame merging onto a backfilled row is +reachable: both sides key on the shared `'unknown'` sentinel, so they collide. +Relabelling that row `'live'` would destroy the provenance split AC-25 requires +consumers to disclose, and moving its `observed_at` forward would silently +re-date a measurement. The row keeps `source = 'backfill'` and its original +`observed_at`; the live frame still fills every other column per AC-4 and AC-5. + +**AC-1a** — `observed_at` SHALL be the instant Kandev first observed a frame it +could *recognise* as a subagent, not the instant the subagent began work. For +Claude and OpenCode the initial `tool_call` carries no `rawInput`, so +recognition occurs on a later `tool_call_update`; the column is therefore a +Kandev observation time and SHALL NOT be published as a subagent start time. + +*(revision 3)* **Precisely: `observed_at` is the observation time carried by the frame +that CREATED the row.** Where two frames for one key are processed concurrently, that is +the first frame to **commit**, which under reordering is not necessarily the numerically +earliest observation. `observed_at` SHALL NOT be recomputed as the minimum of the +observations seen, because AC-1 makes it write-once and a merging frame leaves it +unchanged. + +The distinction is worth one sentence because the alternative reading is a test that +fails a correct implementation: "first observed" and "first committed" diverge only under +the concurrency AC-14 and AC-15 already contemplate, and a builder trying to satisfy the +stricter reading would add a `LEAST(observed_at, excluded.observed_at)` to the conflict +clause — which AC-1 forbids in the same breath as it forbids relabelling `source`. The +skew is bounded by how far apart two frames for one tool call can commit, which is +irrelevant at the resolution AC-29's `MAX(observed_at)` and AC-28's `:since` window +actually use. + +**AC-2** — WHEN a subagent frame carries an empty `tool_call_id`, an empty +session id, or an empty task id, THEN the system SHALL NOT write a row, SHALL +increment the `skipped_no_identity` writer counter, and SHALL NOT fail the +enclosing message write or turn. + +*(clarified by Amendment 1)* This skip set is exactly those three fields and +SHALL NOT be extended to `agent_execution_id`. An absent execution id is +handled by AC-31's sentinel, not by dropping the row: the other three fields +are what make a row joinable to anything, whereas a row with no execution +identity is still a correct, attributable record of a fan-out that occurred. +Skipping it would manufacture exactly the "absence of a row is not absence of a +subagent" hazard AC-25 exists to prevent. + +**AC-2a** *(amended by Amendment 1)* — WHEN a `(task_session_id, +agent_execution_id, tool_call_id)` that already has a row is observed again +under a *different* `turn_id`, THEN the existing row SHALL be updated per AC-3 +and its original `turn_id` SHALL be preserved. A second row SHALL NOT be +created, and the fan-out SHALL NOT be re-attributed to the later turn. A frame +bearing a different `agent_execution_id` is a different key and is governed by +AC-32, not by this criterion. + +*(revision 2)* **"First observation wins" governs re-attribution between two +different NON-EMPTY turns. It does not freeze a NULL.** WHEN the stored `turn_id` is +NULL and a later frame for the same key carries a non-empty turn id, THEN the stored +`turn_id` SHALL be set to that value. + +The two readings differ observably and the case is ordinary production behaviour, not +a corner. A NULL `turn_id` records that Kandev could not resolve a turn when it first +recognised the subagent — the orchestrator passes a literal `""` at two of its +`recordSubagentContextFromFrame` call sites, and its turn lookup returns `""` whenever +no turn is active or the peek errors. That is an absence of information, not an +assertion that the fan-out belonged to no turn. Freezing it would leave a real +fan-out permanently unattributable to the turn that spawned it, which is the single +measurement this feature exists to produce. Filling it forward is also what AC-4's +rule implies in mirror image: an absent value never blanks a stored one, and a stored +absence never blocks a real value. + +**AC-3** *(amended by Amendment 1)* — WHEN a later frame for an +already-recorded `(task_session_id, agent_execution_id, tool_call_id)` arrives, +THEN the system SHALL update that same row and SHALL NOT create a second row. + +**AC-4** — WHEN a frame reports a field that the stored row already holds +non-NULL, and the frame's value for that field is absent or empty, THEN the +stored value SHALL be preserved unchanged. (This mirrors `applySubagentResult`, +which never blanks a value already learned from an earlier frame.) + +**AC-5** *(revision 1)* — WHEN a frame reports a non-empty value for a field, +THEN that value SHALL replace the stored value, EXCEPT for the fields enumerated +below, which are governed by their own criteria and SHALL NOT be replaced by this +rule. + +The exception list is **exhaustive and normative**. An earlier draft named AC-9 +as the only exception, which was wrong — at least six other criteria already +override AC-5, and AC-5 is what a builder codes the upsert's `SET` list from. The +fields AC-5 does **not** govern: + +| Field | Governed by | Behaviour | +|---|---|---| +| `total_tokens`, `tool_use_count`, `duration_ms` when the reported value is negative *(revision 3)* | AC-9 | Read as "not reported": stored NULL on creation, and SHALL NOT blank a stored non-NULL value; `anomalous_value`++ either way | +| `total_tokens`, `duration_ms` when the reported value is exactly `0` *(revision 2)* | AC-7a + AC-4 | Read as "not reported"; SHALL NOT blank a stored non-NULL value | +| `turn_id` | AC-2a | First observation wins **between two non-empty turns**; a stored NULL is filled forward *(revision 2)* | +| `tool_status` once terminal | AC-12 | Frozen at the terminal value | +| `settled_at` | AC-11 | Write-once at first terminal observation | +| `source` | AC-1, AC-22, *Persistence guarantees* | Write-once at row creation | +| `observed_at` | AC-1, AC-1a | Write-once at row creation | +| `task_id` *(revision 2)* | AC-1, AC-2 | Write-once at row creation; a denormalised parent identity, never rewritten by a later frame | +| `agent_execution_id` | AC-30, AC-31, AC-32 | Write-once; it is part of the row's identity | +| `is_async` | *Column-level normalization rules* | Sticky: set to 1 on a positive report, never back to 0 | +| every other column, on a frame arriving **after** `settled_at` is set *(revision 3)* | AC-12 | Not an exception: AC-5 continues to govern unchanged. Only `tool_status` and `settled_at` freeze at settlement | + +Every other column — `parent_tool_call_id`, `subagent_type`, `description`, +`agent_id`, `child_session_id`, `model`, `agent_status`, a non-terminal +`tool_status`, and the three metric columns when the reported value is neither +negative nor (for `total_tokens` and `duration_ms`) exactly zero *(revision 2)* — +follows AC-5's replace rule, subject to AC-4 (an absent or empty reported value never +blanks a stored one). + +**AC-6** — WHEN a frame reports a subagent whose `parent_tool_call_id` is +non-empty, THEN the row SHALL be written with that `parent_tool_call_id`, and +SHALL NOT be suppressed for being nested. + +### Nulls, zeroes, and absence + +**AC-7** — WHEN an agent does not report `total_tokens`, `tool_use_count`, or +`duration_ms`, THEN the corresponding column SHALL be NULL. The system SHALL NOT +write `0` for an unreported value. (This is not hypothetical: 190 of 253 +observed rows report none of the three.) + +**AC-7a** *(added by revision 1)* — **How "did not report" is detected**, which +differs by field and is NOT uniform: + +- `tool_use_count` is `ToolUseCount *int` on `streams.SubagentTaskPayload`. A nil + pointer is "not reported" (NULL); a non-nil pointer to 0 is a reported zero + (AC-8). The distinction is carried by the type. +- `total_tokens` and `duration_ms` are `TotalTokens int64` and `DurationMs int64` + on the same struct — **plain values, not pointers**, both `json:"…,omitempty"`. + They cannot express the distinction: an agent that reported 0 and an agent that + reported nothing both arrive as `0`. THEREFORE, for these two fields + specifically, a reported value of exactly `0` SHALL be stored as **NULL**. + +The accepted cost is named rather than hidden: a subagent that genuinely consumed +zero tokens, or completed in under a millisecond, is recorded as unmeasured rather +than as a measured zero. That is the correct direction of error here. 190 of 253 +observed invocations are `async_launched` dispatches that arrive as `0` for all +three fields and are known non-measurements; storing those zeroes would drag every +future token-per-subagent average down by roughly a factor of four (see § *The one +shape a builder must not get wrong*), whereas losing a true zero costs one row's +worth of precision on a quantity that is zero anyway. A builder SHALL NOT "fix" +this by inferring absence from a sibling field, by reading `agent_status`, or by +adding a pointer to the payload — the last is new instrumentation and out of scope. + +*(revision 2)* **A later frame reporting `0` SHALL NOT blank a stored non-NULL +`total_tokens` or `duration_ms`.** Because this criterion defines `0` as "not +reported" for these two fields, such a frame is an *absent* value in AC-4's terms, and +AC-4's rule applies unchanged: an absent value never blanks a value already learned. + +This is spelled out because the chain that establishes it runs through three criteria +and AC-5's exception table calls itself exhaustive — so a builder coding the upsert's +`SET` list from that table alone would write NULL over a real measurement, and AC-12 +explicitly contemplates the later frames that would trigger it. The zero rule is now +a row in that table. The outcome: a completed subagent's token count, once learned, +survives every subsequent frame. + +**AC-8** *(citation corrected in revision 1)* — WHEN an agent reports +`tool_use_count` as exactly `0` — that is, `SubagentTaskPayload.ToolUseCount` is a +non-nil pointer whose value is 0 — THEN the column SHALL be `0`, distinguishable +by query from an unreported count. + +The earlier wording cited "the payload's `ToolUseCountKnown`". That field exists, +but on `SubagentTaskResult` in the ACP adapter, not on +`streams.SubagentTaskPayload` — which contradicted this document's own +§ *The payload as parsed*. The observable outcome is unchanged. + +**AC-9** — WHEN a reported numeric value for `total_tokens`, `tool_use_count`, +or `duration_ms` is negative, THEN the system SHALL store NULL for that field, +SHALL increment the `anomalous_value` writer counter, and SHALL leave every +other field of the frame unaffected. A negative count is not a measurement. + +*(revision 3)* **A negative reported value SHALL NOT blank a stored non-NULL +`total_tokens`, `tool_use_count` or `duration_ms`.** Because this criterion's own +premise is that a negative is not a measurement, such a frame is an *absent* value in +AC-4's terms and AC-4's rule applies unchanged: an absent value never blanks a value +already learned. "Stored NULL" governs the column's value when nothing better is known — +it is not a licence to overwrite something better that is. The counter still increments, +because the frame really did carry a negative and that is what `anomalous_value` counts. + +This is the same rule AC-7a gives the reported zero, and it is spelled out for the same +reason: AC-5's exception table calls itself exhaustive, so a builder coding the upsert's +`SET` list from the row above would write NULL over a real measurement. The two rows now +read alike. It matters because AC-12 explicitly contemplates frames arriving after +settlement, so the destructive frame is an ordinary late frame rather than a corner case: +without this clause one malformed `-1` permanently erases the token count of a completed +subagent, which is the single measurement this feature exists to produce. + +**AC-10** — WHEN a subagent is never observed reaching a terminal `tool_status`, +THEN `agent_status` and `tool_status` SHALL hold their last observed values (or +NULL if none was ever reported) and `settled_at` SHALL be NULL. The row SHALL +still exist. (5 observed rows are in exactly this state.) + +**AC-10a** — WHEN a frame reports an `agent_status` value Kandev does not +recognise, THEN it SHALL be stored verbatim and SHALL NOT affect `settled_at`. +`agent_status` has an open, provider-defined vocabulary. + +### Terminality and ordering + +**AC-11** *(enumeration corrected in revision 1)* — WHEN a frame's **ACP +`tool_status`** is first classified terminal by `isTerminalToolStatus`, THEN +`settled_at` SHALL be set to that observation time and SHALL NOT be modified by +any later frame. + +The terminal set is exactly these **six** values: `complete`, `completed`, +`success`, `error`, `failed`, `cancelled`. An earlier draft enumerated only +`completed`, `failed`, `cancelled`, which was a strict subset of what the cited +function actually accepts — a builder coding that parenthetical would leave a +subagent whose tool status is `success`, `complete`, or `error` permanently +unsettled, with AC-10 then declaring that state correct and hiding the defect. +Where the function and any enumeration in this document disagree, **the function +is authoritative**; the list is reproduced here so the contract is readable, and +a test SHALL pin the two together so a future edit to one fails the other. + +`agent_status` SHALL NOT be used to decide terminality — in particular, +`async_launched` is a Claude *agent* status meaning "dispatched, no usage will +follow", and its own tool call is terminal in the ACP sense, so those rows +settle via `tool_status` like any other. + +**AC-12** — WHEN a non-terminal frame arrives for a row whose `settled_at` is +already set, THEN `settled_at` SHALL remain set, `tool_status` SHALL remain the +terminal value, and the frame MAY still fill columns that are currently NULL. +(Out-of-order and duplicate frame delivery is an observed reality on this +stream.) + +*(revision 3)* **Settlement freezes exactly two columns — `tool_status` and +`settled_at` — and nothing else. Every other column continues to follow AC-5 and AC-4 +after settlement, exactly as before it.** The "MAY still fill columns that are currently +NULL" clause above is a *permission*, granted so a builder does not read the freeze as +covering the whole row; it is not an exhaustive statement of what a post-settlement frame +does, and it does not narrow AC-5. + +Stated because the three criteria could be read two ways and the readings differ +observably on every settled subagent. AC-5's exception table is normative and lists no +post-settlement entry, so `model`, `agent_status`, `description` and the metric columns +replace as usual; AC-14 independently makes the last committed frame win for exactly +those columns. Read narrowly, though, AC-12 alone says a post-settlement frame may only +fill NULLs — under which a subagent that reports its model *after* its tool call settled +would keep a NULL `model` forever. AC-5 and AC-14 govern; this clause removes the +ambiguity rather than changing either. + +**AC-13** *(amended by Amendment 1)* — WHEN subagent contexts are read for a +session or a turn, THEN the order SHALL be +`observed_at ASC, tool_call_id ASC, agent_execution_id ASC`. + +The amendment invalidated this criterion's original justification and the +replacement is not cosmetic. AC-13 previously ordered by +`observed_at ASC, tool_call_id ASC` and defended `tool_call_id` as a total +tiebreak *because* it was unique within the session by construction. Under the +three-column key that is no longer true: two rows in one session may now share a +`tool_call_id` and differ only by execution, so the old ordering is +non-deterministic for exactly the collision this amendment exists to record. +`agent_execution_id` is appended as the final tiebreak, restoring totality — +the triple `(observed_at, tool_call_id, agent_execution_id)` is unique within a +session by construction, since the last two are the non-session components of +the UNIQUE key. + +`id` is a generated UUID and SHALL NOT be used as a tiebreak. + +### Concurrency + +**AC-14** *(amended by Amendment 1)* — WHEN two frames for the same +`(task_session_id, agent_execution_id, tool_call_id)` are processed +concurrently, THEN the write SHALL be a single atomic upsert statement whose +conflict target is those three columns, and exactly one row SHALL result. The +implementation SHALL NOT use a read-then-write sequence for field merging; the +fill-forward and terminality rules are expressed inside the upsert's conflict +clause. + +*(revision 1)* **What is and is not order-independent.** The write-once and +monotonic guarantees hold regardless of commit order: `settled_at` (AC-11), +terminal `tool_status` (AC-12), `turn_id` (AC-2a), `source` and `observed_at` +(AC-1), `agent_execution_id` (AC-30), sticky `is_async`, and AC-4's rule that an +absent value never blanks a stored one. Those are the invariants this AC binds. + +For the remaining columns — the ones AC-5 governs — the **last committed frame +wins**, and that outcome is by definition commit-order dependent. An earlier +draft said the result held "regardless of which frame commits first", which was +not achievable and not intended: two frames each reporting a different non-empty +`model` cannot both win. Last-write-wins is correct here because frames for one +tool call carry progressively more complete data, so the later frame is the +better observation. Stated explicitly so a builder does not attempt to invent an +ordering discipline — a max-of, an observation-time comparison, or a +terminal-frame priority — that this contract does not ask for. + +Two callers on the same row remain a single-row outcome, as before. What +changed is which frames are "the same row": two frames sharing a +`tool_call_id` but differing in `agent_execution_id` are now two independent +inserts that do not conflict, do not merge, and do not order against each other +(AC-15 governs them). + +**AC-15** *(amended by Amendment 1; corrected in revision 1)* — WHEN two frames +whose keys differ in `tool_call_id` **or** in `agent_execution_id` are processed +concurrently (the observed 3-, 4-, 5- and 8-way fan-outs, and the cross-execution +collision of AC-32), THEN both rows SHALL be written, and neither SHALL be +merged into, overwritten by, or lost to the other. + +*(revision 1)* The original wording said "neither SHALL block the other", which +is not satisfiable and is now removed. Kandev's SQLite writer runs on a single +connection (`internal/db/sqlite.go`, `SetMaxOpenConns(1)`), so concurrent writes +**do** serialize by design; blocking is the mechanism, not a defect. The +observable contract is the one stated above: two rows exist afterwards, each +carrying its own frame's data. Nothing in this criterion should be read as a +requirement about write parallelism. + +**AC-16** — WHEN the parent session row is deleted, THEN its +`task_session_subagents` rows SHALL be removed by the foreign key cascade, +consistent with `task_session_messages` and `task_session_turns`. + +### Migration + +**AC-17** — WHEN `runMigrations()` runs against a database that predates this +feature, THEN the table, its indexes and its constraints SHALL be created, and +existing data SHALL be unaffected. + +**AC-18** — WHEN `runMigrations()` is invoked twice in succession against the +same database, THEN the second invocation SHALL succeed and SHALL leave the +schema and the row set unchanged. (Mirrors +`task_external_id_migration_test.go`: seed a pre-migration row, call +`runMigrations` twice, assert idempotence.) + +**AC-19** — WHEN the migration runs against PostgreSQL, THEN it SHALL apply and +replay with the same result as SQLite, using `internal/db` error classification +rather than local error-string matching, per ADR 0027. + +**AC-20** — WHEN a statement in this migration fails, THEN — because the +repository's migration runner swallows errors +(`internal/db/migratelog.go:33`) — the failure SHALL be observable as a `WARN` +log carrying the migration's name, and the writer's health signals (AC-24, +AC-25) SHALL make the resulting absence of rows detectable rather than silent. + +### Backfill + +**AC-21** *(amended by revision 1; sources named in revision 2)* — WHEN the +migration runs, THEN it SHALL insert one row with `source = 'backfill'` for every +existing `task_session_messages` row whose `metadata.normalized.kind` is +`subagent_task` **and whose `task_session_id`, `task_id` and +`metadata.tool_call_id` are all non-empty**. + +*(revision 2)* **Every column's source is named below.** Revision 1 said each column +derives "from `metadata.normalized.subagent_task` and `metadata.tool_call_id`", which +is true of most columns and **false of two** — and in both cases the two readings a +builder could pick produce observably different databases: + +| Target column | Source on `task_session_messages` | +|---|---| +| `task_session_id`, `task_id` | the row's own columns of the same name | +| `turn_id` | *(revision 2)* the row's own **`turn_id` column**, empty string normalized to NULL | +| `tool_call_id` | `metadata.tool_call_id` | +| `parent_tool_call_id` | `metadata.parent_tool_call_id` | +| `agent_status` | `metadata.normalized.subagent_task.status` | +| `tool_status` | *(revision 2)* the **top-level `metadata.status`** | +| `subagent_type`, `description`, `agent_id`, `child_session_id`, `model`, `is_async`, `total_tokens`, `tool_use_count`, `duration_ms` | the same-named fields under `metadata.normalized.subagent_task` | +| `observed_at` | the row's `created_at` column | +| `settled_at` | the row's `updated_at` column, per AC-23a | +| `agent_execution_id` | not derivable; the `'unknown'` sentinel per AC-31 | + +**`turn_id` comes from the message's own column, not from the JSON.** There is no +`turn_id` under either JSON path, so a builder following revision 1 literally finds +nothing and writes NULL for every backfilled row — silently discarding the historical +fan-out-per-turn population, including the 42 three-way turns this document's *Why* +section uses as the entire justification for the feature. The column is populated +from the same turn id the orchestrator resolves when it writes the message, which is +the same value the live path records. + +**`tool_status` comes from the top-level `metadata.status`, and `agent_status` from +`metadata.normalized.subagent_task.status`. They are different fields and must not be +conflated.** This is the distinction § *Two statuses* exists to defend, and on the +backfill path revision 1 left it to a coin flip: the only status the spec named lives +under `normalized.subagent_task`, so one builder maps that onto `tool_status` — +which AC-11 flatly forbids, and which would wrongly settle the ~57 `completed` rows — +while another finds no ACP tool status at all and leaves `tool_status` and +`settled_at` NULL for all 253 rows. The correct source is the top-level key, which +the message writer populates from the frame's ACP tool status: the identical value +`isTerminalToolStatus` classifies on the live path, in the same closed vocabulary +AC-11 enumerates. A builder SHALL NOT substitute `agent_status` for it under any +circumstances. + +**AC-21b** *(added by revision 2)* — WHEN two or more qualifying messages share the +same `(task_session_id, metadata.tool_call_id)`, THEN the backfill SHALL derive the +row from the message with the greatest **`created_at`**, tiebroken by the greatest +**`id`**, and SHALL insert exactly one row for that key. + +Nothing constrains `task_session_messages` to be unique on that pair — `tool_call_id` +lives inside a JSON document, so no database constraint can express it, and this +document's own *Sampled input shapes* notes that the observed uniqueness "is a +property of this sample, not a guarantee." AC-22's `ON CONFLICT DO NOTHING` keeps the +statement from failing but names no winner, and AC-23a mandates a single +`INSERT … SELECT` with no `ORDER BY`. Without a named tiebreak, which message supplies +`observed_at`, `settled_at` and every derived column is non-deterministic between two +correct implementations and between two replays of the same one. Newest-first is the +right direction because a later message carries the more complete tool-call state; +`id` is named as the second key only to make the order total, and both are real +columns on the source table. + +**AC-21a** *(added by revision 1)* — WHEN a qualifying message is missing any of +those three identities, THEN the backfill SHALL skip it: no row, and the +statement SHALL NOT fail. + +The filter is stated explicitly because it is not implied by the column types and +the two readings differ observably. `task_session_messages.task_id` is declared +`TEXT DEFAULT ''` while `task_session_subagents.task_id` is `NOT NULL`, and `''` +satisfies `NOT NULL` in both dialects — so an unfiltered `INSERT … SELECT` would +happily write a `task_id = ''` row rather than failing loudly. Such a row is +exactly what AC-2 refuses on the live path, and for the same reason: a +session-scoped row with no card cannot be joined to anything this feature exists +to answer. The backfill and the live path SHALL agree on which frames are +recordable. These skips are part of AC-28's named allowance on the shortfall +side; they do not increment `skipped_no_identity`, which is a live-path counter. + +**AC-22** *(amended by Amendment 1)* — WHEN the backfill encounters a +`(task_session_id, agent_execution_id, tool_call_id)` that already has a row, +THEN it SHALL leave the existing row untouched (`ON CONFLICT DO NOTHING`), so a +live write always wins over a backfilled one and a replay is a no-op. Because +the backfill always writes the `'unknown'` sentinel (AC-31) and that sentinel is +shared with pre-amendment live rows, a backfill replay conflicts with its own +prior rows and a backfill never duplicates a subagent already captured live +without an execution id. + +WHEN a subagent was captured live **with** a real execution id and the backfill +later derives a row for the same `(task_session_id, tool_call_id)`, THEN the +keys differ and both rows SHALL exist. This is a named, accepted consequence of +execution-scoping rather than an oversight: the two rows are distinguishable by +`source`, AC-25 already forbids comparing `backfill` and `live` rows +like-for-like, and the alternative — matching a backfilled row onto a live row +by a two-column prefix — would reintroduce exactly the cross-execution +clobber this amendment removes. The backfill is a one-time statement, guarded +*(revision 4)* by the presence of **both** backfill keys (AC-24d), so this overlap is +bounded to installations that ran live capture before their backfill. + +**AC-23** — WHEN the backfill derives a value, THEN AC-7 through AC-9 and the +empty-string-to-NULL rule SHALL apply identically: an absent JSON field becomes +NULL, never `0` and never `''`. + +**AC-23a** *(source clarified in revision 2)* — The backfill SHALL be a single +unbounded `INSERT … SELECT` with no `LIMIT` and no batching, and SHALL set +`settled_at` from the message's `updated_at` when the derived `tool_status` is +terminal, else NULL. **"The derived `tool_status`" is the value AC-21 names — the +top-level `metadata.status` — classified against AC-11's six terminal values.** It is +never `agent_status`. It performs +one full scan of `task_session_messages` at first boot after upgrade — a real, +accepted one-time cost on a large store (the reference store is 328 MB), taken +once because a partial or resumable backfill would produce exactly the +mid-series discontinuity AC-25 exists to prevent. + +**AC-23b** — WHEN the backfill runs on PostgreSQL, THEN it SHALL use the +dialect-aware JSON helpers already present in `base_migrations.go` +(`jsonColumn`, `jsonText`), which normalise empty and `'null'` metadata +documents so a single malformed row cannot abort the statement. + +### Activation point and provenance + +**AC-24** *(amended by revision 1)* — WHEN a migration completes **successfully** +for the first time on an installation, THEN `kandev_meta` SHALL hold the +corresponding key below, written once and never overwritten. + +"Successfully" means the statement executed without error — **not** that it was +attempted. This has to be said because the repository's migration runner swallows +errors (AC-20): "the migration ran" and "the migration worked" are different +facts on this codebase, and only the second one may publish an activation point. + +| Key | Written by | Value | +|---|---|---| +| `subagent_context_capture_since` | the AC-21 backfill migration | An instant sampled **inside** the backfill's transaction, at or after the AC-21 `INSERT` and before the key writes (point 4 below), from which `live` capture is authoritative. | +| `subagent_context_backfill_through` | the AC-21 backfill migration | The `created_at` of the newest message **matching AC-21's predicate at that same instant**, or the empty string when no message matched. | +| `subagent_context_execution_since` *(added by revision 1)* | the AC-33b end-state check | The instant at which the AC-33 end state was **observed to hold**, i.e. the point from which `agent_execution_id` carries a real execution identity on the live path. | + +**Five things about that table are load-bearing.** Points 1–3 *(revision 2)* were wrong or +absent in revision 1; points 4–5 *(revision 4)* concern the key as a **written value** — +where its instant comes from, and what happens to one that already exists. + +**1. `backfill_through` is derived from the SOURCE PREDICATE, not from what one +attempt inserted.** Revision 1 defined it as "the newest message the backfill +*inserted a row for*". That is wrong on a retry, and AC-24a makes retries a required +behaviour: the second run's `ON CONFLICT DO NOTHING` (AC-22) inserts nothing, so the +literal reading publishes the **empty string** — which AC-25 tells consumers means +*nothing before this point has known fan-out*, discarding a backfill that in fact +succeeded. Deriving it from the predicate instead is correct on the first run and on +every retry, and it is self-healing: whatever subset a prior attempt managed to +insert, the key still names the true high-water mark of what the backfill covers. + +**2. Both keys are written only if the INSERT succeeded, and neither is written if +the other cannot be** (AC-24d). AC-24a already forbids writing a key after a failed +backfill; revision 1 did not say the three writes relate to each other at all. + +**3. The keys carry nanosecond precision, and the comparison is defined at +equality.** All three values SHALL be RFC3339 with nanosecond precision +(`time.RFC3339Nano`), not second precision. They are compared against `observed_at`, +which is at the sibling session tables' sub-second precision, so a second-precision +key makes up to a second's worth of rows unclassifiable — and under AC-25 and AC-31 +each such row is either a harmless legacy row or a live regression, which is not a +distinction to leave to rounding. The comparison is: a row is **at or after** a key +when `observed_at >= key`, and **before** it when `observed_at < key`. Equality +therefore lands on the *after* side, deterministically, in every criterion that +classifies rows by these instants. + +*(revision 3)* Nanosecond precision is a requirement on the **value written**, and the +shipped pre-amendment writer does not meet it: `subagent_context_capture_since` is +written `time.Now().UTC().Format(time.RFC3339)` and `subagent_context_backfill_through` +via the repository's `rfc3339Timestamp` helper, whose SQLite form is +`strftime('%Y-%m-%dT%H:%M:%SZ', …)` — **both second precision**. `rfc3339Timestamp` is +the helper a builder reaches for and it cannot express this criterion; a builder SHALL +NOT use it for these keys. Precision alone is not sufficient, however — see AC-24e, +which governs how the value is compared, and without which the precision here is +decoration. + +**4. `capture_since` is SAMPLED; it is not read off the commit** *(revision 4)*. The +commit instant is unobtainable from inside the transaction AC-24d requires — it is known +only *after* commit, when an atomic key write is no longer possible — so this criterion +cannot mean the literal commit, and revision 3 left a builder to invent which nearby +instant it did mean. It means this: `capture_since` SHALL be an instant sampled by the +backend process **inside the backfill's transaction, at or after the AC-21 +`INSERT … SELECT` completes and before the key writes**. It SHALL NOT be sampled at +transaction start. + +The direction matters, and it is the **opposite** of `backfill_through`'s. `capture_since` +publishes "from here on, absence of a row is absence of a subagent" (AC-25), so an instant +that is too EARLY over-claims completeness across a window nothing covered, while one that +is too LATE merely leaves a window disclaimed — which AC-25 already handles and keeps the +rows for. Sampling at transaction start therefore over-claims by the entire scan: AC-23a's +full scan of a 328 MB store, so seconds or minutes, not microseconds. Sampling after the +`INSERT` bounds the over-claim to the commit itself, and that residual is accepted and +disclosed here rather than engineered away, because closing it would mean writing the key +outside the transaction and giving up AC-24d. + +**5. A key already present is accepted at whatever precision it carries** *(revision 4)*. +Point 3's precision requirement binds the key writes **this build performs**. The +pre-amendment build already activated some installations, writing `capture_since` via +`time.Now().UTC().Format(time.RFC3339)` and `backfill_through` via `rfc3339Timestamp` — +both second precision, both measured. Those keys SHALL NOT be rewritten, repaired, or +"upgraded": they are write-once (§ *Persistence guarantees*), and moving a published +boundary is a worse failure than the precision it would fix, because a consumer that +already read the old value has no way to learn it changed. The residual on such an +installation is up to one second of boundary ambiguity, in the direction AC-25 already +governs, and it is a **disclosed limitation, not a defect to close**. *Verification* scopes +the precision assertion to keys this build writes, for the same reason. + +The four combinations these keys can present in are enumerated in AC-24c. The empty +value `backfill_through` takes when nothing matched is governed by AC-24f. + +**AC-24a** *(added by revision 1)* — WHEN the AC-21 backfill statement fails, +THEN NEITHER `subagent_context_capture_since` NOR +`subagent_context_backfill_through` SHALL be written, the failure SHALL be +logged at `WARN` per AC-20, and the backfill SHALL be attempted again on the +next boot. + +Writing an activation key after a failed backfill is specifically forbidden, and +it is forbidden for two compounding reasons rather than one. First, the key +publishes a recovery boundary that never happened: AC-25 tells consumers that +activity after `subagent_context_backfill_through` has known fan-out, so the key +would assert coverage over exactly the window it exists to disclaim. Second, the +backfill keys are *also* the guard that makes the backfill one-time (AC-23a) — writing +one after a failure permanently suppresses the retry, so the gap can never close on its +own. The runner's swallow-plus-`WARN` contract means a failure does not abort boot; it +does **not** mean the failure is invisible to the key write. + +*(revision 4)* This is why AC-24d makes the guard **both** keys rather than +`capture_since` alone. Under the single-key guard the hazard above is not merely +hypothetical: the pre-amendment build writes `capture_since` in its own statement, so a +`backfill_through` write that fails afterwards leaves the retry suppressed forever by a key +that was written honestly. The two-key guard closes that without weakening this criterion — +neither key is written after a *failed insert*, and a pair that is nonetheless incomplete is +completed on the next boot. + +**AC-24b** *(added by revision 1)* — WHEN `subagent_context_execution_since` is +absent while `subagent_context_capture_since` is present, THEN a consumer SHALL +read that as "this installation captures subagent contexts but has not +successfully applied Amendment 1", and SHALL NOT interpret any +`agent_execution_id` value on that installation as an execution identity. This is +the durable, queryable signal that the amended shape is not in place (AC-33a). + +*(revision 2)* Read precisely, the key's absence means **the AC-33 end state was not +observed to hold** — not merely that a rebuild did not fire. Those were conflated in +revision 1, and the conflation is what made this criterion fire on healthy +installations: a fresh database reaches the end state with no rebuild at all, so +under the old reading it was permanently and wrongly declared un-amended. AC-33b +now writes the key off the observed end state, which is what makes this criterion's +absence-reading true. + +**AC-24c** *(added by revision 2)* — **All four states of the two consumer-facing +keys are defined**, because AC-24a and AC-33a can each produce a state revision 1 did +not describe. WHEN a consumer reads `kandev_meta`, THEN: + +| `capture_since` | `execution_since` | Meaning, and what a consumer SHALL do | +|---|---|---| +| present | present | Fully activated. AC-25's disclosure rules apply as written. | +| present | absent | Capture works; Amendment 1 has **not** successfully applied (AC-24b). No `agent_execution_id` on this installation may be read as an execution identity. | +| absent | present | The execution dimension is trustworthy, but the **backfill has not succeeded** (AC-24a) and no coverage boundary exists. The consumer SHALL treat **all** history as unknown fan-out, and the AC-28 health comparison SHALL NOT be run (see AC-28's absent-key branch). The backfill retries on the next boot. | +| absent | absent | The feature has not successfully activated on this installation. Rows MAY exist from live capture, and each one is a valid measurement, but **no** absence is evidence of anything and no boundary may be published. | + +A consumer SHALL NOT infer any of these states from the presence or absence of rows. +The keys are the signal precisely because rows can exist in all four states. + +*(revision 3)* **`subagent_context_backfill_through` is deliberately not a dimension of +this table.** AC-24d binds it to `capture_since` — both are written or neither is — so it +adds no state beyond the two columns above. *(revision 4)* That binding holds for pairs +this build writes; a partial pair predating it **is** reachable on the installed base, and +AC-24d **repairs** it on the next boot rather than adding it here as a fifth state a +consumer would have to branch on. A consumer therefore still sees only these four. What it *does* add is a value dimension: when +present it is either an instant or the empty-string sentinel, and those two readings are +governed by AC-24f. A consumer that has resolved a row of this table still has to branch +on that. + +**AC-24d** *(added by revision 2)* — WHEN the backfill migration runs, THEN the +AC-21 `INSERT … SELECT` and both of its `kandev_meta` key writes SHALL be atomic +with respect to each other: either the insert commits and both keys are written, or +neither key is written. A state in which rows were inserted but only one key exists +SHALL NOT be an allowed outcome. + +This is stated because revision 1 required each key not to be written after a +failure (AC-24a) but never related the three writes to one another, and the partial +state is the one that corrupts a published boundary rather than merely delaying it. +Note that atomicity alone would not have been sufficient: even with all three writes +in one transaction, a retry recomputes `backfill_through`, which is why AC-24 also +derives it from the source predicate rather than from the rows one attempt inserted. + +*(revision 3)* **The `MAX` that derives `backfill_through` SHALL be evaluated over the +same snapshot as the AC-21 `INSERT … SELECT`, or before it — never after it.** Atomicity +is not the same guarantee as a shared snapshot: on PostgreSQL's default READ COMMITTED +each statement in a transaction takes its own snapshot, so a message committed between +the insert and the `MAX` advances the published high-water mark past a row the backfill +never inserted. The key would then assert coverage over exactly the window it exists to +disclaim, and AC-25 tells consumers that activity after it has known fan-out. The +asymmetry is what makes the rule cheap: evaluating the `MAX` first can only +**under-claim** — the key names an instant the backfill definitely covered, and AC-25's +guarantee stays true — whereas evaluating it afterwards can over-claim, which is +unrecoverable because the key is write-once. The shipped pre-amendment writer takes it +afterwards, as a third independent `r.migrate.Apply` with no enclosing transaction, so +this is a change to real behaviour and not a restatement. + +*(revision 4)* **A partial key pair that predates this rule is REPAIRED by the activation +guard, not left standing.** This criterion forbids the state going forward, but it says +nothing about the installed base — and the pre-amendment build makes the state reachable +there today. It applies the `INSERT` and the two key writes as three independent +`r.migrate.Apply` calls with no enclosing transaction, and it guards re-runs on +`subagent_context_capture_since` **alone** (`subagentContextBackfillActivated`, +`base_migrations.go`). So an installation can already hold exactly one of the two, and the +`capture_since`-only case is permanent: the guard suppresses the retry on every subsequent +boot and `backfill_through` is never written. THEREFORE the backfill's activation guard +SHALL be "**both** backfill keys present", never `capture_since` present. + +That one change repairs both partial states and introduces no new mechanism, because every +part of the repair is already required elsewhere: the guard does not skip; the +`INSERT … SELECT` is a no-op via AC-22's `ON CONFLICT DO NOTHING`; `backfill_through` is +recomputed from AC-21's source predicate rather than from what this attempt inserted +(AC-24 point 1), so it lands on the true high-water mark; and whichever key is already +present is preserved by its own `ON CONFLICT DO NOTHING`, keeping AC-24's write-once. The +cost is one extra full scan on one boot per affected installation, after which both keys +are present and the guard skips exactly as before. Note this is the same self-healing +property AC-24 point 1 was introduced for — it is being applied to a second state rather +than added as a new behaviour. + +**AC-24e** *(added by revision 3)* — **Every comparison between an activation key and a +timestamp column SHALL be a timestamp comparison, not a string comparison.** The key +SHALL be parsed and bound as a timestamp value of the column's type, or both sides SHALL +be normalized to one lexical form before comparing. A raw key string SHALL NOT be +compared against a timestamp column's stored form. This governs every criterion and +query that classifies rows against these instants — AC-25, AC-28, AC-31, and the AC-28 +health queries as written. + +This is a measured defect in the shipped writer, not a hypothetical. The two sides are +in different formats and always have been: + +| Side | Real value | Produced by | +|---|---|---| +| Column (`observed_at`, `created_at`) | `2026-07-30 01:39:37.271235+00:00` | the driver's `TIMESTAMP` storage form — space separator, microseconds, `+00:00` offset | +| Key (`kandev_meta.value`) | `2026-07-30T01:39:37Z` | `time.RFC3339` / `strftime('%Y-%m-%dT%H:%M:%SZ', …)` — `T` separator, `Z` suffix | + +Compared as text, the two agree on the first ten characters and then diverge at the +separator, where `' '` (0x20) sorts before `'T'` (0x54). **The boundary therefore snaps +to midnight of the key's date: every row on that calendar day reads as *before* the key +whatever its actual time.** Evaluated in SQLite against the real formats: + +```sql +SELECT '2026-07-30 01:39:37.271235+00:00' >= '2026-07-30T01:39:37Z'; -- 0 (same instant) +SELECT '2026-07-30 23:59:59.999999+00:00' >= '2026-07-30T00:00:00Z'; -- 0 (23h59m AFTER) +SELECT '2026-07-30 01:39:37.271235+00:00' >= '2026-07-29T23:59:59Z'; -- 1 (previous day) +``` + +Only the date part does any work. That is up to a full day of rows landing on the wrong +side of a boundary whose entire purpose is to separate a harmless legacy row from a live +regression (AC-25, AC-31) — and it silently truncates AC-28's `:since` window by up to a +day, in the direction that hides missing rows. The nanosecond requirement in AC-24 +addresses rounding at the boundary and is necessary, but it is not sufficient and does +not touch this: two values can both carry nanoseconds and still compare wrongly if one is +a string in the other's non-format. + +*(revision 4)* **What the comparison has to ACHIEVE, and the helper that does not achieve +it.** Revision 3 said what the comparison must not be — a raw string comparison — without +saying what it must reach, which left the obvious mechanism looking compliant. The +comparison SHALL resolve at **no coarser than the compared column's own storage precision, +which is microseconds, and SHALL be monotonic across the whole comparable range.** + +The repository's existing helper does not meet that bar, and it is precisely the one a +builder reaches for here, exactly as `rfc3339Timestamp` was on the write side: +`timestampColumn` (`base_migrations.go`) renders SQLite's side as `julianday()`, a +double-precision day count whose ULP at this epoch is roughly 40 µs, so two values tens of +microseconds apart compare **equal**. A builder SHALL NOT use `julianday` for these +comparisons. Normalizing both sides to one fixed-width lexical UTC form, or binding the key +as a native timestamp on PostgreSQL, both clear the bar; beyond that the mechanism is +Build's. *Verification* requires a one-microsecond boundary assertion specifically because +the same-day test above passes against a `julianday` implementation, and a criterion whose +own test cannot fail the mechanism it forbids is not yet a criterion. + +**AC-24's RFC3339Nano requirement is a FLOOR, not a claim that the extra digits are +reachable.** A key carrying sub-microsecond digits never compares exactly equal to a +microsecond column value, so AC-24's "equality lands on the *after* side" convention +applies vacuously to such a key. That is harmless — the boundary is still exact to the +microsecond, which is the precision the data actually has — and it is not a licence to +write a coarser key. + +**AC-24f** *(added by revision 3)* — **WHEN no message matched AC-21's predicate, THEN +`subagent_context_backfill_through` SHALL hold the empty string, which is a sentinel and +not a timestamp.** It is explicitly exempt from AC-24's RFC3339Nano requirement, SHALL +NOT be parsed or compared as an instant under AC-24e, and SHALL be read by consumers as: +*the backfill matched nothing, so no pre-capture history is claimed as covered and none +needs to be — there was none to recover.* A consumer SHALL NOT read it as "no boundary is +known" and SHALL NOT, on the strength of it, treat post-`capture_since` history as +unknown fan-out. + +This has to be stated because it is the state of **every fresh installation** — the +majority of installations this feature will ever run on — and revision 2 left it to a +coin flip. AC-24 mandates the empty string and the shipped statement already produces it +via `COALESCE(…, '')`, while AC-24's own next paragraph requires all three values to be +RFC3339Nano, which `''` is not. AC-25's consumer rule is keyed on "activity that predates +`subagent_context_backfill_through`" and had no branch for it: compared lexically every +timestamp exceeds `''`, so one consumer concludes all history has known fan-out and +another concludes no boundary exists and distrusts everything. Those are opposite +readings of the same store. `capture_since` remains the boundary that matters on such an +installation, exactly as AC-25 already says. + +**AC-25** — The published contract for consumers SHALL be: a session whose +activity predates `subagent_context_backfill_through` has **unknown** fan-out, +not zero fan-out; rows with `source = 'backfill'` are limited to what survived +in message metadata and SHALL NOT be compared like-for-like against `live` +rows without disclosing the provenance split. Absence of a row is never +evidence of absence of a subagent before `subagent_context_capture_since`. + +*(extended by Amendment 1)* The same rule governs the execution dimension: a row +whose `agent_execution_id` is `'unknown'` asserts that its execution is +**unrecorded**, never that it is shared. Consumers SHALL NOT group or count +distinct executions by that column without excluding the sentinel, and SHALL NOT +read two `'unknown'` rows as belonging to one execution. + +*(revision 1)* The execution dimension has its own activation point, and it is +**not** `subagent_context_capture_since`. A row whose `observed_at` precedes +`subagent_context_execution_since` (AC-24) predates Amendment 1 and has unknown +execution identity by construction; a `'unknown'` row after that instant is a +live frame that genuinely arrived without one. Consumers comparing execution +counts across that boundary SHALL disclose the split, exactly as AC-25 already +requires for the `backfill`/`live` split. Where +`subagent_context_execution_since` is absent entirely, AC-24b applies and no +`agent_execution_id` on that installation may be read as an execution identity. + +*(revision 4)* **"By construction" above governs the `'unknown'` population only. A +NON-SENTINEL `agent_execution_id` is always a real execution identity, whichever side of +`subagent_context_execution_since` its row falls on**, and a consumer SHALL NOT discard or +distrust one for preceding the key. + +The qualification is required because revision 3's own AC-33b introduced a state that +falsifies the unqualified reading. A boot can commit the AC-33 rebuild and then fail to +write the key — the two are not bound the way AC-24d binds the backfill pair, and the +runner swallows the error (AC-20) — so every row written across the remainder of that +boot's uptime carries a genuine UUID while preceding the key the *next* boot writes. Under +the unqualified reading a consumer would treat those real execution ids as unknown; under +revision 2's rule two paragraphs above ("SHALL NOT discard, distrust, or exclude rows +merely for preceding a key") it would keep them. Two competent consumers, opposite queries, +over exactly the population the key exists to classify. + +This is the same completeness-not-validity distinction revision 2 already drew, applied to +the execution dimension: `subagent_context_execution_since` bounds **when the `'unknown'` +sentinel stops meaning "this installation had no execution dimension"**, and nothing more. +It never bounds the validity of a value that is present. The self-heal window therefore +under-claims — some genuinely-identified rows sit before the key — in exactly the direction +AC-24d's `MAX`-first rule chooses for the same reason. + +*(revision 2)* **These keys bound COMPLETENESS, never VALIDITY.** A row observed +before an activation key is still a real, correct measurement of a fan-out that +happened; what the key withholds is the guarantee that *every* such fan-out has a +row. Consumers SHALL NOT discard, distrust, or exclude rows merely for preceding a +key. + +This has to be said because AC-24a makes a window of exactly that shape reachable +and the earlier wording invited the wrong reading. When a backfill fails and retries +on a later boot, `subagent_context_capture_since` is stamped at the instant the +retry succeeded, so every row the **live** path captured between the failed boot and +that retry precedes the key — correct rows, on the wrong side of a boundary that +means "coverage begins here." Under the withheld-completeness reading they are kept +and counted, and only completeness is disclaimed for that window. Under the +discard-them reading a working writer's output is thrown away because a migration +was slow to succeed. The first reading is the contract. + +### Writer health + +**AC-26** *(amended by Amendment 1; unit defined in revision 1)* — The system +SHALL expose counters, under the existing `expvar` convention used by `routing_*` +and `subproc_*`, for: `attempted`, `persisted`, `skipped_no_identity`, +`anomalous_value`, `failed`, and `unknown_execution` (AC-31). + +**The counting unit is one observed frame, not one row and not one SQL +statement.** Every recognized `subagent_task` frame that reaches the writer +increments exactly one of `attempted`'s outcomes, and a frame that merges onto an +existing row increments `persisted` exactly as a frame that creates one does. +Stated because the unit is otherwise ambiguous and the two readings differ by +roughly the fan-out's frame count: + +- `attempted` = frames that reached the writer carrying a recognized payload. +- `skipped_no_identity` = of those, the ones rejected by AC-2 before any SQL ran. +- `persisted` = of those, the ones whose upsert committed. **This counts writes, + not rows**, so `persisted` is expected to exceed `COUNT(*)` on the table by a + wide margin (each subagent is typically observed on several frames), and the + two SHALL NOT be reconciled against each other. AC-28's row-count comparison is + the reconciliation; the counters are not. +- `failed` = of those, the ones whose upsert returned an error (AC-27). +- `anomalous_value` = frames carrying at least one negative metric (AC-9). A + qualifier, not an outcome. *(revision 2)* It is counted only for frames that + reached the write, i.e. **not** for frames AC-2 skipped: AC-2 rejects on identity + before any metric is examined, so a skipped frame increments + `skipped_no_identity` and nothing else, whatever its payload contained. +- `unknown_execution` = live frames written with the `'unknown'` sentinel + (AC-31). A qualifier, not an outcome. + +So `attempted` = `skipped_no_identity` + `persisted` + `failed`, exactly, while +`anomalous_value` and `unknown_execution` overlap the others freely. + +**AC-27** — WHEN a persist attempt fails, THEN the system SHALL log at `WARN` +with the session id and tool-call id, SHALL increment the `failed` counter, and +SHALL NOT fail the enclosing message write, the turn, or the agent stream. +Telemetry never breaks the product path. + +**AC-28** — The expected-versus-observed health check SHALL be answerable from +the database alone, with no counter access, as: + +*(queries corrected in revision 1 — they now carry the activation predicate the +prose always required, and the PostgreSQL form)* + +> ***(revision 3) Which formulation is normative.*** This criterion carries three +> renderings of the same idea, accumulated across revisions, and only one of them is the +> contract. **The two directed anti-joins in *Two directions, never netted* are +> normative** — those are the queries to implement, test and alarm on. The two `COUNT` +> pairs immediately below establish *what* is being compared (distinct +> `(session, tool_call_id)` keys on each side, scoped by `:since`) and are retained as +> the explanation; the filtered expected count later in this criterion shows *which* +> messages qualify, and its predicate is carried verbatim into the shortfall anti-join. +> A builder implementing the `COUNT` pair as the check has implemented a signed +> difference, which revision 3 exists to replace — see the banking argument below. +> Stated explicitly because a criterion that shows three queries and marks none of them +> authoritative is exactly the defect the stale *Failure modes* row was. +> +> ***(revision 4) Two consequences of that hierarchy.*** First, **only the normative +> anti-joins carry the `msg` CTE**; the explanatory queries below are written unwrapped for +> readability. That is a presentation choice, not an exemption — any query from this +> criterion that is actually EXECUTED SHALL normalize `metadata` the way the anti-joins do, +> because a `''` metadata row raises on both dialects (see *Two directions, never netted*). +> Second, **what Build ships for this criterion is the two anti-joins as executable queries +> exercised by tests on both dialects** — that is the surface AC-28a's "SHALL NOT be run" +> branch and AC-24c's key-state assertions act on. No production endpoint, scheduled check +> or alerting integration is in scope; wiring a monitor to the shortfall query is a +> deployment concern, and § *Out of scope* already excludes a read API. + +```sql +-- SQLite. :since is kandev_meta.subagent_context_capture_since. +-- Both sides count DISTINCT (session, tool_call_id) keys (revision 2). +SELECT COUNT(DISTINCT task_session_id || ':' || json_extract(metadata,'$.tool_call_id')) + FROM task_session_messages + WHERE json_extract(metadata,'$.normalized.kind') = 'subagent_task' + AND created_at >= :since; +-- versus (revision 2: DISTINCT on the execution-agnostic key, see below) +SELECT COUNT(DISTINCT task_session_id || ':' || tool_call_id) + FROM task_session_subagents + WHERE observed_at >= :since; +``` + +```sql +-- PostgreSQL: same two counts, dialect-appropriate JSON extraction. +SELECT COUNT(DISTINCT task_session_id || ':' || (metadata::jsonb #>> '{tool_call_id}')) + FROM task_session_messages + WHERE metadata::jsonb #>> '{normalized,kind}' = 'subagent_task' + AND created_at >= :since; +-- versus (revision 2: DISTINCT on the execution-agnostic key, see below) +SELECT COUNT(DISTINCT task_session_id || ':' || tool_call_id) + FROM task_session_subagents + WHERE observed_at >= :since; +``` + +The `:since` predicate is part of the contract, not decoration. Without it the +comparison spans history the backfill could not see — sessions deleted before the +upgrade left no message row and no subagent row, but sessions deleted *between* +the two counts, and messages whose sessions cascaded away, skew the sides +differently — so an unscoped comparison drifts permanently and reads as writer +failure. AC-19's dialect-parity expectation applies to this check as much as to +the migration: `json_extract` does not exist on PostgreSQL, so a SQLite-only +health query is a health query that cannot run on half the supported deployments. + +*(amended by Amendment 1; comparison corrected in revision 2; direction split in +revision 3)* These two counts SHALL **agree** for activity at or after +`subagent_context_capture_since`, up to the named allowances below. + +*(revision 3)* **The two directions SHALL be measured separately, as directed set +differences, and SHALL NOT be reduced to one signed number.** The actionable signal is +the SHORTFALL set — expected keys with no matching row — and it is the only one that +alarms. The EXCESS set is attributed and does not alarm. See *Two directions, never +netted* below for the queries and the reason. + +**Why the observed side counts DISTINCT keys rather than rows.** Tool-call +*messages* are looked up by `(session_id, tool_call_id)` only +(`getToolCallMessageWithRetry`, `internal/task/service/service_messages.go:483`), so +a cross-execution collision updates one message row, while this table correctly holds +one row **per execution** (AC-32). Counting rows therefore makes the two sides +structurally incomparable, and Amendment 1 is what introduced the asymmetry. + +Revision 1 handled that by relaxing the check to +`COUNT(task_session_subagents) >= COUNT(messages)` and treating only a shortfall as +evidence of a stopped writer. *(revision 2)* **That relaxation was unsound and is +replaced.** Every cross-execution collision banks one row of permanent headroom, and +headroom absorbs later missed writes: with N banked rows the writer can stop dead and +the comparison stays "healthy" for the next N subagent messages. AC-29 promises the +comparison diverges when the writer stops while message writes continue, and under +the `>=` rule that promise was false in exactly the configuration Amendment 1 exists +to create — the hole growing in proportion to how often the defect being recorded +actually occurs. + +Collapsing the observed side to `COUNT(DISTINCT task_session_id || ':' || +tool_call_id)` removes the excess from the comparison at the source: one subagent +counts once however many executions recorded it, which is exactly one per message +row. Equality is restored as the expectation, a shortfall regains its meaning, and +AC-29 becomes true again. The `':'` separator is not required for correctness — both +operands are fixed-length UUIDs and cannot collide — but it costs nothing and removes +the question permanently. + +**Both sides count distinct keys, not rows, and the symmetry is required.** The +message side can over-count for the mirror-image reason: nothing constrains +`task_session_messages` to be unique on `(task_session_id, tool_call_id)` (AC-21b), +so two messages can describe one subagent. If only the observed side were collapsed, +every such duplicate would present as a permanent one-row shortfall on a perfectly +healthy writer — the same false-alarm failure that got the `>=` relaxation introduced +in the first place. Counting distinct keys on both sides makes the comparison +symmetric: each side answers "how many distinct subagents does this table know +about", and those are the numbers that must agree. + +#### Two directions, never netted *(revision 3)* + +Revision 2 restored equality as the expectation but named allowances only on the +shortfall side, leaving what a persistent **excess** means undefined. It is not an +academic state: AC-27 deliberately keeps the subagent write independent of the message +write, so a failed message write with a successful upsert leaves a permanent +1; and +AC-1a puts recognition on a later `tool_call_update`, so a message created just before +`capture_since` whose recognizing frame lands just after it counts on one side only. +Under a bare "SHALL agree" such an installation reads unhealthy forever, and a check that +fires on correct behaviour gets muted — the exact failure the `>=` relaxation was +reaching for, arriving from the other direction. + +**The fix is not to tolerate excess.** Tolerating it as slack in a single signed +difference would reintroduce SR19's banking hole verbatim: with N banked excess keys the +writer could stop dead and the difference would read zero for the next N subagents. +Instead the comparison is split, so neither direction can cancel the other: + +```sql +-- SQLite. :since is kandev_meta.subagent_context_capture_since, bound as a timestamp +-- (AC-24e). The `msg` CTE is AC-23b's normalization: it maps NULL / '' / 'null' metadata +-- to '{}' so one legacy row cannot abort the scan (revision 4). The CTE is byte-identical +-- on PostgreSQL; only the extraction changes (AC-19). +WITH msg AS ( + SELECT m.task_session_id, m.task_id, m.created_at, + (CASE WHEN m.metadata IS NULL OR m.metadata = '' OR m.metadata = 'null' + THEN '{}' ELSE m.metadata END) AS meta + FROM task_session_messages m +) +-- SHORTFALL — expected keys with no subagent row. THIS is the alarm. +SELECT COUNT(*) FROM ( + SELECT msg.task_session_id, json_extract(msg.meta,'$.tool_call_id') AS tool_call_id + FROM msg + WHERE json_extract(msg.meta,'$.normalized.kind') = 'subagent_task' + AND msg.created_at >= :since + AND COALESCE(msg.task_session_id,'') <> '' + AND COALESCE(msg.task_id,'') <> '' + AND COALESCE(json_extract(msg.meta,'$.tool_call_id'),'') <> '' + GROUP BY 1, 2 + EXCEPT + SELECT s.task_session_id, s.tool_call_id + FROM task_session_subagents s + WHERE s.observed_at >= :since +) AS shortfall; + +-- EXCESS — subagent keys with no accounting message. Attributed, does NOT alarm. +WITH msg AS ( + SELECT m.task_session_id, m.created_at, + (CASE WHEN m.metadata IS NULL OR m.metadata = '' OR m.metadata = 'null' + THEN '{}' ELSE m.metadata END) AS meta + FROM task_session_messages m +) +SELECT COUNT(*) FROM ( + SELECT s.task_session_id, s.tool_call_id + FROM task_session_subagents s + WHERE s.observed_at >= :since + GROUP BY 1, 2 + EXCEPT + SELECT msg.task_session_id, json_extract(msg.meta,'$.tool_call_id') + FROM msg + WHERE json_extract(msg.meta,'$.normalized.kind') = 'subagent_task' + AND msg.created_at >= :since +) AS excess; +``` + +`EXCEPT` is set-based and deduplicates on both dialects, so this subsumes revision 2's +distinct-key collapse rather than replacing it: a cross-execution collision (AC-32) +contributes one key however many rows recorded it, and two messages describing one +subagent (AC-21b) contribute one key however many messages exist. The PostgreSQL form is +identical with `msg.meta::jsonb #>> '{tool_call_id}'` and `#>> '{normalized,kind}'` +substituted for `json_extract`, per AC-19. + +*(revision 4)* **Three things about the SQL above are load-bearing and were wrong or +unstated before.** + +**The derived tables are ALIASED, and they have to be.** PostgreSQL rejects a subquery in +`FROM` without an alias, so revision 3's `SELECT COUNT(*) FROM ( … EXCEPT … );` did not +parse on PostgreSQL at all — while the surrounding prose claimed the PostgreSQL form was +"identical with the extraction substituted", which was therefore false of the normative +queries themselves. `AS shortfall` / `AS excess` are accepted by both dialects, so the +identical-modulo-extraction claim above is now true rather than aspirational. + +**Every `metadata` reference goes through the `msg` CTE, for exactly AC-23b's reason.** The +repository writes `NULL`, `''` and `'null'` for "no metadata" — that is what `jsonColumn` +(`base_migrations.go`) exists to absorb — and an unwrapped extraction raises on such a row: +`json_extract('')` is malformed JSON on SQLite and `''::jsonb` errors on PostgreSQL. A +single legacy row would therefore disable the health check on either dialect. That is a +worse failure than any it detects, because the check whose job is noticing that this writer +stopped would itself stop, silently, in the same direction. The CTE is the same +normalization AC-23b already requires of the backfill, applied to the same rows. + +**The excess direction's message half deliberately does NOT carry the three identity +predicates the shortfall half carries.** This asymmetry is intentional, not an oversight. +The shortfall side asks "which messages should have produced a row", so it must exclude +exactly what AC-2 and AC-21a exclude. The excess side asks the opposite question — "is +there any message that accounts for this row" — so it must subtract the **widest** message +set available. Filtering it would report excess for a subagent row whose message exists but +whose own `task_id` column is empty for an unrelated reason, which is a real row correctly +written from a frame that did carry a task id. A builder SHALL NOT mirror the filter onto +the excess side for symmetry's sake. + +**A non-zero SHORTFALL is evidence of a stopped or failing writer and is the check.** +A non-zero EXCESS is expected on any installation that has taken a message-write failure +or straddled the activation boundary; it SHALL be reported separately for attribution and +SHALL NOT be raised as writer failure, and it can never mask a shortfall because it is +counted in the other direction. Cross-execution collisions no longer appear in either +direction at all, which is what makes AC-29's guarantee unconditional. + +Where a deployment wants the collision count itself — rows minus distinct subagents, the +quantity revision 2 published — it remains available and is orthogonal to both directions +above: + +```sql +-- Cross-execution collisions (AC-32). Expected > 0 wherever a tool_call_id was reused. +SELECT COUNT(*) - COUNT(DISTINCT task_session_id || ':' || tool_call_id) + FROM task_session_subagents + WHERE observed_at >= :since; +``` + +**AC-28a** *(added by revision 2)* — WHEN `subagent_context_capture_since` is +**absent**, THEN the AC-28 comparison SHALL NOT be run, and the key's absence SHALL +itself be the health signal, read per AC-24c. + +This branch has to exist because revision 1 created the state and left the query +undefined in it. AC-20 promises that a failed migration is made detectable by the +health signals; AC-24a requires `capture_since` to be **absent** after a failed +backfill; and AC-28's own prose disowns the unscoped comparison as one that "drifts +permanently and reads as writer failure." So in precisely the failure state AC-20 +points at, the prescribed query had no value to bind to `:since` and either could not +run or had to fall back to the form the spec rejects. The resolution is that the +detection in that state is *the missing key*, not a shortfall — which is a stronger +signal anyway, since it is present from the first boot after the failure rather than +accumulating. + +**AC-28b** *(added by revision 2)* — WHEN a shortfall is observed, THEN it SHALL be +confirmed by a second evaluation before being treated as evidence of a failing +writer, OR both counts SHALL be taken within a single read transaction. + +AC-28's claim that a shortfall means a stopped writer "and nothing else" is not true +of two independently-executed statements against a live store: rows and messages are +written continuously and a session cascade-deleting between the two counts skews them +differently, so a healthy writer can present a transient shortfall. + +*(revision 3)* The anti-join form largely retires this hazard rather than merely +mitigating it: each direction is now a **single** statement, so both of its sides are read +under one snapshot and the two-statement skew this criterion was written against cannot +arise within a direction. The confirm-on-second-evaluation rule is retained for the +residual case — a session cascade-deleting mid-statement, and any deployment whose +monitor still evaluates the two directions separately — but a builder using the queries as +written satisfies it by construction with the single-statement reading. A check that +fires on correct behaviour gets muted, and a muted check is the same as no check — +which is the outcome this feature exists to prevent. + +*(revision 1)* **The shortfall side needs its own allowance, for the mirror-image +reason.** A frame carrying a session id but no task id writes its message and is +then correctly skipped by AC-2, so it produces a permanent, benign shortfall that +is indistinguishable from a dead writer if the expected count is taken naively. +The expected count SHALL therefore exclude what AC-2 and AC-21a exclude, which is +answerable from the database alone: + +```sql +-- SQLite: expected count, AC-2 skips removed. Same shape on PostgreSQL with +-- metadata::jsonb #>> '{...}' substituted for json_extract. +SELECT COUNT(DISTINCT task_session_id || ':' || json_extract(metadata,'$.tool_call_id')) + FROM task_session_messages + WHERE json_extract(metadata,'$.normalized.kind') = 'subagent_task' + AND created_at >= :since + AND COALESCE(task_session_id,'') <> '' + AND COALESCE(task_id,'') <> '' + AND COALESCE(json_extract(metadata,'$.tool_call_id'),'') <> ''; +``` + +With that expected count, a shortfall is evidence of a stopped or failing writer +and nothing else, which is what makes the check actionable. A health check that +fires on correct behaviour gets muted, and a muted check is the same as no check — +which is the outcome this feature exists to prevent. + +The message count remains a valid independent expectation **because the message +write is +load-bearing for the UI**: a broken message write is noticed by a human within +one turn, whereas a broken context write is noticed by nobody. That asymmetry +is what makes the comparison a real check rather than two readings of the same +failure. + +**AC-29** *(scope corrected in revision 2)* — WHEN the writer has stopped while +message writes continue, THEN the AC-28 comparison SHALL diverge, and the divergence +SHALL be attributable in time via `MAX(observed_at)` on `task_session_subagents`. No +separate heartbeat row is written, precisely so that the health signal cannot itself +be the thing that silently stops. + +*(revision 2)* This guarantee is made **against AC-28's distinct-key count**, and it +holds only because of that. Under revision 1's `>=` comparison it was false: banked +cross-execution excess absorbed the first N missed writes, so a stopped writer +produced no divergence at all until the backlog was consumed. The distinct-key count +does not accumulate excess, so the first missed subagent is the first divergence. + +*(revision 3)* **"Diverge" means the SHORTFALL direction becomes non-zero** — the first +anti-join in *Two directions, never netted*. Naming the direction is what makes this +guarantee unconditional: because the shortfall is a directed set difference rather than a +signed count, no amount of banked excess from any source (cross-execution collisions, a +failed message write, an activation-boundary straddle) can offset it, so the first missed +subagent is the first divergence on every installation rather than only on one with no +excess. A monitor SHALL alarm on the shortfall query, not on the equality of two counts. +This criterion is the reason AC-28's comparison changed, rather than the other way +round — the requirement that a stopped writer be noticed is the fixed point, and the +query is what had to move. + +*(revision 4)* **The attribution half is scoped, and its empty result has a defined +meaning.** The `MAX(observed_at)` SHALL be taken over rows **at or after +`subagent_context_capture_since`** — the same `:since` and the same timestamp comparison the +shortfall query uses (AC-24e) — not over the whole table. Unscoped it returns the newest of +whatever exists, so on an installation whose only rows are backfilled or legacy it reports +an instant that never established live-writer health at all, and reads as a healthy writer. + +WHEN that scoped `MAX` returns **NULL** — no row has been observed since activation — THEN +it SHALL be read as *"the writer has produced nothing since this installation activated"*, +which is the strongest available divergence signal rather than an absence of information. +It SHALL NOT be reported as "no divergence", "unknown", or a healthy state. This is the +reachable case where the very first post-activation subagent is the one missed, and it is +exactly the case a naive `MAX` reports most reassuringly. + +### Execution identity *(added by Amendment 1)* + +**AC-30** — WHEN the orchestrator records a subagent context from a frame whose +`ExecutionID` is non-empty, THEN the row's `agent_execution_id` SHALL be that +value verbatim, and the row SHALL be keyed on `(task_session_id, +agent_execution_id, tool_call_id)`. + +**AC-31** — WHEN a subagent context is written and no execution identity is +available — the frame's `ExecutionID` is empty, or the write is the backfill, +whose source rows carry no execution identity — THEN `agent_execution_id` SHALL +be the reserved literal `'unknown'`. It SHALL NOT be NULL, SHALL NOT be the +empty string, and SHALL NOT be omitted from the insert. WHEN this occurs on the +**live** path specifically, the system SHALL additionally increment an +`unknown_execution` writer counter alongside the AC-26 counters, so that a +regression which silently stops propagating execution identity is observable +rather than merely absorbed by the sentinel. + +`unknown_execution` is **additive, not exclusive**: a live frame with an empty +`ExecutionID` still increments `attempted` and, on success, `persisted`, exactly +as any other write does. It is a qualifier on a write that happened, not an +alternative outcome like `skipped_no_identity`. Stated because the counter set +otherwise reads as mutually exclusive outcomes, and a builder choosing the other +reading would break the `attempted` = `skipped_no_identity` + `persisted` + +`failed` identity that AC-26 requires. + +*(revision 1)* Because expvar counters reset with the process, the counter is not +the durable half of this signal. `subagent_context_execution_since` (AC-24) is: +a `'unknown'` row observed **after** that instant is a live frame that arrived +without an execution id, and one observed before it is a legacy row that never +had the dimension. The counter tells an operator that it is happening now; the +key lets a consumer tell which population a row belongs to months later. + +This mirrors AC-7's NULL-not-zero rigor for the metric columns: there, a +fabricated `0` would corrupt an average; here, a fabricated *shared* identity +would silently re-merge rows the key exists to keep apart. The sentinel is +honest about being unknown, the counter makes "unknown" countable, and AC-25's +disclosure rule extends to it — a `'unknown'` row asserts that the execution is +unrecorded, never that the rows share an execution. + +**AC-32** — WHEN two executions in the same session emit the same +`tool_call_id`, and a frame from the earlier execution arrives **after** the +later execution's row exists, THEN the earlier frame SHALL create or update its +own row keyed on its own `agent_execution_id`, and the later execution's row +SHALL be unchanged in every column — including `tool_status`, `settled_at`, and +the three metric columns. + +This is the defect Amendment 1 exists to close, so it is stated as an +observable outcome and not only as a key change. It is reachable in production: +late frames from completed executions are recorded rather than dropped +(`event_handlers_streaming.go:325-333`), and `tool_call_id` uniqueness is only +per-execution (*Execution identity, as sampled*). Verification requires a +regression test at the repository layer driving exactly this interleaving. + +**AC-33** *(amended by revision 1)* — WHEN `runMigrations()` runs against a +database whose `task_session_subagents` table predates Amendment 1, THEN after +the migration the table SHALL have: + +1. the `agent_execution_id` column, NOT NULL; +2. the three-column `UNIQUE(task_session_id, agent_execution_id, tool_call_id)`; +3. **no** remaining two-column `UNIQUE(task_session_id, tool_call_id)`; +4. all three secondary indexes — on `task_session_id`, `task_id`, `turn_id` — + present, matching the fresh-database DDL exactly *(revision 1)*; +5. every pre-existing row surviving with all column values intact, timestamps + included, and `agent_execution_id` set to `'unknown'`; +6. `subagent_context_execution_since` written per AC-24. *(revision 2)* The key is + **not** conditional on this criterion's WHEN: AC-33b writes it on every + installation where the key-bearing clauses are observed to hold, including those + where no rebuild was ever needed. *(revision 3)* Those are clauses 1–3; clauses 4 + and 5 remain required outcomes of this criterion but do not gate the key write. + +**This end state SHALL hold on PostgreSQL as well as SQLite** *(revision 1)*. +The dialect scope is stated because the two mechanisms are genuinely different +and the earlier draft's pointer covered only one of them: + +- On **SQLite**, the two-column key is an inline table constraint + (`base_schema.go`), which SQLite cannot alter in place, so the table must be + rebuilt. `recreateTableNamed` is the repository's pattern for that. +- On **PostgreSQL**, `recreateTable` returns `(false, nil)` immediately — it is + SQLite-only by construction. A migration wired only through it therefore + **silently no-ops on PostgreSQL**: no error, no `WARN`, not even a "migration + applied" line, leaving the two-column key in place. PostgreSQL needs its own + path, which drops the auto-named constraint by looking it up in `pg_constraint` + by its column set (names truncate at 63 bytes and cannot be hardcoded) and adds + the new one. **The repository already owns this exact shape** — add a column and + widen a two-column UNIQUE to three columns, on both dialects — in + `migrateTaskEnvironmentReposAllowMultiBranch` and its `…Postgres` twin + (`base_migrations.go`). That is the precedent to follow, not the two + `recreateTableNamed` call sites that merely drop a column. + +The mechanism beyond that is Build's to choose. Fresh databases SHALL obtain the +same shape from the schema-init DDL. Per the repository's migration rules, the +column and the key change SHALL be introduced by an idempotent migration and +SHALL NOT rely on `CREATE TABLE IF NOT EXISTS` alone, which is a no-op on an +existing database. + +**AC-33a** *(added by revision 1)* — WHEN the AC-33 migration fails or does not +apply, THEN boot SHALL NOT abort, the failure SHALL be logged at `WARN`, +`subagent_context_execution_since` SHALL NOT be written (AC-24b), the table SHALL +be left in its pre-amendment shape rather than a partial one, and the migration +SHALL be attempted again on the next boot. + +This is the branch the earlier draft left to a coin flip, and the two guesses had +opposite consequences, so it is settled here rather than in Build. Not aborting +boot is the correct half: this is a telemetry table, and AC-27's principle — +telemetry never breaks the product path — does not stop being true because the +failure happened in a migration rather than a write. The rebuild runs inside a +transaction, so a failure rolls back and the pre-amendment table survives intact; +a half-migrated table is not an allowed outcome. + +The resulting degraded state is fully specified and **must not be silent**: the +amended upsert's three-column conflict target cannot match the surviving +two-column constraint, so every live write fails, and each one increments +`failed` and logs at `WARN` per AC-27. That state is therefore detectable three +independent ways — the missing `subagent_context_execution_since` key (AC-24b), +a climbing `failed` counter, and the AC-28 shortfall — which is the standard this +feature is held to: if this writer stops, something notices. + +**AC-33b** *(added by revision 2; observation scope fixed in revision 3)* — WHEN +`runMigrations()` completes a pass in which the **key-bearing clauses** of the AC-33 end +state hold, THEN `subagent_context_execution_since` SHALL be written per AC-24 if absent, +and left unchanged if already present. This applies regardless of *how* the end state came +to hold: whether a rebuild applied it in this pass, an earlier boot applied it, or the +schema-init DDL created the table already-amended and no rebuild was ever needed. + +*(revision 3)* **The key-bearing clauses are AC-33's clauses 1, 2 and 3, and only those:** +the `agent_execution_id` column exists and is NOT NULL; the three-column +`UNIQUE(task_session_id, agent_execution_id, tool_call_id)` exists; and no two-column +`UNIQUE(task_session_id, tool_call_id)` remains. Revision 2 wrote "clauses 1–5", which is +not a decidable condition and left a builder to invent one: **clause 5 is not observable +at all** — it asserts that rows which existed *before* this pass survived intact, and a +migration holds no record of the pre-state to compare against — while clause 4 (the index +set matches the fresh-database DDL) is observable but is not what this key attests to. + +Clauses 4 and 5 remain **required outcomes** of AC-33 with their own verification; they +are simply not preconditions of this key write. The split is principled rather than +convenient: `subagent_context_execution_since` publishes exactly one fact — that +`agent_execution_id` on this installation carries a real execution identity (AC-24b) — +and clauses 1–3 are precisely the schema facts that make that true. A missing secondary +index is a parity and performance defect, not a reason to tell every consumer to discard +the execution dimension. + +**The migration SHALL determine this by OBSERVING the live schema** — querying for +the `agent_execution_id` column and the constraint set — and SHALL NOT infer it from +whether a rebuild helper reported that it fired. `recreateTable` returns +`(false, nil)` in three materially different situations: the table does not exist, +the trigger phrase is absent because the shape is *already correct*, and the dialect +is PostgreSQL. A `fired` boolean therefore cannot separate "already correct" from +"did nothing and is still wrong", and gating a durable published key on it is gating +it on a value that does not carry the fact. + +This criterion exists because revision 1 attached the key to AC-33, whose WHEN is +scoped to a table that "predates Amendment 1" — with the key write sitting inside +that scope as clause 6. Two whole classes of installation never satisfy it: + +- a **brand-new database**, which AC-33 itself says obtains the amended shape from + the schema-init DDL; +- a database that **predates the feature entirely**, where `CREATE TABLE IF NOT + EXISTS` creates the table already-amended, after which it no longer "predates + Amendment 1" either. + +On both, the backfill still succeeded and wrote `capture_since` while +`execution_since` was never written by anything — and AC-24b then instructs every +consumer to read that combination as "has not successfully applied Amendment 1" and +to discard every `agent_execution_id` on the installation. On installations where +every row carries a genuine execution identity. The keys are write-once and no +rebuild will ever fire on them, so the mislabelling was permanent, and it applied to +the majority of installations this feature will ever run on. It inverted the durable +half of revision 1's own detection story. + +*(revision 3)* **There is a fourth state, and it is the one that makes schema observation +load-bearing rather than stylistic: the key write itself failing after a successful +rebuild.** Nothing binds `subagent_context_execution_since` to the AC-33 rebuild the way +AC-24d binds the two backfill keys to their insert, and the runner swallows errors +(AC-20). So boot 1 can commit the rebuild and then fail to write the key, leaving an +installation whose schema is fully amended and whose key is absent — which AC-24b reads as +"has not successfully applied Amendment 1". Boot 2 SHALL self-heal it: the end state holds, +no rebuild fires, no `CREATE TABLE` fires, and the key SHALL be written anyway. + +This is the state that separates a conforming implementation from one gated on +"rebuild fired **or** the table was created this pass" — that gate satisfies every other +case in this criterion and fails only this one. It is therefore the case a test has to +construct, which is why *Verification* now requires it explicitly rather than leaving it +implied by the SHALL-observe sentence. + +**AC-33c** *(added by revision 2)* — WHEN `runMigrations()` runs, THEN the AC-33 +shape change and the AC-33b key write SHALL be attempted **before** the AC-21 +backfill within the same pass. AND WHEN the AC-33 end state does not hold, THEN the +backfill SHALL NOT run, SHALL NOT write either of its keys, and SHALL be attempted +again on the next boot (AC-24a). + +The ordering is a contract, not an implementation detail, because the other order is +a permanent failure rather than a transient one. AC-31 requires the backfill's INSERT +to carry `agent_execution_id` and forbids omitting it. Consider an installation that +took the pre-amendment table but whose backfill *failed* — so `capture_since` is +absent and AC-24a requires a retry — and which then upgrades to the amended binary. +If the retried backfill ran first, its INSERT would reference a column AC-33 has not +yet added, the statement would fail, `capture_since` would stay unwritten, and the +identical sequence would repeat on every subsequent boot. AC-24a's retry contract is +precisely what would make that failure permanent instead of self-correcting. + +**AC-34** *(amended by revision 1)* — WHEN `runMigrations()` is invoked twice in +succession against a database migrated by AC-33, THEN the second invocation SHALL +succeed, SHALL leave the schema and the row set unchanged, and SHALL NOT +duplicate, drop, or re-sentinel any row. + +This SHALL hold on PostgreSQL as well as SQLite, per AC-19 and ADR 0027 — and on +**both** dialects the assertion SHALL be that AC-33's six-part end state is +present after each invocation, not merely that the second invocation changed +nothing since the first. *(revision 1)* An unchanged-only assertion is satisfied +vacuously by a migration that no-ops on PostgreSQL and never applied at all, +which is precisely the failure this criterion needs to catch. + +--- + +## Failure modes + +| Condition | Behaviour | +|---|---| +| Frame with no `tool_call_id` or no session id | No row; `skipped_no_identity`++; product path unaffected (AC-2). | +| Upsert returns an error | `WARN` + `failed`++; product path unaffected (AC-27). | +| Negative reported metric | That field NULL; `anomalous_value`++; other fields written (AC-9). | +| Frames arrive out of order | Terminality is monotonic; NULLs may still fill (AC-11, AC-12). | +| Duplicate frames | Upsert; exactly one row (AC-3). | +| Parent session deleted mid-turn | FK cascade removes rows; no orphan (AC-16). | +| Migration statement fails | Swallowed by the runner; surfaced as `WARN` + detectable via AC-28 (AC-20). Boot never aborts. | +| **Backfill statement fails** | No activation key written; `WARN`; retried next boot (AC-24a). Never publishes a boundary it did not reach. | +| **AC-33 rebuild fails or does not apply** | Pre-amendment table survives whole (transactional rollback); `WARN`; `subagent_context_execution_since` absent; retried next boot (AC-33a). | +| **AC-33 rebuild silently no-ops on PostgreSQL** | Prevented by AC-33's dialect scope and AC-34's end-state assertion; if it still occurs it presents exactly as the row above (AC-24b). | +| Live writes rejected because the rebuild left the two-column key | Every write `failed`++ and `WARN` per AC-27; product path unaffected; detectable three ways (AC-33a). | +| Backfill re-runs | `ON CONFLICT DO NOTHING`; no duplicates, no clobber (AC-22). | +| Agent reports an `agent_status` Kandev does not recognise | Stored verbatim; never affects `settled_at` (AC-10a). | +| Frame carries no task id | No row; `skipped_no_identity`++ (AC-2). | +| Same `tool_call_id` observed under a later turn | Existing row updated; original `turn_id` preserved (AC-2a). | +| A turn row is deleted | Subagent rows survive; `turn_id` has no FK, by design. | +| Same `tool_call_id` reused by a **later execution** | Two rows, one per execution; neither clobbers the other (AC-32). | +| Late frame from a completed execution after a newer execution reused its `tool_call_id` | Updates only its own execution's row; the newer row is untouched (AC-32). | +| Live frame carries an empty `ExecutionID` | Row written with `agent_execution_id = 'unknown'`; `unknown_execution`++ (AC-31). Never skipped — absence would be indistinguishable from no fan-out. | +| Backfill derives a row (source rows have no execution identity) | `agent_execution_id = 'unknown'` (AC-31); replay is a no-op via the shared sentinel (AC-22). | +| Backfill overlaps a subagent already captured live with a real execution id | Both rows exist, distinguishable by `source`; named and accepted (AC-22). | +| An execution row is deleted from `executors_running` | Subagent rows survive; `agent_execution_id` has no FK, by design. | +| Cross-execution collision *(revision 3)* | Does **not** affect the AC-28 comparison at all: both directions are `EXCEPT` set differences over `(session, tool_call_id)`, so one subagent contributes one key however many executions recorded it. Attributed by the separate collision query (AC-28, AC-32). | +| AC-2 skip deflates the AC-28 count | Expected; the expected-count query excludes the same rows, so the shortfall stays actionable (AC-28, revision 1). | +| **Two executions BOTH arrive with an empty `ExecutionID` and reuse one `tool_call_id`** | They share the `'unknown'` key and merge into one row (AC-3) — the pre-amendment clobber, still reachable when execution identity is absent from both frames. Accepted residual, not a defect to design around: the sentinel cannot manufacture a distinction the frames did not carry, and `unknown_execution` plus `subagent_context_execution_since` make the population countable and datable (AC-31). | +| Backfill row later merged onto by a live frame | Row keeps `source = 'backfill'` and its original `observed_at`; all other columns fill forward (AC-1, revision 1). | +| A message with an empty `task_id` is backfilled | Skipped, matching AC-2's live-path rule (AC-21a). | +| **Fresh database, or one predating the feature entirely** | No rebuild fires — the schema-init DDL already produced the amended shape — but the end state is observed and `subagent_context_execution_since` is written anyway (AC-33b). The installation is never mislabelled un-amended. | +| **Backfill retried after a partial failure** | `backfill_through` is recomputed from the AC-21 predicate, not from the rows this attempt inserted, so a no-op replay still publishes the true high-water mark rather than `''` (AC-24). | +| **`capture_since` absent** | The AC-28 comparison is not run; the missing key is itself the health signal, read per AC-24c (AC-28a). | +| **Live rows captured between a failed backfill and its successful retry** | Kept and counted. They precede `capture_since`, so completeness is disclaimed for that window, but validity is not (AC-25, revision 2). | +| **Two messages share one `(task_session_id, tool_call_id)`** | Greatest `created_at`, tiebroken by greatest `id`, supplies the row; exactly one row results (AC-21b). | +| **Writer stops after cross-execution collisions banked excess rows** | Divergence is immediate: the health comparison counts distinct `(session, tool_call_id)` keys, not rows, so excess cannot absorb a missed write (AC-28, AC-29). | +| **Two messages describe one subagent (AC-21b), inflating the expected side** | No false shortfall: both sides of the AC-28 comparison count distinct keys, so the duplicate collapses on the expected side exactly as it does on the observed side (AC-28). | +| **Backfill would otherwise run before the AC-33 shape change** | Prevented by AC-33c: the shape change goes first, and the backfill does not run against a stale shape. Without it, an install whose earlier backfill failed would fail identically on every boot forever. | +| **First frame resolves no turn, a later frame carries one** | `turn_id` is filled forward; "first observation wins" applies only between two non-empty turns (AC-2a, revision 2). | +| **Later frame reports `total_tokens` or `duration_ms` as `0` after a real value was learned** | The stored value is preserved — `0` means "not reported" for these two fields, so AC-4 applies (AC-7a, revision 2). | +| **Later frame reports a NEGATIVE metric after a real value was learned** *(revision 3)* | The stored value is preserved; `anomalous_value`++ still fires. A negative is not a measurement, so AC-4 applies exactly as it does to the reported zero (AC-9). | +| **An activation key is compared against a timestamp column as a raw string** *(revision 3)* | Forbidden. The boundary would snap to midnight of the key's date and misclassify up to a day of rows. The key is parsed and bound as a timestamp (AC-24e). | +| **`backfill_through` is the empty string** *(revision 3)* | The fresh-install default. A sentinel, not an instant: the backfill matched nothing, so no pre-capture history is claimed and none needs to be. Never parsed as a timestamp (AC-24f). | +| **Persistent observed-side EXCESS on a healthy writer** *(revision 3)* | Expected and reportable, never an alarm — reachable from a failed message write (AC-27 decouples the two) or an activation-boundary straddle (AC-1a). It cannot mask a shortfall because the two directions are separate anti-joins (AC-28). | +| **`MAX(created_at)` evaluated after the backfill INSERT** *(revision 3)* | Forbidden. A message committed between the two would publish coverage the backfill never reached, and the key is write-once. Evaluate it over the insert's snapshot or before it (AC-24d). | +| **Non-terminal frame arrives after `settled_at` is set** *(revision 3)* | `tool_status` and `settled_at` stay frozen; every other column follows AC-5 and AC-4 unchanged, so a late-reported `model` is still recorded (AC-12). | +| **Two frames for one key commit in reverse observation order** *(revision 3)* | `observed_at` is the creating (first-committed) frame's observation time. It is not recomputed to the earliest observation, because AC-1 makes it write-once (AC-1a). | +| **AC-33 rebuild commits but the `execution_since` key write fails** *(revision 3)* | Schema amended, key absent — an installation AC-24b would mislabel. The next boot observes the end state, fires no rebuild, and writes the key anyway (AC-33b). This is the state a `fired`-gated implementation never recovers from. | +| **`capture_since` sampled at transaction start** *(revision 4)* | Forbidden. It would over-claim completeness across the whole AC-23a scan — seconds or minutes on a large store. Sample inside the transaction, at or after the `INSERT` (AC-24 point 4). The commit instant itself is unobtainable there and is not what the key means. | +| **A row carrying a REAL UUID `agent_execution_id` observed BEFORE `execution_since`** *(revision 4)* | Its execution identity is real and is read as real. `execution_since` dates only the `'unknown'` population; it never bounds the validity of a value that is present (AC-25). Reachable from AC-33b's fourth state, so this is ordinary, not exotic. | +| **An activation key already present at second precision** *(revision 4)* | Accepted as-is and never rewritten — write-once outranks the precision mandate, and moving a published boundary is worse than the second of ambiguity it would fix. A disclosed limitation of already-activated installations (AC-24 point 5). | +| **Exactly one of the two backfill keys present** *(revision 4)* | Repaired, not tolerated. The activation guard is "both keys present", so the backfill re-runs once: the INSERT no-ops via `ON CONFLICT`, the missing key is computed from AC-21's predicate, and the present key is preserved. Reachable today because the pre-amendment build wrote three independent statements and guarded on `capture_since` alone (AC-24d). | +| **`julianday()` used to compare a key against a timestamp column** *(revision 4)* | Forbidden. Its ULP at this epoch is roughly 40 µs, so values tens of microseconds apart compare equal — coarser than the column's own microseconds. `timestampColumn` is the helper that does this and SHALL NOT be used here (AC-24e). | +| **An AC-28 anti-join run on PostgreSQL without a derived-table alias** *(revision 4)* | It does not parse; PostgreSQL requires the alias. `AS shortfall` / `AS excess` are accepted by both dialects (AC-28, AC-19). | +| **A message row whose `metadata` is `''` or `'null'`** *(revision 4)* | Absorbed by the anti-joins' `msg` CTE, exactly as AC-23b absorbs it in the backfill. Unwrapped, one such row raises and disables the health check on either dialect — the check failing silently in the same direction as the writer it watches (AC-28). | +| **Scoped `MAX(observed_at)` returns NULL** *(revision 4)* | Read as "the writer has produced nothing since activation" — the strongest divergence signal, never as healthy or unknown. Reachable when the very first post-activation subagent is the one missed (AC-29). | + +## Persistence guarantees + +- A row, once written, is durable until its parent session is deleted. +- `settled_at` is write-once (AC-11). +- `source` is write-once: a backfilled row is never relabelled `live`, and a + live row is never overwritten by the backfill (AC-22). *(revision 1)* This + holds on the merge path too — a live frame landing on an existing backfilled + row leaves `source` alone (AC-1). +- *(added by revision 1)* `observed_at` is write-once. It records when Kandev + first observed this subagent (AC-1a), so a later frame never moves it forward; + otherwise the column would drift toward "last seen" and AC-29's + `MAX(observed_at)` would stop locating when a writer failure began. +- *(added by Amendment 1)* `agent_execution_id` is write-once. It is part of the + row's identity, so a later frame cannot change it — a frame with a different + execution id addresses a different row (AC-30, AC-32). A row that was written + with the `'unknown'` sentinel is never later "upgraded" to a real execution + id, because that would silently move a row between identities and defeat the + key. +- The **three** `kandev_meta` activation keys are write-once (AC-24), and none is + written unless the work it attests to actually succeeded (AC-24a, AC-33a). + *(revision 2)* For the two backfill keys that means the `INSERT … SELECT` + committed, and both are written or neither is (AC-24d). For + `subagent_context_execution_since` it means the AC-33 end state was **observed to + hold** — not that a rebuild fired, which is a different and much narrower fact + (AC-33b). All three carry nanosecond precision, and every criterion that classifies + a row against one of them uses `observed_at >= key` for "at or after" (AC-24). + *(revision 3)* That comparison is a **timestamp** comparison — the key is parsed and + bound, never compared as a raw string against a column's storage form (AC-24e) — and + `subagent_context_backfill_through` additionally carries a non-timestamp sentinel + state, the empty string, which is exempt from both the precision requirement and the + comparison (AC-24f). `subagent_context_execution_since` is the one key with no + atomicity partner, which is why AC-33b requires the next boot to write it when the + end state already holds. + *(revision 4)* Write-once is the **stronger** invariant wherever it collides with + anything else in this document: a key already present is never rewritten, not to raise + its precision (AC-24 point 5) and not for any other repair. Completing a partial pair + (AC-24d) does not breach this — it writes the key that is **missing** and leaves the one + that is present untouched, which is what `ON CONFLICT DO NOTHING` already guarantees. +- No column in this table participates in any existing rollup + (`task_sessions.cost_subcents`, `tokens_in`, `tokens_out` are untouched). + +## Permissions + +The table is session-scoped and inherits the session's scope. Any read surface +added later MUST authorize via the parent session's task +(`authorizeTaskID(session.TaskID)`), per the backend's session-keyed entry-point +rule. This spec adds no read surface, so it adds no new authorization path. + +--- + +## Out of scope + +Named exclusions. Each is a contract, not an omission. + +- **No `parent_session_id` column on `task_sessions`.** Rejected above. +- **No change to `dim_session` or to "sessions per card".** That series keeps its + current meaning; the new table is a separate, additive fact. +- **No UI surface, no DTO field, no WebSocket event.** The subagent card + continues to render from message metadata. No E2E coverage is implied, + because no user-visible surface changes. +- **No REST/MCP read API.** Consumers read the table directly via the extract. + If a product surface is wanted later, it is a separate spec with its own + authorization design. +- **No merging of OpenCode's child ACP session.** `child_session_id` is stored; + resolving it into the parent transcript is a separate feature. +- **No reading of Claude's async `outputFile`.** The 190 `async_launched` rows + stay usage-less by design; inventing their usage is out of scope, and + approximating it is forbidden by AC-7. +- **No cost or dollar attribution.** Tokens are as-reported, unpriced, + unaggregated into any existing cost column. +- **No `prompt` or `result_text` column.** Structure, not content. +- **No recursive subagent-tree API.** `parent_tool_call_id` is recorded (AC-6); + walking it is a consumer concern. +- **No retention or pruning policy beyond the FK cascade.** The table grows with + message history and is bounded by the same session lifetime. +- **No retroactive repair of rows the backfill could not see.** Sessions already + deleted are gone; AC-25 requires disclosing that, not fixing it. + +*(added by Amendment 1)* + +- **No foreign key from `agent_execution_id` to any execution table.** Executions + are removed from `executors_running` when they stop; an FK would delete the + measurement when the measured thing ended. Same reasoning as `turn_id`. +- **No retroactive resolution of `'unknown'` execution ids.** A sentinel row is + never later upgraded to a real execution id, not by a subsequent frame and not + by a repair job. The value is part of the row's identity, and rewriting it + would move a row between identities — which is the class of silent mutation + this amendment removes. +- **No de-duplication of cross-execution `tool_call_id` collisions.** Two rows + differing only by execution are the correct outcome (AC-32), not a defect to + collapse. Any consumer wanting an execution-agnostic count derives it with + `COUNT(DISTINCT task_session_id || tool_call_id)` rather than having it + imposed on the store. +- **No `agent_execution_id` on `task_session_messages`.** The backfill's + sentinel is accepted instead. Adding execution identity to the message table + is a separate change with its own migration and its own backfill problem, and + nothing in this feature needs it. + +## Verification + +- Repository tests alongside `task_external_id_migration_test.go`: fresh-DB + create, pre-migration seeded row, `runMigrations` twice, backfill idempotence, + and the NULL-not-zero assertions for an `async_launched`-shaped payload. +- The same fresh-plus-replay matrix under `KANDEV_TEST_POSTGRES_DSN` (AC-19). +- Orchestrator tests for the frame paths: first-frame insert, later-frame merge, + out-of-order terminal, nested `parent_tool_call_id`, empty-identity skip + (each of the three missing ids), same-`tool_call_id`-later-turn, negative + metric, reported-zero tool count, empty-string-to-NULL, and concurrent + same-key upsert. +- A query-level check of AC-28 against a seeded store. + +*(added by Amendment 1)* Additionally: + +- **The cross-execution regression test (AC-32), which is the point of the + amendment.** Seed a row for `(session S, execution B, tool_call_id T)` + carrying a terminal `tool_status`, settled timestamp, and metric values; then + deliver a frame for `(session S, execution A, tool_call_id T)` where A is an + earlier, already-completed execution. Assert two rows exist and that B's row + is byte-identical to its seeded state. A test that only asserts "two rows + exist" does not cover this — the clobber it replaces was an *update*, so the + assertion must be on B's column values. +- The AC-33 rebuild test in the shape `apps/backend/AGENTS.md` requires for + table-rebuild migrations: seed a pre-amendment row (including its timestamps + and NULL metric columns), run `runMigrations` twice, and assert the row + survives with values intact, `agent_execution_id = 'unknown'`, the + three-column key present, and the two-column key gone. +- An AC-31 test that a live frame with an empty `ExecutionID` still writes a + row, stores `'unknown'`, and increments `unknown_execution` — asserting the + row is *written*, since the failure mode being guarded is a silent skip. +- The same fresh-plus-replay matrix for all of the above under + `KANDEV_TEST_POSTGRES_DSN` (AC-19, AC-34). + +*(added by revision 1)* And, because every finding below was a failure or +detection path that no existing test would have caught: + +- **AC-24a — the failed-backfill test.** Force the backfill statement to fail, + then assert that *neither* `subagent_context_capture_since` nor + `subagent_context_backfill_through` exists in `kandev_meta`, and that a second + `runMigrations()` re-attempts the backfill rather than skipping it. Asserting + only "the boot survived" does not cover this — the defect is a key written + after a failure, which is invisible unless the key is the assertion. +- **AC-33a — the failed-rebuild test.** Force the rebuild to fail and assert: + boot did not abort; the pre-amendment table is intact (two-column key still + present, no partial table, no lost rows); `subagent_context_execution_since` is + absent; and the next `runMigrations()` retries. +- **AC-33 clause 4 — the index-set assertion**, comparing a migrated database's + index set against a fresh one's rather than checking the indexes exist. +- **AC-34 on PostgreSQL — assert the end state, not just stability.** A test that + only re-runs and diffs passes against a migration that never applied. This is + the specific check that would have caught `recreateTable`'s PostgreSQL no-op. +- **AC-7a — a reported-zero test for `total_tokens` and `duration_ms`**, asserting + both store NULL, alongside the existing `tool_use_count` reported-zero test + asserting it stores 0. The three columns deliberately do not behave alike, and a + test suite that only covers the pointer field documents the wrong rule. +- **AC-1 on the merge path** — seed a `source = 'backfill'` row, deliver a live + frame on the same key, and assert `source` and `observed_at` are unchanged + while other columns filled forward. +- **AC-11 — a test pinning the spec's six terminal values to + `isTerminalToolStatus`**, so the enumeration in this document cannot drift from + the function again. +- **AC-28 — the health queries as written**, including the `:since` predicate and + the identity filter, executed on both dialects against a seeded store that + contains an AC-2-skipped message, so the query is proven not to false-alarm. + +*(added by revision 2)* And, because each finding this revision closes was a state no +existing test constructs: + +- **AC-33b — the fresh-database activation test.** Create a database from scratch, + run `runMigrations()`, and assert `subagent_context_execution_since` **is present**. + Repeat on a database seeded to predate the feature entirely (no + `task_session_subagents` table at all). Both are the classes revision 1 silently + excluded, and a test that only exercises the pre-amendment-table upgrade path passes + against the broken contract — which is exactly what happened. +- **AC-33b — the negative half.** Assert the key is written when the end state holds + but no rebuild fired. A test that asserts "rebuild fired ⇒ key written" cannot + distinguish the two and would have missed this. +- **AC-33c — the ordering test.** Seed a pre-amendment table with `capture_since` + absent (an installation whose earlier backfill failed), run `runMigrations()`, and + assert the backfill completed and both its keys exist. Under the wrong order this + fails on every invocation, so the test must run `runMigrations()` at least twice and + assert success both times. +- **AC-24 — `backfill_through` across a retry.** Run the backfill to success, clear + only `capture_since`, run again, and assert `backfill_through` is unchanged and + non-empty. The literal revision-1 reading publishes `''` here. +- **AC-24c — the four-state matrix**, one case each, asserting *(revision 4)* which keys + are present and absent in each state — never a row count, and never the consumer reading + laid on that state, which is contract rather than a test target (§ *Acceptance criteria* + preamble). +- **AC-24d — atomicity.** Force the second key write to fail and assert neither key + is present. +- **AC-28a — the absent-key branch.** With `capture_since` absent, assert the health + comparison is not run and the absence is reported as the signal. +- **AC-28/AC-29 — the banked-excess test, which is the point of the comparison + change.** Seed a store containing a cross-execution collision (two rows, one + message), then add subagent messages with the writer disabled, and assert the + comparison diverges on the **first** missed write. Against revision 1's `>=` rule + it does not diverge at all until the excess is consumed, so a test that only checks + "eventually diverges" passes the broken version. +- **AC-28 — the duplicate-message symmetry test.** Seed two qualifying messages + describing one subagent (AC-21b) and assert the comparison shows **no** shortfall. + Collapsing only the observed side reintroduces a permanent false alarm on a healthy + writer, which is the failure the `>=` relaxation was originally reaching for. +- **AC-21 — the two named sources.** Assert a backfilled row's `turn_id` equals the + source message's `turn_id` column (not NULL), and that its `tool_status` comes from + top-level `metadata.status` while `agent_status` comes from + `metadata.normalized.subagent_task.status` — with a case where the two differ, so + conflating them fails. +- **AC-21b — the duplicate-source test.** Seed two qualifying messages sharing one + `(task_session_id, tool_call_id)` with different payloads, and assert exactly one + row results and that it came from the newer `created_at`. +- **AC-2a — `turn_id` fill-forward.** Deliver a first frame with an empty turn id and + a later frame with a real one; assert the row ends attributed to that turn. +- **AC-7a — the later-zero test.** Learn a non-zero `total_tokens`, then deliver a + frame reporting `0`; assert the stored value survives. Same for `duration_ms`. + +*(added by revision 3)* And, because each of these is a state no listed test constructs +and three of them are already wrong in the shipped writer: + +- **AC-24e — the cross-format comparison test, which is the one that would have caught + the live defect.** Write an activation key, then insert a row whose timestamp column is + **later in the same calendar day** than that key, and assert the row is classified *at + or after* the key. A test using a key from a different day passes against the broken + string comparison — the date part alone decides it — so the same-day construction is + mandatory, on both dialects. Assert the same for the AC-28 `:since` window. +- **AC-24e — the precision test.** Assert each of the three keys round-trips at + nanosecond precision, and specifically that the value written is not the second-precision + form `time.RFC3339` and `rfc3339Timestamp` produce. *(revision 4)* This asserts keys + **this build writes**; a key already present from the pre-amendment build is out of its + scope and SHALL NOT be rewritten to satisfy it (AC-24 point 5). +- **AC-24f — the empty-sentinel test.** Run the backfill on a database with no qualifying + message; assert `backfill_through` is `''` and that nothing parses it as an instant. This + is the fresh-install path, so it is also the most-executed one. *(revision 4)* The + consumer-side reading is contract, not a test target (§ *Acceptance criteria* preamble). +- **AC-24d — the snapshot-order test.** Assert `backfill_through` never names an instant + later than the newest message the insert actually covered. Constructing a concurrent + commit is not required: asserting the evaluation order (the `MAX` is taken over the + insert's snapshot or before it) is sufficient and is what the criterion constrains. +- **AC-9 — the later-negative test.** Learn a non-zero `total_tokens`, then deliver a + frame reporting `-1`; assert the stored value survives and `anomalous_value` still + incremented. Pair it with the existing later-zero test so the two rules read alike. +- **AC-28 — the two-direction test.** Seed a store with a persistent excess (a subagent + row whose message write failed) AND then stop the writer while messages continue. + Assert the SHORTFALL query goes non-zero on the **first** missed subagent despite the + standing excess. A test that asserts only "the counts differ" passes against a signed + difference, which is the formulation this replaces. +- **AC-28 — the excess-does-not-alarm test.** With a standing excess and a healthy + writer, assert the shortfall query is zero and the excess query is non-zero, so a + monitor wired to the shortfall does not fire on correct behaviour. +- **AC-33b — the self-heal test, which is the discriminating one.** Seed a database whose + table is **already amended** (clauses 1–3 hold) but whose + `subagent_context_execution_since` is **absent** — the state a committed rebuild plus a + failed key write leaves. Run `runMigrations()` and assert the key appears, with no + rebuild and no table creation having fired. An implementation gated on "rebuild fired or + the table was created this pass" passes every other AC-33b test and fails only this one, + which is precisely why it is required. +- **AC-12 / AC-5 — the post-settlement replace test.** Settle a row, then deliver a + non-terminal frame carrying a different non-empty `model`; assert `model` updated while + `tool_status` and `settled_at` did not. + +*(added by revision 4)* And, because three of these construct states that exist on the +INSTALLED BASE rather than on a fresh database, and two of them fail against queries +revision 3 declared normative: + +- **AC-24e — the one-microsecond boundary test, which is the discriminating one.** Write an + activation key, then insert two rows whose timestamp column is exactly **one microsecond + before** and exactly **at** that key; assert the first classifies *before* and the second + *at or after*. On both dialects. The same-day test above passes against a `julianday` + implementation — its ULP is roughly 40 µs, so it cannot see a 1 µs difference — which is + why the same-day test alone does not establish AC-24e and this one does. +- **AC-24 point 4 — the sampling-order test.** Assert `capture_since` is not earlier than + the instant the AC-21 `INSERT … SELECT` completed. Constructing a slow scan is not + required: asserting the sample is taken at or after the insert, and never at transaction + start, is what the criterion constrains. A test that merely asserts the key exists passes + against a transaction-start sample that over-claims by the whole scan. +- **AC-24 point 5 — the legacy-key test.** Seed `kandev_meta` with a second-precision + `capture_since` and `backfill_through` (the exact values the pre-amendment writer + produces), run `runMigrations()` twice, and assert **both keys are byte-identical + afterwards**. This is the negative of the precision test and they must both hold: an + implementation that "repairs" precision passes the precision test and fails this one. +- **AC-24d — the partial-pair repair test.** Seed a database with `capture_since` present + and `backfill_through` **absent** — the state the pre-amendment build's three independent + `Apply` calls leave — plus qualifying messages. Run `runMigrations()` and assert + `backfill_through` appears, names the true high-water mark of the source predicate rather + than `''`, and that `capture_since` is unchanged. Repeat with the mirror state + (`backfill_through` present, `capture_since` absent). An implementation guarding on + `capture_since` alone never runs at all in the first case, so the assertion must be on the + missing key appearing, not on the boot succeeding. +- **AC-28 — the malformed-metadata test.** Seed a qualifying store that also contains a + message row with `metadata = ''` and one with `metadata = 'null'`, then run both anti-joins + on both dialects and assert they return counts rather than raising. Without the `msg` CTE + this fails on SQLite (`malformed JSON`) and on PostgreSQL (`''::jsonb`), and it fails in + the direction that silently disables the check. +- **AC-28 / AC-19 — the PostgreSQL parse test.** Execute both anti-joins verbatim against + PostgreSQL. Revision 3's form had no derived-table alias and did not parse there at all, + so a SQLite-only execution of these queries is not evidence that they run. +- **AC-29 — the attribution test.** With `capture_since` written and **no** subagent row + observed after it, assert the scoped `MAX(observed_at)` returns NULL and that NULL is + reported as "nothing produced since activation", not as healthy. Seed backfilled and + pre-activation rows in the same store so an unscoped `MAX` would return a non-NULL + instant — that is the implementation this test exists to fail. +- **AC-25 — the self-heal-window classification test.** Seed a row carrying a **real UUID** + `agent_execution_id` whose `observed_at` precedes `subagent_context_execution_since` (the + state AC-33b's fourth case produces), and assert it is classified as carrying a real + execution identity — not folded into the `'unknown'` population by the date comparison. + +### The one shape a builder must not get wrong + +A regression test SHALL assert, on an `async_launched`-shaped payload, that +`total_tokens`, `tool_use_count` and `duration_ms` are all `IS NULL` and none is +`0`. This is 75% of real traffic; a `DEFAULT 0` on any of those three columns +would make every future token-per-subagent average wrong by roughly a factor of +four, silently and permanently, and would do so *after* the activation point, +where AC-25 offers consumers no protection. + +## Implementation notes carried by Amendment 1 (not contract) + +The same PR review raised a second, smaller point. It is recorded here so the +Build round that implements Amendment 1 picks it up in the same diff — it +touches the identical files — but it is **not** an acceptance criterion, because +it constrains no observable behaviour and the spec should not freeze an internal +Go interface shape. + +`orchestrator.SubagentContextRecorder`'s repository-side dependency is declared +as `repository.SubagentContextRepository` +(`internal/task/repository/interface.go:488-495`), which requires three methods +while the service uses only `UpsertSubagentContext`; +`ListSubagentContextsBySession` and `ListSubagentContextsByTurn` have no +production caller (they exist for the read surface this spec places out of +scope). Narrowing the service's dependency to a write-only interface, and +dropping `subagentContextAdapter` +(`internal/backendapp/adapters.go:780-788`) — a pure pass-through, since +`taskservice.Service` already satisfies the recorder interface directly — is +in scope for that round at Build's discretion. Removing the two list methods +from the repository implementation is **not** implied: they are the read path a +future spec would use, and deleting them is a separate decision. + +## Related + +- ADR [0027 — replayable schema migrations](../../decisions/0027-replayable-schema-migrations.md) +- `apps/backend/AGENTS.md` § *Schema & migrations (SQLite repository)* +- `apps/backend/internal/agentctl/AGENTS.md` § *Subagent tool-call nesting: what each agent emits*