Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
24 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/subagent-context-persistence/spec.md | 2026-08-13 | 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.
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_subagentscarries a newagent_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 incrementsunknown_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 theINSERT ... SELECT, thecapture_sincewrite, and thebackfill_throughwrite, thentx.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.RecordSubagentContextRequestcarriesExecutionID 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:
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):
task_session_subagents.backfill— one unboundedINSERT 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. NoLIMIT, no batching (AC-23a).kandev_meta.subagent_context_capture_since— write-onceINSERT ... ON CONFLICT(key) DO NOTHINGwith anfmt.Sprintf-inlinedtime.Now().UTC().Format(time.RFC3339)literal.kandev_meta.subagent_context_backfill_through— write-once, value from a subselectCOALESCE(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 fortool_call_id,parent_tool_call_id,status, andnormalized.kind.jsonInt(postgres, column, parent, key string) string—CASTof the text extract toBIGINT(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'; SQLitejson_extractyields1. 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:
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 singleINSERT ... 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_idCOALESCE(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_statusCASE WHEN task_session_subagents.settled_at IS NOT NULL THEN task_session_subagents.tool_status ELSE COALESCE(excluded.tool_status, ...) ENDAC-12 — frozen at the terminal value is_asyncCASE WHEN excluded.is_async = 1 THEN 1 ELSE task_session_subagents.is_async ENDsticky, never reset to 0 metrics COALESCE(excluded.<c>, task_session_subagents.<c>)AC-4, AC-7 settled_atCOALESCE(task_session_subagents.settled_at, excluded.settled_at)AC-11 — write-once updated_atexcluded.updated_atsource,observed_at,idabsent 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-qualifiedtask_session_subagents.<c>reads are valid on both. Statement built once as a package-levelconstand passed throughr.db.Rebind, matchingreview.go:27anddocument.go:291. -
ListSubagentContextsBySession(ctx, sessionID string) ([]*models.SubagentContext, error)andListSubagentContextsByTurn(ctx, turnID string)— bothORDER 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:
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, orToolCallID⇒ no write,skipped_no_identity++, return (AC-2). nilIfEmpty(string) *stringapplied to every nullable TEXT field, so""becomes NULL andCOUNT(model)counts models (spec § Column-level normalization rules).- Metric normalization:
DurationMs/TotalTokensmap to*int64and are left nil when zero-or-absent;ToolUseCountmaps to*int64only when the payload pointer is non-nil, preserving a reported0(AC-7, AC-8). Any negative value ⇒ that field nil andanomalous_value++, other fields unaffected (AC-9). - Terminality:
settled_at = &req.ObservedAtonly whenorchestrator-equivalent terminal classification ofreq.ToolStatusis true.agent_statusis stored verbatim and never consulted (AC-10a, AC-11). The classifier is unexported ininternal/orchestrator, so the service gets its ownisTerminalToolStatusover the identical set (complete,completed,success,error,failed,cancelled) with a comment namingevent_handlers_streaming.go:762as the peer, plus a test that pins the two lists together. - Failure handling: repository error ⇒
WARNwith 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:
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 theCreateToolCallMessageblock, usings.getActiveTurnID(payload.SessionID).persistToolUpdateMessage(~line 565) — afterUpdateToolCallMessage, using theturnIDthat function already resolved (peekActiveTurnIDfor terminal frames,getActiveTurnIDotherwise).
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:NewWithDBover a temp SQLite file, then assertidx_subagents_session_id,idx_subagents_task_id,idx_subagents_turn_idexist insqlite_masterand 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', thenrunMigrations(); assert one row each withsource = 'backfill'andobserved_at = m.created_at. (AC-21) async_launchedshape stores NULL, not0— the spec's named must-not-get-wrong regression. How: seed a message whosesubagent_taskobject hasstatus: "async_launched"and nototal_tokens/tool_use_count/duration_ms; assert all three columnsIS NULLand that none is0. (AC-7)- A reported
tool_use_countof0survives as0. (AC-8) - A negative reported metric backfills as NULL. (AC-9, AC-23)
- Empty JSON strings become NULL. How: a payload with
model: ""; assertmodel IS NULL, not''. - Malformed metadata does not abort the statement. How: seed one message row
with
metadata = ''and another withmetadata = '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, runrunMigrations(), assertsourceis still'live'and there is exactly one row. (AC-22) - Rows with no
task_idor notool_call_idare not backfilled. (AC-2) - Activation keys are written once. How: assert both
kandev_metakeys are present and RFC3339-parseable (backfill_throughmay be''), capture their values,runMigrations()again, assert both unchanged. (AC-24) - Fresh DB with no subagent messages sets
backfill_throughto''. (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_atis write-once: a second terminal frame with a later timestamp leaves the first value. (AC-11)- A non-terminal frame after settling leaves
settled_atandtool_statusalone but still fills NULL columns. (AC-12) - Never-terminal row exists with
settled_at IS NULL. (AC-10) turn_idfrom the first frame survives a later frame carrying a different turn. (AC-2a)is_asyncis sticky across a later frame reporting false.sourceandobserved_atare not overwritten by a later upsert.- Nested
parent_tool_call_idis 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_subagentstoassertNoWorkspaceCascadeDependentsinworkspace_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 anobserved_atand assert thetool_call_idtiebreak, per AC-13's explicit rejection ofidas 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_identityincremented by one. (AC-2) - Table-driven normalization:
""⇒ NULL for every nullable TEXT column; absent metric ⇒ nil; reported0ToolUseCount⇒*int64(0); negative ⇒ nil plusanomalous_value++ with the other fields of the same frame intact. (AC-7, AC-8, AC-9) - Terminal
tool_statussetssettled_at;agent_status = "async_launched"alone does not. (AC-10a, AC-11) - An unrecognised
agent_statusis stored verbatim and does not settle. (AC-10a) - A repository error logs
WARN, incrementsfailed, and the call still returns normally. How: a stub repo returning an error pluszaptest/observed-logs. (AC-27) attemptedandpersistedmove 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.
handleToolCallEventwith asubagent_taskpayload records a context with the active turn id. (AC-1)handleToolUpdateEventfor 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_statusthrough. (AC-11) - A non-
subagent_tasknormalized kind records nothing. - A nil
subagentContextsfield is safe — no panic, message write still happens. (AC-27) - A nested frame (
ParentToolCallIDset) is recorded, not dropped. (AC-6) - Integration through the real service and repository, using
newServiceBackedMessageCreator(repo)asevent_handlers_streaming_test.go:906does, 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 onKANDEV_TEST_POSTGRES_DSNviatestutil.OpenIsolatedPostgres. (AC-19) - The upsert's conflict clause behaves identically on Postgres — fill-forward,
settled_atwrite-once,is_asyncsticky,turn_idpreserved. 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_messagessubagent_taskcount equalsCOUNT(*) FROM task_session_subagentsafter 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:
- "Schema-init DDL and an idempotent migration" for a new table would be
a duplicated
CREATE TABLE.apps/backend/AGENTS.mdsettles it: schema init runs beforerunMigrations()on every boot, so the DDL alone satisfies AC-17. See Schema (task 01). - 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).