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 <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-15 22:58:14 +08:00
committed by GitHub
parent 6b27e67b04
commit 1ae8f4cf9f
37 changed files with 7751 additions and 40 deletions
@@ -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
@@ -143,6 +143,7 @@ func provideOrchestrator(
msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log}
orchestratorSvc.SetMessageCreator(msgCreator)
orchestratorSvc.SetSubagentContextRecorder(&subagentContextAdapter{svc: taskSvc})
orchestratorSvc.SetTurnService(newTurnServiceAdapter(taskSvc))
@@ -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,
@@ -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.
@@ -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
@@ -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")
}
@@ -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) {
@@ -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
}
@@ -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)
}
@@ -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)
@@ -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.
@@ -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 {
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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')"
}
@@ -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)
}
}
@@ -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",
} {
@@ -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,
@@ -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
}
@@ -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")
@@ -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)
}
}
}
@@ -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.
+1
View File
@@ -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 |
@@ -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 <kind> = '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.<c>, task_session_subagents.<c>)` | 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.<c>, task_session_subagents.<c>)` | 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.<c>` 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)*.
@@ -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`.
@@ -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`.
@@ -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`.
@@ -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`.
@@ -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`.
+1
View File
@@ -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 |
File diff suppressed because it is too large Load Diff