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:
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
Reference in New Issue
Block a user