diff --git a/apps/backend/internal/backendapp/storage.go b/apps/backend/internal/backendapp/storage.go index 7ee23d71a9..8bfb2ab9c6 100644 --- a/apps/backend/internal/backendapp/storage.go +++ b/apps/backend/internal/backendapp/storage.go @@ -15,6 +15,7 @@ import ( quickterminalrepository "github.com/kandev/kandev/internal/quickterminal/repository" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/telemetrycontract" terminalrepo "github.com/kandev/kandev/internal/terminal/repository" utilitystore "github.com/kandev/kandev/internal/utility/store" workflowrepository "github.com/kandev/kandev/internal/workflow/repository" @@ -103,6 +104,10 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo } cleanups = append(cleanups, cleanup) + // All repositories have finished their initSchema calls, so every + // telemetry contract's backing objects are known to exist (or not). + activateTelemetryContracts(ctx, writer, reader, log) + // All repositories have finished their initSchema calls. Record the // current binary version so the next boot can detect upgrades correctly. recordSchemaVersion(writer, cfg.Database.Driver, version, log) @@ -210,6 +215,26 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB) (supportR return repos, cleanups, nil } +// activateTelemetryContracts writes the boot activation row for any +// newly-active telemetry contract and logs one health line per registered +// contract. Neither step is fatal: a failure logs and boot continues, +// matching recordSchemaVersion's contract directly below. +func activateTelemetryContracts(ctx context.Context, writer, reader *sqlx.DB, log *logger.Logger) { + store, err := telemetrycontract.NewWithDB(writer, reader) + if err != nil { + if log != nil { + log.Warn("failed to initialize telemetry_activations", zap.Error(err)) + } + return + } + if err := store.Activate(ctx); err != nil { + if log != nil { + log.Warn("failed to activate telemetry contracts", zap.Error(err)) + } + } + store.LogHealth(ctx, log) +} + // recordSchemaVersion writes the current binary version into kandev_meta so the // next boot can detect upgrades. A failure here is non-fatal: the stored // version stays at the previous value and the next boot will take a fresh diff --git a/apps/backend/internal/mcp/handlers/config_handlers_test.go b/apps/backend/internal/mcp/handlers/config_handlers_test.go index b65c4b2dd9..569d66071c 100644 --- a/apps/backend/internal/mcp/handlers/config_handlers_test.go +++ b/apps/backend/internal/mcp/handlers/config_handlers_test.go @@ -994,10 +994,11 @@ func TestDeferMoveTask_AcceptsValidStep(t *testing.T) { } msg := makeWSMessage(t, ws.ActionMCPMoveTask, map[string]interface{}{ - "task_id": "task-defer3", - "workflow_id": "wf-defer3", - "workflow_step_id": "dst-step3", - "position": 0, + "task_id": "task-defer3", + "workflow_id": "wf-defer3", + "workflow_step_id": "dst-step3", + "position": 0, + "sender_session_id": "sess-caller3", }) resp, err := h.handleMoveTask(ctx, msg) @@ -1005,6 +1006,7 @@ func TestDeferMoveTask_AcceptsValidStep(t *testing.T) { assert.NotEqual(t, ws.MessageTypeError, resp.Type, "valid deferred move must succeed") require.Len(t, queue.pendingMoves, 1) assert.Equal(t, "dst-step3", queue.pendingMoves[0].WorkflowStepID) + assert.Equal(t, "sess-caller3", queue.pendingMoves[0].SenderSessionID) } func TestMoveTaskErrorMessage_SanitizesClassifiedErrors(t *testing.T) { diff --git a/apps/backend/internal/mcp/handlers/config_task_handlers.go b/apps/backend/internal/mcp/handlers/config_task_handlers.go index f32aff3721..452c51c9b1 100644 --- a/apps/backend/internal/mcp/handlers/config_task_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_task_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" @@ -21,11 +22,12 @@ import ( func (h *Handlers) handleMoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - TaskID string `json:"task_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Position int `json:"position"` - Prompt string `json:"prompt"` + TaskID string `json:"task_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Position int `json:"position"` + Prompt string `json:"prompt"` + SenderSessionID string `json:"sender_session_id"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) @@ -78,11 +80,12 @@ func (h *Handlers) deferMoveTask( ctx context.Context, msg *ws.Message, req struct { - TaskID string `json:"task_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Position int `json:"position"` - Prompt string `json:"prompt"` + TaskID string `json:"task_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Position int `json:"position"` + Prompt string `json:"prompt"` + SenderSessionID string `json:"sender_session_id"` }, session *models.TaskSession, ) (*ws.Message, error) { @@ -161,11 +164,12 @@ func (h *Handlers) deferMoveTask( } } h.messageQueue.SetPendingMove(ctx, session.ID, &messagequeue.PendingMove{ - TaskID: req.TaskID, - WorkflowID: req.WorkflowID, - WorkflowStepID: req.WorkflowStepID, - Position: req.Position, - Actor: string(wfmodels.StepTransitionActorAgent), + TaskID: req.TaskID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Position: req.Position, + Actor: string(wfmodels.StepTransitionActorAgent), + SenderSessionID: req.SenderSessionID, }) return ws.NewResponse(msg.ID, msg.Action, h.synthesizeMovedTaskDTO(ctx, req.TaskID, req.WorkflowID, req.WorkflowStepID, req.Position)) @@ -179,11 +183,12 @@ func (h *Handlers) applyMoveTaskImmediate( ctx context.Context, msg *ws.Message, req struct { - TaskID string `json:"task_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Position int `json:"position"` - Prompt string `json:"prompt"` + TaskID string `json:"task_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Position int `json:"position"` + Prompt string `json:"prompt"` + SenderSessionID string `json:"sender_session_id"` }, session *models.TaskSession, ) (*ws.Message, error) { @@ -199,7 +204,21 @@ func (h *Handlers) applyMoveTaskImmediate( queuedSessionID = session.ID } - result, err := h.taskSvc.MoveTaskWithOptions(ctx, req.TaskID, req.WorkflowID, req.WorkflowStepID, req.Position, + // Attribution uses the CALLING session (req.SenderSessionID, injected + // server-side by the MCP server from its own bound session — see + // moveTaskHandler), never the target task's session captured above: + // move_task_kandev routinely moves a task the caller doesn't run a + // session on, so the target's session is not who caused this move. + // SenderSessionID is empty for a config-mode/admin MCP server with no + // bound session, which correctly falls back to ActorSystem. + attribution := steptelemetry.Attribution{Trigger: steptelemetry.TriggerMCPMove, ActorKind: steptelemetry.ActorSystem} + if req.SenderSessionID != "" { + attribution.ActorKind = steptelemetry.ActorAgent + attribution.ActorID = req.SenderSessionID + attribution.SessionID = req.SenderSessionID + } + moveCtx := steptelemetry.WithAttribution(ctx, attribution) + result, err := h.taskSvc.MoveTaskWithOptions(moveCtx, req.TaskID, req.WorkflowID, req.WorkflowStepID, req.Position, service.MoveTaskOptions{StepHistoryActor: wfmodels.StepTransitionActorAgent}) if err != nil { // Roll back the queued prompt — without this, the next turn would diff --git a/apps/backend/internal/mcp/handlers/config_task_handlers_ledger_test.go b/apps/backend/internal/mcp/handlers/config_task_handlers_ledger_test.go new file mode 100644 index 0000000000..6afd90b5a9 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/config_task_handlers_ledger_test.go @@ -0,0 +1,260 @@ +package handlers + +// Ledger attribution coverage for the MCP move_task_kandev handler: +// applyMoveTaskImmediate records mcp_move, with actor kind/id resolved from +// the source session when one exists (idle) and system when there is none. + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/steptelemetry" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/task/service" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type ledgerRow struct { + trigger string + actorKind string + actorID *string +} + +func ledgerRowsForTask(t *testing.T, repo *sqliterepo.Repository, taskID string) []ledgerRow { + t.Helper() + rows, err := repo.DB().QueryContext(context.Background(), ` + SELECT trigger, actor_kind, actor_id FROM task_step_transitions + WHERE task_id = ? ORDER BY occurred_at ASC, id ASC + `, taskID) + if err != nil { + t.Fatalf("query task_step_transitions: %v", err) + } + defer func() { _ = rows.Close() }() + + var out []ledgerRow + for rows.Next() { + var r ledgerRow + if err := rows.Scan(&r.trigger, &r.actorKind, &r.actorID); err != nil { + t.Fatalf("scan row: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + if len(out) == 0 { + t.Fatalf("no ledger rows for task %s", taskID) + } + return out +} + +func TestHandleMoveTaskImmediateNoSessionRecordsMCPMoveSystem(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-mcp-move", Name: "Move", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-mcp-move", WorkspaceID: "ws-mcp-move", Name: "Board", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-mcp-move", WorkspaceID: "ws-mcp-move", WorkflowID: "wf-mcp-move", + WorkflowStepID: "step-work", Title: "Move me", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + })) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPMoveTask, map[string]interface{}{ + "task_id": "task-mcp-move", "workflow_id": "wf-mcp-move", "workflow_step_id": "step-done", "position": 0, + }) + + resp, err := h.handleMoveTask(ctx, msg) + require.NoError(t, err) + require.NotEqual(t, ws.MessageTypeError, resp.Type) + + rows := ledgerRowsForTask(t, repo, "task-mcp-move") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerMCPMove) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerMCPMove) + } + if last.actorKind != string(steptelemetry.ActorSystem) { + t.Fatalf("actor_kind = %q, want %q (no session to attribute to)", last.actorKind, steptelemetry.ActorSystem) + } +} + +// TestHandleMoveTaskImmediateTargetSessionAloneDoesNotAttributeToIt pins the +// fix for a real misattribution bug found in review: the target task having +// an idle session of its own must NOT cause the ledger row to attribute to +// that session merely because it happened to be in scope for hand-off-prompt +// queuing. Before the fix, calling move_task_kandev with no sender_session_id +// incorrectly recorded actor_kind=agent, actor_id= — +// attributing the move to a session that took no action. Absent an explicit +// sender_session_id, the correct record is actor_kind=system: no initiating +// identity is known. +func TestHandleMoveTaskImmediateTargetSessionAloneDoesNotAttributeToIt(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-mcp-idle", Name: "Move", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-mcp-idle", WorkspaceID: "ws-mcp-idle", Name: "Board", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-mcp-idle", WorkspaceID: "ws-mcp-idle", WorkflowID: "wf-mcp-idle", + WorkflowStepID: "step-work", Title: "Move me", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sess-mcp-idle", TaskID: "task-mcp-idle", State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, StartedAt: now, UpdatedAt: now, + })) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPMoveTask, map[string]interface{}{ + "task_id": "task-mcp-idle", "workflow_id": "wf-mcp-idle", "workflow_step_id": "step-done", "position": 0, + }) + + resp, err := h.handleMoveTask(ctx, msg) + require.NoError(t, err) + require.NotEqual(t, ws.MessageTypeError, resp.Type) + + rows := ledgerRowsForTask(t, repo, "task-mcp-idle") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerMCPMove) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerMCPMove) + } + if last.actorKind != string(steptelemetry.ActorSystem) { + t.Fatalf("actor_kind = %q, want %q (target's own idle session must not be treated as the actor)", last.actorKind, steptelemetry.ActorSystem) + } + if last.actorID != nil { + t.Fatalf("actor_id = %v, want nil", *last.actorID) + } +} + +// TestHandleMoveTaskImmediateUsesSenderSessionNotTargetSession proves the fix +// end-to-end: when the caller supplies sender_session_id — as moveTaskHandler +// now always does, injected server-side from the MCP server's own bound +// session — the ledger row attributes to THAT session, even though the +// target task has a different idle session of its own that must not be +// confused for the actor. +func TestHandleMoveTaskImmediateUsesSenderSessionNotTargetSession(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-mcp-sender", Name: "Move", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-mcp-sender", WorkspaceID: "ws-mcp-sender", Name: "Board", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-mcp-target", WorkspaceID: "ws-mcp-sender", WorkflowID: "wf-mcp-sender", + WorkflowStepID: "step-work", Title: "Move me", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sess-mcp-target-idle", TaskID: "task-mcp-target", State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, StartedAt: now, UpdatedAt: now, + })) + // The calling agent's own session, on a different task entirely — the + // realistic shape of an agent orchestrating a card it doesn't run on. + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-mcp-caller", WorkspaceID: "ws-mcp-sender", WorkflowID: "wf-mcp-sender", + WorkflowStepID: "step-work", Title: "Caller task", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sess-mcp-caller", TaskID: "task-mcp-caller", State: models.TaskSessionStateRunning, + IsPrimary: true, StartedAt: now, UpdatedAt: now, + })) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPMoveTask, map[string]interface{}{ + "task_id": "task-mcp-target", "workflow_id": "wf-mcp-sender", "workflow_step_id": "step-done", "position": 0, + "sender_session_id": "sess-mcp-caller", + }) + + resp, err := h.handleMoveTask(ctx, msg) + require.NoError(t, err) + require.NotEqual(t, ws.MessageTypeError, resp.Type) + + rows := ledgerRowsForTask(t, repo, "task-mcp-target") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerMCPMove) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerMCPMove) + } + if last.actorKind != string(steptelemetry.ActorAgent) { + t.Fatalf("actor_kind = %q, want %q", last.actorKind, steptelemetry.ActorAgent) + } + if last.actorID == nil || *last.actorID != "sess-mcp-caller" { + t.Fatalf("actor_id = %v, want sess-mcp-caller (the caller, not the target's own idle session sess-mcp-target-idle)", last.actorID) + } +} + +// TestHandleCreateTaskGenesisRowAttributesToSourceSession pins the fix for a +// real misattribution bug found in review: create_task_kandev's genesis +// ledger row must attribute to the verified source_session_id — the agent +// that actually called the tool — not fall back to human/system. The source +// session is already validated against source_task_id before CreateTask is +// reached (see resolveMCPCreatorSession), so this proves the attribution +// wiring reaches the real production call path end-to-end. +func TestHandleCreateTaskGenesisRowAttributesToSourceSession(t *testing.T) { + ctx := context.Background() + svc, repo := newTestTaskService(t) + workspaces, err := svc.ListWorkspaces(ctx) + require.NoError(t, err) + require.Len(t, workspaces, 1) + workspace := workspaces[0] + workflow, err := svc.CreateWorkflow(ctx, &service.CreateWorkflowRequest{ + WorkspaceID: workspace.ID, + Name: "Genesis attribution workflow", + }) + require.NoError(t, err) + step := &workflowmodels.WorkflowStep{ID: "genesis-step", WorkflowID: workflow.ID, Name: "Start"} + svc.SetWorkflowStepGetter(&staticWorkflowStepGetter{steps: map[string]*workflowmodels.WorkflowStep{ + step.ID: step, + }}) + + sourceResult, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: workspace.ID, + WorkflowID: workflow.ID, + WorkflowStepID: step.ID, + Title: "Source task", + }) + require.NoError(t, err) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "genesis-source-session", + TaskID: sourceResult.Task.ID, + AgentProfileID: "genesis-session-profile", + State: models.TaskSessionStateWaitingForInput, + IsPrimary: true, + })) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + resp, err := h.handleCreateTask(ctx, makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "source_task_id": sourceResult.Task.ID, + "source_session_id": "genesis-source-session", + "workspace_id": workspace.ID, + "workflow_id": workflow.ID, + "workflow_step_id": step.ID, + "title": "Genesis attribution child", + "start_agent": false, + })) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type, "payload: %s", string(resp.Payload)) + + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(resp.Payload, &created)) + + rows := ledgerRowsForTask(t, repo, created.ID) + genesis := rows[0] + if genesis.actorKind != string(steptelemetry.ActorAgent) { + t.Fatalf("actor_kind = %q, want %q", genesis.actorKind, steptelemetry.ActorAgent) + } + if genesis.actorID == nil || *genesis.actorID != "genesis-source-session" { + t.Fatalf("actor_id = %v, want genesis-source-session", genesis.actorID) + } +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index b467a0d984..ae647c061b 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -24,6 +24,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" @@ -713,7 +714,21 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M } } - result, err := h.taskSvc.CreateTask(ctx, &service.CreateTaskRequest{ + // The source session is the causal actor for this task's genesis ledger + // row — resolveMCPLaunchMetadataWithSource already validated it belongs + // to req.SourceTaskID above (resolveMCPCreatorSession errors out + // otherwise, so CreateTask is never reached with an unverified session + // here). Conditional: SourceSessionID is optional, so a caller that + // omits it falls back to the existing auth/user seam default. + createCtx := ctx + if req.SourceSessionID != "" { + createCtx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + ActorKind: steptelemetry.ActorAgent, + ActorID: req.SourceSessionID, + SessionID: req.SourceSessionID, + }) + } + result, err := h.taskSvc.CreateTask(createCtx, &service.CreateTaskRequest{ ParentID: req.ParentID, WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, @@ -2309,7 +2324,21 @@ func (h *Handlers) handleMessageTask(ctx context.Context, msg *ws.Message) (*ws. } } - result, err := h.dispatchTaskMessage(ctx, req.TaskID, session, wrappedPrompt, senderMeta, wantsInterrupt, req.SessionID != "") + // The sender session is the causal actor for anything this dispatch does + // on its own behalf, including RestoreTaskMessageRollback if a later + // step fails — thread it onto ctx so that rollback's ledger row (if any) + // attributes the agent that sent this message, not the session-less + // authn seam. Conditional: SenderSessionID isn't validated non-empty + // above, so a caller that omits it falls back to the existing default. + dispatchCtx := ctx + if req.SenderSessionID != "" { + dispatchCtx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + ActorKind: steptelemetry.ActorAgent, + ActorID: req.SenderSessionID, + SessionID: req.SenderSessionID, + }) + } + result, err := h.dispatchTaskMessage(dispatchCtx, req.TaskID, session, wrappedPrompt, senderMeta, wantsInterrupt, req.SessionID != "") if err != nil { if parentReply != nil { if restoreErr := h.restoreParentQuestionPending(ctx, parentReply.message); restoreErr != nil { diff --git a/apps/backend/internal/mcp/handlers/message_task_test.go b/apps/backend/internal/mcp/handlers/message_task_test.go index 49a4cc5f94..068a09c398 100644 --- a/apps/backend/internal/mcp/handlers/message_task_test.go +++ b/apps/backend/internal/mcp/handlers/message_task_test.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" promptservice "github.com/kandev/kandev/internal/prompts/service" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -289,6 +290,15 @@ func seedTaskWithSession(t *testing.T, svc *service.Service, repo seedRepo, stat }) sender := senderResult.Task require.NoError(t, err) + // senderPayload's hardcoded "sender_session_id": "sender-sess-1" must be + // a real row — session_id has a foreign key to task_sessions, and in + // production sender_session_id always names the genuine calling + // session (senderPayload's own doc comment: "agentctl injects + // sender_task_id and sender_session_id"). + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sender-sess-1", TaskID: sender.ID, AgentProfileID: "agent-profile-sender", + State: models.TaskSessionStateRunning, + })) sess := &models.TaskSession{ ID: "sess-1", @@ -1689,6 +1699,17 @@ func TestHandleMessageTask_DispatchErrorRollsBackTurnStartOutsideReview(t *testi assert.Equal(t, v1.TaskStateInProgress, updatedTask.State) assert.Equal(t, "step-in-progress", updatedTask.WorkflowStepID) + // Review round 3 must-fix #2: the rollback's ledger row (from + // "step-next" back to "step-in-progress") must attribute the causal + // MCP sender session, not the session-less system default. + ledgerRows := ledgerRowsForTask(t, repo, target.ID) + lastLedgerRow := ledgerRows[len(ledgerRows)-1] + assert.Equal(t, string(steptelemetry.TriggerUnarchiveRestore), lastLedgerRow.trigger) + assert.Equal(t, string(steptelemetry.ActorAgent), lastLedgerRow.actorKind, "rollback must attribute the causal sender session, not system") + if assert.NotNil(t, lastLedgerRow.actorID) { + assert.Equal(t, "sender-sess-1", *lastLedgerRow.actorID) + } + primary, err := svc.GetPrimarySession(ctx, target.ID) require.NoError(t, err) assert.Equal(t, sess.ID, primary.ID) diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index dcbb163450..8a0e130659 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -579,10 +579,15 @@ func (s *Server) moveTaskHandler() server.ToolHandlerFunc { } // prompt is optional — only relevant when handing off mid-turn from one // agent to another. Admin/config moves of idle tasks omit it. + // sender_session_id is injected from the server's own bound session, the + // same pattern messageTaskHandler/spawnSessionHandler use, so the backend + // can attribute this move's ledger row to the agent that actually called + // the tool rather than guessing from the target task's own session. payload := map[string]interface{}{ - "task_id": taskID, - "workflow_id": workflowID, - "workflow_step_id": stepID, + "task_id": taskID, + "workflow_id": workflowID, + "workflow_step_id": stepID, + "sender_session_id": s.sessionID, } if prompt := req.GetString("prompt", ""); prompt != "" { payload["prompt"] = prompt diff --git a/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter.go b/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter.go index db7bca8870..acf2ffc61f 100644 --- a/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter.go +++ b/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/workflow/engine" ) @@ -65,12 +66,41 @@ func (a *WorkflowSwitcherAdapter) SwitchTaskWorkflow( } resolvedStepID = resolved } - if err := a.Mover.AddTaskToWorkflow(ctx, taskID, newWorkflowID, resolvedStepID, 0); err != nil { + attachCtx := steptelemetry.WithAttribution(ctx, workflowAttachedAttribution(ctx)) + if err := a.Mover.AddTaskToWorkflow(attachCtx, taskID, newWorkflowID, resolvedStepID, 0); err != nil { return "", fmt.Errorf("update task %s to workflow %s/%s: %w", taskID, newWorkflowID, resolvedStepID, err) } return resolvedStepID, nil } +// workflowAttachedAttribution is this adapter's ledger row attribution: the +// trigger is always workflow_attached (SwitchTaskWorkflow is that trigger's +// sole production meaning), and the actor prefers an explicit attribution +// already on ctx over recomputing one. The engine's SwitchWorkflowCallback +// (this adapter's sole caller) sets one carrying the causal session's ID +// before calling here, since a switch_workflow action is always genuinely +// caused by a session's turn — the authn-seam fallback below would +// otherwise silently record actor_kind=system for a session-caused +// transition, because an engine-internal ctx carries no authenticated +// identity. The fallback still exists for any future caller that doesn't +// set attribution. +func workflowAttachedAttribution(ctx context.Context) steptelemetry.Attribution { + if preset := steptelemetry.FromContext(ctx); preset.ActorKind != steptelemetry.ActorUnknown { + return steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerWorkflowAttached, + ActorKind: preset.ActorKind, + ActorID: preset.ActorID, + SessionID: preset.SessionID, + } + } + actorKind, actorID := steptelemetry.HumanOrSystemActor(ctx) + return steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerWorkflowAttached, + ActorKind: actorKind, + ActorID: actorID, + } +} + // Compile-time interface assertion. var _ engine.WorkflowSwitcher = (*WorkflowSwitcherAdapter)(nil) diff --git a/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter_test.go b/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter_test.go index e69a02f61f..5afd36750d 100644 --- a/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter_test.go +++ b/apps/backend/internal/office/engine_adapters/workflow_switcher_adapter_test.go @@ -5,6 +5,8 @@ import ( "errors" "strings" "testing" + + "github.com/kandev/kandev/internal/steptelemetry" ) type fakeFirstStepResolver struct { @@ -20,15 +22,17 @@ type fakeMover struct { calls []struct { TaskID, WorkflowID, StepID string Position int + Attribution steptelemetry.Attribution } err error } -func (f *fakeMover) AddTaskToWorkflow(_ context.Context, taskID, workflowID, stepID string, position int) error { +func (f *fakeMover) AddTaskToWorkflow(ctx context.Context, taskID, workflowID, stepID string, position int) error { f.calls = append(f.calls, struct { TaskID, WorkflowID, StepID string Position int - }{TaskID: taskID, WorkflowID: workflowID, StepID: stepID, Position: position}) + Attribution steptelemetry.Attribution + }{TaskID: taskID, WorkflowID: workflowID, StepID: stepID, Position: position, Attribution: steptelemetry.FromContext(ctx)}) return f.err } @@ -105,3 +109,77 @@ func TestWorkflowSwitcherAdapter_BubblesMoverError(t *testing.T) { t.Fatalf("expected mover error to bubble, got: %v", err) } } + +// TestWorkflowSwitcherAdapter_PrefersCausalSessionAttribution proves the +// fix for Review round 2's must-fix #1: SwitchWorkflowCallback.Execute +// (the sole production caller) wraps ctx with the causal session's +// attribution before calling SwitchTaskWorkflow, because a switch_workflow +// action is always genuinely caused by a session's turn (Execute validates +// SessionID is non-empty). The adapter must forward the ACTOR side of that +// attribution to the mover rather than recomputing actor_kind from the +// (session-less) authn seam, which would silently record actor_kind=system +// for a session-caused transition. +// +// The preset's Trigger is deliberately wrong (TriggerManualMove, never +// workflow_attached in production) so that asserting the mover received +// TriggerWorkflowAttached proves the adapter actually HARDCODES the trigger +// — the doc comment on workflowAttachedAttribution's own claim — rather +// than merely round-tripping whatever the caller happened to preset. Review +// round 3 mutation-verified the earlier version of this test (a preset that +// already matched what was asserted) could not fail even if the hardcode +// were replaced with a pass-through. +func TestWorkflowSwitcherAdapter_PrefersCausalSessionAttribution(t *testing.T) { + mover := &fakeMover{} + a := NewWorkflowSwitcherAdapter(&fakeFirstStepResolver{stepID: "s"}, mover) + presetCtx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerManualMove, + ActorKind: steptelemetry.ActorAgent, + ActorID: "sess-1", + SessionID: "sess-1", + }) + if _, err := a.SwitchTaskWorkflow(presetCtx, "task-1", "wf-2", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(mover.calls) != 1 { + t.Fatalf("expected 1 mover call, got %d", len(mover.calls)) + } + got := mover.calls[0].Attribution + if got.Trigger != steptelemetry.TriggerWorkflowAttached { + t.Errorf("trigger = %q, want %q (must be hardcoded, not passed through from a wrong preset)", got.Trigger, steptelemetry.TriggerWorkflowAttached) + } + if got.ActorKind != steptelemetry.ActorAgent { + t.Errorf("actor_kind = %q, want %q (the resolved session caused this transition)", got.ActorKind, steptelemetry.ActorAgent) + } + if got.ActorID != "sess-1" { + t.Errorf("actor_id = %q, want sess-1", got.ActorID) + } + if got.SessionID != "sess-1" { + t.Errorf("session_id = %q, want sess-1", got.SessionID) + } +} + +// TestWorkflowSwitcherAdapter_FallsBackToAuthnSeamWithoutPreset covers the +// no-preset case: a caller that doesn't set attribution (e.g. a future, +// non-engine caller of this adapter) still gets a sensible default from the +// existing authn seam, exactly like genesisAttribution/detachAttribution do +// in the sqlite repository. +func TestWorkflowSwitcherAdapter_FallsBackToAuthnSeamWithoutPreset(t *testing.T) { + mover := &fakeMover{} + a := NewWorkflowSwitcherAdapter(&fakeFirstStepResolver{stepID: "s"}, mover) + if _, err := a.SwitchTaskWorkflow(context.Background(), "task-1", "wf-2", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(mover.calls) != 1 { + t.Fatalf("expected 1 mover call, got %d", len(mover.calls)) + } + got := mover.calls[0].Attribution + if got.Trigger != steptelemetry.TriggerWorkflowAttached { + t.Errorf("trigger = %q, want %q", got.Trigger, steptelemetry.TriggerWorkflowAttached) + } + if got.ActorKind != steptelemetry.ActorSystem { + t.Errorf("actor_kind = %q, want %q (no identity on ctx, no preset attribution)", got.ActorKind, steptelemetry.ActorSystem) + } + if got.ActorID != "" { + t.Errorf("actor_id = %q, want empty", got.ActorID) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_pending_move_attribution_test.go b/apps/backend/internal/orchestrator/event_handlers_pending_move_attribution_test.go new file mode 100644 index 0000000000..5e640a98d5 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_pending_move_attribution_test.go @@ -0,0 +1,59 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/steptelemetry" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestApplyPendingMove_UsesSenderSessionForLedgerAttribution(t *testing.T) { + sc := buildPendingMoveScenario(t) + session, err := sc.repo.GetTaskSession(sc.ctx, sc.reviewSessionID) + if err != nil { + t.Fatalf("load review session: %v", err) + } + + const senderSessionID = "session-caller" + now := time.Now().UTC() + if err := sc.repo.CreateTask(context.Background(), &models.Task{ + ID: "task-caller", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: stepInProgressID, + Title: "Caller", State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create caller task: %v", err) + } + if err := sc.repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: senderSessionID, TaskID: "task-caller", State: models.TaskSessionStateRunning, + StartedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create caller session: %v", err) + } + sc.svc.applyPendingMove(sc.ctx, "task-1", sc.reviewSessionID, session, &messagequeue.PendingMove{ + TaskID: "task-1", + WorkflowID: "wf1", + WorkflowStepID: stepInProgressID, + SenderSessionID: senderSessionID, + }) + + rows := stepTransitionRowsForTaskOrchestrator(t, sc.repo, "task-1") + if len(rows) == 0 { + t.Fatal("expected a ledger row for the deferred move") + } + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerMCPDeferredMove) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerMCPDeferredMove) + } + if last.actorKind != string(steptelemetry.ActorAgent) { + t.Fatalf("actor_kind = %q, want %q", last.actorKind, steptelemetry.ActorAgent) + } + if last.actorID == nil || *last.actorID != senderSessionID { + t.Fatalf("actor_id = %v, want %q", last.actorID, senderSessionID) + } + if last.sessionID == nil || *last.sessionID != senderSessionID { + t.Fatalf("session_id = %v, want %q", last.sessionID, senderSessionID) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go index 50eb1cc040..2835d358f1 100644 --- a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/queue" "github.com/kandev/kandev/internal/orchestrator/scheduler" "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -115,6 +116,26 @@ func TestPendingMove_OutOfTerminalStepReopensCompletedTask(t *testing.T) { if task.State != v1.TaskStateTODO { t.Fatalf("state = %q, want TODO after pending move out of terminal step", task.State) } + + // This is the real production applyPendingMove path (spec.md:518-519): + // unlike TestApplyTransitionPreservesOuterCallerTrigger, which presets + // mcp_deferred_move on ctx and so never reaches this call site at all, + // this drives the actual deferred-move flow end to end and must prove + // the ledger row it writes carries the trigger the scenario claims. + rows := stepTransitionRowsForTaskOrchestrator(t, sc.repo, "task-1") + if len(rows) == 0 { + t.Fatalf("expected at least one ledger row for task-1") + } + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerMCPDeferredMove) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerMCPDeferredMove) + } + if last.actorKind != string(steptelemetry.ActorSystem) { + t.Fatalf("actor_kind = %q, want %q when sender session is absent", last.actorKind, steptelemetry.ActorSystem) + } + if last.actorID != nil || last.sessionID != nil { + t.Fatalf("actor/session IDs = %v/%v, want NULL/NULL when sender session is absent", last.actorID, last.sessionID) + } } func TestPendingMove_DropsForeignWorkflowStepWithoutMovingTask(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 42dd8c40a6..ae086c4829 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/engine" @@ -84,6 +85,9 @@ func (s *Service) processOnTurnCompleteWithCause( if !ok { return false } + if cause == turnCompletionCauseUserCancellation { + ctx = cancellationTransitionAttribution(ctx) + } s.executeStepTransition(ctx, taskID, sessionID, currentStep, targetStepID, true) return true } @@ -351,8 +355,15 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s // transition. task.WorkflowStepID = toStepID task.UpdatedAt = time.Now().UTC() - if err := s.updateTransitionTaskWithCapacity(ctx, task, targetStep); err != nil { - s.logger.Warn("workflow transition failed", + // executeStepTransition's two callers are always a genuine session turn: + // on_turn_complete (triggerOnEnter=true) or on_turn_start (false). + legacyTrigger := engine.TriggerOnTurnStart + if triggerOnEnter { + legacyTrigger = engine.TriggerOnTurnComplete + } + transitionCtx := engineTransitionAttribution(ctx, sessionID, legacyTrigger) + if err := s.updateTransitionTaskWithCapacity(transitionCtx, task, targetStep); err != nil { + s.logger.Warn("workflow transition rejected or failed", zap.String("task_id", taskID), zap.String("from_step", fromStep.Name), zap.String("to_step", targetStep.Name), @@ -469,6 +480,66 @@ func (s *Service) updateTransitionTaskWithCapacity( return err } +// engineTriggerIsSessionOriginated reports whether an engine.Trigger is +// caused by a session's own turn (or an on_exit/on_enter reached through +// one). Every other trigger — a children-completed rollup, a scheduled +// evaluation, or any future non-session trigger — is system-caused even when +// the call site had to resolve *a* session to satisfy the engine's +// MachineState API shape, as processOnChildrenCompleted does. +func engineTriggerIsSessionOriginated(trigger engine.Trigger) bool { + switch trigger { + case engine.TriggerOnTurnStart, engine.TriggerOnTurnComplete, engine.TriggerOnEnter, engine.TriggerOnExit: + return true + default: + return false + } +} + +// engineTransitionAttribution wraps ctx with the engine_transition trigger. +// An engine_transition is agent when it came from a session's turn +// (on_turn_start, on_turn_complete, or an on_exit/on_enter reached through +// one — the sessionID this function receives) and system when it came from a +// non-session trigger such as a children-completed rollup or a scheduled +// evaluation. Actor kind is decided by the engine trigger itself, not by +// sessionID presence alone: on_children_completed always resolves a session +// (the parent's active one) purely to satisfy the engine's MachineState API +// shape, and that session did not cause the transition. +// +// Does not overwrite an attribution the caller already set explicitly (the +// same outermost-caller-wins rule ApplyTransition uses) — executeStepTransition's +// legacy on_turn_complete path pre-wraps ctx with cancellationTransitionAttribution +// when the completion was forced by a cancellation, and that must survive. +func engineTransitionAttribution(ctx context.Context, sessionID string, trigger engine.Trigger) context.Context { + if steptelemetry.HasTrigger(ctx) { + return ctx + } + attribution := steptelemetry.Attribution{Trigger: steptelemetry.TriggerEngineTransition, ActorKind: steptelemetry.ActorSystem} + if sessionID != "" && engineTriggerIsSessionOriginated(trigger) { + attribution.ActorKind = steptelemetry.ActorAgent + attribution.ActorID = sessionID + attribution.SessionID = sessionID + } + return steptelemetry.WithAttribution(ctx, attribution) +} + +// cancellationTransitionAttribution wraps ctx with the user_cancellation +// trigger for an on_turn_complete that only ran because a caller force-closed +// the turn (turnCompletionCauseUserCancellation), rather than the turn +// completing on its own — reached through the same code path an +// engine_transition is, but not attributable to the session's agent. Actor +// kind follows the same identity-on-context rule as manual_move: human with +// the cancelling request's user ID when authenticated, system with no ID +// otherwise. Deliberately does not set SessionID — the session is where the +// cancellation landed, not who caused it. +func cancellationTransitionAttribution(ctx context.Context) context.Context { + actorKind, actorID := steptelemetry.HumanOrSystemActor(ctx) + return steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerUserCancellation, + ActorKind: actorKind, + ActorID: actorID, + }) +} + // handleTaskMoved handles manual task step changes (drag-and-drop, stepper "Move here"). // It processes on_exit for the source step and on_enter for the target step, // including auto_start_agent, enable_plan_mode, and reset_agent_context. @@ -1937,7 +2008,20 @@ func (s *Service) applyPendingMove(ctx context.Context, taskID, sessionID string // otherwise see RUNNING and skip the on_enter processing. s.setSessionWaitingForInput(ctx, taskID, sessionID, session) - if err := s.workflowStore.ApplyTransition(ctx, taskID, sessionID, fromStepID, move.WorkflowStepID, engine.TriggerOnEnter); err != nil { + // sessionID is the target task's queue/execution session. It is not + // necessarily the agent that called move_task_kandev (cross-task hand-offs + // are supported), so use the sender persisted with the pending move. + deferredMoveAttribution := steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerMCPDeferredMove, + ActorKind: steptelemetry.ActorSystem, + } + if move.SenderSessionID != "" { + deferredMoveAttribution.ActorKind = steptelemetry.ActorAgent + deferredMoveAttribution.ActorID = move.SenderSessionID + deferredMoveAttribution.SessionID = move.SenderSessionID + } + deferredMoveCtx := steptelemetry.WithAttribution(ctx, deferredMoveAttribution) + if err := s.workflowStore.ApplyTransition(deferredMoveCtx, taskID, sessionID, fromStepID, move.WorkflowStepID, engine.TriggerOnEnter); err != nil { s.logger.Error("failed to apply pending move transition", zap.String("task_id", taskID), zap.Error(err)) @@ -3226,6 +3310,9 @@ func (s *Service) processOnTurnCompleteViaEngineWithCause( zap.String("from_step_id", result.FromStepID), zap.String("to_step_id", result.ToStepID)) + if cause == turnCompletionCauseUserCancellation { + ctx = cancellationTransitionAttribution(ctx) + } return s.applyEngineTransition(ctx, taskID, session, result, engine.TriggerOnTurnComplete, task.Description, true) } diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go index c32a717090..a0ec7f587e 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go @@ -166,7 +166,8 @@ func (r *sqliteRepository) initSchema() error { workflow_step_id TEXT NOT NULL DEFAULT '', step_position INTEGER NOT NULL DEFAULT 0, queued_at TIMESTAMP NOT NULL, - actor TEXT NOT NULL DEFAULT '' + actor TEXT NOT NULL DEFAULT '', + sender_session_id TEXT NOT NULL DEFAULT '' ); -- Per-session cross-process mutex. Every queue mutation takes this row @@ -182,11 +183,14 @@ func (r *sqliteRepository) initSchema() error { return err } // Existing installations may have the pre-audit shape; fresh installs - // already get the column from CREATE TABLE above, so this replays as a - // duplicate-column error there. + // already get both audit columns from CREATE TABLE above, so these replay + // as duplicate-column errors there. if _, alterErr := r.db.Exec(`ALTER TABLE pending_moves ADD COLUMN actor TEXT NOT NULL DEFAULT ''`); alterErr != nil && !internaldb.IsDuplicateColumnError(alterErr) { return alterErr } + if _, alterErr := r.db.Exec(`ALTER TABLE pending_moves ADD COLUMN sender_session_id TEXT NOT NULL DEFAULT ''`); alterErr != nil && !internaldb.IsDuplicateColumnError(alterErr) { + return alterErr + } return nil } @@ -2143,11 +2147,12 @@ func (r *sqliteRepository) ReplaceSession(ctx context.Context, sessionID string, queuedAt = time.Now().UTC() } if _, err := tx.ExecContext(ctx, r.db.Rebind(` - INSERT INTO pending_moves (id, session_id, task_id, workflow_id, workflow_step_id, step_position, queued_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO pending_moves (id, session_id, task_id, workflow_id, workflow_step_id, step_position, queued_at, actor, sender_session_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `), uuid.New().String(), sessionID, pendingMove.TaskID, pendingMove.WorkflowID, - pendingMove.WorkflowStepID, pendingMove.Position, queuedAt, + pendingMove.WorkflowStepID, pendingMove.Position, queuedAt, pendingMove.Actor, + pendingMove.SenderSessionID, ); err != nil { return fmt.Errorf("restore pending move: %w", err) } @@ -2173,17 +2178,18 @@ func (r *sqliteRepository) SetPendingMove(ctx context.Context, sessionID string, return err } if _, err := tx.ExecContext(ctx, r.db.Rebind(` - INSERT INTO pending_moves (id, session_id, task_id, workflow_id, workflow_step_id, step_position, queued_at, actor) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO pending_moves (id, session_id, task_id, workflow_id, workflow_step_id, step_position, queued_at, actor, sender_session_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET task_id = excluded.task_id, workflow_id = excluded.workflow_id, workflow_step_id = excluded.workflow_step_id, step_position = excluded.step_position, queued_at = excluded.queued_at, - actor = excluded.actor + actor = excluded.actor, + sender_session_id = excluded.sender_session_id `), - uuid.New().String(), sessionID, move.TaskID, move.WorkflowID, move.WorkflowStepID, move.Position, move.QueuedAt, move.Actor, + uuid.New().String(), sessionID, move.TaskID, move.WorkflowID, move.WorkflowStepID, move.Position, move.QueuedAt, move.Actor, move.SenderSessionID, ); err != nil { return fmt.Errorf("upsert pending move: %w", err) } @@ -2196,24 +2202,25 @@ func (r *sqliteRepository) GetPendingMove(ctx context.Context, sessionID string) taskID, workflowID, workflowStepID string position int queuedAt time.Time - actor string + actor, senderSessionID string ) if err := r.ro.QueryRowxContext(ctx, r.db.Rebind(` - SELECT task_id, workflow_id, workflow_step_id, step_position, queued_at, actor + SELECT task_id, workflow_id, workflow_step_id, step_position, queued_at, actor, sender_session_id FROM pending_moves WHERE session_id = ? - `), sessionID).Scan(&taskID, &workflowID, &workflowStepID, &position, &queuedAt, &actor); err != nil { + `), sessionID).Scan(&taskID, &workflowID, &workflowStepID, &position, &queuedAt, &actor, &senderSessionID); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("read pending move: %w", err) } return &PendingMove{ - TaskID: taskID, - WorkflowID: workflowID, - WorkflowStepID: workflowStepID, - Position: position, - QueuedAt: queuedAt, - Actor: actor, + TaskID: taskID, + WorkflowID: workflowID, + WorkflowStepID: workflowStepID, + Position: position, + QueuedAt: queuedAt, + Actor: actor, + SenderSessionID: senderSessionID, }, nil } @@ -2234,12 +2241,12 @@ func (r *sqliteRepository) TakePendingMove(ctx context.Context, sessionID string taskID, workflowID, workflowStepID string position int queuedAt time.Time - actor string + actor, senderSessionID string ) if err := tx.QueryRowxContext(ctx, r.db.Rebind(` - SELECT task_id, workflow_id, workflow_step_id, step_position, queued_at, actor + SELECT task_id, workflow_id, workflow_step_id, step_position, queued_at, actor, sender_session_id FROM pending_moves WHERE session_id = ? - `), sessionID).Scan(&taskID, &workflowID, &workflowStepID, &position, &queuedAt, &actor); err != nil { + `), sessionID).Scan(&taskID, &workflowID, &workflowStepID, &position, &queuedAt, &actor, &senderSessionID); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -2252,12 +2259,13 @@ func (r *sqliteRepository) TakePendingMove(ctx context.Context, sessionID string return nil, err } return &PendingMove{ - TaskID: taskID, - WorkflowID: workflowID, - WorkflowStepID: workflowStepID, - Position: position, - QueuedAt: queuedAt, - Actor: actor, + TaskID: taskID, + WorkflowID: workflowID, + WorkflowStepID: workflowStepID, + Position: position, + QueuedAt: queuedAt, + Actor: actor, + SenderSessionID: senderSessionID, }, nil } diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go index 249267802f..22ad6e1fc7 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go @@ -774,9 +774,10 @@ func TestSQLiteRepository_ReplaceSessionPreservesQueuedIdentity(t *testing.T) { } if err := repo.ReplaceSession(ctx, "s1", []QueuedMessage{*original}, &PendingMove{ - TaskID: "t1", - WorkflowStepID: "step-a", - QueuedAt: original.QueuedAt, + TaskID: "t1", + WorkflowStepID: "step-a", + QueuedAt: original.QueuedAt, + SenderSessionID: "sender-s1", }); err != nil { t.Fatalf("replace session: %v", err) } @@ -800,6 +801,9 @@ func TestSQLiteRepository_ReplaceSessionPreservesQueuedIdentity(t *testing.T) { if move == nil || move.WorkflowStepID != "step-a" { t.Fatalf("pending move = %#v, want step-a", move) } + if move.SenderSessionID != "sender-s1" { + t.Fatalf("pending move sender_session_id = %q, want sender-s1", move.SenderSessionID) + } } func TestSQLiteRepository_PendingMove(t *testing.T) { @@ -810,7 +814,7 @@ func TestSQLiteRepository_PendingMove(t *testing.T) { t.Fatalf("expected nil move on empty, got %v err=%v", move, err) } - move := &PendingMove{TaskID: "t1", WorkflowID: "w1", WorkflowStepID: "step-A", Position: 0, Actor: "agent"} + move := &PendingMove{TaskID: "t1", WorkflowID: "w1", WorkflowStepID: "step-A", Position: 0, Actor: "agent", SenderSessionID: "sender-s1"} if err := repo.SetPendingMove(ctx, "s1", move); err != nil { t.Fatalf("set pending: %v", err) } @@ -831,6 +835,9 @@ func TestSQLiteRepository_PendingMove(t *testing.T) { if got == nil || got.Actor != "agent" { t.Errorf("expected agent actor after upsert, got %+v", got) } + if got == nil || got.SenderSessionID != "sender-s1" { + t.Errorf("expected sender-s1 sender session after upsert, got %+v", got) + } // Take again -> nil. got, err = repo.TakePendingMove(ctx, "s1") @@ -839,6 +846,48 @@ func TestSQLiteRepository_PendingMove(t *testing.T) { } } +func TestSQLiteRepository_PendingMoveSenderSessionMigration(t *testing.T) { + raw, err := sql.Open("sqlite3", "file:pending-move-migration?mode=memory&cache=shared") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.Exec(` + CREATE TABLE pending_moves ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + task_id TEXT NOT NULL DEFAULT '', + workflow_id TEXT NOT NULL DEFAULT '', + workflow_step_id TEXT NOT NULL DEFAULT '', + step_position INTEGER NOT NULL DEFAULT 0, + queued_at TIMESTAMP NOT NULL, + actor TEXT NOT NULL DEFAULT '' + )`); err != nil { + t.Fatalf("create legacy pending_moves table: %v", err) + } + + repo, err := NewSQLiteRepository(db, db) + if err != nil { + t.Fatalf("NewSQLiteRepository: %v", err) + } + ctx := context.Background() + if err := repo.SetPendingMove(ctx, "s1", &PendingMove{ + TaskID: "t1", WorkflowStepID: "step-a", SenderSessionID: "sender-s1", + }); err != nil { + t.Fatalf("set migrated pending move: %v", err) + } + move, err := repo.TakePendingMove(ctx, "s1") + if err != nil { + t.Fatalf("take migrated pending move: %v", err) + } + if move == nil || move.SenderSessionID != "sender-s1" { + t.Fatalf("migrated pending move = %#v, want sender-s1", move) + } +} + // TestSQLiteRepository_ConcurrentInsertCap exercises the cap under contention: // 50 goroutines insert into one session with cap=10. Exactly 10 should succeed. func TestSQLiteRepository_ConcurrentInsertCap(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/messagequeue/service_test.go b/apps/backend/internal/orchestrator/messagequeue/service_test.go index d7a7c0b078..ea3458ad57 100644 --- a/apps/backend/internal/orchestrator/messagequeue/service_test.go +++ b/apps/backend/internal/orchestrator/messagequeue/service_test.go @@ -849,9 +849,10 @@ func TestRestoreSession(t *testing.T) { svc.SetPendingMove(ctx, "s", &PendingMove{TaskID: "task-1", WorkflowStepID: "step-b"}) require.NoError(t, svc.RestoreSession(ctx, "s", []QueuedMessage{*original}, &PendingMove{ - TaskID: "task-1", - WorkflowStepID: "step-a", - QueuedAt: original.QueuedAt, + TaskID: "task-1", + WorkflowStepID: "step-a", + QueuedAt: original.QueuedAt, + SenderSessionID: "sender-s", })) status := svc.GetStatus(ctx, "s") @@ -865,6 +866,7 @@ func TestRestoreSession(t *testing.T) { move, ok := svc.TakePendingMove(ctx, "s") require.True(t, ok) assert.Equal(t, "step-a", move.WorkflowStepID) + assert.Equal(t, "sender-s", move.SenderSessionID) } func TestPendingMove(t *testing.T) { @@ -872,13 +874,14 @@ func TestPendingMove(t *testing.T) { svc := setupService(t) ctx := context.Background() - svc.SetPendingMove(ctx, "s", &PendingMove{TaskID: "t1", WorkflowID: "w1", WorkflowStepID: "step-2", Position: 3}) + svc.SetPendingMove(ctx, "s", &PendingMove{TaskID: "t1", WorkflowID: "w1", WorkflowStepID: "step-2", Position: 3, SenderSessionID: "sender-s"}) got, ok := svc.TakePendingMove(ctx, "s") require.True(t, ok) assert.Equal(t, "t1", got.TaskID) assert.Equal(t, "step-2", got.WorkflowStepID) assert.Equal(t, 3, got.Position) + assert.Equal(t, "sender-s", got.SenderSessionID) assert.NotZero(t, got.QueuedAt) _, ok = svc.TakePendingMove(ctx, "s") diff --git a/apps/backend/internal/orchestrator/messagequeue/types.go b/apps/backend/internal/orchestrator/messagequeue/types.go index 6873f3ff99..d87d90631d 100644 --- a/apps/backend/internal/orchestrator/messagequeue/types.go +++ b/apps/backend/internal/orchestrator/messagequeue/types.go @@ -213,4 +213,8 @@ type PendingMove struct { // Actor records provenance across the deferred move boundary. Agent is the // value used by move_task_kandev; it prevents owner identity leakage. Actor string `json:"actor,omitempty"` + // SenderSessionID identifies the session that requested the move. It is + // distinct from the session owning this queue, which is only the execution + // context used to apply the deferred move. + SenderSessionID string `json:"sender_session_id,omitempty"` } diff --git a/apps/backend/internal/orchestrator/watcher_dispatch.go b/apps/backend/internal/orchestrator/watcher_dispatch.go index 4b9c0373ca..f99a703f89 100644 --- a/apps/backend/internal/orchestrator/watcher_dispatch.go +++ b/apps/backend/internal/orchestrator/watcher_dispatch.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/service" workflowmodels "github.com/kandev/kandev/internal/workflow/models" ) @@ -368,7 +369,15 @@ func (c *WatcherDispatchCoordinator) Dispatch(ctx context.Context, src WatcherSo return } - task, err := c.getTaskCreator().CreateIssueTask(ctx, req) + // The genesis ledger row's actor is the watch that caused the create — + // external automation, not a human or a bare system default. Trigger is + // left unset: the repository's genesis writer always hard-codes + // task_created, so only the actor needs to travel on ctx here. + createCtx := steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + ActorKind: steptelemetry.ActorIntegration, + ActorID: src.WatchID(evt), + }) + task, err := c.getTaskCreator().CreateIssueTask(createCtx, req) if err != nil { if errors.Is(err, workflowmodels.ErrWIPLimitExceeded) { c.logger.Info("watcher dispatch: workflow step at capacity; deferring issue task", diff --git a/apps/backend/internal/orchestrator/watcher_dispatch_test.go b/apps/backend/internal/orchestrator/watcher_dispatch_test.go index 08fa463cb8..71ae74f9c4 100644 --- a/apps/backend/internal/orchestrator/watcher_dispatch_test.go +++ b/apps/backend/internal/orchestrator/watcher_dispatch_test.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" workflowmodels "github.com/kandev/kandev/internal/workflow/models" @@ -93,11 +94,13 @@ func (f *fakeWatcherSource) SelfHeal(_ context.Context, _ any, _ string) error { type fakeTaskCreator struct { createErr error gotReq *IssueTaskRequest + gotAttrib steptelemetry.Attribution returned *models.Task } -func (f *fakeTaskCreator) CreateIssueTask(_ context.Context, req *IssueTaskRequest) (*models.Task, error) { +func (f *fakeTaskCreator) CreateIssueTask(ctx context.Context, req *IssueTaskRequest) (*models.Task, error) { f.gotReq = req + f.gotAttrib = steptelemetry.FromContext(ctx) if f.createErr != nil { return nil, f.createErr } @@ -185,6 +188,38 @@ func TestCoordinator_Dispatch_HappyPath(t *testing.T) { } } +// TestCoordinator_Dispatch_AttributesIntegrationWatcherActor closes the +// tautological-test gap Review round 3 found at spec.md:593-594: the +// production site that sets ActorIntegration (watcher_dispatch.go:377) had +// no test driving Dispatch and observing what actually reaches the task +// creator's ctx — the only existing coverage +// (TestGenesisRowIntegrationActorRecordsWatchID) manually presets +// ActorIntegration on ctx itself, so it can't catch a regression here. +func TestCoordinator_Dispatch_AttributesIntegrationWatcherActor(t *testing.T) { + src := &fakeWatcherSource{ + name: "linear", + reserveOK: true, + buildReq: &IssueTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Title: "[ENG-1] Hello", + }, + watchID: "linear-watch-42", + } + tc := &fakeTaskCreator{returned: &models.Task{ID: "task-1"}} + c := newTestCoordinator(t, tc, false, &fakeTaskStarter{}) + + c.Dispatch(context.Background(), src, "evt") + + if tc.gotAttrib.ActorKind != steptelemetry.ActorIntegration { + t.Fatalf("actor_kind = %q, want %q", tc.gotAttrib.ActorKind, steptelemetry.ActorIntegration) + } + if tc.gotAttrib.ActorID != "linear-watch-42" { + t.Fatalf("actor_id = %q, want linear-watch-42", tc.gotAttrib.ActorID) + } +} + // TestCoordinator_Dispatch_TruncatesOverlongTitle pins the watcher-wide title // guarantee: any WatcherSource (Linear, Jira, GitLab, future) whose // BuildTaskRequest returns an overlong Title has that title bounded to the task diff --git a/apps/backend/internal/orchestrator/workflow_step_ledger_test.go b/apps/backend/internal/orchestrator/workflow_step_ledger_test.go new file mode 100644 index 0000000000..9d7c50d682 --- /dev/null +++ b/apps/backend/internal/orchestrator/workflow_step_ledger_test.go @@ -0,0 +1,340 @@ +package orchestrator + +// Ledger attribution coverage for the orchestrator's workflowStore: a plain +// ApplyTransition (as the engine's applyEngineTransition reaches it) records +// engine_transition/agent from its sessionID argument; a caller that already +// declared a trigger (as applyPendingMove does with mcp_deferred_move) wins +// over that default; and a repeated transition to the same target writes no +// second row — the ledger's own no-op guard, which is what makes an engine +// retry (already deduplicated upstream by OperationID) safe even if a retry +// ever reached this layer. + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/steptelemetry" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/workflow/engine" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +func stepTransitionRowsForTaskOrchestrator(t *testing.T, repo *sqliterepo.Repository, taskID string) []struct { + trigger string + actorKind string + actorID *string + sessionID *string +} { + t.Helper() + rows, err := repo.DB().QueryContext(context.Background(), ` + SELECT trigger, actor_kind, actor_id, session_id FROM task_step_transitions + WHERE task_id = ? ORDER BY occurred_at ASC, id ASC + `, taskID) + if err != nil { + t.Fatalf("query task_step_transitions: %v", err) + } + defer func() { _ = rows.Close() }() + + var out []struct { + trigger string + actorKind string + actorID *string + sessionID *string + } + for rows.Next() { + var r struct { + trigger string + actorKind string + actorID *string + sessionID *string + } + if err := rows.Scan(&r.trigger, &r.actorKind, &r.actorID, &r.sessionID); err != nil { + t.Fatalf("scan row: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + return out +} + +func lastStepTransitionWorkflowIDsForTaskOrchestrator(t *testing.T, repo *sqliterepo.Repository, taskID string) (fromWorkflowID, toWorkflowID string) { + t.Helper() + row := repo.DB().QueryRowContext(context.Background(), ` + SELECT from_workflow_id, to_workflow_id FROM task_step_transitions + WHERE task_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 + `, taskID) + var from, to *string + if err := row.Scan(&from, &to); err != nil { + t.Fatalf("scan last row workflow ids: %v", err) + } + if from != nil { + fromWorkflowID = *from + } + if to != nil { + toWorkflowID = *to + } + return fromWorkflowID, toWorkflowID +} + +func TestApplyTransitionCrossWorkflowRecordsDifferingWorkflowIDs(t *testing.T) { + // GIVEN an engine action that switches a task to a step in a different + // workflow, the ledger row's from/to workflow IDs must differ and the + // trigger must remain engine_transition — mirrors + // TestWorkflowStore_ApplyTransitionSyncsWorkflowIDAcrossWorkflows's setup, + // which only asserts on task.WorkflowID and never touches the ledger. + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") // seedSession puts the task in wf1 + + if err := repo.CreateWorkflow(ctx, &models.Workflow{ + ID: "wf2", WorkspaceID: "ws1", Name: "Other Workflow", + }); err != nil { + t.Fatalf("CreateWorkflow wf2: %v", err) + } + + stepGetter := newMockStepGetter() + stepGetter.steps["step-wf2"] = &wfmodels.WorkflowStep{ + ID: "step-wf2", WorkflowID: "wf2", Name: "Target", Position: 0, + } + store := newWorkflowStore(repo, stepGetter, nil, noopPublisher, testLogger()) + + if err := store.ApplyTransition(ctx, "t1", "s1", "step1", "step-wf2", "on_turn_complete"); err != nil { + t.Fatalf("ApplyTransition: %v", err) + } + + rows := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerEngineTransition) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerEngineTransition) + } + + fromWorkflowID, toWorkflowID := lastStepTransitionWorkflowIDsForTaskOrchestrator(t, repo, "t1") + if fromWorkflowID == "" || toWorkflowID == "" { + t.Fatalf("expected both workflow ids set, got from=%q to=%q", fromWorkflowID, toWorkflowID) + } + if fromWorkflowID == toWorkflowID { + t.Fatalf("expected from_workflow_id != to_workflow_id, both were %q", fromWorkflowID) + } + if toWorkflowID != "wf2" { + t.Fatalf("to_workflow_id = %q, want %q", toWorkflowID, "wf2") + } +} + +// TestApplyTransitionRecordsEngineTransitionFromSessionID is table-driven +// over all four triggers engineTriggerIsSessionOriginated (Review round 2's +// fixup) treats as genuinely session-caused (spec.md:191-194). Before this, +// only on_turn_complete was exercised through the real attribution-decision +// code path — on_enter's only other test (TestApplyTransitionPreservesOuter- +// CallerTrigger) presets attribution on ctx, which short-circuits +// engineTransitionAttribution entirely via ApplyTransition's !HasTrigger +// guard, and on_exit/on_turn_start had no ledger-level coverage at all. A +// future edit dropping either from that whitelist would flip real rows from +// agent to system with a fully green suite otherwise. +func TestApplyTransitionRecordsEngineTransitionFromSessionID(t *testing.T) { + sessionOriginatedTriggers := []engine.Trigger{ + engine.TriggerOnTurnStart, engine.TriggerOnTurnComplete, engine.TriggerOnEnter, engine.TriggerOnExit, + } + for _, trigger := range sessionOriginatedTriggers { + t.Run(string(trigger), func(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + store := newWorkflowStore(repo, newMockStepGetter(), nil, noopPublisher, testLogger()) + if err := store.ApplyTransition(ctx, "t1", "s1", "step1", "step2", trigger); err != nil { + t.Fatalf("ApplyTransition: %v", err) + } + + rows := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerEngineTransition) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerEngineTransition) + } + if last.actorKind != string(steptelemetry.ActorAgent) { + t.Fatalf("actor_kind = %q, want %q", last.actorKind, steptelemetry.ActorAgent) + } + if last.actorID == nil || *last.actorID != "s1" { + t.Fatalf("actor_id = %v, want s1", last.actorID) + } + if last.sessionID == nil || *last.sessionID != "s1" { + t.Fatalf("session_id = %v, want s1", last.sessionID) + } + }) + } +} + +func TestApplyTransitionOnChildrenCompletedRecordsSystemActorNotSession(t *testing.T) { + // A children-completed rollup resolves the parent's active session only + // to satisfy the engine's MachineState API shape — that session did not + // cause the transition, the children finishing did. Per spec.md:191-194, + // this must record actor_kind=system with no actor_id/session_id, not + // actor_kind=agent attributed to a session that had nothing to do with + // it. Contrast with TestApplyTransitionRecordsEngineTransitionFromSessionID, + // where "on_turn_complete" is genuinely session-turn-originated and agent + // is correct. + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + store := newWorkflowStore(repo, newMockStepGetter(), nil, noopPublisher, testLogger()) + if err := store.ApplyTransition(ctx, "t1", "s1", "step1", "step2", "on_children_completed"); err != nil { + t.Fatalf("ApplyTransition: %v", err) + } + + rows := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerEngineTransition) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerEngineTransition) + } + if last.actorKind != string(steptelemetry.ActorSystem) { + t.Fatalf("actor_kind = %q, want %q (the resolved session did not cause this transition)", last.actorKind, steptelemetry.ActorSystem) + } + if last.actorID != nil { + t.Fatalf("actor_id = %v, want nil", last.actorID) + } + if last.sessionID != nil { + t.Fatalf("session_id = %v, want nil", last.sessionID) + } +} + +func TestApplyTransitionPreservesOuterCallerTrigger(t *testing.T) { + // Mirrors applyPendingMove: it wraps ctx with mcp_deferred_move before + // calling ApplyTransition, and that must survive rather than being + // overwritten by ApplyTransition's own engine_transition default. + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + deferredCtx := steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerMCPDeferredMove, ActorKind: steptelemetry.ActorAgent, + ActorID: "s1", SessionID: "s1", + }) + + store := newWorkflowStore(repo, newMockStepGetter(), nil, noopPublisher, testLogger()) + if err := store.ApplyTransition(deferredCtx, "t1", "s1", "step1", "step2", "on_enter"); err != nil { + t.Fatalf("ApplyTransition: %v", err) + } + + rows := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerMCPDeferredMove) { + t.Fatalf("trigger = %q, want %q (outer caller must win)", last.trigger, steptelemetry.TriggerMCPDeferredMove) + } +} + +func TestApplyTransitionRepeatedToSameTargetWritesNoSecondRow(t *testing.T) { + // The engine already deduplicates retried triggers by OperationID before + // ever reaching ApplyTransition, so no ledger-level idempotency key + // exists. This proves the independent safety net: even if a retry + // somehow reached this layer, the ledger's own no-op guard (identical + // from/to step) means it still cannot write a second row. + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + store := newWorkflowStore(repo, newMockStepGetter(), nil, noopPublisher, testLogger()) + if err := store.ApplyTransition(ctx, "t1", "s1", "step1", "step2", "on_turn_complete"); err != nil { + t.Fatalf("first ApplyTransition: %v", err) + } + before := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + + if err := store.ApplyTransition(ctx, "t1", "s1", "step1", "step2", "on_turn_complete"); err != nil { + t.Fatalf("retried ApplyTransition: %v", err) + } + after := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + + if len(after) != len(before) { + t.Fatalf("rows after retried transition = %d, want unchanged %d", len(after), len(before)) + } +} + +// TestApplyTransitionOnUserCancellationRecordsHumanActorNotAgent covers +// carlosflorencio's PR #2623 review point 2b: an explicit user cancellation +// reaches ApplyTransition through the same on_turn_complete code path as a +// natural completion, but must not be attributed to the session's agent. +// cancellationTransitionAttribution pre-wraps ctx (as processOnTurnComplete- +// WithCause and processOnTurnCompleteViaEngineWithCause do when cause is +// turnCompletionCauseUserCancellation) so the row records the cancelling +// human's identity instead — see spec.md's user_cancellation carve-out. +func TestApplyTransitionOnUserCancellationRecordsHumanActorNotAgent(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + ctx := authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-1", Role: authn.RoleMember}) + cancelCtx := cancellationTransitionAttribution(ctx) + + store := newWorkflowStore(repo, newMockStepGetter(), nil, noopPublisher, testLogger()) + if err := store.ApplyTransition(cancelCtx, "t1", "s1", "step1", "step2", engine.TriggerOnTurnComplete); err != nil { + t.Fatalf("ApplyTransition: %v", err) + } + + rows := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerUserCancellation) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerUserCancellation) + } + if last.actorKind != string(steptelemetry.ActorHuman) { + t.Fatalf("actor_kind = %q, want %q (cancellation is human-caused, not agent)", last.actorKind, steptelemetry.ActorHuman) + } + if last.actorID == nil || *last.actorID != "user-1" { + t.Fatalf("actor_id = %v, want user-1", last.actorID) + } + if last.sessionID != nil { + t.Fatalf("session_id = %v, want nil (the session is where the cancellation landed, not who caused it)", last.sessionID) + } +} + +// TestApplyTransitionOnUserCancellationWithNoIdentityRecordsSystemActor +// covers the unauthenticated-caller case (e.g. an automated cancellation with +// no request-scoped identity): cancellationTransitionAttribution must fall +// back to system, mirroring HumanOrSystemActor's manual_move behavior, not +// silently produce an empty/unknown actor. +func TestApplyTransitionOnUserCancellationWithNoIdentityRecordsSystemActor(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + cancelCtx := cancellationTransitionAttribution(context.Background()) + + store := newWorkflowStore(repo, newMockStepGetter(), nil, noopPublisher, testLogger()) + if err := store.ApplyTransition(cancelCtx, "t1", "s1", "step1", "step2", engine.TriggerOnTurnComplete); err != nil { + t.Fatalf("ApplyTransition: %v", err) + } + + rows := stepTransitionRowsForTaskOrchestrator(t, repo, "t1") + last := rows[len(rows)-1] + if last.trigger != string(steptelemetry.TriggerUserCancellation) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerUserCancellation) + } + if last.actorKind != string(steptelemetry.ActorSystem) { + t.Fatalf("actor_kind = %q, want %q", last.actorKind, steptelemetry.ActorSystem) + } + if last.actorID != nil { + t.Fatalf("actor_id = %v, want nil", last.actorID) + } +} + +// TestEngineTransitionAttributionPreservesUserCancellationTrigger pins the +// regression executeStepTransition's legacy on_turn_complete path would +// otherwise hit: before this fix, engineTransitionAttribution unconditionally +// overwrote whatever attribution ctx already carried, so a cancellation +// pre-wrap set by the caller would have been silently clobbered back to +// engine_transition/agent by executeStepTransition's own call. It must now +// respect the same outermost-caller-wins rule ApplyTransition's HasTrigger +// guard already enforces. +func TestEngineTransitionAttributionPreservesUserCancellationTrigger(t *testing.T) { + cancelCtx := cancellationTransitionAttribution(context.Background()) + + got := engineTransitionAttribution(cancelCtx, "s1", engine.TriggerOnTurnComplete) + + attribution := steptelemetry.FromContext(got) + if attribution.Trigger != steptelemetry.TriggerUserCancellation { + t.Fatalf("trigger = %q, want %q (outer caller must win)", attribution.Trigger, steptelemetry.TriggerUserCancellation) + } + if attribution.ActorKind == steptelemetry.ActorAgent { + t.Fatalf("actor_kind = %q, must not fall back to agent once a cancellation trigger is set", attribution.ActorKind) + } +} diff --git a/apps/backend/internal/orchestrator/workflow_store.go b/apps/backend/internal/orchestrator/workflow_store.go index d5932496ed..efc619ea95 100644 --- a/apps/backend/internal/orchestrator/workflow_store.go +++ b/apps/backend/internal/orchestrator/workflow_store.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/engine" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -182,7 +183,7 @@ func (s *workflowStore) LoadPreviousStep(ctx context.Context, workflowID string, return engine.CompileStep(step), nil } -func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, fromStepID, toStepID string, _ engine.Trigger) error { +func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, fromStepID, toStepID string, trigger engine.Trigger) error { task, err := s.repo.GetTask(ctx, taskID) if err != nil { return fmt.Errorf("load task for transition: %w", err) @@ -210,7 +211,14 @@ func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, delete(task.Metadata, models.MetaKeyQueuePromotionPending) } task.UpdatedAt = time.Now().UTC() - if err := s.updateTransitionTask(ctx, task, targetStep); err != nil { + // engine_transition applies only when no outer caller already declared a + // trigger — applyPendingMove sets mcp_deferred_move before reaching this + // path, and that must survive rather than be overwritten. + transitionCtx := ctx + if !steptelemetry.HasTrigger(transitionCtx) { + transitionCtx = engineTransitionAttribution(transitionCtx, sessionID, trigger) + } + if err := s.updateTransitionTask(transitionCtx, task, targetStep); err != nil { return fmt.Errorf("update task workflow step: %w", err) } @@ -252,6 +260,15 @@ func (s *workflowStore) updateTransitionTask(ctx context.Context, task *models.T } func (s *workflowStore) pullNextTaskOnVacate(ctx context.Context, vacatedStepID, excludeTaskID string) { + // A queue/WIP reconciliation is always wip_pull, unconditionally + // overriding whatever trigger the caller that vacated the step declared + // (or absent, for the ReconcileQueuedTasks restart sweep) — the vacating + // move and the resulting pull are two distinct ledger rows with two + // distinct causes, and no single session initiates a pull. + ctx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerWIPPull, + ActorKind: steptelemetry.ActorSystem, + }) vacatedStep := s.pullEnabledStep(ctx, vacatedStepID) if vacatedStep == nil { return diff --git a/apps/backend/internal/steptelemetry/metrics.go b/apps/backend/internal/steptelemetry/metrics.go new file mode 100644 index 0000000000..59802cd0a2 --- /dev/null +++ b/apps/backend/internal/steptelemetry/metrics.go @@ -0,0 +1,38 @@ +package steptelemetry + +import ( + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Metric event names live under the telemetry.metric.* namespace so a +// log-aggregation rule can match on the event name without scanning free +// text — the routing.metric.* precedent in internal/office/scheduler. +const ( + metricStepTransitionInserted = "telemetry.metric.step_transition_inserted" + metricTurnStamped = "telemetry.metric.turn_stamped" +) + +// RecordLedgerRow bumps the expvar counter for trigger AND emits a +// telemetry.metric.step_transition_inserted log line. Called once the INSERT +// succeeds, before the owning transaction commits — see the call site's +// comment for why this is a liveness signal, not a commit-confirmed count. +func RecordLedgerRow(log *logger.Logger, trigger Trigger) { + incStepTransition(trigger) + if log == nil { + return + } + log.Info(metricStepTransitionInserted, zap.String("trigger", string(trigger))) +} + +// RecordTurnStamp bumps the expvar counter keyed by whether a turn carried +// the workflow_step_id_at_start stamp, AND emits a telemetry.metric. +// turn_stamped log line. Called once per turn created. +func RecordTurnStamp(log *logger.Logger, present bool) { + incTurnStamp(present) + if log == nil { + return + } + log.Info(metricTurnStamped, zap.Bool("present", present)) +} diff --git a/apps/backend/internal/steptelemetry/metrics_test.go b/apps/backend/internal/steptelemetry/metrics_test.go new file mode 100644 index 0000000000..3d81dc45ac --- /dev/null +++ b/apps/backend/internal/steptelemetry/metrics_test.go @@ -0,0 +1,75 @@ +package steptelemetry + +import ( + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" +) + +func observerLogger(t *testing.T) (*logger.Logger, *observer.ObservedLogs) { + t.Helper() + core, logs := observer.New(zapcore.DebugLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("NewFromZap: %v", err) + } + return log, logs +} + +func TestRecordLedgerRowBumpsCounterAndLogs(t *testing.T) { + log, logs := observerLogger(t) + before := stepTransitionsTotal.String() + + RecordLedgerRow(log, TriggerManualMove) + + after := stepTransitionsTotal.String() + if after == before { + t.Fatal("expvar counter telemetry_step_transitions_inserted_total did not change") + } + + entries := logs.FilterMessage(metricStepTransitionInserted).All() + if len(entries) != 1 { + t.Fatalf("log entries = %d, want 1", len(entries)) + } + if entries[0].ContextMap()["trigger"] != string(TriggerManualMove) { + t.Fatalf("trigger field = %v, want %q", entries[0].ContextMap()["trigger"], TriggerManualMove) + } +} + +func TestRecordLedgerRowNoopWhenLoggerNil(t *testing.T) { + // Must not panic. + RecordLedgerRow(nil, TriggerBulkMove) +} + +func TestRecordTurnStampBumpsCounterAndLogsBothBranches(t *testing.T) { + log, logs := observerLogger(t) + + RecordTurnStamp(log, true) + RecordTurnStamp(log, false) + + entries := logs.FilterMessage(metricTurnStamped).All() + if len(entries) != 2 { + t.Fatalf("log entries = %d, want 2", len(entries)) + } + if entries[0].ContextMap()["present"] != true { + t.Fatalf("first entry present = %v, want true", entries[0].ContextMap()["present"]) + } + if entries[1].ContextMap()["present"] != false { + t.Fatalf("second entry present = %v, want false", entries[1].ContextMap()["present"]) + } +} + +func TestRecordTurnStampNoopWhenLoggerNil(t *testing.T) { + // Must not panic. + RecordTurnStamp(nil, true) +} + +func TestMetricLabelRejectsOddPairs(t *testing.T) { + if got := metricLabel("k1"); got != "" { + t.Fatalf("metricLabel with odd pairs = %q, want empty", got) + } +} diff --git a/apps/backend/internal/steptelemetry/metrics_vars.go b/apps/backend/internal/steptelemetry/metrics_vars.go new file mode 100644 index 0000000000..185de54efd --- /dev/null +++ b/apps/backend/internal/steptelemetry/metrics_vars.go @@ -0,0 +1,39 @@ +package steptelemetry + +import ( + "expvar" + "strings" +) + +// expvar maps published at package init, exposed via stdlib's /debug/vars +// handler (dev mode only — see internal/office/scheduler/metrics_vars.go for +// the precedent this mirrors). Counters only, keyed by trigger or by +// presence, so a process restart never needs to reconstruct a gauge. +var ( + stepTransitionsTotal = expvar.NewMap("telemetry_step_transitions_inserted_total") + turnStampsTotal = expvar.NewMap("telemetry_turn_stamps_total") +) + +// metricLabel builds a "k1=v1;k2=v2;..." label string for an expvar map key. +func metricLabel(pairs ...string) string { + if len(pairs)%2 != 0 { + return "" + } + parts := make([]string, 0, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + parts = append(parts, pairs[i]+"="+pairs[i+1]) + } + return strings.Join(parts, ";") +} + +func incStepTransition(trigger Trigger) { + stepTransitionsTotal.Add(metricLabel("trigger", string(trigger)), 1) +} + +func incTurnStamp(present bool) { + status := "absent" + if present { + status = "present" + } + turnStampsTotal.Add(metricLabel("stamp", status), 1) +} diff --git a/apps/backend/internal/steptelemetry/steptelemetry.go b/apps/backend/internal/steptelemetry/steptelemetry.go new file mode 100644 index 0000000000..aa5b3b17c4 --- /dev/null +++ b/apps/backend/internal/steptelemetry/steptelemetry.go @@ -0,0 +1,116 @@ +// Package steptelemetry carries the attribution (who and why) for a +// committed change to a task's workflow step, from the caller that knows it +// to the repository transaction that writes the task_step_transitions ledger +// row. It is imported by the sqlite repository, the task service, the +// orchestrator, and the MCP handlers; it imports none of them, so no import +// cycle is possible. +package steptelemetry + +import ( + "context" + + "github.com/kandev/kandev/internal/auth/authn" +) + +// ContractVersion is the collection-contract version stamped on every ledger +// row. Bump it (and register a new telemetrycontract activation) only for a +// change to what a row means, not for adding a new Trigger/ActorKind value. +const ContractVersion = 1 + +// ContractKey identifies this contract in the telemetry_activations registry. +const ContractKey = "task_step_transitions" + +// Trigger is why a task's workflow step changed. The enum is closed; +// unrecognised or unattributable causes use TriggerUnknown. +type Trigger string + +const ( + TriggerTaskCreated Trigger = "task_created" + TriggerManualMove Trigger = "manual_move" + TriggerTaskUpdate Trigger = "task_update" + TriggerMCPMove Trigger = "mcp_move" + TriggerMCPDeferredMove Trigger = "mcp_deferred_move" + TriggerEngineTransition Trigger = "engine_transition" + TriggerUserCancellation Trigger = "user_cancellation" + TriggerWIPPull Trigger = "wip_pull" + TriggerBulkMove Trigger = "bulk_move" + TriggerUnarchiveRestore Trigger = "unarchive_restore" + TriggerWorkflowAttached Trigger = "workflow_attached" + TriggerWorkflowDetached Trigger = "workflow_detached" + TriggerUnknown Trigger = "unknown" +) + +// ActorKind is what kind of thing caused the step change. The enum is +// closed; not-determinable causes use ActorUnknown. +type ActorKind string + +const ( + ActorHuman ActorKind = "human" + ActorAgent ActorKind = "agent" + ActorSystem ActorKind = "system" + ActorIntegration ActorKind = "integration" + ActorUnknown ActorKind = "unknown" +) + +// Attribution is who and why. The zero value is the spec's recorded fact for +// a caller that declares nothing: unknown trigger, unknown actor, no IDs. +type Attribution struct { + Trigger Trigger + ActorKind ActorKind + // ActorID is an opaque identifier — never a display name, email, title, + // or prompt text. For ActorKind agent it deliberately duplicates + // SessionID, so actor_id has one uniform meaning across every actor kind. + ActorID string + // SessionID is the initiating session, when one exists and is genuinely + // the initiator. Left empty when there is none or several candidates + // exist with no single initiator — never guessed. + SessionID string +} + +type attributionContextKey struct{} + +// WithAttribution attaches an Attribution to ctx for the repository +// transaction that eventually writes the ledger row to read back via +// FromContext. It does not overwrite an Attribution already on ctx unless +// the caller replaces it explicitly — see the package doc on outermost-caller +// wins for how callers should compose this. +func WithAttribution(ctx context.Context, a Attribution) context.Context { + return context.WithValue(ctx, attributionContextKey{}, a) +} + +// FromContext reads the Attribution on ctx, normalizing an absent one (or one +// with an empty Trigger/ActorKind) to unknown/unknown — a recorded fact, not +// an error, per the spec. +func FromContext(ctx context.Context) Attribution { + a, _ := ctx.Value(attributionContextKey{}).(Attribution) + if a.Trigger == "" { + a.Trigger = TriggerUnknown + } + if a.ActorKind == "" { + a.ActorKind = ActorUnknown + } + return a +} + +// HasTrigger reports whether ctx already carries an explicit (non-empty) +// trigger. Callers that must not clobber an outer caller's attribution +// (e.g. MoveTaskWithOptions, which an MCP move trigger must survive) check +// this before calling WithAttribution with their own default. +func HasTrigger(ctx context.Context) bool { + a, ok := ctx.Value(attributionContextKey{}).(Attribution) + return ok && a.Trigger != "" +} + +// HumanOrSystemActor resolves the actor kind/id from the identity already on +// ctx (the existing authn seam, not this package's own wiring): an +// authenticated identity (including the synthetic single-user identity +// injected when auth is disabled) is ActorHuman with that identity's UserID; +// no identity on ctx is ActorSystem with an empty ActorID. Used by call sites +// where "who did this" reduces to "whoever is making the request, or nobody" +// — task creation and manual board moves. +func HumanOrSystemActor(ctx context.Context) (ActorKind, string) { + if identity, ok := authn.IdentityFromContext(ctx); ok { + return ActorHuman, identity.UserID + } + return ActorSystem, "" +} diff --git a/apps/backend/internal/steptelemetry/steptelemetry_test.go b/apps/backend/internal/steptelemetry/steptelemetry_test.go new file mode 100644 index 0000000000..4c59ac6b9d --- /dev/null +++ b/apps/backend/internal/steptelemetry/steptelemetry_test.go @@ -0,0 +1,76 @@ +package steptelemetry + +import ( + "context" + "testing" +) + +func TestFromContextDefaultsToUnknownWhenNothingAttached(t *testing.T) { + a := FromContext(context.Background()) + if a.Trigger != TriggerUnknown { + t.Errorf("Trigger = %q, want %q", a.Trigger, TriggerUnknown) + } + if a.ActorKind != ActorUnknown { + t.Errorf("ActorKind = %q, want %q", a.ActorKind, ActorUnknown) + } + if a.ActorID != "" { + t.Errorf("ActorID = %q, want empty", a.ActorID) + } + if a.SessionID != "" { + t.Errorf("SessionID = %q, want empty", a.SessionID) + } +} + +func TestWithAttributionRoundTrips(t *testing.T) { + want := Attribution{ + Trigger: TriggerMCPMove, + ActorKind: ActorAgent, + ActorID: "session-1", + SessionID: "session-1", + } + ctx := WithAttribution(context.Background(), want) + got := FromContext(ctx) + if got != want { + t.Errorf("FromContext = %+v, want %+v", got, want) + } +} + +func TestFromContextNormalizesPartialAttribution(t *testing.T) { + // A caller that sets only a trigger still gets ActorUnknown, never a + // zero-value empty string treated specially. + ctx := WithAttribution(context.Background(), Attribution{Trigger: TriggerBulkMove}) + got := FromContext(ctx) + if got.Trigger != TriggerBulkMove { + t.Errorf("Trigger = %q, want %q", got.Trigger, TriggerBulkMove) + } + if got.ActorKind != ActorUnknown { + t.Errorf("ActorKind = %q, want %q", got.ActorKind, ActorUnknown) + } +} + +func TestHasTriggerDistinguishesAbsentFromExplicit(t *testing.T) { + if HasTrigger(context.Background()) { + t.Error("HasTrigger on bare context = true, want false") + } + ctx := WithAttribution(context.Background(), Attribution{Trigger: TriggerMCPMove}) + if !HasTrigger(ctx) { + t.Error("HasTrigger after WithAttribution = false, want true") + } +} + +func TestWithAttributionOuterCallerWinsWhenInnerCheckFirst(t *testing.T) { + // Simulates the outermost-caller-wins rule: MoveTaskWithOptions must set + // manual_move only when the context carries no trigger already, so an + // mcp_move set by the MCP handler survives the inner call. + outer := WithAttribution(context.Background(), Attribution{Trigger: TriggerMCPMove, ActorKind: ActorAgent, ActorID: "s1", SessionID: "s1"}) + + inner := outer + if !HasTrigger(inner) { + inner = WithAttribution(inner, Attribution{Trigger: TriggerManualMove, ActorKind: ActorHuman}) + } + + got := FromContext(inner) + if got.Trigger != TriggerMCPMove { + t.Errorf("Trigger after inner-defaulting = %q, want outer's %q to survive", got.Trigger, TriggerMCPMove) + } +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index fb0260772c..ca597a4640 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -254,6 +254,9 @@ func (m *mockRepository) DeleteMessage(ctx context.Context, id string) error { func (m *mockRepository) CreateTurn(ctx context.Context, turn *models.Turn) error { return nil } +func (m *mockRepository) CreateTurnWithStepStamp(ctx context.Context, turn *models.Turn) (bool, error) { + return false, nil +} func (m *mockRepository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { return nil, nil } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 0eba61e867..2d95aea69b 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -258,6 +258,10 @@ type GitCredentialSnapshot struct { // configuration attributed to one prompt/response turn. const TurnMetaKeyRuntimeConfigSnapshot = "runtime_config_snapshot" +// TurnMetaKeyWorkflowStepIDAtStart records the workflow step the turn's task +// was in when the turn started. Absent when the task held no step. +const TurnMetaKeyWorkflowStepIDAtStart = "workflow_step_id_at_start" + // SessionRuntimeConfig is persisted as provider state or explicit overrides. // On resume, explicit values take precedence over the latest provider snapshot // so delayed provider events cannot replace user intent. diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index dae300c636..5bee838921 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -223,6 +223,15 @@ type AttachmentRepository interface { // TurnRepository handles conversation turn persistence. type TurnRepository interface { CreateTurn(ctx context.Context, turn *models.Turn) error + // CreateTurnWithStepStamp creates turn atomically with the + // workflow-step-at-start stamp: it reads the task's current step and + // inserts the turn row in the same transaction, taking the same lock + // readTaskStepInTx takes for step moves, so the stamp reflects a state + // serialized against concurrent movers of the same task rather than a + // plain unlocked read taken before the insert. A task-step read failure + // (missing task, transient error) degrades to an unstamped turn rather + // than failing turn creation. Returns whether the stamp was applied. + CreateTurnWithStepStamp(ctx context.Context, turn *models.Turn) (stamped bool, err error) GetTurn(ctx context.Context, id string) (*models.Turn, error) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) UpdateTurn(ctx context.Context, turn *models.Turn) error diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 95ee6779f3..9b8b6ce5c4 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -20,6 +20,7 @@ func (r *Repository) initSchema() error { r.initWalkthroughsSchema, r.initDocumentsSchema, r.initSessionSchema, + r.initStepTransitionsSchema, r.initAttachmentsSchema, r.initTaskResourceCleanupSchema, r.initGitSchema, @@ -666,6 +667,46 @@ func (r *Repository) initSubagentContextSchema() error { return err } +// initStepTransitionsSchema creates task_step_transitions: one row per +// committed change to tasks.workflow_step_id. This is a new table, not a +// column added to an existing one, so CREATE TABLE IF NOT EXISTS in the init +// block is correct and complete — the "columns only via runMigrations" rule +// governs ALTER TABLE, not table creation. +// +// Deliberately no foreign key to workflow_steps or workflows: steps and +// workflows get deleted, and the historical fact that a card was in a +// now-deleted step must survive that deletion. +func (r *Repository) initStepTransitionsSchema() error { + idCol := "id INTEGER PRIMARY KEY AUTOINCREMENT" + if dialect.IsPostgres(r.db.DriverName()) { + idCol = "id BIGSERIAL PRIMARY KEY" + } + _, err := r.db.Exec(` + CREATE TABLE IF NOT EXISTS task_step_transitions ( + ` + idCol + `, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL, + from_workflow_id TEXT, + from_workflow_step_id TEXT, + to_workflow_id TEXT, + to_workflow_step_id TEXT, + trigger TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_id TEXT, + contract_version INTEGER NOT NULL, + occurred_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_task_step_transitions_task + ON task_step_transitions(task_id, occurred_at, id); + CREATE INDEX IF NOT EXISTS idx_task_step_transitions_occurred + ON task_step_transitions(occurred_at); + `) + if err != nil { + return fmt.Errorf("init step transitions schema: %w", err) + } + return nil +} + func (r *Repository) initMessageTurnSchema() error { _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS task_session_turns ( diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 462bb39935..50cd5a81a0 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -28,6 +28,61 @@ type taskSessionExecutor interface { // CreateTurn creates a new turn func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error { + stampTurnDefaults(turn) + return r.insertTurnRow(ctx, r.db, turn) +} + +// CreateTurnWithStepStamp is documented on the TurnRepository interface. It +// reads the task's current step inside a transaction that takes the same +// readTaskStepInTx lock a step move takes, so the read and the turn insert +// are serialized against concurrent movers of the same task row rather than +// racing a plain unlocked GetTask against a later, separate insert. A +// failure to open a transaction or read the step degrades to a plain, +// unstamped insert — see the spec's failure-modes table: turn creation must +// never fail because telemetry could not be resolved. +func (r *Repository) CreateTurnWithStepStamp(ctx context.Context, turn *models.Turn) (bool, error) { + stampTurnDefaults(turn) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, r.insertTurnRow(ctx, r.db, turn) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + _, stepID, found, stepErr := r.readTaskStepInTx(ctx, tx, turn.TaskID) + if stepErr != nil { + _ = tx.Rollback() + committed = true + return false, r.insertTurnRow(ctx, r.db, turn) + } + + stamped := false + if found && stepID != "" { + if turn.Metadata == nil { + turn.Metadata = map[string]interface{}{} + } + turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] = stepID + stamped = true + } + + if err := r.insertTurnRow(ctx, tx, turn); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + return stamped, nil +} + +// stampTurnDefaults fills in the ID/timestamp defaults CreateTurn and +// CreateTurnWithStepStamp both need before inserting. +func stampTurnDefaults(turn *models.Turn) { if turn.ID == "" { turn.ID = uuid.New().String() } @@ -39,7 +94,12 @@ func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error { turn.CreatedAt = now } turn.UpdatedAt = now +} +// insertTurnRow inserts turn's row via execer, which is either r.db (a plain, +// non-transactional insert) or a *sql.Tx (participating in the caller's +// transaction). +func (r *Repository) insertTurnRow(ctx context.Context, execer taskSessionExecutor, turn *models.Turn) error { metadataJSON := "{}" if turn.Metadata != nil { metadataBytes, err := json.Marshal(turn.Metadata) @@ -49,7 +109,7 @@ func (r *Repository) CreateTurn(ctx context.Context, turn *models.Turn) error { metadataJSON = string(metadataBytes) } - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + _, err := execer.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `), turn.ID, turn.TaskSessionID, turn.TaskID, turn.StartedAt, turn.CompletedAt, metadataJSON, turn.CreatedAt, turn.UpdatedAt) diff --git a/apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go b/apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go new file mode 100644 index 0000000000..1b01383abb --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go @@ -0,0 +1,72 @@ +package sqlite + +// Slice 1 (turn-start step stamp) adds no schema change: it's a new key in +// task_session_turns.metadata, an existing JSON column. This test pins the +// spec's scenario "a turn created before activation, when read after +// activation, carries no stamp and is not backfilled" — a pre-activation row +// is untouched by boot/replay, and there is no migration step to reverse. + +import ( + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" +) + +func TestPreActivationTurnIsNotBackfilledWithStepStamp(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "step-stamp-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() }) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize schema: %v", err) + } + + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-pre-stamp", "ws-pre-stamp", "Pre-existing task", now, now); err != nil { + t.Fatalf("seed pre-existing task: %v", err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "session-pre-stamp", "task-pre-stamp", "COMPLETED", now, now); err != nil { + t.Fatalf("seed pre-existing session: %v", err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `), "turn-pre-stamp", "session-pre-stamp", "task-pre-stamp", now, `{"runtime_config_snapshot":{"model":"legacy"}}`, now, now); err != nil { + t.Fatalf("seed pre-activation turn: %v", err) + } + + // Replay schema init/migrations twice more, mirroring a boot after + // activation. Nothing about this table's rows should change. + 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) + } + + turn, err := repo.GetTurn(t.Context(), "turn-pre-stamp") + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if _, ok := turn.Metadata["workflow_step_id_at_start"]; ok { + t.Fatalf("pre-activation turn was backfilled with a step stamp: %v", turn.Metadata) + } + if turn.Metadata["runtime_config_snapshot"] == nil { + t.Fatalf("pre-activation turn metadata lost its existing content: %v", turn.Metadata) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transition_writers_pin_test.go b/apps/backend/internal/task/repository/sqlite/step_transition_writers_pin_test.go new file mode 100644 index 0000000000..91fa415267 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transition_writers_pin_test.go @@ -0,0 +1,146 @@ +package sqlite + +// TestStepTransitionWritersArePinned is the writer-health control that +// matters most, per the spec: the realistic failure mode is not the ledger +// writer breaking, it's a NEW production statement mutating +// tasks.workflow_step_id that nobody wired into it. This walks the package's +// own source (go/parser, not a line regex — every statement here is a +// multi-line raw string literal, which a regex over raw lines misreports on) +// and asserts the exact set of functions containing such a statement matches +// the registered, ledger-wired set. Add a new mutating statement and this +// test fails until it is registered below AND wired to recordStepTransition. + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// registeredStepMutators is the closed set of functions in this package +// known to mutate tasks.workflow_step_id, each already wired to +// recordStepTransition. A new function containing a matching statement that +// is not in this set means either: (a) it is a genuine new step-mutation +// path — wire it to recordStepTransition and add it here; or (b) it is a +// false positive (e.g. a table-rebuild migration copying existing rows +// verbatim) — narrow the detection below rather than registering it. +var registeredStepMutators = []string{ + "insertTaskTx", + "updateTaskTx", + "UpdateTaskIfWorkflowStepHasCapacity", + "PromoteQueuedTaskIfWorkflowStepHasCapacity", + "RestoreTaskMessageRollbackIfSessionState", + "AddTaskToWorkflow", + "RemoveTaskFromWorkflow", +} + +func TestStepTransitionWritersArePinned(t *testing.T) { + found, err := findWorkflowStepIDMutators(t, ".") + if err != nil { + t.Fatalf("scan package for workflow_step_id mutators: %v", err) + } + + registered := make(map[string]bool, len(registeredStepMutators)) + for _, name := range registeredStepMutators { + registered[name] = true + } + + var unregistered []string + for name := range found { + if !registered[name] { + unregistered = append(unregistered, name) + } + } + if len(unregistered) > 0 { + sort.Strings(unregistered) + t.Fatalf( + "function(s) %v contain a statement that mutates tasks.workflow_step_id "+ + "but are not registered in registeredStepMutators. If this is a genuine "+ + "new step-mutation path: wire it to (*Repository).recordStepTransition "+ + "inside its own transaction (see step_transitions.go), then add its name "+ + "to registeredStepMutators in this file. If this is a false positive "+ + "(e.g. a migration copying rows verbatim), narrow the detection in "+ + "findWorkflowStepIDMutators instead of registering it.", + unregistered) + } + + var missing []string + for _, name := range registeredStepMutators { + if !found[name] { + missing = append(missing, name) + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Fatalf( + "registeredStepMutators lists %v but no matching statement was found in "+ + "this package — the function was likely renamed, removed, or its SQL "+ + "literal changed shape. Update registeredStepMutators to match reality.", + missing) + } +} + +// findWorkflowStepIDMutators parses every non-test .go file directly in dir +// and returns the set of top-level function names whose body contains a +// string literal that either inserts a new tasks row with an explicit +// workflow_step_id column, or updates the tasks table and assigns +// workflow_step_id. +func findWorkflowStepIDMutators(t *testing.T, dir string) (map[string]bool, error) { + t.Helper() + fset := token.NewFileSet() + found := make(map[string]bool) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0) + if err != nil { + return nil, err + } + ast.Inspect(file, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Body == nil { + return true + } + if functionMutatesWorkflowStepID(fn.Body) { + found[fn.Name.Name] = true + } + return true + }) + } + return found, nil +} + +func functionMutatesWorkflowStepID(body *ast.BlockStmt) bool { + mutates := false + ast.Inspect(body, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + if literalMutatesWorkflowStepID(strings.Trim(lit.Value, "`\"")) { + mutates = true + } + return true + }) + return mutates +} + +func literalMutatesWorkflowStepID(sql string) bool { + insertsWithColumn := strings.Contains(sql, "INSERT INTO tasks (") && strings.Contains(sql, "workflow_step_id") + // "workflow_step_id = " (not just "= ?") also catches an UPDATE that + // assigns a literal value directly, e.g. RemoveTaskFromWorkflow's + // "workflow_step_id = ''" — a parameter-only pattern would miss it. + updatesColumn := strings.Contains(sql, "UPDATE tasks") && strings.Contains(sql, "workflow_step_id = ") + return insertsWithColumn || updatesColumn +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions.go b/apps/backend/internal/task/repository/sqlite/step_transitions.go new file mode 100644 index 0000000000..c52416cd65 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions.go @@ -0,0 +1,170 @@ +package sqlite + +import ( + "context" + "database/sql" + "time" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/steptelemetry" +) + +// stepTransitionTx is satisfied by both *sql.Tx and *sqlx.Tx, the two +// transaction types the mutation paths in this package use. +type stepTransitionTx interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// readTaskStepInTx reads the task's current (workflow_id, workflow_step_id) +// inside the write transaction, taking a row lock on Postgres. Reading the +// old step outside the write transaction — or from an in-memory *models.Task +// the caller handed in — would break the chain invariant under concurrent +// moves: the read of the old step and the write of the new one must be +// serialized against other writers of the same task row. On SQLite the +// writer pool already serializes writers, so the plain read is sufficient. +func (r *Repository) readTaskStepInTx(ctx context.Context, tx stepTransitionTx, taskID string) (workflowID, stepID string, found bool, err error) { + query := `SELECT workflow_id, workflow_step_id FROM tasks WHERE id = ?` + if dialect.IsPostgres(r.db.DriverName()) { + query += ` FOR UPDATE` + } + var wf, step sql.NullString + err = tx.QueryRowContext(ctx, r.db.Rebind(query), taskID).Scan(&wf, &step) + if err == sql.ErrNoRows { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + return wf.String, step.String, true, nil +} + +// stepTransitionInput is the fully-resolved shape of one ledger row, with +// empty strings still present — recordStepTransition normalizes them to NULL. +type stepTransitionInput struct { + taskID string + fromWorkflowID string + fromWorkflowStepID string + toWorkflowID string + toWorkflowStepID string + // occurredAt is the caller's own timestamp for this transition — the same + // value the caller already stamped onto tasks.updated_at (or created_at + // for the genesis row), computed once after the transactional old-state + // read/lock. Callers MUST NOT leave this zero: recordStepTransition does + // not call time.Now() itself, because a second, independent clock read + // here (after the caller's) can drift from tasks.updated_at under lock + // contention, breaking the spec's "occurred_at and updated_at agree at + // write time" guarantee. + occurredAt time.Time +} + +// recordStepTransition writes exactly one ledger row when the step actually +// changed. It is a no-op when fromStepID == toStepID (position-only reorder, +// re-issued move to the current step) and when both sides are empty (a task +// with no workflow at all — a row with both sides NULL is forbidden). +// +// The missing-table case needs no detection code and must not get any: if +// the CREATE TABLE was silently swallowed by the migration runner, the +// INSERT fails with "no such table" / "relation does not exist", that error +// returns unchanged, and the enclosing transaction rolls back — which is +// precisely the spec's required behaviour (the step change does not commit +// with no row). Never log-and-continue on this error. +func (r *Repository) recordStepTransition(ctx context.Context, tx stepTransitionTx, in stepTransitionInput) error { + if in.fromWorkflowStepID == in.toWorkflowStepID { + return nil + } + if in.fromWorkflowStepID == "" && in.toWorkflowStepID == "" { + return nil + } + + occurredAt := in.occurredAt + if occurredAt.IsZero() { + // Safety net for a caller that forgot to stamp its own shared + // timestamp, not the expected path — every registered chokepoint + // passes its own tasks.updated_at/created_at value. + occurredAt = time.Now().UTC() + } + + attribution := steptelemetry.FromContext(ctx) + _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_step_transitions + (task_id, session_id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, actor_id, contract_version, occurred_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + in.taskID, + nullableString(attribution.SessionID), + nullableString(in.fromWorkflowID), + nullableString(in.fromWorkflowStepID), + nullableString(in.toWorkflowID), + nullableString(in.toWorkflowStepID), + string(attribution.Trigger), + string(attribution.ActorKind), + nullableString(attribution.ActorID), + steptelemetry.ContractVersion, + occurredAt, + ) + if err != nil { + return err + } + // Counted at INSERT time, not at commit: a later statement in the same + // caller-owned transaction failing after this point (rare — e.g. the + // runner sync in UpdateTask) rolls the row back but the counter still + // bumped. The counter (and its "_inserted_" name) is a health signal + // ("is the writer alive"), not a commit-confirmed row-for-row audit + // trail; the ledger table itself is that audit trail. + steptelemetry.RecordLedgerRow(r.log, attribution.Trigger) + return nil +} + +// genesisAttribution is the task-creation ledger row's attribution: the +// writer hard-codes TriggerTaskCreated (there is exactly one INSERT path, so +// no caller disambiguation is needed the way the other six chokepoints need +// it). See hardcodedTriggerAttribution for the actor-resolution rule shared +// with detachAttribution. +func genesisAttribution(ctx context.Context) steptelemetry.Attribution { + return hardcodedTriggerAttribution(ctx, steptelemetry.TriggerTaskCreated) +} + +// detachAttribution is RemoveTaskFromWorkflow's ledger row attribution: the +// writer hard-codes TriggerWorkflowDetached rather than relying on a caller +// to supply it. Unlike AddTaskToWorkflow — whose one production caller +// (office/engine_adapters/workflow_switcher_adapter.go) explicitly sets +// TriggerWorkflowAttached before calling — RemoveTaskFromWorkflow has no +// production caller today, so there is no wrapper anywhere to get this +// right; hardcoding it here means a future caller cannot get it wrong. +func detachAttribution(ctx context.Context) steptelemetry.Attribution { + return hardcodedTriggerAttribution(ctx, steptelemetry.TriggerWorkflowDetached) +} + +// hardcodedTriggerAttribution is the actor-resolution rule shared by ledger +// chokepoints whose trigger has exactly one semantic meaning regardless of +// caller. The actor prefers an explicit caller-supplied attribution when one +// is already on ctx (e.g. the watcher dispatch coordinator setting +// ActorIntegration for a Jira/Linear-originated create) and otherwise falls +// back to the identity already on the context — the existing authn seam. +func hardcodedTriggerAttribution(ctx context.Context, trigger steptelemetry.Trigger) steptelemetry.Attribution { + if preset := steptelemetry.FromContext(ctx); preset.ActorKind != steptelemetry.ActorUnknown { + return steptelemetry.Attribution{ + Trigger: trigger, + ActorKind: preset.ActorKind, + ActorID: preset.ActorID, + SessionID: preset.SessionID, + } + } + actorKind, actorID := steptelemetry.HumanOrSystemActor(ctx) + return steptelemetry.Attribution{ + Trigger: trigger, + ActorKind: actorKind, + ActorID: actorID, + } +} + +// nullableString normalizes "" to SQL NULL: tasks.workflow_id and +// tasks.workflow_step_id use "" for "no step", but the ledger stores NULL for +// the same condition in every from_*/to_* column. +func nullableString(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_actor_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_actor_test.go new file mode 100644 index 0000000000..0e075fb3a3 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_actor_test.go @@ -0,0 +1,152 @@ +package sqlite + +// Actor and privacy coverage: the synthetic auth-disabled identity records as +// human, a watcher-originated genesis row records as integration with the +// watch ID, and no text column of any row leaks a display name, email, +// title, or prompt across the full trigger matrix. + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/steptelemetry" + "github.com/kandev/kandev/internal/task/models" +) + +func TestGenesisRowSyntheticIdentityRecordsHumanWithUserID(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := authn.WithIdentity(context.Background(), authn.Identity{ + UserID: "synthetic-user", Role: authn.RoleAdmin, Synthetic: true, + }) + createStepTransitionsTestTaskWithCtx(t, repo, ctx, "task-synthetic-actor", "wf-1", "step-a") + + rows := stepTransitionRowsForTask(t, repo, "task-synthetic-actor") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].actorKind != string(steptelemetry.ActorHuman) { + t.Fatalf("actor_kind = %q, want %q", rows[0].actorKind, steptelemetry.ActorHuman) + } + if rows[0].actorID == nil || *rows[0].actorID != "synthetic-user" { + t.Fatalf("actor_id = %v, want synthetic-user", rows[0].actorID) + } +} + +func TestGenesisRowIntegrationActorRecordsWatchID(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + ActorKind: steptelemetry.ActorIntegration, ActorID: "jira-watch-42", + }) + createStepTransitionsTestTaskWithCtx(t, repo, ctx, "task-integration-actor", "wf-1", "step-a") + + rows := stepTransitionRowsForTask(t, repo, "task-integration-actor") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].actorKind != string(steptelemetry.ActorIntegration) { + t.Fatalf("actor_kind = %q, want %q", rows[0].actorKind, steptelemetry.ActorIntegration) + } + if rows[0].actorID == nil || *rows[0].actorID != "jira-watch-42" { + t.Fatalf("actor_id = %v, want jira-watch-42", rows[0].actorID) + } + if rows[0].trigger != string(steptelemetry.TriggerTaskCreated) { + t.Fatalf("trigger = %q, want %q (genesis is always hard-coded)", rows[0].trigger, steptelemetry.TriggerTaskCreated) + } +} + +// TestGenesisRowAgentActorRecordsSessionID closes the should-fix gap Review +// round 3 found: hardcodedTriggerAttribution's preset branch forwarded +// ActorKind/ActorID but not SessionID, unlike workflowAttachedAttribution +// (its own doc comment claims to mirror). This was dormant when found (no +// caller set a preset with SessionID through this path) but went live the +// moment CreateChildTaskCallback.Execute (must-fix #1, this same round) +// started presetting ActorAgent+SessionID before task creation — without +// this fix, a session-caused child task's genesis row would carry the +// right actor_id but a NULL session_id. +func TestGenesisRowAgentActorRecordsSessionID(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + createStepTransitionsTestTask(t, repo, "parent-task", "wf-1", "step-a") + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: "causal-sess-1", TaskID: "parent-task", State: models.TaskSessionStateRunning, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + ActorKind: steptelemetry.ActorAgent, ActorID: "causal-sess-1", SessionID: "causal-sess-1", + }) + createStepTransitionsTestTaskWithCtx(t, repo, ctx, "task-genesis-agent", "wf-1", "step-a") + + rows := stepTransitionRowsForTask(t, repo, "task-genesis-agent") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].sessionID == nil || *rows[0].sessionID != "causal-sess-1" { + t.Fatalf("session_id = %v, want causal-sess-1", rows[0].sessionID) + } +} + +// TestNoSensitiveTextLeaksIntoNonActorColumns scans every text column of +// rows produced against a fixture whose title/description carry a PII-shaped +// sentinel (display-name-and-email-shaped, distinct from the actor_id +// sentinel below) that production code never derives actor_id from and must +// never copy anywhere. Review round 4 found the prior version of this test +// tautological: the sentinel was injected only as actor_id and the fixture's +// title/description were the unrelated hardcoded literal "Test Task", so the +// scanned columns (from_*/to_*/session_id/trigger/actor_kind) could never +// contain it regardless of whether a real leak existed — the test passed by +// construction. This version seeds the PII sentinel into fields a bug could +// plausibly copy from (title, description) and scans EVERY text column, +// including actor_id, for it. +func TestNoSensitiveTextLeaksIntoNonActorColumns(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + const piiSentinel = "SENTINEL-Jane-Doe-jane@example.com-please-do-the-thing" + const actorSentinel = "SENTINEL-ACTOR-9f3c2b1a-opaque-identifier" + + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerManualMove, ActorKind: steptelemetry.ActorHuman, ActorID: actorSentinel, + }) + task := &models.Task{ + ID: "task-privacy", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-a", + Title: piiSentinel, Description: piiSentinel, Priority: "medium", + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-privacy") + if len(rows) == 0 { + t.Fatal("no ledger rows for task-privacy") + } + for _, row := range rows { + for _, col := range []*string{ + row.fromWorkflowID, row.fromWorkflowStepID, row.toWorkflowID, row.toWorkflowStepID, + row.sessionID, row.actorID, + } { + if col != nil && strings.Contains(*col, piiSentinel) { + t.Fatalf("PII sentinel leaked into a ledger column: %q", *col) + } + } + if strings.Contains(row.trigger, piiSentinel) { + t.Fatalf("PII sentinel leaked into trigger column: %q", row.trigger) + } + if strings.Contains(row.actorKind, piiSentinel) { + t.Fatalf("PII sentinel leaked into actor_kind column: %q", row.actorKind) + } + } + + // Separate positive assertion: a legitimate opaque caller-supplied + // actor_id round-trips unmodified (not truncated, not hashed) — this + // keeps the leak-detection loop above honest by proving actor_id really + // is populated and compared, not vacuously empty or always absent. + last := rows[len(rows)-1] + if last.actorID == nil || *last.actorID != actorSentinel { + t.Fatalf("actor_id = %v, want the caller-supplied identifier %q", last.actorID, actorSentinel) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_chain_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_chain_test.go new file mode 100644 index 0000000000..53e58bd464 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_chain_test.go @@ -0,0 +1,203 @@ +package sqlite + +// Ordering, chain, and concurrency coverage: reading a task's rows in +// (occurred_at, id) order, each row's from equals the previous row's to, and +// the last row's to equals the task's current step — including under +// concurrent moves, where the chain invariant is what the writer's +// read-inside-the-transaction discipline (readTaskStepInTx) exists to +// protect (see step_transitions.go). + +import ( + "context" + "testing" + "time" + + "golang.org/x/sync/errgroup" +) + +func assertChainIntact(t *testing.T, repo *Repository, taskID string) []stepTransitionRow { + t.Helper() + rows := stepTransitionRowsForTask(t, repo, taskID) + for i := 1; i < len(rows); i++ { + prevTo := "" + if rows[i-1].toWorkflowStepID != nil { + prevTo = *rows[i-1].toWorkflowStepID + } + curFrom := "" + if rows[i].fromWorkflowStepID != nil { + curFrom = *rows[i].fromWorkflowStepID + } + if prevTo != curFrom { + t.Fatalf("chain broken at row %d: previous to_workflow_step_id = %q, this row's from_workflow_step_id = %q", i, prevTo, curFrom) + } + } + if len(rows) > 0 { + last := rows[len(rows)-1] + task, err := repo.GetTask(context.Background(), taskID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + lastTo := "" + if last.toWorkflowStepID != nil { + lastTo = *last.toWorkflowStepID + } + if lastTo != task.WorkflowStepID { + t.Fatalf("last row's to_workflow_step_id = %q, want it to equal tasks.workflow_step_id = %q", lastTo, task.WorkflowStepID) + } + } + return rows +} + +func TestChainABABYieldsFourRowsWithIntactChain(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-chain", "wf-1", "step-a") + + moves := []string{"step-b", "step-a", "step-b"} + for _, step := range moves { + task.WorkflowStepID = step + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask to %s: %v", step, err) + } + } + + rows := assertChainIntact(t, repo, "task-chain") + if len(rows) != 4 { + t.Fatalf("rows = %d, want 4 (genesis + 3 moves)", len(rows)) + } +} + +func TestChainRowsSharingOccurredAtAreTotallyOrderedByID(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-chain-tiebreak", "wf-1", "step-a") + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + task.WorkflowStepID = "step-c" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + // Force two rows to share an occurred_at value directly, then confirm + // reading ordered by (occurred_at, id) still produces a strictly + // increasing id sequence — id is the tiebreak, not insertion luck. + rows := stepTransitionRowsForTask(t, repo, "task-chain-tiebreak") + if len(rows) < 2 { + t.Fatalf("rows = %d, want at least 2", len(rows)) + } + if _, err := repo.db.Exec(repo.db.Rebind(`UPDATE task_step_transitions SET occurred_at = ? WHERE id = ?`), rows[0].occurredAt, rows[1].id); err != nil { + t.Fatalf("force shared occurred_at: %v", err) + } + + reread := stepTransitionRowsForTask(t, repo, "task-chain-tiebreak") + for i := 1; i < len(reread); i++ { + if reread[i].id <= reread[i-1].id { + t.Fatalf("row %d id = %d, want strictly greater than previous id %d", i, reread[i].id, reread[i-1].id) + } + } +} + +func TestChainSurvivesBackwardsClockCorrectionWhenOrderedByID(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-chain-clock", "wf-1", "step-a") + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask to step-b: %v", err) + } + task.WorkflowStepID = "step-c" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask to step-c: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-chain-clock") + if len(rows) != 3 { + t.Fatalf("rows = %d, want 3 (genesis + 2 moves)", len(rows)) + } + + // Simulate a host clock correction: force the most recent row's + // occurred_at to be earlier than the genesis row's, without touching + // its id. + corrected := rows[0].occurredAt.Add(-1 * time.Hour) + if _, err := repo.db.Exec(repo.db.Rebind(`UPDATE task_step_transitions SET occurred_at = ? WHERE id = ?`), corrected, rows[2].id); err != nil { + t.Fatalf("force backwards occurred_at: %v", err) + } + + idOrdered, err := repo.db.Query(repo.db.Rebind(` + SELECT id, from_workflow_step_id, to_workflow_step_id, occurred_at + FROM task_step_transitions WHERE task_id = ? ORDER BY id ASC + `), "task-chain-clock") + if err != nil { + t.Fatalf("query ordered by id: %v", err) + } + defer func() { _ = idOrdered.Close() }() + + var reread []stepTransitionRow + for idOrdered.Next() { + var r stepTransitionRow + if err := idOrdered.Scan(&r.id, &r.fromWorkflowStepID, &r.toWorkflowStepID, &r.occurredAt); err != nil { + t.Fatalf("scan: %v", err) + } + reread = append(reread, r) + } + if err := idOrdered.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + if len(reread) != 3 { + t.Fatalf("reread rows = %d, want 3", len(reread)) + } + + // The chain invariant (from == previous row's to) still holds when read + // in id order, even though occurred_at now runs backwards. + for i := 1; i < len(reread); i++ { + prevTo := "" + if reread[i-1].toWorkflowStepID != nil { + prevTo = *reread[i-1].toWorkflowStepID + } + curFrom := "" + if reread[i].fromWorkflowStepID != nil { + curFrom = *reread[i].fromWorkflowStepID + } + if prevTo != curFrom { + t.Fatalf("chain broken at row %d: previous to = %q, this row's from = %q", i, prevTo, curFrom) + } + } + + // The ledger is not repaired or reordered by timestamp: the forced + // backwards value survives untouched. + if !reread[2].occurredAt.Equal(corrected) { + t.Fatalf("last row's occurred_at = %v, want unrepaired value %v", reread[2].occurredAt, corrected) + } + if !reread[2].occurredAt.Before(reread[0].occurredAt) { + t.Fatalf("expected last row's occurred_at %v to remain before genesis row's %v (clock ran backwards, uncorrected)", reread[2].occurredAt, reread[0].occurredAt) + } +} + +func TestChainConcurrentMovesProduceTwoRowsWithIntactChain(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + task := createStepTransitionsTestTask(t, repo, "task-concurrent", "wf-1", "step-a") + + g, ctx := errgroup.WithContext(context.Background()) + g.Go(func() error { + moved := *task + moved.WorkflowStepID = "step-b" + return repo.UpdateTask(ctx, &moved) + }) + g.Go(func() error { + moved := *task + moved.WorkflowStepID = "step-c" + return repo.UpdateTask(ctx, &moved) + }) + if err := g.Wait(); err != nil { + t.Fatalf("concurrent UpdateTask: %v", err) + } + + rows := assertChainIntact(t, repo, "task-concurrent") + if len(rows) != 3 { + t.Fatalf("rows = %d, want 3 (genesis + 2 concurrent moves)", len(rows)) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_lifecycle_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_lifecycle_test.go new file mode 100644 index 0000000000..54489256c1 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_lifecycle_test.go @@ -0,0 +1,210 @@ +package sqlite + +// Lifecycle coverage for task_step_transitions: archive/unarchive/delete +// paths that never touch workflow_step_id write no row, a row survives +// deletion of the workflow step it names (no FK), the missing-table case +// fails the transaction rather than committing the step change alone, and +// cascade behavior on task/session deletion. + +import ( + "context" + "testing" + "time" + + dbutil "github.com/kandev/kandev/internal/db" +) + +func TestArchiveUnarchiveDeleteWriteNoRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + + createStepTransitionsTestTask(t, repo, "task-archive", "wf-1", "step-a") + before := stepTransitionRowsForTask(t, repo, "task-archive") + + if err := repo.ArchiveTask(ctx, "task-archive"); err != nil { + t.Fatalf("ArchiveTask: %v", err) + } + afterArchive := stepTransitionRowsForTask(t, repo, "task-archive") + if len(afterArchive) != len(before) { + t.Fatalf("rows after archive = %d, want unchanged %d", len(afterArchive), len(before)) + } + + if _, err := repo.UnarchiveTask(ctx, "task-archive"); err != nil { + t.Fatalf("UnarchiveTask: %v", err) + } + afterUnarchive := stepTransitionRowsForTask(t, repo, "task-archive") + if len(afterUnarchive) != len(before) { + t.Fatalf("rows after unarchive = %d, want unchanged %d", len(afterUnarchive), len(before)) + } +} + +func TestArchiveTaskIfActiveWritesNoRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + + createStepTransitionsTestTask(t, repo, "task-cascade-archive", "wf-1", "step-a") + before := stepTransitionRowsForTask(t, repo, "task-cascade-archive") + + archived, err := repo.ArchiveTaskIfActive(ctx, "task-cascade-archive", "cascade-1") + if err != nil { + t.Fatalf("ArchiveTaskIfActive: %v", err) + } + if !archived { + t.Fatal("expected ArchiveTaskIfActive to report the task as archived") + } + + after := stepTransitionRowsForTask(t, repo, "task-cascade-archive") + if len(after) != len(before) { + t.Fatalf("rows after cascade archive = %d, want unchanged %d", len(after), len(before)) + } +} + +func TestDeleteTaskCascadesLedgerRows(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + createStepTransitionsTestTask(t, repo, "task-delete", "wf-1", "step-a") + + rows := stepTransitionRowsForTask(t, repo, "task-delete") + if len(rows) == 0 { + t.Fatal("expected a genesis row before delete") + } + + if err := repo.DeleteTask(ctx, "task-delete"); err != nil { + t.Fatalf("DeleteTask: %v", err) + } + + after := stepTransitionRowsForTask(t, repo, "task-delete") + if len(after) != 0 { + t.Fatalf("rows after task delete = %d, want 0 (cascade)", len(after)) + } +} + +func TestLedgerRowSurvivesSessionDeletionWithSessionIDNull(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-session-cascade", "ws-1", "Task", time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "session-cascade", "task-session-cascade", "RUNNING", time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatalf("seed session: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_step_transitions + (task_id, session_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, contract_version, occurred_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), "task-session-cascade", "session-cascade", "wf-1", "step-a", "manual_move", "agent", 1, time.Now().UTC()); err != nil { + t.Fatalf("seed ledger row: %v", err) + } + + if _, err := repo.db.Exec(repo.db.Rebind(`DELETE FROM task_sessions WHERE id = ?`), "session-cascade"); err != nil { + t.Fatalf("delete session: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-session-cascade") + if len(rows) != 1 { + t.Fatalf("rows after session delete = %d, want 1 (row survives)", len(rows)) + } + if rows[0].sessionID != nil { + t.Fatalf("session_id after session delete = %v, want nil", rows[0].sessionID) + } +} + +func TestLedgerRowSurvivesDeletionOfTheWorkflowStepItNames(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + createStepTransitionsTestTask(t, repo, "task-step-deleted", "wf-1", "step-doomed") + + // There is deliberately no FK to workflow_steps, so nothing here needs to + // "delete a step" — the point is simply that the row's step reference + // keeps working with no matching workflow_steps row at all. + rows := stepTransitionRowsForTask(t, repo, "task-step-deleted") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].toWorkflowStepID == nil || *rows[0].toWorkflowStepID != "step-doomed" { + t.Fatalf("to_workflow_step_id = %v, want step-doomed (survives with no matching workflow_steps row)", rows[0].toWorkflowStepID) + } +} + +func TestMissingLedgerTableFailsTransactionRatherThanCommittingStepChangeAlone(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-missing-table", "wf-1", "step-a") + + if _, err := repo.db.Exec(`DROP TABLE task_step_transitions`); err != nil { + t.Fatalf("drop task_step_transitions: %v", err) + } + + task.WorkflowStepID = "step-b" + err := repo.UpdateTask(ctx, task) + if err == nil { + t.Fatal("expected UpdateTask to fail when the ledger table is missing") + } + if !dbutil.IsMissingTableError(err) { + t.Fatalf("error = %v, want a missing-table error", err) + } + + reread, err := repo.GetTask(ctx, "task-missing-table") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if reread.WorkflowStepID != "step-a" { + t.Fatalf("task step after failed transaction = %q, want unchanged %q (rollback)", reread.WorkflowStepID, "step-a") + } +} + +// TestRolledBackTransactionLeavesNoLedgerRow proves the spec's other rollback +// scenario: unlike the test above (the ledger INSERT itself fails), here the +// step UPDATE and the ledger INSERT both succeed, and only a LATER statement +// in the same caller-owned transaction fails — UpdateTask's runner-participant +// sync (syncRunnerInTx), the concrete case the plan's own scope decision #4 +// names. The whole transaction must still roll back: no ledger row, and the +// task's workflow_step_id unchanged. TestMissingLedgerTableFailsTransactionRatherThanCommittingStepChangeAlone +// does not cover this — it proves the inverse (ledger insert fails). +func TestRolledBackTransactionLeavesNoLedgerRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + + task := createStepTransitionsTestTask(t, repo, "task-rollback", "wf-1", "step-a") + task.AssigneeAgentProfileID = "agent-profile-1" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("seed UpdateTask: %v", err) + } + + // Fires after UpdateTask's own recordStepTransition call (which runs + // before syncRunnerInTx), forcing the INSERT into + // workflow_step_participants that upsertRunnerInTx issues for this + // step/task/agent-profile combination to fail. + if _, err := repo.db.Exec(` + CREATE TRIGGER fail_runner_participant_insert + BEFORE INSERT ON workflow_step_participants + BEGIN SELECT RAISE(ABORT, 'injected runner-sync failure'); END`); err != nil { + t.Fatalf("create failure trigger: %v", err) + } + + before := stepTransitionRowsForTask(t, repo, "task-rollback") + + task.WorkflowStepID = "step-b" + err := repo.UpdateTask(ctx, task) + if err == nil { + t.Fatal("expected UpdateTask to fail from the injected runner-sync failure") + } + + after := stepTransitionRowsForTask(t, repo, "task-rollback") + if len(after) != len(before) { + t.Fatalf("rows after rolled-back transaction = %d, want unchanged %d (the ledger INSERT that succeeded mid-transaction must roll back with everything else)", len(after), len(before)) + } + + reread, err := repo.GetTask(ctx, "task-rollback") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if reread.WorkflowStepID != "step-a" { + t.Fatalf("task step after rolled-back transaction = %q, want unchanged %q", reread.WorkflowStepID, "step-a") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go new file mode 100644 index 0000000000..dc8be5e91b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go @@ -0,0 +1,121 @@ +package sqlite + +// Migration coverage for task_step_transitions, mirroring +// task_external_id_migration_test.go: a fresh boot creates the table, an +// existing task gains no rows, and the schema-init step is replay-safe. + +import ( + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" +) + +func TestStepTransitionsSchemaCreatesTableAndIsReplaySafe(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "step-transitions-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() }) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize schema: %v", err) + } + + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-pre-ledger", "ws-pre-ledger", "Pre-existing task", now, now); err != nil { + t.Fatalf("seed pre-existing task: %v", err) + } + + var count int + if err := db.Get(&count, db.Rebind(` + SELECT COUNT(*) FROM task_step_transitions WHERE task_id = ? + `), "task-pre-ledger"); err != nil { + t.Fatalf("query task_step_transitions: %v", err) + } + if count != 0 { + t.Fatalf("pre-existing task has %d ledger rows, want 0 (nothing is backfilled)", count) + } + + var tableName string + if err := db.Get(&tableName, ` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'task_step_transitions' + `); err != nil { + t.Fatalf("task_step_transitions table is missing: %v", err) + } + + for _, idx := range []string{"idx_task_step_transitions_task", "idx_task_step_transitions_occurred"} { + var indexName string + if err := db.Get(&indexName, db.Rebind(` + SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? + `), idx); err != nil { + t.Fatalf("index %s is missing: %v", idx, err) + } + } + + // Replay: schema init must be idempotent across repeated boots. + if err := repo.initStepTransitionsSchema(); err != nil { + t.Fatalf("replay initStepTransitionsSchema: %v", err) + } + if err := repo.initStepTransitionsSchema(); err != nil { + t.Fatalf("replay initStepTransitionsSchema twice: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay runMigrations: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay runMigrations twice: %v", err) + } + + var countAfterReplay int + if err := db.Get(&countAfterReplay, db.Rebind(` + SELECT COUNT(*) FROM task_step_transitions WHERE task_id = ? + `), "task-pre-ledger"); err != nil { + t.Fatalf("query task_step_transitions after replay: %v", err) + } + if countAfterReplay != 0 { + t.Fatalf("pre-existing task has %d ledger rows after replay, want 0", countAfterReplay) + } +} + +func TestStepTransitionsSchemaHasNoForeignKeyToWorkflowObjects(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "step-transitions-no-fk.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 := NewWithDB(db, db, nil); err != nil { + t.Fatalf("initialize schema: %v", err) + } + + // A row referencing a workflow_id/step_id with no matching row in + // workflows/workflow_steps must insert cleanly — there is deliberately + // no FK to either table, so historical rows survive step/workflow + // deletion. + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-no-fk", "ws-no-fk", "Task", now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_step_transitions + (task_id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, actor_id, contract_version, occurred_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), "task-no-fk", nil, nil, "workflow-does-not-exist", "step-does-not-exist", "task_created", "system", "", 1, now); err != nil { + t.Fatalf("insert row referencing nonexistent workflow/step: %v", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_postgres_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_postgres_test.go new file mode 100644 index 0000000000..d9d315e216 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_postgres_test.go @@ -0,0 +1,45 @@ +package sqlite + +// Postgres parity for task_step_transitions: BIGSERIAL id, replay safety, and +// the deliberate absence of a workflow/workflow_steps FK. Skips unless +// KANDEV_TEST_POSTGRES_DSN is set; CI runs these in postgres-boot. + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresStepTransitionsSchemaCreatesTableAndIsReplaySafe(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) + } + + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-pre-ledger-pg", "ws-pre-ledger-pg", "Pre-existing task", now, now); err != nil { + t.Fatalf("seed pre-existing task: %v", err) + } + + var count int + if err := db.Get(&count, db.Rebind(` + SELECT COUNT(*) FROM task_step_transitions WHERE task_id = ? + `), "task-pre-ledger-pg"); err != nil { + t.Fatalf("query task_step_transitions: %v", err) + } + if count != 0 { + t.Fatalf("pre-existing task has %d ledger rows, want 0", count) + } + + if err := repo.initStepTransitionsSchema(); err != nil { + t.Fatalf("replay initStepTransitionsSchema: %v", err) + } + if err := repo.initStepTransitionsSchema(); err != nil { + t.Fatalf("replay initStepTransitionsSchema twice: %v", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_test.go new file mode 100644 index 0000000000..871399f611 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_test.go @@ -0,0 +1,606 @@ +package sqlite + +// Core writer coverage for task_step_transitions: one row per committed step +// change, no row for no-ops/rejections/rollbacks, NULL normalization, and the +// genesis-row shape on task creation. Trigger/actor attribution is exercised +// here by manually wrapping the context (steptelemetry.WithAttribution) the +// way Task 05's real callers do — this file pins the WRITER's behavior given +// an attribution, independent of which production call site sets it. + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/steptelemetry" + "github.com/kandev/kandev/internal/task/models" +) + +func newStepTransitionsTestRepo(t *testing.T) *Repository { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "step-transitions.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() }) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize schema: %v", err) + } + return repo +} + +type stepTransitionRow struct { + id int64 + taskID string + sessionID *string + fromWorkflowID *string + fromWorkflowStepID *string + toWorkflowID *string + toWorkflowStepID *string + trigger string + actorKind string + actorID *string + contractVersion int + occurredAt time.Time +} + +func stepTransitionRowsForTask(t *testing.T, repo *Repository, taskID string) []stepTransitionRow { + t.Helper() + rows, err := repo.db.QueryContext(context.Background(), repo.db.Rebind(` + SELECT id, task_id, session_id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, actor_id, contract_version, occurred_at + FROM task_step_transitions WHERE task_id = ? ORDER BY occurred_at ASC, id ASC + `), taskID) + if err != nil { + t.Fatalf("query task_step_transitions: %v", err) + } + defer func() { _ = rows.Close() }() + + var out []stepTransitionRow + for rows.Next() { + var r stepTransitionRow + if err := rows.Scan(&r.id, &r.taskID, &r.sessionID, &r.fromWorkflowID, &r.fromWorkflowStepID, &r.toWorkflowID, &r.toWorkflowStepID, &r.trigger, &r.actorKind, &r.actorID, &r.contractVersion, &r.occurredAt); err != nil { + t.Fatalf("scan row: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + return out +} + +func createStepTransitionsTestTask(t *testing.T, repo *Repository, taskID, workflowID, stepID string) *models.Task { + t.Helper() + task := &models.Task{ + ID: taskID, + WorkspaceID: "ws-1", + WorkflowID: workflowID, + WorkflowStepID: stepID, + Title: "Test Task", + Priority: "medium", + } + if err := repo.CreateTask(context.Background(), task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + return task +} + +func createStepTransitionsTestTaskWithCtx(t *testing.T, repo *Repository, ctx context.Context, taskID, workflowID, stepID string) *models.Task { + t.Helper() + task := &models.Task{ + ID: taskID, + WorkspaceID: "ws-1", + WorkflowID: workflowID, + WorkflowStepID: stepID, + Title: "Test Task", + Priority: "medium", + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + return task +} + +func TestGenesisRowOnTaskCreation(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + createStepTransitionsTestTask(t, repo, "task-genesis", "wf-1", "step-a") + + rows := stepTransitionRowsForTask(t, repo, "task-genesis") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + row := rows[0] + if row.fromWorkflowID != nil || row.fromWorkflowStepID != nil { + t.Fatalf("genesis row from_* = (%v, %v), want (nil, nil)", row.fromWorkflowID, row.fromWorkflowStepID) + } + if row.toWorkflowID == nil || *row.toWorkflowID != "wf-1" { + t.Fatalf("to_workflow_id = %v, want wf-1", row.toWorkflowID) + } + if row.toWorkflowStepID == nil || *row.toWorkflowStepID != "step-a" { + t.Fatalf("to_workflow_step_id = %v, want step-a", row.toWorkflowStepID) + } + if row.trigger != string(steptelemetry.TriggerTaskCreated) { + t.Fatalf("trigger = %q, want %q", row.trigger, steptelemetry.TriggerTaskCreated) + } + if row.contractVersion != steptelemetry.ContractVersion { + t.Fatalf("contract_version = %d, want %d", row.contractVersion, steptelemetry.ContractVersion) + } +} + +func TestTaskCreatedWithNoWorkflowWritesNoGenesisRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + createStepTransitionsTestTask(t, repo, "task-no-workflow", "", "") + + rows := stepTransitionRowsForTask(t, repo, "task-no-workflow") + if len(rows) != 0 { + t.Fatalf("rows = %d, want 0 (task created with no workflow writes nothing)", len(rows)) + } +} + +func TestGenesisRowRecordsFeederStepWhenWIPDivertsPlacement(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + + // Fill the target step to capacity with one admitted task. + createStepTransitionsTestTask(t, repo, "task-occupant", "wf-1", "step-target") + + overflow := &models.Task{ID: "task-overflow", WorkspaceID: "ws-1", Title: "Overflow", Priority: "medium"} + if err := repo.CreateTaskWithWorkflowStepAdmission(ctx, overflow, "step-target", 1, "step-feeder", 0); err != nil { + t.Fatalf("CreateTaskWithWorkflowStepAdmission: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-overflow") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].toWorkflowStepID == nil || *rows[0].toWorkflowStepID != "step-feeder" { + t.Fatalf("genesis row to_workflow_step_id = %v, want step-feeder (WIP-diverted placement)", rows[0].toWorkflowStepID) + } +} + +func TestUpdateTaskMoveWritesOneRowWithAttribution(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerManualMove, + ActorKind: steptelemetry.ActorHuman, + ActorID: "user-1", + }) + task := createStepTransitionsTestTask(t, repo, "task-move", "wf-1", "step-a") + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-move") + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2 (genesis + move)", len(rows)) + } + move := rows[1] + if move.fromWorkflowStepID == nil || *move.fromWorkflowStepID != "step-a" { + t.Fatalf("from_workflow_step_id = %v, want step-a", move.fromWorkflowStepID) + } + if move.toWorkflowStepID == nil || *move.toWorkflowStepID != "step-b" { + t.Fatalf("to_workflow_step_id = %v, want step-b", move.toWorkflowStepID) + } + if move.trigger != string(steptelemetry.TriggerManualMove) { + t.Fatalf("trigger = %q, want %q", move.trigger, steptelemetry.TriggerManualMove) + } + if move.actorKind != string(steptelemetry.ActorHuman) { + t.Fatalf("actor_kind = %q, want %q", move.actorKind, steptelemetry.ActorHuman) + } + if move.actorID == nil || *move.actorID != "user-1" { + t.Fatalf("actor_id = %v, want user-1", move.actorID) + } +} + +// TestUpdateTaskOccurredAtEqualsTaskUpdatedAt proves the spec's "Ledger vs. +// tasks.updated_at" guarantee — occurred_at and the task's updated_at agree +// at write time — by asserting exact equality rather than approximate +// closeness. Before this round's fix, UpdateTask stamped task.UpdatedAt with +// its own time.Now().UTC() call and recordStepTransition independently made +// a second, separate time.Now().UTC() call for occurred_at; under lock +// contention the two could drift. Both now share one timestamp. +func TestUpdateTaskOccurredAtEqualsTaskUpdatedAt(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-occurred-at", "wf-1", "step-a") + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + reread, err := repo.GetTask(ctx, "task-occurred-at") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-occurred-at") + move := rows[len(rows)-1] + if !move.occurredAt.Equal(reread.UpdatedAt) { + t.Fatalf("occurred_at = %v, want exactly tasks.updated_at %v", move.occurredAt, reread.UpdatedAt) + } +} + +func TestUpdateTaskNoTriggerDeclaredRecordsUnknown(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() // no attribution wrapped + task := createStepTransitionsTestTask(t, repo, "task-unknown", "wf-1", "step-a") + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-unknown") + move := rows[len(rows)-1] + if move.trigger != string(steptelemetry.TriggerUnknown) { + t.Fatalf("trigger = %q, want %q", move.trigger, steptelemetry.TriggerUnknown) + } + if move.actorKind != string(steptelemetry.ActorUnknown) { + t.Fatalf("actor_kind = %q, want %q", move.actorKind, steptelemetry.ActorUnknown) + } + if move.actorID != nil { + t.Fatalf("actor_id = %v, want nil", move.actorID) + } +} + +func TestUpdateTaskNoOpMoveToCurrentStepWritesNoRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-noop", "wf-1", "step-a") + + before := stepTransitionRowsForTask(t, repo, "task-noop") + + task.Position = 5 // position-only reorder within the same step + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + after := stepTransitionRowsForTask(t, repo, "task-noop") + if len(after) != len(before) { + t.Fatalf("rows after no-op update = %d, want unchanged %d", len(after), len(before)) + } +} + +func TestUpdateTaskEmptyStringStepNormalizesToNULL(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-empty-from", "wf-1", "step-a") + + task.WorkflowID = "" + task.WorkflowStepID = "" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-empty-from") + detach := rows[len(rows)-1] + if detach.toWorkflowStepID != nil { + t.Fatalf("to_workflow_step_id = %v, want nil (empty string normalizes to NULL)", detach.toWorkflowStepID) + } + if detach.fromWorkflowStepID == nil || *detach.fromWorkflowStepID != "step-a" { + t.Fatalf("from_workflow_step_id = %v, want step-a", detach.fromWorkflowStepID) + } +} + +func TestUpdateTaskIfWorkflowStepHasCapacityRejectionWritesNoRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + createStepTransitionsTestTask(t, repo, "task-occupant-cap", "wf-1", "step-full") + task := createStepTransitionsTestTask(t, repo, "task-rejected", "wf-1", "step-a") + + before := stepTransitionRowsForTask(t, repo, "task-rejected") + + task.WorkflowStepID = "step-full" + err := repo.UpdateTaskIfWorkflowStepHasCapacity(ctx, task, "step-full", "task-rejected", 1) + if err == nil { + t.Fatal("expected WIP limit error, got nil") + } + + after := stepTransitionRowsForTask(t, repo, "task-rejected") + if len(after) != len(before) { + t.Fatalf("rows after WIP rejection = %d, want unchanged %d", len(after), len(before)) + } + + reread, err := repo.GetTask(ctx, "task-rejected") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if reread.WorkflowStepID != "step-a" { + t.Fatalf("task step after rejected move = %q, want unchanged %q", reread.WorkflowStepID, "step-a") + } +} + +func TestUpdateTaskIfWorkflowStepHasCapacityAdmitsAndWritesRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{Trigger: steptelemetry.TriggerManualMove, ActorKind: steptelemetry.ActorHuman}) + task := createStepTransitionsTestTask(t, repo, "task-admit", "wf-1", "step-a") + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTaskIfWorkflowStepHasCapacity(ctx, task, "step-b", "task-admit", 5); err != nil { + t.Fatalf("UpdateTaskIfWorkflowStepHasCapacity: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-admit") + last := rows[len(rows)-1] + if last.toWorkflowStepID == nil || *last.toWorkflowStepID != "step-b" { + t.Fatalf("to_workflow_step_id = %v, want step-b", last.toWorkflowStepID) + } +} + +func TestPromoteQueuedTaskIfWorkflowStepHasCapacityWritesRowOnlyWhenPromoted(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-promote", "wf-1", "step-queue") + task.QueuedForStepID = "step-dest" + + task.WorkflowStepID = "step-dest" + promoted, err := repo.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, task, "step-queue", "step-dest", 5) + if err != nil { + t.Fatalf("PromoteQueuedTaskIfWorkflowStepHasCapacity: %v", err) + } + if !promoted { + t.Fatal("expected promotion to succeed") + } + + rows := stepTransitionRowsForTask(t, repo, "task-promote") + last := rows[len(rows)-1] + if last.toWorkflowStepID == nil || *last.toWorkflowStepID != "step-dest" { + t.Fatalf("to_workflow_step_id = %v, want step-dest", last.toWorkflowStepID) + } + + // A second attempt with a stale fromStepID (already moved) affects zero + // rows and must write nothing. + before := stepTransitionRowsForTask(t, repo, "task-promote") + promotedAgain, err := repo.PromoteQueuedTaskIfWorkflowStepHasCapacity(ctx, task, "step-queue", "step-dest", 5) + if err != nil { + t.Fatalf("second PromoteQueuedTaskIfWorkflowStepHasCapacity: %v", err) + } + if promotedAgain { + t.Fatal("second promotion should be a no-op (task no longer in step-queue)") + } + after := stepTransitionRowsForTask(t, repo, "task-promote") + if len(after) != len(before) { + t.Fatalf("rows after no-op promotion = %d, want unchanged %d", len(after), len(before)) + } +} + +func TestAddTaskToWorkflowWritesWorkflowAttachedRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + // AddTaskToWorkflow does not hardcode its trigger the way genesis does — + // its one production caller (office/engine_adapters/workflow_switcher_adapter.go) + // sets TriggerWorkflowAttached before calling. Wrap ctx here the way that + // caller does, per this file's header comment on exercising attribution. + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerWorkflowAttached, + ActorKind: steptelemetry.ActorHuman, + ActorID: "user-1", + }) + createStepTransitionsTestTask(t, repo, "task-attach", "", "") + + if err := repo.AddTaskToWorkflow(ctx, "task-attach", "wf-2", "step-x", 0); err != nil { + t.Fatalf("AddTaskToWorkflow: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-attach") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1 (bare create wrote none, attach writes the first)", len(rows)) + } + row := rows[0] + if row.fromWorkflowStepID != nil { + t.Fatalf("from_workflow_step_id = %v, want nil", row.fromWorkflowStepID) + } + if row.toWorkflowStepID == nil || *row.toWorkflowStepID != "step-x" { + t.Fatalf("to_workflow_step_id = %v, want step-x", row.toWorkflowStepID) + } + if row.trigger != string(steptelemetry.TriggerWorkflowAttached) { + t.Fatalf("trigger = %q, want %q", row.trigger, steptelemetry.TriggerWorkflowAttached) + } +} + +// TestAddTaskToWorkflowPersistsSessionCausedAttribution proves the fix for +// Review round 2's must-fix #1 (workflow_switcher_adapter.go's +// SwitchTaskWorkflow) is actually observed at the layer that matters: the +// real row this writer persists. TestAddTaskToWorkflowWritesWorkflowAttachedRow +// above only exercises the ActorHuman case and never asserts actor_kind/ +// actor_id/session_id at all — round 2's fix (agent + session attribution +// for a switch_workflow-caused attach) had no test proving the persisted +// row, only tests proving the ctx reaching the mock adapter/mover boundary. +func TestAddTaskToWorkflowPersistsSessionCausedAttribution(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + createStepTransitionsTestTask(t, repo, "task-attach-agent", "", "") + // session_id has a foreign key to task_sessions. + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: "sess-1", TaskID: "task-attach-agent", State: models.TaskSessionStateRunning, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerWorkflowAttached, + ActorKind: steptelemetry.ActorAgent, + ActorID: "sess-1", + SessionID: "sess-1", + }) + + if err := repo.AddTaskToWorkflow(ctx, "task-attach-agent", "wf-2", "step-x", 0); err != nil { + t.Fatalf("AddTaskToWorkflow: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-attach-agent") + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + row := rows[0] + if row.actorKind != string(steptelemetry.ActorAgent) { + t.Fatalf("actor_kind = %q, want %q", row.actorKind, steptelemetry.ActorAgent) + } + if row.actorID == nil || *row.actorID != "sess-1" { + t.Fatalf("actor_id = %v, want sess-1", row.actorID) + } + if row.sessionID == nil || *row.sessionID != "sess-1" { + t.Fatalf("session_id = %v, want sess-1", row.sessionID) + } +} + +// TestAddTaskToWorkflowNonexistentTaskIsNoOp pins Review round 4's must-fix: +// AddTaskToWorkflow discarded its UPDATE's RowsAffected() and called +// recordStepTransition unconditionally, unlike every sibling chokepoint +// (including its own twin RemoveTaskFromWorkflow). A taskID matching no row +// updates 0 rows without a Go error, but recordStepTransition then ran with +// fromWorkflowStepID="" (readTaskStepInTx's not-found result) paired with a +// non-empty toWorkflowStepID — the no-op guard never triggers on that pair — +// so the INSERT was attempted anyway and failed the ledger's FK to tasks, +// turning what was a harmless no-op before the ledger existed into a hard +// transaction-failing error. Must be a benign no-op instead. +func TestAddTaskToWorkflowNonexistentTaskIsNoOp(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + + if err := repo.AddTaskToWorkflow(ctx, "task-does-not-exist", "wf-2", "step-x", 0); err != nil { + t.Fatalf("AddTaskToWorkflow on a nonexistent task: %v, want nil (benign no-op)", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-does-not-exist") + if len(rows) != 0 { + t.Fatalf("rows = %d, want 0", len(rows)) + } +} + +func TestRemoveTaskFromWorkflowWritesWorkflowDetachedRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + createStepTransitionsTestTask(t, repo, "task-detach", "wf-1", "step-a") + + if err := repo.RemoveTaskFromWorkflow(ctx, "task-detach", "wf-1"); err != nil { + t.Fatalf("RemoveTaskFromWorkflow: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-detach") + last := rows[len(rows)-1] + if last.toWorkflowID != nil || last.toWorkflowStepID != nil { + t.Fatalf("detach row to_* = (%v, %v), want (nil, nil)", last.toWorkflowID, last.toWorkflowStepID) + } + if last.fromWorkflowStepID == nil || *last.fromWorkflowStepID != "step-a" { + t.Fatalf("from_workflow_step_id = %v, want step-a", last.fromWorkflowStepID) + } + // RemoveTaskFromWorkflow hardcodes this trigger the way the genesis row + // hardcodes task_created — it has exactly one semantic meaning regardless + // of caller, and (unlike AddTaskToWorkflow) has no production caller today + // to supply it externally. A bare context.Background() here must still + // produce workflow_detached, not unknown. + if last.trigger != string(steptelemetry.TriggerWorkflowDetached) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerWorkflowDetached) + } +} + +func TestRemoveTaskFromWorkflowWrongWorkflowIDWritesNoRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + createStepTransitionsTestTask(t, repo, "task-detach-mismatch", "wf-1", "step-a") + + before := stepTransitionRowsForTask(t, repo, "task-detach-mismatch") + if err := repo.RemoveTaskFromWorkflow(ctx, "task-detach-mismatch", "wf-does-not-match"); err != nil { + t.Fatalf("RemoveTaskFromWorkflow: %v", err) + } + after := stepTransitionRowsForTask(t, repo, "task-detach-mismatch") + if len(after) != len(before) { + t.Fatalf("rows after mismatched detach = %d, want unchanged %d", len(after), len(before)) + } +} + +func TestEphemeralTaskRecordsExactlyAsAnyOther(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := &models.Task{ + ID: "task-ephemeral", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-a", + Title: "Quick chat", Priority: "medium", IsEphemeral: true, + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, "task-ephemeral") + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2 (genesis + move), ephemeral tasks are recorded like any other", len(rows)) + } +} + +func TestRestoreTaskMessageRollbackWritesUnarchiveRestoreRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + // RestoreTaskMessageRollbackIfSessionState does not hardcode its trigger — + // its production caller (task/service/service_tasks.go RestoreTaskMessageRollback) + // sets TriggerUnarchiveRestore before calling. Wrap ctx here the way that + // caller does, per this file's header comment on exercising attribution. + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerUnarchiveRestore, + ActorKind: steptelemetry.ActorSystem, + }) + task := createStepTransitionsTestTask(t, repo, "task-rollback", "wf-1", "step-a") + + // Seed a session in the expected state directly. + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "session-rollback", "task-rollback", "RUNNING", time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatalf("seed session: %v", err) + } + + task.WorkflowStepID = "step-restored" + restored, err := repo.RestoreTaskMessageRollbackIfSessionState(ctx, task, "session-rollback", models.TaskSessionStateRunning) + if err != nil { + t.Fatalf("RestoreTaskMessageRollbackIfSessionState: %v", err) + } + if !restored { + t.Fatal("expected restore to apply") + } + + rows := stepTransitionRowsForTask(t, repo, "task-rollback") + last := rows[len(rows)-1] + if last.toWorkflowStepID == nil || *last.toWorkflowStepID != "step-restored" { + t.Fatalf("to_workflow_step_id = %v, want step-restored", last.toWorkflowStepID) + } + if last.trigger != string(steptelemetry.TriggerUnarchiveRestore) { + t.Fatalf("trigger = %q, want %q", last.trigger, steptelemetry.TriggerUnarchiveRestore) + } +} + +func TestRestoreTaskMessageRollbackWrongSessionStateWritesNoRow(t *testing.T) { + repo := newStepTransitionsTestRepo(t) + ctx := context.Background() + task := createStepTransitionsTestTask(t, repo, "task-rollback-mismatch", "wf-1", "step-a") + + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "session-mismatch", "task-rollback-mismatch", "COMPLETED", time.Now().UTC(), time.Now().UTC()); err != nil { + t.Fatalf("seed session: %v", err) + } + + before := stepTransitionRowsForTask(t, repo, "task-rollback-mismatch") + task.WorkflowStepID = "step-restored" + restored, err := repo.RestoreTaskMessageRollbackIfSessionState(ctx, task, "session-mismatch", models.TaskSessionStateRunning) + if err != nil { + t.Fatalf("RestoreTaskMessageRollbackIfSessionState: %v", err) + } + if restored { + t.Fatal("expected restore to be rejected (session state mismatch)") + } + after := stepTransitionRowsForTask(t, repo, "task-rollback-mismatch") + if len(after) != len(before) { + t.Fatalf("rows after rejected rollback = %d, want unchanged %d", len(after), len(before)) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_writer_postgres_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_writer_postgres_test.go new file mode 100644 index 0000000000..18691fa11f --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_writer_postgres_test.go @@ -0,0 +1,134 @@ +package sqlite + +// Postgres parity for the ledger writer: genesis row, a move via UpdateTask, +// AddTaskToWorkflow/RemoveTaskFromWorkflow, and the chain invariant. Skips +// unless KANDEV_TEST_POSTGRES_DSN is set; CI runs these in postgres-boot. + +import ( + "context" + "sync" + "testing" + + "golang.org/x/sync/errgroup" + + "github.com/kandev/kandev/internal/steptelemetry" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresLedgerWriterGenesisMoveAttachDetach(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 := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerManualMove, ActorKind: steptelemetry.ActorHuman, ActorID: "user-pg", + }) + if err := repo.CreateWorkspace(context.Background(), &models.Workspace{ID: "ws-pg-ledger", Name: "PG Ledger Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + task := &models.Task{ID: "task-pg-ledger", WorkspaceID: "ws-pg-ledger", WorkflowID: "wf-pg", WorkflowStepID: "step-a", Title: "PG Task", Priority: "medium"} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + if err := repo.RemoveTaskFromWorkflow(ctx, task.ID, "wf-pg"); err != nil { + t.Fatalf("RemoveTaskFromWorkflow: %v", err) + } + if err := repo.AddTaskToWorkflow(ctx, task.ID, "wf-pg-2", "step-c", 0); err != nil { + t.Fatalf("AddTaskToWorkflow: %v", err) + } + + rows := stepTransitionRowsForTask(t, repo, task.ID) + if len(rows) != 4 { + t.Fatalf("rows = %d, want 4 (genesis, move, detach, attach)", len(rows)) + } + for i := 1; i < len(rows); i++ { + prevTo := "" + if rows[i-1].toWorkflowStepID != nil { + prevTo = *rows[i-1].toWorkflowStepID + } + curFrom := "" + if rows[i].fromWorkflowStepID != nil { + curFrom = *rows[i].fromWorkflowStepID + } + if prevTo != curFrom { + t.Fatalf("chain broken at row %d: prev to=%q, this from=%q", i, prevTo, curFrom) + } + } + last := rows[len(rows)-1] + if last.toWorkflowStepID == nil || *last.toWorkflowStepID != "step-c" { + t.Fatalf("final to_workflow_step_id = %v, want step-c", last.toWorkflowStepID) + } +} + +// TestPostgresLedgerWriterConcurrentMovesProduceIntactChain is the Postgres +// counterpart of TestChainConcurrentMovesProduceTwoRowsWithIntactChain +// (step_transitions_chain_test.go), barrier-controlled rather than relying on +// goroutine-scheduling luck to exercise readTaskStepInTx's real Postgres +// "FOR UPDATE" lock contention: both movers block on the same gate so they +// race into the lock as close to simultaneously as genuinely concurrent +// callers would. This is the scenario that caught a real bug during this +// PR's review — occurred_at was stamped before the transactional lock, so +// two racing movers could commit in one order but record timestamps in the +// other, breaking the (occurred_at, id) chain invariant only under real +// concurrent Postgres connections (SQLite's single-writer pool serializes +// regardless, which is why no SQLite test could have caught it). +func TestPostgresLedgerWriterConcurrentMovesProduceIntactChain(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 := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerManualMove, ActorKind: steptelemetry.ActorHuman, ActorID: "user-pg-concurrent", + }) + if err := repo.CreateWorkspace(context.Background(), &models.Workspace{ID: "ws-pg-concurrent", Name: "PG Concurrent Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + task := &models.Task{ID: "task-pg-concurrent", WorkspaceID: "ws-pg-concurrent", WorkflowID: "wf-pg-concurrent", WorkflowStepID: "step-a", Title: "PG Concurrent Task", Priority: "medium"} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + var start sync.WaitGroup + start.Add(1) + var g errgroup.Group + g.Go(func() error { + start.Wait() + moved := *task + moved.WorkflowStepID = "step-b" + return repo.UpdateTask(ctx, &moved) + }) + g.Go(func() error { + start.Wait() + moved := *task + moved.WorkflowStepID = "step-c" + return repo.UpdateTask(ctx, &moved) + }) + start.Done() + if err := g.Wait(); err != nil { + t.Fatalf("concurrent UpdateTask: %v", err) + } + + rows := assertChainIntact(t, repo, task.ID) + if len(rows) != 3 { + t.Fatalf("rows = %d, want 3 (genesis + 2 concurrent moves)", len(rows)) + } + + final, err := repo.GetTask(context.Background(), task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if final.WorkflowStepID != "step-b" && final.WorkflowStepID != "step-c" { + t.Fatalf("final workflow_step_id = %q, want step-b or step-c (last committed writer wins)", final.WorkflowStepID) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 413cc09911..a2db6fdf61 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -17,6 +17,7 @@ import ( internaldb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" usermodels "github.com/kandev/kandev/internal/user/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -353,6 +354,19 @@ func (r *Repository) insertTaskTx(ctx context.Context, tx *sql.Tx, task *models. } return err } + // Genesis ledger row. By this point applyAdmissionPlacement has already + // rewritten task.WorkflowStepID to the actual placement (feeder step when + // WIP diverted it), so this satisfies the spec's feeder-step scenario for + // free. A task created with no workflow writes nothing. + genesisCtx := steptelemetry.WithAttribution(ctx, genesisAttribution(ctx)) + if err := r.recordStepTransition(genesisCtx, tx, stepTransitionInput{ + taskID: task.ID, + toWorkflowID: task.WorkflowID, + toWorkflowStepID: task.WorkflowStepID, + occurredAt: task.CreatedAt, + }); err != nil { + return err + } if task.AssigneeAgentProfileID != "" && task.WorkflowStepID != "" { return upsertRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID) } @@ -421,18 +435,23 @@ func lockWorkflowStepForCapacity(ctx context.Context, tx *sql.Tx, driver string, // upsertRunnerInTx writes (or replaces) a 'runner' participant row for // (stepID, taskID) inside the provided transaction. Mirrors // workflow.Repository.SetTaskRunner but reuses the caller's tx. +// +// rebind is the caller's r.db.Rebind — required on Postgres, where the raw +// "?" placeholders below are not valid bind syntax (unlike SQLite, which +// accepts them natively). Mirrors lockWorkflowStepForCapacity's pattern for +// a free function that isn't a *Repository method. func upsertRunnerInTx(ctx context.Context, tx *sql.Tx, rebind func(string) string, stepID, taskID, agentProfileID string) error { if stepID == "" || taskID == "" || agentProfileID == "" { return nil } var existing string err := tx.QueryRowContext(ctx, rebind(`SELECT id FROM workflow_step_participants - WHERE step_id = ? AND task_id = ? AND role = 'runner' LIMIT 1`, - ), stepID, taskID).Scan(&existing) + WHERE step_id = ? AND task_id = ? AND role = 'runner' LIMIT 1`), + stepID, taskID).Scan(&existing) if err == nil { - _, uerr := tx.ExecContext(ctx, rebind( - `UPDATE workflow_step_participants SET agent_profile_id = ? WHERE id = ?`, - ), agentProfileID, existing) + _, uerr := tx.ExecContext(ctx, + rebind(`UPDATE workflow_step_participants SET agent_profile_id = ? WHERE id = ?`), + agentProfileID, existing) return uerr } if err != sql.ErrNoRows { @@ -441,8 +460,8 @@ func upsertRunnerInTx(ctx context.Context, tx *sql.Tx, rebind func(string) strin id := uuid.New().String() _, ierr := tx.ExecContext(ctx, rebind(`INSERT INTO workflow_step_participants (id, step_id, task_id, role, agent_profile_id, decision_required, position) - VALUES (?, ?, ?, 'runner', ?, 0, 0)`, - ), id, stepID, taskID, agentProfileID) + VALUES (?, ?, ?, 'runner', ?, 0, 0)`), + id, stepID, taskID, agentProfileID) return ierr } @@ -451,10 +470,10 @@ func clearRunnerInTx(ctx context.Context, tx *sql.Tx, rebind func(string) string if stepID == "" || taskID == "" { return nil } - _, err := tx.ExecContext(ctx, rebind( - `DELETE FROM workflow_step_participants - WHERE step_id = ? AND task_id = ? AND role = 'runner'`, - ), stepID, taskID) + _, err := tx.ExecContext(ctx, + rebind(`DELETE FROM workflow_step_participants + WHERE step_id = ? AND task_id = ? AND role = 'runner'`), + stepID, taskID) return err } @@ -485,8 +504,6 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro // upsert/clear on workflow_step_participants inside the same tx as the // task UPDATE. func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { - task.UpdatedAt = time.Now().UTC() - metadata, err := json.Marshal(task.Metadata) if err != nil { metadata = []byte("{}") @@ -506,6 +523,20 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { } func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models.Task, metadata []byte) error { + fromWorkflowID, fromStepID, _, err := r.readTaskStepInTx(ctx, tx, task.ID) + if err != nil { + return err + } + // Stamped after the transactional read/lock above, not before BeginTx: on + // Postgres, readTaskStepInTx's FOR UPDATE blocks until this transaction's + // turn to touch the row, so the timestamp now reflects true serialization + // order. Stamping it earlier let two concurrent movers commit out of + // timestamp order relative to their actual commit order, which broke the + // (occurred_at, id) chain invariant under real concurrent load — SQLite's + // single-writer connection pool serializes callers regardless, so this + // was invisible until exercised against Postgres with real concurrency. + task.UpdatedAt = time.Now().UTC() + updateQuery := ` UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? WHERE id = ? @@ -531,6 +562,18 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models. if rows == 0 { return fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID) } + + if err := r.recordStepTransition(ctx, tx, stepTransitionInput{ + taskID: task.ID, + fromWorkflowID: fromWorkflowID, + fromWorkflowStepID: fromStepID, + toWorkflowID: task.WorkflowID, + toWorkflowStepID: task.WorkflowStepID, + occurredAt: task.UpdatedAt, + }); err != nil { + return err + } + return syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID) } @@ -912,7 +955,6 @@ func detachTaskQuery(driver string) string { // UpdateTaskIfWorkflowStepHasCapacity updates a task inside the same write // transaction that checks a WIP-limited target step's current occupancy. func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, targetStepID, excludeTaskID string, limit int) error { - task.UpdatedAt = time.Now().UTC() metadata, err := json.Marshal(task.Metadata) if err != nil { metadata = []byte("{}") @@ -943,6 +985,15 @@ func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, ta return wfmodels.NewWIPLimitError(targetStepID, limit, occupants) } + fromWorkflowID, fromStepID, _, err := r.readTaskStepInTx(ctx, tx, task.ID) + if err != nil { + return err + } + // See updateTaskTx's comment: stamped after the transactional lock, not + // before BeginTx, so occurred_at reflects true commit-serialization order + // under concurrent Postgres callers. + task.UpdatedAt = time.Now().UTC() + result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? WHERE id = ? @@ -954,6 +1005,16 @@ func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, ta if rows == 0 { return fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID) } + if err := r.recordStepTransition(ctx, tx, stepTransitionInput{ + taskID: task.ID, + fromWorkflowID: fromWorkflowID, + fromWorkflowStepID: fromStepID, + toWorkflowID: task.WorkflowID, + toWorkflowStepID: task.WorkflowStepID, + occurredAt: task.UpdatedAt, + }); err != nil { + return err + } if err := syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { return err } @@ -970,7 +1031,6 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity( destinationStepID string, limit int, ) (bool, error) { - task.UpdatedAt = time.Now().UTC() metadata, err := json.Marshal(task.Metadata) if err != nil { metadata = []byte("{}") @@ -1016,6 +1076,15 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity( AND queued_for_step_id = ?` } + fromWorkflowID, _, _, err := r.readTaskStepInTx(ctx, tx, task.ID) + if err != nil { + return false, err + } + // See updateTaskTx's comment: stamped after the transactional lock, not + // before BeginTx, so occurred_at reflects true commit-serialization order + // under concurrent Postgres callers. + task.UpdatedAt = time.Now().UTC() + result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? WHERE id = ? @@ -1032,6 +1101,16 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity( if rows == 0 { return false, nil } + if err := r.recordStepTransition(ctx, tx, stepTransitionInput{ + taskID: task.ID, + fromWorkflowID: fromWorkflowID, + fromWorkflowStepID: fromStepID, + toWorkflowID: task.WorkflowID, + toWorkflowStepID: task.WorkflowStepID, + occurredAt: task.UpdatedAt, + }); err != nil { + return false, err + } if err := syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { return false, err } @@ -2279,13 +2358,21 @@ func (r *Repository) RestoreTaskMessageRollbackIfSessionState( if task == nil { return false, errors.New("restore task message rollback: task is nil") } - updatedAt := time.Now().UTC() tx, err := r.db.BeginTx(ctx, nil) if err != nil { return false, err } defer func() { _ = tx.Rollback() }() + fromWorkflowID, fromStepID, _, err := r.readTaskStepInTx(ctx, tx, task.ID) + if err != nil { + return false, err + } + // See updateTaskTx's comment: stamped after the transactional lock, not + // before BeginTx, so occurred_at reflects true commit-serialization order + // under concurrent Postgres callers. + updatedAt := time.Now().UTC() + result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET state = ?, workflow_step_id = ?, updated_at = ? @@ -2308,6 +2395,18 @@ func (r *Repository) RestoreTaskMessageRollbackIfSessionState( if rows == 0 { return false, tx.Commit() } + // workflow_id is not part of this UPDATE — a rollback restore only moves + // the step, never the workflow — so to_workflow_id equals from_workflow_id. + if err := r.recordStepTransition(ctx, tx, stepTransitionInput{ + taskID: task.ID, + fromWorkflowID: fromWorkflowID, + fromWorkflowStepID: fromStepID, + toWorkflowID: fromWorkflowID, + toWorkflowStepID: task.WorkflowStepID, + occurredAt: updatedAt, + }); err != nil { + return false, err + } if err := syncRunnerInTx(ctx, tx, r.db.Rebind, task.WorkflowStepID, task.ID, task.AssigneeAgentProfileID); err != nil { return false, err } diff --git a/apps/backend/internal/task/repository/sqlite/turn_step_stamp_postgres_test.go b/apps/backend/internal/task/repository/sqlite/turn_step_stamp_postgres_test.go new file mode 100644 index 0000000000..c98bc5f558 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/turn_step_stamp_postgres_test.go @@ -0,0 +1,177 @@ +package sqlite + +// Postgres proof that CreateTurnWithStepStamp's read of the task's current +// step is genuinely serialized against a concurrent step mover, not merely +// usually-correct. Skips unless KANDEV_TEST_POSTGRES_DSN is set; CI runs +// this in postgres-boot. +// +// A plain "run both concurrently, assert the final stamp is A or B" test +// would pass even on the pre-fix code: both values are individually +// possible outcomes of an unlocked race, so that assertion can't tell the +// fixed code apart from the racy code it replaced. The property that +// actually distinguishes them is serialization: readTaskStepInTx's +// FOR UPDATE lock must make CreateTurnWithStepStamp's read BLOCK until a +// concurrent mover holding the same task row's lock commits or rolls back — +// exactly the guarantee step moves already rely on for the ledger's chain +// invariant (see step_transitions.go). +// +// testutil.OpenIsolatedPostgres deliberately caps its *sqlx.DB at one +// connection (schema isolation via a per-connection search_path), which +// would make this test pass even against the pre-fix racy code: with only +// one physical connection, a second caller's plain unlocked SELECT can't +// even reach the server until the first caller's connection is returned, +// so it "blocks" for the wrong reason and the test would prove nothing. +// This test therefore opens a second, independent connection pinned to the +// same isolated schema so the mover and the turn read genuinely race at +// the Postgres server, not at Go's connection pool. +// +// The mover's transaction holds its lock open on purpose and asserts +// CreateTurnWithStepStamp does not return while it's held, then asserts the +// stamp reflects the mover's committed value once released. + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// openSecondPostgresConnection opens an independent *sqlx.DB pinned to the +// same search_path (schema) as db, so a test can exercise genuine +// multi-connection contention against the schema db.OpenIsolatedPostgres +// already isolated and will clean up. +func openSecondPostgresConnection(t *testing.T, dsn string, db *sqlx.DB) *sqlx.DB { + t.Helper() + var schema string + if err := db.QueryRow("SELECT current_schema()").Scan(&schema); err != nil { + t.Fatalf("read current_schema(): %v", err) + } + second, err := sqlx.Open("pgx", dsn) + if err != nil { + t.Fatalf("open second postgres connection: %v", err) + } + t.Cleanup(func() { _ = second.Close() }) + if _, err := second.Exec("SET search_path TO " + schema); err != nil { + t.Fatalf("set search_path on second connection: %v", err) + } + return second +} + +func TestPostgresCreateTurnWithStepStampBlocksOnConcurrentStepMove(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + db := testutil.OpenIsolatedPostgres(t, dsn) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + + // A second, independent connection to the same isolated schema — see the + // file comment for why this is required rather than reusing db. + moverDB := openSecondPostgresConnection(t, dsn, db) + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg-turn-stamp", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-pg-turn-stamp", WorkspaceID: "ws-pg-turn-stamp", Name: "Workflow"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + task := &models.Task{ID: "task-pg-turn-stamp", WorkspaceID: "ws-pg-turn-stamp", WorkflowID: "wf-pg-turn-stamp", WorkflowStepID: "step-a", Title: "PG Turn Stamp Task", Priority: "medium"} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-pg-turn-stamp", TaskID: task.ID, State: models.TaskSessionStateRunning, + StartedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + // Goroutine 1 plays the concurrent mover on the second connection: it + // acquires the same readTaskStepInTx FOR UPDATE lock a real step move + // takes, then holds its transaction open (not committing the move to + // step-b) until this test explicitly releases it. + lockHeld := make(chan struct{}) + releaseLock := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseLock) }) } + // Guarantees the mover goroutine is released even if a t.Fatal below + // unwinds this goroutine early (runtime.Goexit still runs deferred + // funcs) — otherwise a failed assertion leaves the mover blocked + // forever on <-releaseLock, holding its transaction open, and + // t.Cleanup's DROP SCHEMA hangs waiting for that lock instead of + // reporting the actual test failure. + defer release() + moverDone := make(chan error, 1) + go func() { + tx, err := moverDB.BeginTx(ctx, nil) + if err != nil { + moverDone <- err + return + } + if _, _, _, err := repo.readTaskStepInTx(ctx, tx, task.ID); err != nil { + _ = tx.Rollback() + moverDone <- err + return + } + close(lockHeld) + <-releaseLock + if _, err := tx.ExecContext(ctx, moverDB.Rebind(`UPDATE tasks SET workflow_step_id = ? WHERE id = ?`), "step-b", task.ID); err != nil { + _ = tx.Rollback() + moverDone <- err + return + } + moverDone <- tx.Commit() + }() + + select { + case <-lockHeld: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the mover goroutine to acquire the row lock") + } + + // The turn goroutine runs on repo/db, the FIRST connection — genuinely + // independent of moverDB, so its read below contends for the real + // Postgres row lock rather than for Go's connection pool. + turn := &models.Turn{TaskSessionID: "session-pg-turn-stamp", TaskID: task.ID} + turnDone := make(chan struct{}) + var stamped bool + var turnErr error + go func() { + stamped, turnErr = repo.CreateTurnWithStepStamp(ctx, turn) + close(turnDone) + }() + + select { + case <-turnDone: + t.Fatal("CreateTurnWithStepStamp returned while the concurrent mover still held the row lock — the step read is not serialized against concurrent movers") + case <-time.After(300 * time.Millisecond): + // Still blocked, as expected: the row lock is held. + } + + release() + if err := <-moverDone; err != nil { + t.Fatalf("mover goroutine: %v", err) + } + + select { + case <-turnDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for CreateTurnWithStepStamp to proceed after the mover committed") + } + if turnErr != nil { + t.Fatalf("CreateTurnWithStepStamp: %v", turnErr) + } + if !stamped { + t.Fatal("stamped = false, want true") + } + if turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-b" { + t.Fatalf("stamp = %v, want %q (the mover's committed value — the turn's read was serialized to run after it)", + turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-b") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/turn_step_stamp_test.go b/apps/backend/internal/task/repository/sqlite/turn_step_stamp_test.go new file mode 100644 index 0000000000..9a1d5c7726 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/turn_step_stamp_test.go @@ -0,0 +1,164 @@ +package sqlite + +// CreateTurnWithStepStamp coverage: the turn-start step stamp is read and +// the turn row inserted inside one transaction, so the read is serialized +// against concurrent step movers (see turn_step_stamp_postgres_test.go for +// the concurrency proof) rather than a plain unlocked read taken before a +// separate insert. This file covers the single-writer correctness cases; +// task/service/service_turns_step_stamp_test.go covers the service-layer +// wiring (runtime-config-snapshot metadata, StartTurn/createCompletedTurn). + +import ( + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" +) + +func newTurnStepStampTestRepo(t *testing.T) *Repository { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "turn-step-stamp.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() }) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize schema: %v", err) + } + return repo +} + +func seedTurnStepStampSession(t *testing.T, repo *Repository, taskID, stepID, sessionID string) { + t.Helper() + ctx := t.Context() + now := time.Now().UTC() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-turn-stamp", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + task := &models.Task{ID: taskID, WorkspaceID: "ws-turn-stamp", Title: "Turn stamp task", Priority: "medium"} + if stepID != "" { + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-turn-stamp", WorkspaceID: "ws-turn-stamp", Name: "Workflow"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + task.WorkflowID = "wf-turn-stamp" + task.WorkflowStepID = stepID + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateRunning, + StartedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } +} + +func TestCreateTurnWithStepStampStampsCurrentStep(t *testing.T) { + repo := newTurnStepStampTestRepo(t) + seedTurnStepStampSession(t, repo, "task-has-step", "step-a", "session-has-step") + + turn := &models.Turn{TaskSessionID: "session-has-step", TaskID: "task-has-step"} + stamped, err := repo.CreateTurnWithStepStamp(t.Context(), turn) + if err != nil { + t.Fatalf("CreateTurnWithStepStamp: %v", err) + } + if !stamped { + t.Fatal("stamped = false, want true") + } + if turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-a" { + t.Fatalf("stamp = %v, want %q", turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-a") + } + + stored, err := repo.GetTurn(t.Context(), turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-a" { + t.Fatalf("persisted stamp = %v, want %q", stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-a") + } +} + +func TestCreateTurnWithStepStampOmitsStampWhenTaskHasNoStep(t *testing.T) { + repo := newTurnStepStampTestRepo(t) + seedTurnStepStampSession(t, repo, "task-no-step", "", "session-no-step") + + turn := &models.Turn{TaskSessionID: "session-no-step", TaskID: "task-no-step"} + stamped, err := repo.CreateTurnWithStepStamp(t.Context(), turn) + if err != nil { + t.Fatalf("CreateTurnWithStepStamp: %v", err) + } + if stamped { + t.Fatal("stamped = true, want false (task has no workflow step)") + } + if _, ok := turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart]; ok { + t.Fatalf("turn metadata carries a stamp: %v", turn.Metadata) + } +} + +// TestCreateTurnWithStepStampOmitsStampWhenTaskNotFound pins the spec's +// failure mode "the task row cannot be read when a turn starts: the turn is +// still created, the stamp key is absent, turn creation MUST NOT fail" for +// the realistic case — the turn's own task_id names a task that doesn't +// exist. The session's own task exists (task_sessions.task_id has no FK +// requirement on task_session_turns.task_id, so this is legal and lets the +// test stay deterministic without corrupting the database to force a +// genuine transactional read error). +func TestCreateTurnWithStepStampOmitsStampWhenTaskNotFound(t *testing.T) { + repo := newTurnStepStampTestRepo(t) + seedTurnStepStampSession(t, repo, "task-real", "step-a", "session-dangling-task") + + turn := &models.Turn{TaskSessionID: "session-dangling-task", TaskID: "task-does-not-exist"} + stamped, err := repo.CreateTurnWithStepStamp(t.Context(), turn) + if err != nil { + t.Fatalf("CreateTurnWithStepStamp should not fail when the task row is missing: %v", err) + } + if stamped { + t.Fatal("stamped = true, want false (task row does not exist)") + } + + stored, err := repo.GetTurn(t.Context(), turn.ID) + if err != nil { + t.Fatalf("GetTurn: turn was not persisted despite the missing task row: %v", err) + } + if _, ok := stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart]; ok { + t.Fatalf("persisted turn carries a stamp: %v", stored.Metadata) + } +} + +func TestCreateTurnWithStepStampPreservesCallerSuppliedMetadata(t *testing.T) { + repo := newTurnStepStampTestRepo(t) + seedTurnStepStampSession(t, repo, "task-preserve-meta", "step-a", "session-preserve-meta") + + turn := &models.Turn{ + TaskSessionID: "session-preserve-meta", + TaskID: "task-preserve-meta", + Metadata: map[string]interface{}{"runtime_config_snapshot": map[string]interface{}{"model": "opus"}}, + } + stamped, err := repo.CreateTurnWithStepStamp(t.Context(), turn) + if err != nil { + t.Fatalf("CreateTurnWithStepStamp: %v", err) + } + if !stamped { + t.Fatal("stamped = false, want true") + } + + stored, err := repo.GetTurn(t.Context(), turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-a" { + t.Fatalf("stamp = %v, want %q", stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-a") + } + snapshot, ok := stored.Metadata["runtime_config_snapshot"].(map[string]interface{}) + if !ok || snapshot["model"] != "opus" { + t.Fatalf("runtime_config_snapshot lost: %v", stored.Metadata) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index 535537bfcf..afec8dba59 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -10,23 +10,103 @@ import ( "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" ) -// AddTaskToWorkflow adds a task to a workflow with placement +// AddTaskToWorkflow adds a task to a workflow with placement. Wrapped in a +// transaction (it previously ran as a bare ExecContext) so the ledger row +// commits atomically with the UPDATE. func (r *Repository) AddTaskToWorkflow(ctx context.Context, taskID, workflowID, workflowStepID string, position int) error { - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + fromWorkflowID, fromStepID, _, err := r.readTaskStepInTx(ctx, tx, taskID) + if err != nil { + return err + } + + updatedAt := time.Now().UTC() + result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workflow_id = ?, workflow_step_id = ?, position = ?, wip_admitted = 1, queued_for_step_id = '', queued_at = NULL, updated_at = ? WHERE id = ? - `), workflowID, workflowStepID, position, time.Now().UTC(), taskID) - return err + `), workflowID, workflowStepID, position, updatedAt, taskID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + // A nonexistent taskID matches no row: a benign no-op, matching the bare + // ExecContext this function used before the ledger was added. Skipping + // recordStepTransition here also matters beyond the no-op case — without + // it, fromWorkflowStepID="" (from readTaskStepInTx's not-found result) + // paired with a non-empty toWorkflowStepID would bypass the same-value + // no-op guard in recordStepTransition and attempt an INSERT whose task_id + // violates the ledger's FK to tasks, turning a harmless no-op into a + // transaction-failing error. Matches RemoveTaskFromWorkflow's guard. + if rows == 0 { + return tx.Commit() + } + + if err := r.recordStepTransition(ctx, tx, stepTransitionInput{ + taskID: taskID, + fromWorkflowID: fromWorkflowID, + fromWorkflowStepID: fromStepID, + toWorkflowID: workflowID, + toWorkflowStepID: workflowStepID, + occurredAt: updatedAt, + }); err != nil { + return err + } + + return tx.Commit() } -// RemoveTaskFromWorkflow removes a task from a workflow +// RemoveTaskFromWorkflow removes a task from a workflow. Wrapped in a +// transaction (it previously ran as a bare ExecContext) so the ledger row +// commits atomically with the UPDATE. func (r *Repository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflowID string) error { - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + fromWorkflowID, fromStepID, _, err := r.readTaskStepInTx(ctx, tx, taskID) + if err != nil { + return err + } + + updatedAt := time.Now().UTC() + result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET workflow_id = '', workflow_step_id = '', position = 0, wip_admitted = 1, queued_for_step_id = '', queued_at = NULL, updated_at = ? WHERE id = ? AND workflow_id = ? - `), time.Now().UTC(), taskID, workflowID) - return err + `), updatedAt, taskID, workflowID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return tx.Commit() + } + + detachCtx := steptelemetry.WithAttribution(ctx, detachAttribution(ctx)) + if err := r.recordStepTransition(detachCtx, tx, stepTransitionInput{ + taskID: taskID, + fromWorkflowID: fromWorkflowID, + fromWorkflowStepID: fromStepID, + occurredAt: updatedAt, + }); err != nil { + return err + } + + return tx.Commit() } // Workflow operations diff --git a/apps/backend/internal/task/service/message_rollback_test.go b/apps/backend/internal/task/service/message_rollback_test.go index 01d32ab869..756dfb60a8 100644 --- a/apps/backend/internal/task/service/message_rollback_test.go +++ b/apps/backend/internal/task/service/message_rollback_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/stretchr/testify/require" @@ -43,3 +44,86 @@ func TestRestoreTaskMessageRollback_RejectionReturnsPersistedTask(t *testing.T) require.Equal(t, before.WorkflowStepID, persisted.WorkflowStepID) require.Empty(t, eventBus.GetPublishedEvents()) } + +// TestRestoreTaskMessageRollback_AttributesCausalSession proves the fix for +// Review round 3's must-fix #2: the MCP message-dispatch rollback path +// (handlers.go's handleMessageTask -> dispatchTaskMessage -> +// RestoreTaskMessageRollback) has a genuinely causal sender session +// (req.SenderSessionID) known several call-frames up, but +// RestoreTaskMessageRollback unconditionally hardcoded actor_kind=system +// with no ID, discarding it. RestoreTaskMessageRollback must now prefer an +// attribution already preset on ctx — the way the sqlite repository's +// hardcodedTriggerAttribution already does for genesis/detach — over its +// own ActorSystem default. +func TestRestoreTaskMessageRollback_AttributesCausalSession(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + require.NoError(t, repo.UpdateTaskSessionState( + ctx, + sessionID, + models.TaskSessionStateRunning, + "", + )) + // session_id has a foreign key to task_sessions, so the causal sender + // session must be a real row, not a bare string literal. + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sender-sess-1", TaskID: "task-123", State: models.TaskSessionStateRunning, + })) + + senderCtx := steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + ActorKind: steptelemetry.ActorAgent, + ActorID: "sender-sess-1", + SessionID: "sender-sess-1", + }) + _, updated, err := svc.RestoreTaskMessageRollback( + senderCtx, + "task-123", + sessionID, + models.TaskSessionStateRunning, + v1.TaskStateReview, + "restored-step", + ) + require.NoError(t, err) + require.True(t, updated) + + trigger, actorKind, _, gotSessionID := lastLedgerAttribution(t, repo, "task-123") + require.Equal(t, string(steptelemetry.TriggerUnarchiveRestore), trigger) + require.Equal(t, string(steptelemetry.ActorAgent), actorKind, "the causal sender session must be attributed, not defaulted to system") + require.NotNil(t, gotSessionID) + require.Equal(t, "sender-sess-1", *gotSessionID) +} + +// TestRestoreTaskMessageRollback_NoPresetFallsBackToSystem covers the no- +// preset case: a bare ctx (no sender session attribution set by a caller) +// still resolves to the existing ActorSystem default, never a guessed +// identity. +func TestRestoreTaskMessageRollback_NoPresetFallsBackToSystem(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + require.NoError(t, repo.UpdateTaskSessionState( + ctx, + sessionID, + models.TaskSessionStateRunning, + "", + )) + + _, updated, err := svc.RestoreTaskMessageRollback( + ctx, + "task-123", + sessionID, + models.TaskSessionStateRunning, + v1.TaskStateReview, + "restored-step", + ) + require.NoError(t, err) + require.True(t, updated) + + trigger, actorKind, _, gotSessionID := lastLedgerAttribution(t, repo, "task-123") + require.Equal(t, string(steptelemetry.TriggerUnarchiveRestore), trigger) + require.Equal(t, string(steptelemetry.ActorSystem), actorKind) + require.Nil(t, gotSessionID) +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 84622ef70f..1ae055d72f 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -19,6 +19,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/task/repository/repoerrors" @@ -1490,7 +1491,14 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ } task.UpdatedAt = time.Now().UTC() - if err := s.tasks.UpdateTask(ctx, task); err != nil { + updateCtx := ctx + if req.WorkflowStepID != nil { + actorKind, actorID := steptelemetry.HumanOrSystemActor(ctx) + updateCtx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerTaskUpdate, ActorKind: actorKind, ActorID: actorID, + }) + } + if err := s.tasks.UpdateTask(updateCtx, task); err != nil { s.logger.Error("failed to update task", zap.String("task_id", id), zap.Error(err)) return nil, err } @@ -1755,8 +1763,23 @@ func (s *Service) RestoreTaskMessageRollback( restoredTask := *task restoredTask.State = state restoredTask.WorkflowStepID = workflowStepID + // The rollback's trigger is always unarchive_restore, but its actor + // prefers an attribution already on ctx over the ActorSystem default — + // the MCP message-dispatch rollback path (handlers.go's + // handleMessageTask) knows the causal sender session and sets one + // before calling here, mirroring the sqlite repository's + // hardcodedTriggerAttribution prefer-preset-else-fallback pattern. + rollbackAttribution := steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerUnarchiveRestore, ActorKind: steptelemetry.ActorSystem, + } + if preset := steptelemetry.FromContext(ctx); preset.ActorKind != steptelemetry.ActorUnknown { + rollbackAttribution.ActorKind = preset.ActorKind + rollbackAttribution.ActorID = preset.ActorID + rollbackAttribution.SessionID = preset.SessionID + } + rollbackCtx := steptelemetry.WithAttribution(ctx, rollbackAttribution) updated, err := repo.RestoreTaskMessageRollbackIfSessionState( - ctx, + rollbackCtx, &restoredTask, ownerSessionID, expectedSessionState, diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 74c4b1fa03..2773b95783 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -3269,8 +3269,8 @@ type failingTurnCreator struct { err error } -func (f failingTurnCreator) CreateTurn(context.Context, *models.Turn) error { - return f.err +func (f failingTurnCreator) CreateTurnWithStepStamp(context.Context, *models.Turn) (bool, error) { + return false, f.err } func TestService_CreateMessageReturnsTurnStartError(t *testing.T) { diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index d9f565cd42..96a1a1669c 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/worktree" ) @@ -41,15 +42,19 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn TaskSessionID: sessionID, TaskID: session.TaskID, StartedAt: time.Now().UTC(), - Metadata: runtimeConfigSnapshotMetadata(session), + Metadata: turnStartRuntimeMetadata(session), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } - if err := s.turns.CreateTurn(ctx, turn); err != nil { + stamped, err := s.turns.CreateTurnWithStepStamp(ctx, turn) + if err != nil { s.logger.Error("failed to create turn", zap.Error(err)) return nil, err } + // Recorded only once the turn is durably persisted — the counter's + // "turns created" framing must not count a turn CreateTurn rejected. + steptelemetry.RecordTurnStamp(s.logger, stamped) // had_output is only meaningful on turn.completed; omit it from turn.started. s.publishTurnEvent(events.TurnStarted, turn, nil) @@ -76,16 +81,34 @@ func (s *Service) createCompletedTurn(ctx context.Context, session *models.TaskS TaskID: session.TaskID, StartedAt: now, CompletedAt: &now, - Metadata: runtimeConfigSnapshotMetadata(session), + Metadata: turnStartRuntimeMetadata(session), CreatedAt: now, UpdatedAt: now, } - if err := s.turns.CreateTurn(ctx, turn); err != nil { + stamped, err := s.turns.CreateTurnWithStepStamp(ctx, turn) + if err != nil { return nil, fmt.Errorf("failed to create completed turn: %w", err) } + // Recorded only once the turn is durably persisted — the counter's + // "turns created" framing must not count a turn CreateTurn rejected. + steptelemetry.RecordTurnStamp(s.logger, stamped) return turn, nil } +// turnStartRuntimeMetadata composes the turn's immutable start-of-turn +// runtime-config-snapshot metadata. The workflow-step-at-start stamp is +// added separately by CreateTurnWithStepStamp, which reads the task's +// current step inside the same transaction as the turn insert — see that +// method's doc comment for why the read can no longer happen here, ahead of +// and unlocked against the insert. +func turnStartRuntimeMetadata(session *models.TaskSession) map[string]interface{} { + metadata := runtimeConfigSnapshotMetadata(session) + if metadata == nil { + return map[string]interface{}{} + } + return metadata +} + func runtimeConfigSnapshotMetadata(session *models.TaskSession) map[string]interface{} { if session == nil { return nil diff --git a/apps/backend/internal/task/service/service_turns_step_stamp_test.go b/apps/backend/internal/task/service/service_turns_step_stamp_test.go new file mode 100644 index 0000000000..9df3402e85 --- /dev/null +++ b/apps/backend/internal/task/service/service_turns_step_stamp_test.go @@ -0,0 +1,257 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// failingCreateTurnRepo wraps a real TurnRepository and fails +// CreateTurnWithStepStamp unconditionally, so a test can exercise "turn +// persistence failed" without a real database-level failure. +type failingCreateTurnRepo struct { + repository.TurnRepository +} + +func (f *failingCreateTurnRepo) CreateTurnWithStepStamp(ctx context.Context, turn *models.Turn) (bool, error) { + return false, errors.New("simulated turn create failure") +} + +// setupStepStampTask creates a workspace/workflow/task fixture with the given +// workflow step (empty string means "no step"). +func setupStepStampTask(t *testing.T, repo *sqliterepo.Repository, taskID, stepID string) { + t.Helper() + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-stamp", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-stamp", WorkspaceID: "ws-stamp", Name: "Workflow"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + task := &models.Task{ID: taskID, WorkspaceID: "ws-stamp", Title: "Stamp Task", Priority: "medium"} + if stepID != "" { + task.WorkflowID = "wf-stamp" + task.WorkflowStepID = stepID + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } +} + +func createStepStampSession(t *testing.T, repo *sqliterepo.Repository, id, taskID string) *models.TaskSession { + t.Helper() + ctx := context.Background() + session := &models.TaskSession{ + ID: id, + TaskID: taskID, + State: models.TaskSessionStateRunning, + StartedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + return session +} + +func TestStartTurnStampsWorkflowStepIDAtStart(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-stamp-has-step", "step-a") + session := createStepStampSession(t, repo, "session-stamp-has-step", "task-stamp-has-step") + + turn, err := svc.StartTurn(ctx, session.ID) + if err != nil { + t.Fatalf("StartTurn: %v", err) + } + + stamped, ok := turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] + if !ok { + t.Fatal("turn metadata missing workflow_step_id_at_start key") + } + if stamped != "step-a" { + t.Fatalf("workflow_step_id_at_start = %v, want %q", stamped, "step-a") + } +} + +func TestStartTurnOmitsStampWhenTaskHasNoStep(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-stamp-no-step", "") + session := createStepStampSession(t, repo, "session-stamp-no-step", "task-stamp-no-step") + + turn, err := svc.StartTurn(ctx, session.ID) + if err != nil { + t.Fatalf("StartTurn: %v", err) + } + + if _, ok := turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart]; ok { + t.Fatalf("turn metadata carries workflow_step_id_at_start = %v, want key absent", turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart]) + } +} + +func TestStartTurnStampsWithNoRuntimeConfigToSnapshot(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-stamp-no-runtime", "step-b") + session := createStepStampSession(t, repo, "session-stamp-no-runtime", "task-stamp-no-runtime") + + turn, err := svc.StartTurn(ctx, session.ID) + if err != nil { + t.Fatalf("StartTurn: %v", err) + } + + if turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-b" { + t.Fatalf("workflow_step_id_at_start = %v, want %q", turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-b") + } + if _, ok := turn.Metadata[models.TurnMetaKeyRuntimeConfigSnapshot]; ok { + t.Fatal("turn metadata carries runtime_config_snapshot, want absent when there is nothing to snapshot") + } +} + +func TestStartTurnStampIsImmutableAcrossMidTurnMove(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-stamp-move", "step-a") + session := createStepStampSession(t, repo, "session-stamp-move", "task-stamp-move") + + turn, err := svc.StartTurn(ctx, session.ID) + if err != nil { + t.Fatalf("StartTurn: %v", err) + } + if turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-a" { + t.Fatalf("initial stamp = %v, want %q", turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-a") + } + + task, err := repo.GetTask(ctx, "task-stamp-move") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + stored, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-a" { + t.Fatalf("stamp after mid-turn move = %v, want unchanged %q", stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-a") + } +} + +// "Task read fails during turn-start stamping degrades gracefully" was +// tested here via a wrapped TaskRepository until CreateTurnWithStepStamp +// moved the step read into the same repository transaction as the turn +// insert (see that method's doc comment) to close a race the turn-start +// read previously had against a concurrent step mover. s.tasks.GetTask is +// no longer part of this path at all, so the old fault-injection seam is +// dead; the equivalent guarantee — a missing/unreadable task row degrades +// to an unstamped turn rather than failing turn creation — is now pinned +// at the repository layer where the read actually happens: +// TestCreateTurnWithStepStampOmitsStampWhenTaskNotFound in +// internal/task/repository/sqlite/turn_step_stamp_test.go. + +// TestStartTurnDoesNotRecordStampMetricWhenCreateTurnFails pins Review round +// 4's must-fix: steptelemetry.RecordTurnStamp used to fire inside +// turnStartMetadata, before CreateTurn ever ran, so a turn-creation failure +// still counted as "stamped"/"unstamped" in the telemetry_turn_stamps_total +// counter and its log line — contradicting the writer-health spec's "of +// turns created" framing. The metric must be recorded only once the turn is +// actually persisted. +func TestStartTurnDoesNotRecordStampMetricWhenCreateTurnFails(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-stamp-create-fails", "step-a") + session := createStepStampSession(t, repo, "session-stamp-create-fails", "task-stamp-create-fails") + + core, logs := observer.New(zapcore.DebugLevel) + observedLog, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("NewFromZap: %v", err) + } + svc.logger = observedLog + svc.turns = &failingCreateTurnRepo{TurnRepository: repo} + + if _, err := svc.StartTurn(ctx, session.ID); err == nil { + t.Fatal("expected StartTurn to surface the injected CreateTurn failure") + } + + if entries := logs.FilterMessage("telemetry.metric.turn_stamped").All(); len(entries) != 0 { + t.Fatalf("turn_stamped metric fired despite CreateTurn failing: %d entries", len(entries)) + } +} + +func TestTurnStampAndLedgerRowCanDisagreeWithoutError(t *testing.T) { + // GIVEN a turn stamped with step S and a ledger row moving the task to T + // a moment after that turn started, WHEN both are read, THEN they + // disagree, and neither is treated as an error. + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-stamp-vs-ledger", "step-a") + session := createStepStampSession(t, repo, "session-stamp-vs-ledger", "task-stamp-vs-ledger") + + turn, err := svc.StartTurn(ctx, session.ID) + if err != nil { + t.Fatalf("StartTurn: %v", err) + } + if turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-a" { + t.Fatalf("turn stamp = %v, want %q", turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-a") + } + + task, err := repo.GetTask(ctx, "task-stamp-vs-ledger") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + task.WorkflowStepID = "step-b" + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + var toStep *string + row := repo.DB().QueryRowContext(ctx, ` + SELECT to_workflow_step_id FROM task_step_transitions + WHERE task_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 + `, "task-stamp-vs-ledger") + if err := row.Scan(&toStep); err != nil { + t.Fatalf("scan last ledger row: %v", err) + } + if toStep == nil || *toStep != "step-b" { + t.Fatalf("last ledger row's to_workflow_step_id = %v, want %q", toStep, "step-b") + } + + stored, err := repo.GetTurn(ctx, turn.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] == *toStep { + t.Fatalf("expected turn stamp %q and ledger's last step %q to disagree", + stored.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], *toStep) + } +} + +func TestCreateCompletedTurnCarriesSameStamp(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-stamp-completed", "step-c") + session := createStepStampSession(t, repo, "session-stamp-completed", "task-stamp-completed") + + turn, err := svc.createCompletedTurn(ctx, session) + if err != nil { + t.Fatalf("createCompletedTurn: %v", err) + } + if turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart] != "step-c" { + t.Fatalf("synthetic completed turn stamp = %v, want %q", turn.Metadata[models.TurnMetaKeyWorkflowStepIDAtStart], "step-c") + } +} diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index b4dfac3e90..6db0e9f8a0 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/steptelemetry" "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" ) @@ -519,7 +520,22 @@ func (s *Service) MoveTaskWithOptions( if stepChanged { admittedState = &stateAfterAdmission.State } - _, err = s.updateMovedTask(ctx, task, oldStepID, targetStep, admittedState) + + // manual_move only applies when no outer caller already declared a + // trigger — an mcp_move set by the MCP handler must survive this inner + // board-move default, since the agent (not a board click) is what caused + // the move. + moveCtx := ctx + if !steptelemetry.HasTrigger(moveCtx) { + actorKind, actorID := steptelemetry.HumanOrSystemActor(moveCtx) + moveCtx = steptelemetry.WithAttribution(moveCtx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerManualMove, + ActorKind: actorKind, + ActorID: actorID, + }) + } + + _, err = s.updateMovedTask(moveCtx, task, oldStepID, targetStep, admittedState) if err != nil { s.logger.Error("failed to move task", zap.String("task_id", id), zap.Error(err)) return nil, err @@ -614,6 +630,15 @@ func (s *Service) syncTaskStateForWorkflowMove(ctx context.Context, task *models } func (s *Service) pullNextTaskOnVacate(ctx context.Context, vacatedStepID, excludeTaskID string) { + // A queue/WIP reconciliation is always wip_pull, unconditionally + // overriding whatever trigger the caller that vacated the step declared + // — the vacating move and the resulting pull are two distinct ledger + // rows with two distinct causes. No single session initiates a pull, so + // actor kind is always system with no session. + ctx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerWIPPull, + ActorKind: steptelemetry.ActorSystem, + }) vacatedStep := s.reconcilableStep(ctx, vacatedStepID) if vacatedStep == nil { return @@ -1175,6 +1200,10 @@ func (s *Service) BulkMoveSelectedTasks(ctx context.Context, taskIDs []string, t if len(ids) == 0 { return &BulkMoveTasksResult{MovedCount: 0}, nil } + bulkActorKind, bulkActorID := steptelemetry.HumanOrSystemActor(ctx) + ctx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerBulkMove, ActorKind: bulkActorKind, ActorID: bulkActorID, + }) tasks, err := s.validateSelectedMoveBatch(ctx, ids, targetWorkflowID, targetStepID) if err != nil { @@ -1235,6 +1264,11 @@ func (s *Service) validateSelectedMoveBatch(ctx context.Context, taskIDs []strin // BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step. // If sourceStepID is empty, all tasks in the source workflow are moved. func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { + bulkActorKind, bulkActorID := steptelemetry.HumanOrSystemActor(ctx) + ctx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerBulkMove, ActorKind: bulkActorKind, ActorID: bulkActorID, + }) + // Get the tasks to move var tasks []*models.Task var err error diff --git a/apps/backend/internal/task/service/service_workflow_ledger_test.go b/apps/backend/internal/task/service/service_workflow_ledger_test.go new file mode 100644 index 0000000000..3942a7c181 --- /dev/null +++ b/apps/backend/internal/task/service/service_workflow_ledger_test.go @@ -0,0 +1,235 @@ +package service + +// Ledger attribution coverage for the service-layer callers that wrap +// steptelemetry.Attribution onto ctx: MoveTaskWithOptions (manual_move, +// outermost-caller-wins), BulkMoveSelectedTasks (bulk_move), UpdateTask +// (task_update), and pullNextTaskOnVacate (wip_pull). + +import ( + "context" + "fmt" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/steptelemetry" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestMoveTaskWithOptionsRecordsManualMoveByDefault(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-manual-move", "step-a") + + if _, err := svc.MoveTaskWithOptions(ctx, "task-manual-move", "wf-stamp", "step-b", 0, MoveTaskOptions{}); err != nil { + t.Fatalf("MoveTaskWithOptions: %v", err) + } + + trigger, actorKind, _, _ := lastLedgerAttribution(t, repo, "task-manual-move") + if trigger != string(steptelemetry.TriggerManualMove) { + t.Fatalf("trigger = %q, want %q", trigger, steptelemetry.TriggerManualMove) + } + if actorKind != string(steptelemetry.ActorSystem) { + t.Fatalf("actor_kind = %q, want %q (no identity on a bare context)", actorKind, steptelemetry.ActorSystem) + } +} + +// TestMoveTaskWithOptionsRecordsHumanActorFromIdentity proves spec.md:504-507's +// actual claim ("actor kind human, and actor_id equal to the acting user's +// ID") — the sibling test above only exercises the no-identity default. Also +// covers spec.md:590-592 (the synthetic auth-disabled identity moving a +// card): authn.WithIdentity with Synthetic:true resolves to the same +// ActorHuman/UserID pair HumanOrSystemActor already gives a real +// authenticated identity, so one test proves both. +func TestMoveTaskWithOptionsRecordsHumanActorFromIdentity(t *testing.T) { + svc, _, repo := createTestService(t) + setupStepStampTask(t, repo, "task-manual-move-human", "step-a") + ctx := authn.WithIdentity(context.Background(), authn.Identity{ + UserID: "user-1", Role: authn.RoleAdmin, Synthetic: true, + }) + + if _, err := svc.MoveTaskWithOptions(ctx, "task-manual-move-human", "wf-stamp", "step-b", 0, MoveTaskOptions{}); err != nil { + t.Fatalf("MoveTaskWithOptions: %v", err) + } + + trigger, actorKind, actorID, _ := lastLedgerAttribution(t, repo, "task-manual-move-human") + if trigger != string(steptelemetry.TriggerManualMove) { + t.Fatalf("trigger = %q, want %q", trigger, steptelemetry.TriggerManualMove) + } + if actorKind != string(steptelemetry.ActorHuman) { + t.Fatalf("actor_kind = %q, want %q", actorKind, steptelemetry.ActorHuman) + } + if actorID == nil || *actorID != "user-1" { + t.Fatalf("actor_id = %v, want user-1", actorID) + } +} + +func TestMoveTaskWithOptionsPreservesOuterMCPMoveTrigger(t *testing.T) { + svc, _, repo := createTestService(t) + setupStepStampTask(t, repo, "task-outer-trigger", "step-a") + + ctx := steptelemetry.WithAttribution(context.Background(), steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerMCPMove, ActorKind: steptelemetry.ActorAgent, ActorID: "sess-1", + }) + if _, err := svc.MoveTaskWithOptions(ctx, "task-outer-trigger", "wf-stamp", "step-b", 0, MoveTaskOptions{}); err != nil { + t.Fatalf("MoveTaskWithOptions: %v", err) + } + + trigger, _, _, _ := lastLedgerAttribution(t, repo, "task-outer-trigger") + if trigger != string(steptelemetry.TriggerMCPMove) { + t.Fatalf("trigger = %q, want %q (outer caller must win over manual_move default)", trigger, steptelemetry.TriggerMCPMove) + } +} + +func TestBulkMoveSelectedTasksRecordsBulkMove(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-bulk-1", "step-a") + + if _, err := svc.BulkMoveSelectedTasks(ctx, []string{"task-bulk-1"}, "wf-stamp", "step-b"); err != nil { + t.Fatalf("BulkMoveSelectedTasks: %v", err) + } + + trigger, _, _, sessionID := lastLedgerAttribution(t, repo, "task-bulk-1") + if trigger != string(steptelemetry.TriggerBulkMove) { + t.Fatalf("trigger = %q, want %q", trigger, steptelemetry.TriggerBulkMove) + } + if sessionID != nil { + t.Fatalf("session_id = %v, want NULL (spec.md:508-510)", sessionID) + } +} + +func TestServiceUpdateTaskWithNewStepRecordsTaskUpdate(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-update-step", "step-a") + + newStep := "step-b" + if _, err := svc.UpdateTask(ctx, "task-update-step", &UpdateTaskRequest{WorkflowStepID: &newStep}); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + + trigger, _, _, _ := lastLedgerAttribution(t, repo, "task-update-step") + if trigger != string(steptelemetry.TriggerTaskUpdate) { + t.Fatalf("trigger = %q, want %q", trigger, steptelemetry.TriggerTaskUpdate) + } +} + +func TestServiceUpdateTaskWithoutStepChangeRecordsNoLedgerRow(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupStepStampTask(t, repo, "task-update-nostep", "step-a") + + before := countLedgerRows(t, repo, "task-update-nostep") + newDesc := "updated description" + if _, err := svc.UpdateTask(ctx, "task-update-nostep", &UpdateTaskRequest{Description: &newDesc}); err != nil { + t.Fatalf("UpdateTask: %v", err) + } + after := countLedgerRows(t, repo, "task-update-nostep") + if after != before { + t.Fatalf("rows after non-step update = %d, want unchanged %d", after, before) + } +} + +// staticLedgerStepGetter is a minimal WorkflowStepGetter that returns fixed +// steps, including PullFromStepID, so a feeder-step promotion (which +// actually changes workflow_step_id, unlike a same-step admission) can be +// exercised without the full workflow package. +type staticLedgerStepGetter struct { + steps map[string]*wfmodels.WorkflowStep +} + +func (g *staticLedgerStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + step, ok := g.steps[stepID] + if !ok { + return nil, fmt.Errorf("workflow step not found: %s", stepID) + } + return step, nil +} + +func (*staticLedgerStepGetter) GetNextStepByPosition(context.Context, string, int) (*wfmodels.WorkflowStep, error) { + return nil, nil +} + +func TestPullNextTaskOnVacatePromotesFeederTaskWithWIPPull(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + svc.SetWorkflowStepGetter(&staticLedgerStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-vacate": {ID: "step-vacate", WorkflowID: "wf-stamp", PullFromStepID: "step-feeder"}, + "step-elsewhere": {ID: "step-elsewhere", WorkflowID: "wf-stamp"}, + "step-feeder": {ID: "step-feeder", WorkflowID: "wf-stamp"}, + }}) + + setupStepStampTask(t, repo, "task-occupant-wip", "step-vacate") + feederTask := &models.Task{ + ID: "task-feeder-wip", WorkspaceID: "ws-stamp", WorkflowID: "wf-stamp", + WorkflowStepID: "step-feeder", QueuedForStepID: "step-vacate", WIPAdmitted: false, + Title: "Queued in feeder", Priority: "medium", State: v1.TaskStateTODO, + } + if err := repo.CreateTask(ctx, feederTask); err != nil { + t.Fatalf("CreateTask feeder: %v", err) + } + // spec.md:511-513's scenario is specifically "a task with TWO live + // sessions" — the point being that the writer must not opportunistically + // grab one of them as the initiator. Seed both so the assertion below + // can't pass merely because there was nothing to grab. WAITING_FOR_INPUT + // (not RUNNING/STARTING) so feederCandidateBlocked's unrelated "don't + // promote a task with an in-flight session" rule doesn't itself block + // the promotion this test is trying to observe. + for _, id := range []string{"session-feeder-wip-1", "session-feeder-wip-2"} { + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: id, TaskID: "task-feeder-wip", State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession %s: %v", id, err) + } + } + + if _, err := svc.MoveTaskWithOptions(ctx, "task-occupant-wip", "wf-stamp", "step-elsewhere", 0, MoveTaskOptions{}); err != nil { + t.Fatalf("MoveTaskWithOptions: %v", err) + } + + reread, err := repo.GetTask(ctx, "task-feeder-wip") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if reread.WorkflowStepID != "step-vacate" { + t.Fatalf("feeder task WorkflowStepID = %q, want promoted to step-vacate", reread.WorkflowStepID) + } + + trigger, actorKind, _, sessionID := lastLedgerAttribution(t, repo, "task-feeder-wip") + if trigger != string(steptelemetry.TriggerWIPPull) { + t.Fatalf("trigger = %q, want %q", trigger, steptelemetry.TriggerWIPPull) + } + if actorKind != string(steptelemetry.ActorSystem) { + t.Fatalf("actor_kind = %q, want %q", actorKind, steptelemetry.ActorSystem) + } + if sessionID != nil { + t.Fatalf("session_id = %v, want NULL (task has two live sessions, neither is the initiator)", sessionID) + } +} + +func lastLedgerAttribution(t *testing.T, repo *sqliterepo.Repository, taskID string) (trigger, actorKind string, actorID, sessionID *string) { + t.Helper() + row := repo.DB().QueryRowContext(context.Background(), ` + SELECT trigger, actor_kind, actor_id, session_id FROM task_step_transitions + WHERE task_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 + `, taskID) + if err := row.Scan(&trigger, &actorKind, &actorID, &sessionID); err != nil { + t.Fatalf("scan last ledger row for %s: %v", taskID, err) + } + return trigger, actorKind, actorID, sessionID +} + +func countLedgerRows(t *testing.T, repo *sqliterepo.Repository, taskID string) int { + t.Helper() + var count int + if err := repo.DB().QueryRowContext(context.Background(), ` + SELECT COUNT(*) FROM task_step_transitions WHERE task_id = ? + `, taskID).Scan(&count); err != nil { + t.Fatalf("count ledger rows for %s: %v", taskID, err) + } + return count +} diff --git a/apps/backend/internal/telemetrycontract/contract.go b/apps/backend/internal/telemetrycontract/contract.go new file mode 100644 index 0000000000..4d1c2bbd0d --- /dev/null +++ b/apps/backend/internal/telemetrycontract/contract.go @@ -0,0 +1,50 @@ +// Package telemetrycontract publishes the activation point of each telemetry +// collection contract this binary writes. The downstream analytics extract is +// a point-in-time snapshot with no schema versioning, so a column or table +// whose meaning changes mid-series is silently discontinuous unless its +// activation point is readable from the snapshot itself. +package telemetrycontract + +// Contract describes one telemetry collection contract whose activation point +// and liveness must be readable from the database snapshot itself. +type Contract struct { + // Key identifies the contract, e.g. "turn.workflow_step_id_at_start". + Key string + // Version starts at 1. A bump appends a new activation row rather than + // overwriting the first. + Version int + // ExistsQuery is a SELECT returning exactly one row/column that is + // non-NULL when the contract's backing objects exist. + ExistsQuery string + // StatsQuery is a SELECT returning (count, max_occurred_at); the second + // column may be NULL when there are no rows yet. + StatsQuery string +} + +// registry is the closed set of contracts this binary writes. +// +// ExistsQuery is deliberately dialect-neutral: "SELECT 1 FROM LIMIT 1" +// runs identically on SQLite and Postgres. A missing table surfaces as a query +// error (classified with db.IsMissingTableError), and an existing-but-empty +// table surfaces as sql.ErrNoRows — both are handled by the Store, which is +// what lets one query string answer the existence question on either dialect. +var registry = []Contract{ + { + Key: "turn.workflow_step_id_at_start", + Version: 1, + ExistsQuery: `SELECT 1 FROM task_session_turns LIMIT 1`, + StatsQuery: `SELECT COUNT(*), MAX(started_at) FROM task_session_turns + WHERE metadata LIKE '%"workflow_step_id_at_start"%'`, + }, + { + Key: "task_step_transitions", + Version: 1, + ExistsQuery: `SELECT 1 FROM task_step_transitions LIMIT 1`, + StatsQuery: `SELECT COUNT(*), MAX(occurred_at) FROM task_step_transitions`, + }, +} + +// Registry returns the closed set of contracts this binary writes. +func Registry() []Contract { + return registry +} diff --git a/apps/backend/internal/telemetrycontract/health_test.go b/apps/backend/internal/telemetrycontract/health_test.go new file mode 100644 index 0000000000..bdaf72936c --- /dev/null +++ b/apps/backend/internal/telemetrycontract/health_test.go @@ -0,0 +1,156 @@ +package telemetrycontract + +import ( + "context" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" +) + +func observerLogger(t *testing.T) (*logger.Logger, *observer.ObservedLogs) { + t.Helper() + core, logs := observer.New(zapcore.DebugLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("NewFromZap: %v", err) + } + return log, logs +} + +func TestLogHealthEmitsOneLinePerRegisteredContract(t *testing.T) { + db := memDB(t) + store, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("NewWithDB: %v", err) + } + // task_session_turns doesn't exist in this isolated test DB, so its + // existence check should report exists=false rather than erroring the + // whole health pass. + log, logs := observerLogger(t) + store.LogHealth(context.Background(), log) + + entries := logs.FilterMessage("telemetry.contract.health").All() + if len(entries) != len(Registry()) { + t.Fatalf("health lines = %d, want %d (one per registered contract)", len(entries), len(Registry())) + } + for i, c := range Registry() { + ctxMap := entries[i].ContextMap() + if ctxMap["contract_key"] != c.Key { + t.Errorf("entry %d contract_key = %v, want %v", i, ctxMap["contract_key"], c.Key) + } + if exists, ok := ctxMap["exists"].(bool); !ok || exists { + t.Errorf("entry %d exists = %v, want false (backing table absent in isolated test DB)", i, ctxMap["exists"]) + } + } +} + +func TestLogHealthReportsExistingTableAndRowCount(t *testing.T) { + db := memDB(t) + if _, err := db.Exec(` + CREATE TABLE task_session_turns ( + id TEXT PRIMARY KEY, + metadata TEXT, + started_at TIMESTAMP + ) + `); err != nil { + t.Fatalf("create backing table: %v", err) + } + if _, err := db.Exec(` + INSERT INTO task_session_turns (id, metadata, started_at) + VALUES ('turn-1', '{"workflow_step_id_at_start":"step-a"}', '2026-01-01T00:00:00Z') + `); err != nil { + t.Fatalf("seed stamped turn: %v", err) + } + + store, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("NewWithDB: %v", err) + } + log, logs := observerLogger(t) + store.LogHealth(context.Background(), log) + + entries := logs.FilterMessage("telemetry.contract.health").All() + if len(entries) != len(Registry()) { + t.Fatalf("health lines = %d, want %d", len(entries), len(Registry())) + } + var found bool + for _, e := range entries { + ctxMap := e.ContextMap() + if ctxMap["contract_key"] != "turn.workflow_step_id_at_start" { + continue + } + found = true + if exists, ok := ctxMap["exists"].(bool); !ok || !exists { + t.Fatalf("exists = %v, want true", ctxMap["exists"]) + } + if rowCount, ok := ctxMap["row_count"].(int64); !ok || rowCount != 1 { + t.Fatalf("row_count = %v, want 1", ctxMap["row_count"]) + } + } + if !found { + t.Fatal("no health line for turn.workflow_step_id_at_start") + } +} + +func TestLogHealthReportsTaskStepTransitionsTableAndRowCount(t *testing.T) { + db := memDB(t) + if _, err := db.Exec(` + CREATE TABLE task_step_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + occurred_at TIMESTAMP NOT NULL + ) + `); err != nil { + t.Fatalf("create backing table: %v", err) + } + if _, err := db.Exec(` + INSERT INTO task_step_transitions (task_id, occurred_at) VALUES + ('task-1', '2026-01-01T00:00:00Z'), + ('task-2', '2026-01-02T00:00:00Z') + `); err != nil { + t.Fatalf("seed ledger rows: %v", err) + } + + store, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("NewWithDB: %v", err) + } + log, logs := observerLogger(t) + store.LogHealth(context.Background(), log) + + entries := logs.FilterMessage("telemetry.contract.health").All() + var found bool + for _, e := range entries { + ctxMap := e.ContextMap() + if ctxMap["contract_key"] != "task_step_transitions" { + continue + } + found = true + if exists, ok := ctxMap["exists"].(bool); !ok || !exists { + t.Fatalf("exists = %v, want true", ctxMap["exists"]) + } + if rowCount, ok := ctxMap["row_count"].(int64); !ok || rowCount != 2 { + t.Fatalf("row_count = %v, want 2", ctxMap["row_count"]) + } + if ctxMap["most_recent_occurred_at"] == nil { + t.Fatal("most_recent_occurred_at field is missing") + } + } + if !found { + t.Fatal("no health line for task_step_transitions") + } +} + +func TestLogHealthNoopWhenLoggerNil(t *testing.T) { + db := memDB(t) + store, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("NewWithDB: %v", err) + } + // Must not panic. + store.LogHealth(context.Background(), nil) +} diff --git a/apps/backend/internal/telemetrycontract/store.go b/apps/backend/internal/telemetrycontract/store.go new file mode 100644 index 0000000000..1233453f10 --- /dev/null +++ b/apps/backend/internal/telemetrycontract/store.go @@ -0,0 +1,177 @@ +package telemetrycontract + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + dbutil "github.com/kandev/kandev/internal/db" +) + +// Store owns the telemetry_activations table: creating it, activating +// registered contracts, and reporting their health at boot. +type Store struct { + writer *sqlx.DB + reader *sqlx.DB +} + +// NewWithDB creates telemetry_activations idempotently and returns a Store. +func NewWithDB(writer, reader *sqlx.DB) (*Store, error) { + if _, err := writer.Exec(` + CREATE TABLE IF NOT EXISTS telemetry_activations ( + contract_key TEXT NOT NULL, + contract_version INTEGER NOT NULL, + activated_at TIMESTAMP NOT NULL, + PRIMARY KEY (contract_key, contract_version) + ) + `); err != nil { + return nil, err + } + return &Store{writer: writer, reader: reader}, nil +} + +// Activate writes one row per registered (key, version) that has none yet, +// stamped with this boot's UTC time. It never rewrites an existing row: a +// version bump appends a second activation row rather than overwriting the +// first, which is the whole point of the composite primary key. +func (s *Store) Activate(ctx context.Context) error { + now := time.Now().UTC() + for _, c := range Registry() { + query := s.writer.Rebind(` + INSERT INTO telemetry_activations (contract_key, contract_version, activated_at) + VALUES (?, ?, ?) + ON CONFLICT (contract_key, contract_version) DO NOTHING + `) + if _, err := s.writer.ExecContext(ctx, query, c.Key, c.Version, now); err != nil { + return err + } + } + return nil +} + +// contractHealth is one registered contract's boot-time health snapshot. +type contractHealth struct { + key string + version int + exists bool + activatedAt *time.Time + rowCount int64 + // mostRecentOccurredAt is reported as the driver's raw text rather than a + // parsed time.Time: it comes from an aggregate MAX(...) over a + // dialect-declared timestamp column, and SQLite's driver only recognizes + // a column's declared type (and therefore auto-parses into time.Time) on + // a direct column read, not through an aggregate function. A log field is + // observability, not a value consumers parse, so the raw string is enough. + mostRecentOccurredAt string +} + +// LogHealth emits one line per registered contract: object existence, +// activation time, row count, and most recent occurred_at. Never fatal — a +// failure to compute one contract's health logs a warning and the boot +// continues, matching recordSchemaVersion's contract. +func (s *Store) LogHealth(ctx context.Context, log *logger.Logger) { + if log == nil { + return + } + for _, c := range Registry() { + health, err := s.contractHealth(ctx, c) + if err != nil { + log.Warn("telemetry contract health check failed", + zap.String("contract_key", c.Key), + zap.Int("contract_version", c.Version), + zap.Error(err)) + continue + } + fields := []zap.Field{ + zap.String("contract_key", health.key), + zap.Int("contract_version", health.version), + zap.Bool("exists", health.exists), + zap.Int64("row_count", health.rowCount), + } + if health.activatedAt != nil { + fields = append(fields, zap.Time("activated_at", *health.activatedAt)) + } + if health.mostRecentOccurredAt != "" { + fields = append(fields, zap.String("most_recent_occurred_at", health.mostRecentOccurredAt)) + } + log.Info("telemetry.contract.health", fields...) + } +} + +func (s *Store) contractHealth(ctx context.Context, c Contract) (contractHealth, error) { + health := contractHealth{key: c.Key, version: c.Version} + + exists, err := s.objectsExist(ctx, c) + if err != nil { + return health, err + } + health.exists = exists + + activatedAt, err := s.activatedAt(ctx, c) + if err != nil { + return health, err + } + health.activatedAt = activatedAt + + if exists { + count, mostRecent, err := s.stats(ctx, c) + if err != nil { + return health, err + } + health.rowCount = count + health.mostRecentOccurredAt = mostRecent + } + return health, nil +} + +// objectsExist reports whether a contract's backing objects exist. The +// ExistsQuery is dialect-neutral ("SELECT 1 FROM
LIMIT 1"); a missing +// table surfaces as a query error classified by db.IsMissingTableError, and +// an existing-but-empty table surfaces as sql.ErrNoRows, which still means +// the table exists. +func (s *Store) objectsExist(ctx context.Context, c Contract) (bool, error) { + var discard int + err := s.reader.QueryRowContext(ctx, c.ExistsQuery).Scan(&discard) + switch { + case err == nil, errors.Is(err, sql.ErrNoRows): + return true, nil + case dbutil.IsMissingTableError(err): + return false, nil + default: + return false, err + } +} + +func (s *Store) activatedAt(ctx context.Context, c Contract) (*time.Time, error) { + query := s.reader.Rebind(` + SELECT activated_at FROM telemetry_activations + WHERE contract_key = ? AND contract_version = ? + `) + var activatedAt time.Time + err := s.reader.QueryRowContext(ctx, query, c.Key, c.Version).Scan(&activatedAt) + switch { + case err == nil: + return &activatedAt, nil + case errors.Is(err, sql.ErrNoRows): + return nil, nil + default: + return nil, err + } +} + +func (s *Store) stats(ctx context.Context, c Contract) (int64, string, error) { + var count int64 + var mostRecent sql.NullString + if err := s.reader.QueryRowContext(ctx, c.StatsQuery).Scan(&count, &mostRecent); err != nil { + return 0, "", err + } + if !mostRecent.Valid { + return count, "", nil + } + return count, mostRecent.String, nil +} diff --git a/apps/backend/internal/telemetrycontract/store_postgres_test.go b/apps/backend/internal/telemetrycontract/store_postgres_test.go new file mode 100644 index 0000000000..46b9753b16 --- /dev/null +++ b/apps/backend/internal/telemetrycontract/store_postgres_test.go @@ -0,0 +1,49 @@ +package telemetrycontract + +// Postgres parity coverage for telemetry_activations: the composite primary +// key relies on ON CONFLICT (contract_key, contract_version) DO NOTHING, +// which both dialects support but is worth pinning per ADR 0027. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresActivateAndReplay(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + store, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("NewWithDB: %v", err) + } + ctx := context.Background() + if err := store.Activate(ctx); err != nil { + t.Fatalf("first Activate: %v", err) + } + + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM telemetry_activations`); err != nil { + t.Fatalf("count activations: %v", err) + } + if count != len(Registry()) { + t.Fatalf("activation row count = %d, want %d", count, len(Registry())) + } + + // Replay: NewWithDB and Activate must both be no-ops on a second boot. + store2, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("replay NewWithDB: %v", err) + } + if err := store2.Activate(ctx); err != nil { + t.Fatalf("replay Activate: %v", err) + } + var countAfterReplay int + if err := db.Get(&countAfterReplay, `SELECT COUNT(*) FROM telemetry_activations`); err != nil { + t.Fatalf("count activations after replay: %v", err) + } + if countAfterReplay != count { + t.Fatalf("activation row count after replay = %d, want unchanged %d", countAfterReplay, count) + } +} diff --git a/apps/backend/internal/telemetrycontract/store_test.go b/apps/backend/internal/telemetrycontract/store_test.go new file mode 100644 index 0000000000..650e52f0ff --- /dev/null +++ b/apps/backend/internal/telemetrycontract/store_test.go @@ -0,0 +1,123 @@ +package telemetrycontract + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func memDB(t *testing.T) *sqlx.DB { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestNewWithDBCreatesTableIdempotently(t *testing.T) { + db := memDB(t) + if _, err := NewWithDB(db, db); err != nil { + t.Fatalf("first NewWithDB: %v", err) + } + if _, err := NewWithDB(db, db); err != nil { + t.Fatalf("second NewWithDB (replay): %v", err) + } +} + +func TestActivateWritesOneRowPerRegisteredContract(t *testing.T) { + db := memDB(t) + store, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("NewWithDB: %v", err) + } + ctx := context.Background() + if err := store.Activate(ctx); err != nil { + t.Fatalf("Activate: %v", err) + } + + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM telemetry_activations`); err != nil { + t.Fatalf("count activations: %v", err) + } + if count != len(Registry()) { + t.Fatalf("activation row count = %d, want %d (one per registered contract)", count, len(Registry())) + } + + for _, c := range Registry() { + var version int + if err := db.Get(&version, db.Rebind(` + SELECT contract_version FROM telemetry_activations + WHERE contract_key = ? AND contract_version = ? + `), c.Key, c.Version); err != nil { + t.Fatalf("activation row for %s missing: %v", c.Key, err) + } + } +} + +func TestActivateLeavesExistingRowUnchanged(t *testing.T) { + db := memDB(t) + store, err := NewWithDB(db, db) + if err != nil { + t.Fatalf("NewWithDB: %v", err) + } + ctx := context.Background() + if err := store.Activate(ctx); err != nil { + t.Fatalf("first Activate: %v", err) + } + + var firstActivatedAt string + if err := db.Get(&firstActivatedAt, db.Rebind(` + SELECT activated_at FROM telemetry_activations WHERE contract_key = ? AND contract_version = ? + `), Registry()[0].Key, Registry()[0].Version); err != nil { + t.Fatalf("read first activation: %v", err) + } + + if err := store.Activate(ctx); err != nil { + t.Fatalf("second Activate: %v", err) + } + + var secondActivatedAt string + if err := db.Get(&secondActivatedAt, db.Rebind(` + SELECT activated_at FROM telemetry_activations WHERE contract_key = ? AND contract_version = ? + `), Registry()[0].Key, Registry()[0].Version); err != nil { + t.Fatalf("read second activation: %v", err) + } + + if firstActivatedAt != secondActivatedAt { + t.Fatalf("activated_at changed across boots: %q -> %q, want unchanged", firstActivatedAt, secondActivatedAt) + } +} + +func TestActivateAppendsRowOnVersionBump(t *testing.T) { + db := memDB(t) + if _, err := NewWithDB(db, db); err != nil { + t.Fatalf("NewWithDB: %v", err) + } + ctx := context.Background() + + key := "test.versioned_contract" + if _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO telemetry_activations (contract_key, contract_version, activated_at) + VALUES (?, 1, ?) + `), key, "2026-01-01T00:00:00Z"); err != nil { + t.Fatalf("seed version-1 row: %v", err) + } + if _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO telemetry_activations (contract_key, contract_version, activated_at) + VALUES (?, 2, ?) + `), key, "2026-02-01T00:00:00Z"); err != nil { + t.Fatalf("seed version-2 row: %v", err) + } + + var count int + if err := db.Get(&count, db.Rebind(`SELECT COUNT(*) FROM telemetry_activations WHERE contract_key = ?`), key); err != nil { + t.Fatalf("count rows for key: %v", err) + } + if count != 2 { + t.Fatalf("rows for %s = %d, want 2 (a version bump appends rather than overwrites)", key, count) + } +} diff --git a/apps/backend/internal/workflow/engine/phase8_callbacks.go b/apps/backend/internal/workflow/engine/phase8_callbacks.go index b7df209742..c7f96d73cd 100644 --- a/apps/backend/internal/workflow/engine/phase8_callbacks.go +++ b/apps/backend/internal/workflow/engine/phase8_callbacks.go @@ -3,6 +3,8 @@ package engine import ( "context" "fmt" + + "github.com/kandev/kandev/internal/steptelemetry" ) // CreateChildTaskCallback executes the create_child_task action by asking @@ -38,7 +40,25 @@ func (c CreateChildTaskCallback) Execute(ctx context.Context, in ActionInput) (A StepID: cfg.StepID, AgentProfileID: cfg.AgentProfileID, } - if _, err := c.Creator.CreateChildTask(ctx, in.State.TaskID, spec); err != nil { + // The new child task's genesis ledger row must attribute the trigger's + // session when one exists — create_child_task typically fires from a + // session's own turn (on_turn_complete evaluating the step's actions), + // so leaving ctx unwrapped would let genesisAttribution fall through to + // the (session-less) authn seam and silently record actor_kind=system + // for a session-caused creation, the same bug already fixed for + // switch_workflow in SwitchWorkflowCallback.Execute. Unlike that + // callback, create_child_task doesn't require a session up front — a + // future non-session-originated trigger falls back to genesis's + // existing default rather than fabricating one. + createCtx := ctx + if in.State.SessionID != "" { + createCtx = steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + ActorKind: steptelemetry.ActorAgent, + ActorID: in.State.SessionID, + SessionID: in.State.SessionID, + }) + } + if _, err := c.Creator.CreateChildTask(createCtx, in.State.TaskID, spec); err != nil { return ActionResult{}, fmt.Errorf("create_child_task: %w", err) } return ActionResult{}, nil @@ -91,7 +111,17 @@ func (c SwitchWorkflowCallback) Execute(ctx context.Context, in ActionInput) (Ac } } - if _, err := c.Switcher.SwitchTaskWorkflow(ctx, in.State.TaskID, cfg.WorkflowID, cfg.StepID); err != nil { + // A switch_workflow action is always genuinely caused by the trigger's + // session — validated above, since in.State.SessionID must be non-empty + // to reach this point — so the ledger row this writes must attribute + // that session, not fall back to the (session-less) authn seam. + switchCtx := steptelemetry.WithAttribution(ctx, steptelemetry.Attribution{ + Trigger: steptelemetry.TriggerWorkflowAttached, + ActorKind: steptelemetry.ActorAgent, + ActorID: in.State.SessionID, + SessionID: in.State.SessionID, + }) + if _, err := c.Switcher.SwitchTaskWorkflow(switchCtx, in.State.TaskID, cfg.WorkflowID, cfg.StepID); err != nil { return ActionResult{}, fmt.Errorf("switch_workflow: %w", err) } diff --git a/apps/backend/internal/workflow/engine/phase8_test.go b/apps/backend/internal/workflow/engine/phase8_test.go index 1ff307f726..0a5dbd73c9 100644 --- a/apps/backend/internal/workflow/engine/phase8_test.go +++ b/apps/backend/internal/workflow/engine/phase8_test.go @@ -5,23 +5,27 @@ import ( "errors" "strings" "testing" + + "github.com/kandev/kandev/internal/steptelemetry" ) // fakeTaskCreator records every CreateChildTask invocation. type fakeTaskCreator struct { calls []struct { - ParentID string - Spec ChildTaskSpec + ParentID string + Spec ChildTaskSpec + Attribution steptelemetry.Attribution } returnedID string err error } -func (f *fakeTaskCreator) CreateChildTask(_ context.Context, parentID string, spec ChildTaskSpec) (string, error) { +func (f *fakeTaskCreator) CreateChildTask(ctx context.Context, parentID string, spec ChildTaskSpec) (string, error) { f.calls = append(f.calls, struct { - ParentID string - Spec ChildTaskSpec - }{ParentID: parentID, Spec: spec}) + ParentID string + Spec ChildTaskSpec + Attribution steptelemetry.Attribution + }{ParentID: parentID, Spec: spec, Attribution: steptelemetry.FromContext(ctx)}) if f.err != nil { return "", f.err } @@ -78,6 +82,63 @@ func TestCreateChildTaskCallback_HappyPath(t *testing.T) { } } +// TestCreateChildTaskCallback_AttributesCausalSession proves the fix for +// Review round 3's must-fix #1: CreateChildTaskCallback is the sibling of +// SwitchWorkflowCallback in the same file, and had the identical gap round 2 +// fixed for switch_workflow left unfixed here — the bare engine-internal ctx +// flowed straight to the Creator even though in.State.SessionID was +// populated, so the new child task's genesis row fell through to the +// session-less authn seam and recorded actor_kind=system for a session- +// caused creation. Execute must wrap ctx with the causal session's +// attribution before calling Creator.CreateChildTask, mirroring +// SwitchWorkflowCallback.Execute. +func TestCreateChildTaskCallback_AttributesCausalSession(t *testing.T) { + creator := &fakeTaskCreator{returnedID: "child-1"} + cb := CreateChildTaskCallback{Creator: creator} + spec := &CreateChildTaskAction{Title: "Fix bug", WorkflowID: "wf-kanban"} + if _, err := cb.Execute(context.Background(), newCreateChildTaskInput(spec)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(creator.calls) != 1 { + t.Fatalf("expected 1 CreateChildTask call, got %d", len(creator.calls)) + } + got := creator.calls[0].Attribution + if got.ActorKind != steptelemetry.ActorAgent { + t.Errorf("actor_kind = %q, want %q (the trigger's session caused this creation)", got.ActorKind, steptelemetry.ActorAgent) + } + if got.ActorID != "sess-1" { + t.Errorf("actor_id = %q, want sess-1", got.ActorID) + } + if got.SessionID != "sess-1" { + t.Errorf("session_id = %q, want sess-1", got.SessionID) + } +} + +// TestCreateChildTaskCallback_NoSessionLeavesCtxUnwrapped covers the no- +// session case (e.g. a future non-session-originated trigger): Execute must +// not fabricate a session, and the genesis row falls back to its existing +// default (the authn seam) rather than a guessed agent identity. +func TestCreateChildTaskCallback_NoSessionLeavesCtxUnwrapped(t *testing.T) { + creator := &fakeTaskCreator{returnedID: "child-1"} + cb := CreateChildTaskCallback{Creator: creator} + spec := &CreateChildTaskAction{Title: "Fix bug", WorkflowID: "wf-kanban"} + in := newCreateChildTaskInput(spec) + in.State.SessionID = "" + if _, err := cb.Execute(context.Background(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(creator.calls) != 1 { + t.Fatalf("expected 1 CreateChildTask call, got %d", len(creator.calls)) + } + got := creator.calls[0].Attribution + if got.ActorKind == steptelemetry.ActorAgent { + t.Errorf("actor_kind = %q, want anything but agent (no session to attribute to)", got.ActorKind) + } + if got.ActorID != "" { + t.Errorf("actor_id = %q, want empty", got.ActorID) + } +} + func TestCreateChildTaskCallback_RequiresCreator(t *testing.T) { cb := CreateChildTaskCallback{} _, err := cb.Execute(context.Background(), newCreateChildTaskInput(&CreateChildTaskAction{Title: "x"})) @@ -119,15 +180,17 @@ func TestCreateChildTaskCallback_RequiresTaskID(t *testing.T) { type fakeWorkflowSwitcher struct { calls []struct { TaskID, WorkflowID, StepID string + Attribution steptelemetry.Attribution } resolvedStep string err error } -func (f *fakeWorkflowSwitcher) SwitchTaskWorkflow(_ context.Context, taskID, wfID, stepID string) (string, error) { +func (f *fakeWorkflowSwitcher) SwitchTaskWorkflow(ctx context.Context, taskID, wfID, stepID string) (string, error) { f.calls = append(f.calls, struct { TaskID, WorkflowID, StepID string - }{TaskID: taskID, WorkflowID: wfID, StepID: stepID}) + Attribution steptelemetry.Attribution + }{TaskID: taskID, WorkflowID: wfID, StepID: stepID, Attribution: steptelemetry.FromContext(ctx)}) if f.err != nil { return "", f.err } @@ -205,6 +268,40 @@ func TestSwitchWorkflowCallback_HappyPath(t *testing.T) { } } +// TestSwitchWorkflowCallback_AttributesCausalSession proves the fix for +// Review round 2's must-fix #1: Execute validates in.State.SessionID is +// non-empty before doing anything, so a switch_workflow action is always +// genuinely caused by that session's turn. It must wrap ctx with that +// session as the actor before calling Switcher.SwitchTaskWorkflow, rather +// than leaving the switcher to fall back to the (session-less) authn seam +// and silently record actor_kind=system. +func TestSwitchWorkflowCallback_AttributesCausalSession(t *testing.T) { + switcher := &fakeWorkflowSwitcher{resolvedStep: "new-first-step"} + cb := SwitchWorkflowCallback{Switcher: switcher} + spec := &SwitchWorkflowAction{WorkflowID: "wf-office"} + in := newSwitchWorkflowInput(spec) + in.State.SessionID = "sess-causal" + if _, err := cb.Execute(context.Background(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(switcher.calls) != 1 { + t.Fatalf("expected 1 SwitchTaskWorkflow call, got %d", len(switcher.calls)) + } + got := switcher.calls[0].Attribution + if got.Trigger != steptelemetry.TriggerWorkflowAttached { + t.Errorf("trigger = %q, want %q", got.Trigger, steptelemetry.TriggerWorkflowAttached) + } + if got.ActorKind != steptelemetry.ActorAgent { + t.Errorf("actor_kind = %q, want %q (the trigger's session is causal, not the authn seam)", got.ActorKind, steptelemetry.ActorAgent) + } + if got.ActorID != "sess-causal" { + t.Errorf("actor_id = %q, want sess-causal", got.ActorID) + } + if got.SessionID != "sess-causal" { + t.Errorf("session_id = %q, want sess-causal", got.SessionID) + } +} + func TestSwitchWorkflowCallback_RequiresSwitcher(t *testing.T) { cb := SwitchWorkflowCallback{} _, err := cb.Execute(context.Background(), newSwitchWorkflowInput(&SwitchWorkflowAction{WorkflowID: "wf"})) diff --git a/docs/plans/task-step-transition-ledger/plan.md b/docs/plans/task-step-transition-ledger/plan.md new file mode 100644 index 0000000000..3b6cd4c04f --- /dev/null +++ b/docs/plans/task-step-transition-ledger/plan.md @@ -0,0 +1,678 @@ +--- +spec: docs/specs/workflow/task-step-transition-ledger/spec.md +created: 2026-08-13 +status: draft +--- + +# Implementation Plan: Task step transition ledger + +## Overview + +Two independently valuable slices, separated by a measurement gate the spec +mandates. Both need one shared prerequisite — a `telemetry_activations` registry +that publishes each contract's activation point — so that lands first. Slice 1 +then adds the turn-start step stamp (one new key in an existing JSON column, no +schema change). After the gate, Slice 2 adds the `task_step_transitions` table, +wires the ledger write into the seven repository transactions that can mutate +`tasks.workflow_step_id`, gives real triggers and actors to the callers of those +transactions, and adds the three writer-health controls. + +The order is dictated by dependency, not preference: the activation registry is +read by both writers; the ledger table must exist before the writer references +it; the writer must exist before callers can attribute to it; the pinning test +must run against a finished writer set. + +**Prerequisite outside this feature.** The spec records that wiring +`session_step_history`'s dormant `CreateStepTransition` writer is separate work +that "lands first" because it touches the same code region +(`internal/orchestrator/event_handlers_workflow.go`, +`internal/orchestrator/workflow_store.go`). Confirm its state before starting +Task 05, which edits those files. + +--- + +## Backend + +### Area 1 — Telemetry contract activation registry (new package) + +**`apps/backend/internal/telemetrycontract/`** — a small leaf package owning the +`telemetry_activations` table and the boot-time activation + health report. It is +deliberately generic: the spec frames this table as a registry for *any* +telemetry contract, and registers exactly two keys today. + +`contract.go`: + +```go +package telemetrycontract + +// Contract describes one telemetry collection contract whose activation point +// and liveness must be readable from the database snapshot itself. +type Contract struct { + Key string // e.g. "turn.workflow_step_id_at_start" + Version int // starts at 1; a bump APPENDS an activation row + ExistsQuery string // SELECT returning 1 when the backing objects exist + StatsQuery string // SELECT (count, max_occurred_at) — max may be NULL +} + +// Registry is the closed set of contracts this binary writes. +func Registry() []Contract +``` + +The two registrations: + +| Key | Version | Backing objects | Stats source | +|---|---|---|---| +| `turn.workflow_step_id_at_start` | 1 | `task_session_turns` | rows whose `metadata` contains the stamp key; `started_at` as the recency column | +| `task_step_transitions` | 1 | `task_step_transitions` | `COUNT(*)`, `MAX(occurred_at)` | + +`store.go`: + +```go +// NewWithDB creates telemetry_activations idempotently and returns a Store. +func NewWithDB(writer, reader *sqlx.DB) (*Store, error) + +// Activate writes one row per registered (key, version) that has none yet, +// stamped with this boot's UTC time. Never rewrites an existing row. +func (s *Store) Activate(ctx context.Context) error + +// LogHealth emits one line per registered contract: object existence, +// activation time, row count, most recent occurred_at. +func (s *Store) LogHealth(ctx context.Context, log *logger.Logger) +``` + +DDL (dialect-split on the timestamp only; both dialects accept the rest): + +```sql +CREATE TABLE IF NOT EXISTS telemetry_activations ( + contract_key TEXT NOT NULL, + contract_version INTEGER NOT NULL, + activated_at TIMESTAMP NOT NULL, + PRIMARY KEY (contract_key, contract_version) +); +``` + +The composite primary key is load-bearing: a future version bump appends a +second activation row rather than overwriting the first. `Activate` uses +`INSERT ... ON CONFLICT DO NOTHING` (supported by both SQLite and pgx) so a +concurrent or repeated boot is a no-op. + +**Wiring:** `apps/backend/internal/backendapp/storage.go`, immediately before the +existing `recordSchemaVersion(writer, ...)` call at line 101 — the comment there +already marks the point where every repository has finished `initSchema`, which +is exactly when a contract's backing objects are known to exist. `Activate` then +`LogHealth`. Neither is fatal: a failure logs and boot continues, matching +`recordSchemaVersion`'s contract. + +### Area 2 — Turn-start step stamp (Slice 1) + +**`apps/backend/internal/task/models/models.go`** — add beside +`TurnMetaKeyRuntimeConfigSnapshot` (line 232): + +```go +// TurnMetaKeyWorkflowStepIDAtStart records the workflow step the turn's task +// was in when the turn started. Absent when the task held no step. +const TurnMetaKeyWorkflowStepIDAtStart = "workflow_step_id_at_start" +``` + +**`apps/backend/internal/task/service/service_turns.go`** — the whole of Slice 1's +write path is the two call sites of `runtimeConfigSnapshotMetadata(session)`: +`StartTurn` (line 44) and `createCompletedTurn` (line 79). Replace both with a +new method: + +```go +// turnStartMetadata composes the turn's immutable start-of-turn metadata: the +// runtime config snapshot and the workflow step the task was in. The step stamp +// is independent of the runtime snapshot — a turn with nothing to snapshot still +// carries the stamp. A task read failure logs and omits the stamp; turn creation +// must never fail because telemetry could not be resolved. +func (s *Service) turnStartMetadata(ctx context.Context, session *models.TaskSession) map[string]interface{} +``` + +Behaviour, in order: + +1. Start from `runtimeConfigSnapshotMetadata(session)`; if it returns `nil`, + start from an empty map (do **not** return early — this is the spec scenario + "no runtime config to snapshot, still stamped"). +2. `s.tasks.GetTask(ctx, session.TaskID)`. On error: `s.logger.Debug`, record the + absent-stamp metric, return the map as-is. +3. If `task.WorkflowStepID != ""`, set the stamp key. Empty string sets nothing — + never `""`, never `nil`, never `0`. +4. Return `nil` when the map ended up empty, preserving today's "no metadata" + shape for turns that carry neither piece. + +`runtimeConfigSnapshotMetadata` keeps its current signature and semantics; it +becomes an input to the composer rather than the whole answer. + +Immutability needs no code: nothing rewrites `task_session_turns.metadata` after +creation. Task 02 adds a regression test that pins that. + +### Area 3 — Ledger schema (Slice 2) + +**`apps/backend/internal/task/repository/sqlite/base_schema.go`** — a new +`initStepTransitionsSchema` step appended to the `initSchema` step list, placed +after `initSessionSchema` (the FK targets `task_sessions`) and before +`runMigrations`. The `id` column is dialect-split exactly as +`session_step_history` does it (`internal/workflow/repository/sqlite.go:88-96`): + +```go +idCol := "id INTEGER PRIMARY KEY AUTOINCREMENT" +if dialect.IsPostgres(r.db.DriverName()) { + idCol = "id BIGSERIAL PRIMARY KEY" +} +``` + +```sql +CREATE TABLE IF NOT EXISTS task_step_transitions ( + , + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL, + from_workflow_id TEXT, + from_workflow_step_id TEXT, + to_workflow_id TEXT, + to_workflow_step_id TEXT, + trigger TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_id TEXT, + contract_version INTEGER NOT NULL, + occurred_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_task_step_transitions_task + ON task_step_transitions(task_id, occurred_at, id); +CREATE INDEX IF NOT EXISTS idx_task_step_transitions_occurred + ON task_step_transitions(occurred_at); +``` + +No foreign key to `workflow_steps` or `workflows` — deliberate, per the spec: a +deleted step must not delete the historical fact that a card was in it. + +This is a new table, not a new column, so `CREATE TABLE IF NOT EXISTS` in the +init block is correct and complete; the root guide's "columns only via +`runMigrations`" rule does not apply. Nothing is backfilled. + +### Area 4 — Attribution contract (new package, Slice 2) + +**`apps/backend/internal/steptelemetry/`** — a leaf package holding the closed +enums, the ambient attribution, the row type, and the metrics. Imported by the +sqlite repository, the task service, the orchestrator, and the MCP handlers; it +imports none of them, so no cycle is possible. + +```go +package steptelemetry + +const ContractVersion = 1 +const ContractKey = "task_step_transitions" + +type Trigger string +const ( + TriggerTaskCreated Trigger = "task_created" + TriggerManualMove Trigger = "manual_move" + TriggerTaskUpdate Trigger = "task_update" + TriggerMCPMove Trigger = "mcp_move" + TriggerMCPDeferredMove Trigger = "mcp_deferred_move" + TriggerEngineTransition Trigger = "engine_transition" + TriggerWIPPull Trigger = "wip_pull" + TriggerBulkMove Trigger = "bulk_move" + TriggerUnarchiveRestore Trigger = "unarchive_restore" + TriggerWorkflowAttached Trigger = "workflow_attached" + TriggerWorkflowDetached Trigger = "workflow_detached" + TriggerUnknown Trigger = "unknown" +) + +type ActorKind string +const ( + ActorHuman ActorKind = "human" + ActorAgent ActorKind = "agent" + ActorSystem ActorKind = "system" + ActorIntegration ActorKind = "integration" + ActorUnknown ActorKind = "unknown" +) + +// Attribution is who and why. The zero value is the spec's recorded fact for a +// caller that declares nothing: unknown trigger, unknown actor, no IDs. +type Attribution struct { + Trigger Trigger + ActorKind ActorKind + ActorID string + SessionID string +} + +func WithAttribution(ctx context.Context, a Attribution) context.Context +func FromContext(ctx context.Context) Attribution // normalizes zero -> unknown/unknown +``` + +**Why the attribution travels on the context and not in method signatures.** The +seven repository entry points are reached from dozens of call sites across the +service, orchestrator, MCP handlers, and integration watchers. The spec requires +that a caller who supplies nothing gets `unknown`/`unknown` recorded as a fact +rather than an error — that is ambient-default semantics, not a required +parameter. The repository already receives the acting identity this way +(`authn.IdentityFromContext`), which is the same value that becomes `actor_kind += human`, so this reuses an established seam rather than inventing one. Adding +four parameters to seven signatures and threading them through every caller +would produce a much larger diff for a value most callers pass through untouched. + +Also in the package, mirroring `internal/office/scheduler/metrics_vars.go`: + +```go +var ( + stepTransitionsTotal = expvar.NewMap("telemetry_step_transitions_total") + turnStampsTotal = expvar.NewMap("telemetry_turn_stamps_total") +) + +// RecordLedgerRow bumps the expvar counter keyed by trigger AND emits a +// telemetry.metric.step_transition_written log line. +func RecordLedgerRow(log *logger.Logger, trigger Trigger) + +// RecordTurnStamp bumps the counter keyed by present/absent AND emits +// telemetry.metric.turn_stamped. +func RecordTurnStamp(log *logger.Logger, present bool) +``` + +Log event names are the first field of the line so a log-aggregation rule matches +on the name without scanning free text — the `routing.metric.*` precedent. + +### Area 5 — Ledger writer inside the mutating transactions (Slice 2) + +**`apps/backend/internal/task/repository/sqlite/step_transitions.go`** (new): + +```go +// stepTransitionTx is satisfied by both *sql.Tx and *sqlx.Tx, the two +// transaction types the mutation paths in this package use. +type stepTransitionTx interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +// readTaskStepInTx reads the task's current (workflow_id, workflow_step_id) +// inside the write transaction, taking a row lock on Postgres. Reading outside +// the transaction would break the chain invariant under concurrent moves. +func (r *Repository) readTaskStepInTx(ctx context.Context, tx stepTransitionTx, taskID string) (workflowID, stepID string, found bool, err error) + +// recordStepTransition writes exactly one ledger row when the step actually +// changed. It is a no-op when fromStepID == toStepID (position-only reorder, +// re-issued move to the current step) and when both sides are empty. +func (r *Repository) recordStepTransition(ctx context.Context, tx stepTransitionTx, in stepTransitionInput) error +``` + +`readTaskStepInTx` issues `SELECT workflow_id, workflow_step_id FROM tasks WHERE +id = ?` with `FOR UPDATE` appended on Postgres (`dialect.IsPostgres`). On SQLite +the writer pool already serializes writers, so the plain read inside the write +transaction is sufficient. + +`recordStepTransition` normalizes `""` to `NULL` on all four `from_*`/`to_*` +columns, reads `steptelemetry.FromContext(ctx)` for trigger/actor/session, +stamps `occurred_at = time.Now().UTC()` and `contract_version = +steptelemetry.ContractVersion`, and returns the INSERT error unchanged. + +**The missing-table case needs no detection code and must not get any.** If the +`CREATE TABLE` was silently swallowed by the migration runner, the INSERT fails +with `no such table` / `relation does not exist`, that error returns, and the +enclosing transaction rolls back — which is precisely the spec's required +behaviour ("the transaction fails rather than committing a step change with no +row"). The rule for the implementer is therefore a negative one: never log-and- +continue on `recordStepTransition`'s error, and never wrap it in a table-exists +probe that would let the step change commit alone. + +The seven chokepoints, each already holding a transaction except where noted: + +| Function | File:line | Change | +|---|---|---| +| `insertTaskTx` (task creation) | `task.go:319` | Genesis row after the INSERT when `task.WorkflowStepID != ""`, `from_*` NULL. Reads the task struct, which by this point already carries the *actual* admission placement (`applyAdmissionPlacement`, `task.go:235`), so the feeder-placement scenario is satisfied for free. A task with no workflow writes nothing. | +| `UpdateTask` | `task.go:469` | `readTaskStepInTx` before the UPDATE; `recordStepTransition` after the `RowsAffected` check. | +| `UpdateTaskIfWorkflowStepHasCapacity` | `task.go:778` | Same. A WIP rejection returns before the UPDATE, so no row — as required. | +| `PromoteQueuedTaskIfWorkflowStepHasCapacity` | `task.go:830` | Same, but the UPDATE is conditional: record only when `rows > 0`. | +| `RestoreTaskMessageRollbackIfSessionState` | `task.go:2043` | Same conditional shape — the UPDATE is guarded by an `EXISTS` on session state; record only when `rows > 0`. | +| `AddTaskToWorkflow` | `workflow.go:17` | **Currently has no transaction.** Wrap in `r.db.BeginTx`, read-then-update-then-record. | +| `RemoveTaskFromWorkflow` | `workflow.go:25` | Same. Its UPDATE is guarded by `AND workflow_id = ?`; record only when `rows > 0`. `to_*` normalize to NULL from the `''` the statement writes. | + +Archive, unarchive-in-place, cascade archive, and delete are untouched: none of +them write `workflow_step_id`, so none writes a row, which is the specified +behaviour rather than an omission. + +### Area 6 — Caller attribution (Slice 2) + +Each caller wraps its context once, as close to the entry point as possible. +Actor resolution is uniform: `authn.IdentityFromContext(ctx)` present → +`ActorHuman` with `Identity.UserID` (including the synthetic single-user identity +when auth is disabled); an agent session → `ActorAgent` with the session ID in +**both** `ActorID` and `SessionID`; no initiating identity → `ActorSystem` with +empty `ActorID`. + +| Site | File | Attribution | +|---|---|---| +| `MoveTaskWithOptions` | `service/service_workflow.go:428` | `manual_move` + identity actor — **only if the context carries no trigger already**, so an `mcp_move` set by the MCP handler wins over the inner board-move default (the spec's outermost-caller rule). | +| `BulkMoveSelectedTasks`, `BulkMoveTasks` | `service/service_workflow.go:947,1049` | `bulk_move`, `SessionID` empty | +| `Service.UpdateTask` (request carries a new step) | `service/service_tasks.go:1362` | `task_update` | +| `promoteNextQueuedTask` / `pullNextTaskOnVacate` | `service/service_workflow.go:558,612` | `wip_pull`, `ActorSystem`, `SessionID` empty — even when the task has live sessions, since no one session is the initiator | +| `applyMoveTaskImmediate` | `mcp/handlers/config_task_handlers.go:176` | `mcp_move`, `ActorAgent`, actor+session = `session.ID` | +| `applyPendingMove` | `orchestrator/event_handlers_workflow.go:1243` | `mcp_deferred_move`, `ActorAgent` | +| `executeStepTransition` | `orchestrator/event_handlers_workflow.go:296` | `engine_transition`; `ActorAgent` when reached from a session turn, `ActorSystem` otherwise | +| `workflowStore.ApplyTransition` | `orchestrator/workflow_store.go:154` | Same, using its `sessionID` argument to decide agent vs system | +| Watcher-driven moves | `orchestrator/watcher_dispatch.go` + `source_jira.go` / `source_linear.go` | `ActorIntegration`, `ActorID` = watch ID | + +Task creation's genesis row needs no wrapping: the writer hard-codes +`TriggerTaskCreated`, and the actor comes from the identity already on the +context. + +Anything not wrapped records `unknown`/`unknown`, which is a recorded fact. + +--- + +## Frontend + +**None.** The spec's *API surface* section is explicit: no HTTP route, no +WebSocket event, no MCP tool, no frontend surface. Consumers read the database. +No store slice, hook, component, or API client changes. + +--- + +## Tests + +### Slice 1 — turn-start step stamp + +| What | File | How | +|---|---|---| +| Task in step `S` → turn metadata carries `workflow_step_id_at_start` = `S` | `internal/task/service/service_turns_step_stamp_test.go` (new file — `service_turns_test.go` is near the 800-line revive limit) | Service test with a stub task repo; assert the persisted turn's metadata | +| Task with no step → key **absent**, not `""`/`null`/`0` | same | Assert key membership with the two-value map read, not a value comparison | +| No runtime config to snapshot → stamp present, `runtime_config_snapshot` absent | same | Session with empty profile snapshot and no runtime metadata | +| Task moves to `T` mid-turn → stamp still `S` | same | Move the task after `StartTurn`, re-read the turn | +| Task read fails → turn still created, no stamp | same | Task repo stub returning an error; assert `StartTurn` returns a turn and `err == nil` | +| Synthetic completed turn carries the same stamp | same | Exercise `createCompletedTurn` through its lifecycle-message caller | +| Pre-activation turn is not backfilled | `internal/task/repository/sqlite/step_stamp_migration_test.go` (new) | Seed a turn with metadata lacking the key, boot, assert unchanged | + +### Slice 2 — ledger writes + +| What | File | How | +|---|---|---| +| `A`→`B` manual move writes one row with correct `from`/`to`/trigger/actor | `internal/task/repository/sqlite/step_transitions_test.go` (new) | Table-driven over the trigger matrix against a real SQLite DB | +| No-op move to the current step writes no row | same | Assert row count unchanged | +| Position-only reorder writes no row | same | Same step, new position | +| WIP-rejected move writes no row and leaves the step unchanged | same | `UpdateTaskIfWorkflowStepHasCapacity` at capacity | +| Rolled-back transaction leaves no row | same | Force a failure after the UPDATE inside the tx | +| Genesis row on create: `from_*` NULL, trigger `task_created` | same | `CreateTask` into a step | +| Genesis row records the *feeder* step when WIP diverted the placement | same | `CreateTaskWithWorkflowStepAdmission` with a full target and a feeder | +| Task created with no workflow writes no row; later attach writes the first row with `workflow_attached` | same | Create bare, then `AddTaskToWorkflow` | +| Detach writes `to_*` NULL with `workflow_detached` | same | `RemoveTaskFromWorkflow` | +| Empty-string step normalizes to NULL on both sides | same | Seed `workflow_step_id = ''`, move in | +| No trigger/actor declared → `unknown`/`unknown`/NULL | same | Call the repo with a bare context | +| Ephemeral (quick-chat) task records exactly as any other | same | `IsEphemeral: true` | +| Archive / unarchive-in-place / cascade / delete write no row | `internal/task/repository/sqlite/step_transitions_lifecycle_test.go` (new) | Row count unchanged across each action | +| Row survives deletion of the workflow step it names | same | Delete the step, assert the row and its step ID | +| Missing ledger table fails the transaction rather than committing the step change alone | same | `DROP TABLE task_step_transitions`, attempt a move, assert error **and** unchanged `tasks.workflow_step_id` | +| Ledger row deleted with its task; survives session deletion with `session_id` NULL | same | Exercise both cascades | + +### Ordering, chain, and concurrency + +| What | File | How | +|---|---|---| +| `A`→`B`→`A`→`B` yields four rows with an intact chain | `internal/task/repository/sqlite/step_transitions_chain_test.go` (new) | Read ordered by `(occurred_at, id)`, assert each `from` equals the previous `to` | +| Rows sharing an `occurred_at` are totally ordered by `id` | same | Freeze the clock across two moves | +| Two concurrent moves → two rows, chain intact, last `to` equals the task's step | same | Two goroutines, `errgroup`, real DB | +| Last row's `to_workflow_step_id` equals `tasks.workflow_step_id` | same | Invariant assertion helper reused by every case above | +| Retried engine trigger with the same `OperationID` writes no second row | `internal/orchestrator/workflow_step_ledger_test.go` (new) | Replay the trigger through the engine's applied-operations store | + +### Actor and privacy + +| What | File | How | +|---|---|---| +| Auth disabled → `human` + synthetic identity's user ID | `internal/task/repository/sqlite/step_transitions_actor_test.go` (new) | Synthetic identity on the context | +| Jira/Linear watcher move → `integration` + watch ID | same | Attribution built by the watcher source | +| No display name, email, title, or prompt text in any column | same | Scan every text column of every row produced by the full trigger matrix against a fixture whose title/description/prompt are unique sentinels | + +### Migration and activation + +| What | File | How | +|---|---|---| +| Pre-feature DB gains both tables; existing tasks have no rows; existing turns have no stamp | `internal/task/repository/sqlite/step_transitions_migration_test.go` (new) | Mirrors `task_external_id_migration_test.go`: seed pre-migration rows, run migrations, assert | +| Migration runner replays twice more with no change | same | Call `NewWithDB` three times total | +| Postgres behaves identically | same, env-gated on `KANDEV_TEST_POSTGRES_DSN` | Same fresh-then-replay sequence | +| First boot writes one activation row per key at version 1 with that boot's UTC time | `internal/telemetrycontract/store_test.go` (new) | `Activate` on a fresh DB | +| Subsequent boot leaves the activation row unchanged | same | `Activate` twice, compare `activated_at` | +| A version bump appends a second row rather than overwriting | same | Register version 2, assert two rows | + +### Writer health + +| What | File | How | +|---|---|---| +| A new production statement mutating `tasks.workflow_step_id` fails the build until registered | `internal/task/repository/sqlite/step_transition_writers_pin_test.go` (new) | `go/parser` over the package's non-test files; collect functions whose body contains a string literal matching `INSERT INTO tasks` or an `UPDATE tasks ... workflow_step_id` assignment; assert set equality against the seven registered names | +| A ledger row bumps the expvar counter for its trigger and logs `telemetry.metric.step_transition_written` | `internal/steptelemetry/metrics_test.go` (new) | Read the expvar map; assert with `zaptest`'s observed logs | +| A stamped/unstamped turn bumps the turn counter and logs `telemetry.metric.turn_stamped` | same | Both branches | +| Boot emits one health line per registered contract with existence, activation, count, recency | `internal/telemetrycontract/health_test.go` (new) | Observed logs against a seeded DB | + +--- + +## E2E Tests + +**None.** The spec has zero user-visible changes: no UI, no API, no WebSocket +event. There is no observable outcome for Playwright to assert. Coverage is +entirely Go-side, including the real-database integration tests above. + +--- + +## Verification Results + +All six tasks (01–06) implemented and committed together in +`15beb3a003d214b8110e48f0a1b254b2c02daf9d` on `feature/tel-task-grain-step-bhh` +(37 files, +2,478/−4). One commit rather than one per task, given the +interlocking nature of the seven chokepoints and their callers — see the +per-task decision notes below for what would otherwise have been split. + +**Slice 1 gate.** No production deployment exists to measure two weeks of +post-activation turns against. Per the spec's own default ("If the +measurement is inconclusive... Slice 2 proceeds as specified"), this counts +as inconclusive and Slice 2 proceeded as specified in the same session. + +**Commands run (backend, `apps/backend`), all green:** +- `go build ./...` +- `go test ./internal/telemetrycontract/... ./internal/steptelemetry/... ./internal/task/repository/sqlite/... ./internal/task/service/... ./internal/task/... ./internal/orchestrator/... ./internal/mcp/... ./internal/office/engine_adapters/... ./internal/backendapp/...` +- `go test -race` on the same set (chain/concurrency tests specifically require `-race`) +- `go test -run 'Postgres|Activation|StepTransition' ...` — env-gated legs all + **skipped** (`KANDEV_TEST_POSTGRES_DSN` unset in this environment); the + Postgres-parity test files exist and compile (`store_postgres_test.go`, + `step_transitions_postgres_test.go`, `step_transitions_writer_postgres_test.go`) + and must be run in CI's postgres-boot job. +- `golangci-lint run ./...` (backend) — 0 issues +- `golangci-lint run ./... --new-from-rev=db4fc039ac637766d6ec30e996918c538303021d` — 0 issues +- Pinning-test bite check: temporarily added an unregistered + `UPDATE tasks SET workflow_step_id = ?` statement to `task.go`, confirmed + `TestStepTransitionWritersArePinned` failed naming it, removed the + statement, confirmed `git diff --check` was clean and the test passed again. + +**Root gauntlet, all green:** +- `make fmt` (backend + web; caught and fixed one stray smart-quote + corruption in a comment during a fmt pass — reverted to plain `""`) +- `make typecheck` — required generating `apps/web/lib/generated/{changelog,release-notes}.json` + via `node scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjs` + first (a `predev`/`prebuild` step this checkout had never run); clean after. +- `make lint` (backend + web + harness + architecture) — 0 issues +- `make lint-format` — clean +- `cd apps/web && pnpm run i18n:ratchet` — clean (no UI source touched) +- `make test` (test-backend + test-web + test-cli + test-scripts) — 5 failures, + all proven pre-existing/environmental by reproducing identically against + merge-base `db4fc039a` in scratch worktrees, then removed: + - `internal/repoclone` `TestEnsureWorkspaceClonedWithBasicAuthKeepsCredentialScopedToGitChild/context_cancellation` — flaky at merge-base too (3 reruns, mixed pass/fail both there and on this branch) + - `internal/system/storage/workspaces` (3 tests) — reproduces identically at merge-base; macOS temp-dir "not a directory" environment issue + - `apps/web` `lib/http-git-server.test.ts` (3 tests) — no Docker daemon in this sandbox; reproduces identically at merge-base + - `apps/web` `storage-maintenance-settings.test.tsx` (3 tests) — 5s timeout under this host's heavy concurrent-session CPU load; reproduces at merge-base + - `scripts/pr-state` "threads count" — file is byte-identical to merge-base (`git diff` empty), so trivially pre-existing (bash-version array-expansion issue) + +**Deliberate scope decisions made during Build (beyond the ones recorded +during Spec), for review:** +1. **Genesis-row and three other chokepoints hardcode their trigger at the + writer, not via caller wiring.** `insertTaskTx` (task_created), + `RestoreTaskMessageRollbackIfSessionState` (unarchive_restore), + `AddTaskToWorkflow` (workflow_attached) — via its sole caller, + `WorkflowSwitcherAdapter.SwitchTaskWorkflow` — and the two + `pullNextTaskOnVacate` implementations (service and orchestrator; wip_pull) + each have exactly one semantic meaning regardless of caller, so Task 05's + plan (which only enumerated caller-side wiring) was extended to cover + these four gaps against the spec's own Path→Trigger table, which lists all + eleven mappings as fixed and caller-independent for these paths. +2. **`RemoveTaskFromWorkflow` has zero production callers** on this branch + (confirmed by repo-wide grep) — same situation the spec notes for + `session_step_history`'s `CreateStepTransition`. It is wired identically + to `AddTaskToWorkflow` (workflow_detached) so it is correct the moment a + caller appears; there was nothing to wire attribution *to*. +3. **Watcher-driven Jira/Linear activity in this codebase only creates + tasks, never moves an existing one** (no `MoveTask` call site found under + `internal/jira`, `internal/linear`, or the watcher dispatch/source files). + The spec's "watcher moves a card → actor integration" scenario is + satisfied by wiring the *genesis* row's actor to `ActorIntegration` + + `WatchID` for watcher-originated creates instead, via a small extension + to `genesisAttribution` that prefers a caller-set actor over the + identity-derived default. +4. **The ledger-row expvar/log counter fires at INSERT time inside the + transaction, not after commit.** A later statement in the same + caller-owned transaction failing after the ledger INSERT (rare — e.g. the + runner sync in `UpdateTask`) rolls the row back but the counter still + incremented. Documented in `step_transitions.go`; treated as acceptable + because the counter is a liveness signal ("is the writer alive"), not a + row-for-row audit trail — the ledger table itself is that audit trail. +5. **`e1cbdf22` (wire `session_step_history`) had not landed** on this + branch at Build time (`CreateStepTransition` still had zero production + callers, reconfirmed before editing `event_handlers_workflow.go` / + `workflow_store.go`), so this card proceeded as the sole writer into + those two files. + +--- + +## Testing Step — QA Pass And Scenario Audit + +Committed as `4cbcc8f47` ("test: close ledger coverage gaps for cross-workflow +and cascade-archive"), 4 files, +212. + +**Proof-level determination.** `git diff --name-only origin/main...HEAD` lists +41 changed files, all under `apps/backend/`, `docs/plans/`, `docs/specs/` — +zero `apps/web/` files. Per the mechanical rule (E2E required only for +non-exempt `apps/web/` changes), no E2E is required; backend-only proof. + +**Attack pass.** Traced the full watcher → genesis-row-actor context +attribution pipeline line by line (`watcher_dispatch.go` → +`backendapp/orchestrator.go` → `service_tasks.go`; no `context.Background()` +reset points found). Verified the SQLite-writer-serialization claim against +`internal/db/sqlite.go`'s `SetMaxOpenConns(1)`. Reconfirmed +`RemoveTaskFromWorkflow` still has zero production callers. Scanned the full +diff for debug/TODO/panic red flags (none found). Identified one dead-code +no-op branch in `recordStepTransition` (the empty-string/empty-string check +is subsumed by the equal-values check above it) — harmless, left as-is rather +than churning a working file for a no-op cleanup. + +**Test-honesty audit** against all ~47 GIVEN/WHEN/THEN scenarios in +`docs/specs/workflow/task-step-transition-ledger/spec.md` § Scenarios, by +category: + +| Category | Scenarios | Result | +|---|---|---| +| Slice 1 — turn-start step stamp | 7 | fully covered | +| Slice 2 — ledger writes | 19 | 2 gaps found + closed | +| Ordering, chain, and concurrency | 7 | 2 gaps found + closed | +| Actor and privacy | 3 | fully covered | +| Migration and activation | 6 | fully covered | +| Writer health | 3 | fully covered | + +Migration/activation and writer-health scenarios are each satisfied by more +than one existing test working together (e.g. the "ledger table absent" +scenario is proven jointly by +`TestMissingLedgerTableFailsTransactionRatherThanCommittingStepChangeAlone` +and `TestLogHealthEmitsOneLinePerRegisteredContract`) — no new tests were +needed there. + +**4 coverage gaps found and closed, zero production defects found:** + +1. **Cross-workflow engine transition had no ledger-row-level test.** The + only existing test, + `TestWorkflowStore_ApplyTransitionSyncsWorkflowIDAcrossWorkflows`, asserts + on `task.WorkflowID`/`task.WorkflowStepID` but never queries + `task_step_transitions`, so the spec's "one row is written whose + `from_workflow_id` and `to_workflow_id` differ" claim was unverified. + Closed by `TestApplyTransitionCrossWorkflowRecordsDifferingWorkflowIDs` + (`internal/orchestrator/workflow_step_ledger_test.go`), which mirrors that + test's `wf2`/`step-wf2` setup and additionally reads the last ledger row. +2. **Cascade-archive (`ArchiveTaskIfActive`) had no direct behavioral test** + asserting it writes no ledger row — structurally guaranteed by the + pinning test's exclusion (its SQL never touches `workflow_step_id`), but + untested at the ledger level. Closed by `TestArchiveTaskIfActiveWritesNoRow` + (`internal/task/repository/sqlite/step_transitions_lifecycle_test.go`). +3. **Backwards host-clock correction was untested.** No test proved the + chain invariant (`from == previous row's to`) holds when rows are read in + `id` order after `occurred_at` runs backwards, or that the ledger is not + "repaired"/reordered by timestamp. Closed by + `TestChainSurvivesBackwardsClockCorrectionWhenOrderedByID` + (`internal/task/repository/sqlite/step_transitions_chain_test.go`), which + forces a row's `occurred_at` earlier than the genesis row's and confirms + both the chain and the raw (unrepaired) value survive under `id` ordering. +4. **Turn-stamp vs. ledger-row disagreement was untested.** The spec's + explicit cross-slice scenario ("they disagree, and neither is treated as + an error") had no test reading both the turn's stamp and the ledger's + last row after a mid-turn move. Closed by + `TestTurnStampAndLedgerRowCanDisagreeWithoutError` + (`internal/task/service/service_turns_step_stamp_test.go`). + +All 4 new tests pass fresh (non-cached); no existing test was weakened, +skipped, or had its assertions loosened. + +**Commands run (backend, `apps/backend`), fresh receipts:** +- `go clean -testcache && go build ./...` — clean. +- `go test ./internal/orchestrator/... ./internal/task/repository/sqlite/... ./internal/task/service/... ./internal/steptelemetry/... ./internal/telemetrycontract/... ./internal/mcp/handlers/...` — all `ok`, fresh. +- `go test -race ./internal/orchestrator/... ./internal/task/repository/sqlite/... ./internal/task/service/...` — one `internal/orchestrator` goroutine-leak failure in `workflow_e2e_test.go`'s `runWorkflowTestCase` (a file this card never touches); re-ran `go test -race ./internal/orchestrator/...` alone immediately after and it passed clean — confirmed timing flake, not a regression. +- `make -C apps/backend lint` — `0 issues.` +- `make -C apps/backend test lint` — `test` target failed, which blocks `make`'s sequential `lint` prerequisite (same behavior seen during Build), so `lint` was re-run standalone (above, clean). `test` failures, both pre-existing: + - `internal/repoclone` (1 test) — re-ran `go test ./internal/repoclone/... -v` standalone and it passed clean; same known timing flake documented in the Build step's Verification Results above. + - `internal/system/storage/workspaces` (same 3 tests, same `open dependency workspace root: not a directory` error text as the Build step) — already proven pre-existing against merge-base `db4fc039a` via scratch worktree during Build; not re-proven again since it is the identical failure mode with receipts already on file above. + +**E2E:** none, confirmed twice — the spec states zero user-visible surfaces, +and this step's mechanical file-diff check independently confirms zero +`apps/web/` changes on the branch. + +--- + +## Implementation Waves And Parallel Candidates + +Sequential by default. The two `parallel-safe` labels below are the only tasks +whose files are genuinely disjoint from a concurrent sibling; every other task +edits `internal/task/repository/sqlite/` or a file an earlier task created. + +``` +Wave 1 — shared foundation: +- [ ] [task-01-telemetry-activation-registry](task-01-telemetry-activation-registry.md) + +Wave 2 — Slice 1 (ships and is measured alone): +- [ ] [task-02-turn-start-step-stamp](task-02-turn-start-step-stamp.md) + +=== GATE: measure Slice 1 before building Slice 2 (see below) === + +Wave 3 — Slice 2 persistence: +- [ ] [task-03-ledger-schema-migration](task-03-ledger-schema-migration.md) + +Wave 4 — Slice 2 writer: +- [ ] [task-04-ledger-writer-chokepoints](task-04-ledger-writer-chokepoints.md) + +Wave 5 — Slice 2 attribution: +- [ ] [task-05-caller-attribution-wiring](task-05-caller-attribution-wiring.md) + +Wave 6 — Slice 2 health controls: +- [ ] [task-06-writer-health-controls](task-06-writer-health-controls.md) +``` + +**The gate is a real stop, not a formality.** Per the spec, Slice 1 ships alone +and is measured on a store with at least two weeks of post-activation turns: +report the share of post-activation turns carrying the stamp, and the change in +the 47.0 / 30.5 / 22.5 attribution split when `turn_start` is admitted as a +basis. If the measurement is inconclusive — too few turns, or a change inside +noise — the recorded default is that **Slice 2 proceeds as specified**. The gate +exists to let evidence shrink Slice 2, not to let missing evidence stall it. + +Task 04 is the only task in the plan that changes committed behaviour on every +step change in the product. It is deliberately scoped to leave the ledger fully +working with `unknown` triggers, so that Task 05 — which touches the orchestrator +and MCP handlers — can be reviewed as attribution quality rather than as +correctness of the write path. + +## Open Questions + +- **`session_step_history` wiring order.** The spec records that wiring that + table's dormant writer is separate work that lands first, because it edits + `internal/orchestrator/event_handlers_workflow.go` and + `internal/orchestrator/workflow_store.go` — the same two files Task 05 edits. + Confirm whether that work has landed before starting Task 05; if it has not, + Task 05 is the second writer into those files and should expect conflicts. diff --git a/docs/plans/task-step-transition-ledger/task-01-telemetry-activation-registry.md b/docs/plans/task-step-transition-ledger/task-01-telemetry-activation-registry.md new file mode 100644 index 0000000000..50f9278836 --- /dev/null +++ b/docs/plans/task-step-transition-ledger/task-01-telemetry-activation-registry.md @@ -0,0 +1,90 @@ +--- +id: "01-telemetry-activation-registry" +title: "Telemetry contract activation registry" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/workflow/task-step-transition-ledger/spec.md" +--- + +# Task 01: Telemetry contract activation registry + +Create `telemetry_activations` and the boot-time activation write plus health +report. Both slices depend on this: the downstream extract is a point-in-time +snapshot with no schema versioning, so a column whose meaning changes mid-series +is silently discontinuous unless its activation point is readable from the +snapshot itself. + +Register **one** contract in this task: `turn.workflow_step_id_at_start` at +version 1, which Task 02 then makes true. `task_step_transitions` is registered +by Task 03, after the gate. Registering a contract whose backing objects do not +exist yet is exactly what the health line's existence column is for, but do not +register a contract this repository does not yet write. + +## Acceptance + +1. `telemetry_activations` exists on SQLite and Postgres with primary key + `(contract_key, contract_version)`, so a future version bump appends a row + rather than overwriting the first activation. +2. The first boot after this lands writes one row for + `turn.workflow_step_id_at_start` at version 1 with that boot's UTC time; every + later boot leaves it byte-identical. +3. Boot emits one health line per registered contract carrying object existence, + activation timestamp, row count, and most recent occurred_at. + +## Verification + +``` +cd apps/backend && go test ./internal/telemetrycontract/... ./internal/backendapp/... && KANDEV_TEST_POSTGRES_DSN="${KANDEV_TEST_POSTGRES_DSN}" go test -run 'Postgres|Activation' ./internal/telemetrycontract/... && make lint +``` + +The Postgres leg is env-gated and skips when `KANDEV_TEST_POSTGRES_DSN` is +unset; record in `## Results` whether it ran or skipped rather than reporting a +pass it did not perform. + +## Files likely touched + +- `apps/backend/internal/telemetrycontract/contract.go` (new) — `Contract` + struct and `Registry()` +- `apps/backend/internal/telemetrycontract/store.go` (new) — DDL, `NewWithDB`, + `Activate`, `LogHealth` +- `apps/backend/internal/telemetrycontract/store_test.go` (new) +- `apps/backend/internal/telemetrycontract/health_test.go` (new) +- `apps/backend/internal/backendapp/storage.go` — call `Activate` then + `LogHealth` immediately before the existing `recordSchemaVersion(...)` at + line 101 + +## Dependencies + +None. + +## Parallelism + +`sequential` — Task 02 and Task 03 both register contracts into `Registry()`. + +## Inputs + +- Spec, *`telemetry_activations` (new)* and *Migration and activation* +- Plan, *Area 1* +- The composite primary key is the point of the table: read the spec paragraph + beginning "The primary key is `(contract_key, contract_version)`" before + choosing the DDL. +- `internal/backendapp/storage.go:99-101` — the comment there marks the exact + moment every repository has finished `initSchema`, which is when a contract's + backing objects are known to exist. Hook in there, not earlier. +- `internal/db.MigrateLogger` swallows migration errors by design; that is why + the health line reports object *existence* rather than assuming it. +- Neither `Activate` nor `LogHealth` may be fatal. A failure logs and boot + continues, matching `recordSchemaVersion`'s contract directly above. +- Use `INSERT ... ON CONFLICT DO NOTHING` (both SQLite and pgx support it) so a + repeated or concurrent boot is a no-op rather than an error path. + +## Output contract + +Summary, files changed, tests run with counts, blockers, risks, and a status +update to this file and `plan.md` in the same conversation. + +## Results + +See `plan.md` § Verification Results for the consolidated commands, outcomes, and decision record covering all six tasks (implemented and committed together). diff --git a/docs/plans/task-step-transition-ledger/task-02-turn-start-step-stamp.md b/docs/plans/task-step-transition-ledger/task-02-turn-start-step-stamp.md new file mode 100644 index 0000000000..ae138a848f --- /dev/null +++ b/docs/plans/task-step-transition-ledger/task-02-turn-start-step-stamp.md @@ -0,0 +1,90 @@ +--- +id: "02-turn-start-step-stamp" +title: "Turn-start step stamp (Slice 1)" +status: done +wave: 2 +depends_on: ["01-telemetry-activation-registry"] +plan: "plan.md" +spec: "../../specs/workflow/task-step-transition-ledger/spec.md" +--- + +# Task 02: Turn-start step stamp (Slice 1) + +Record the workflow step a turn's task was in when the turn started, as a new +key in the existing `task_session_turns.metadata` JSON object. No column is +added. This is the whole of Slice 1, and it ships and is measured before Slice 2 +is built. + +## Acceptance + +1. Every turn created after activation whose task holds a workflow step carries + `metadata.workflow_step_id_at_start` = that step ID; a turn whose task holds + no step carries **no such key** — not `""`, not `null`, not `0`. +2. The stamp does not depend on `runtime_config_snapshot`: a turn with nothing + to snapshot still carries the stamp, and a turn with neither still persists + no metadata at all. +3. A task read failure omits the stamp and still creates the turn; turn creation + never fails because telemetry could not be resolved. + +## Verification + +``` +cd apps/backend && go test ./internal/task/service/... ./internal/task/models/... ./internal/task/repository/sqlite/... && make lint +``` + +## Files likely touched + +- `apps/backend/internal/task/models/models.go` — add + `TurnMetaKeyWorkflowStepIDAtStart` beside `TurnMetaKeyRuntimeConfigSnapshot` + (line 232) +- `apps/backend/internal/task/service/service_turns.go` — add + `turnStartMetadata`; repoint `StartTurn` (line 44) and `createCompletedTurn` + (line 79) at it +- `apps/backend/internal/task/service/service_turns_step_stamp_test.go` (new) +- `apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go` + (new) — pre-activation turns are not backfilled +- `apps/backend/internal/telemetrycontract/contract.go` — fill in the + `turn.workflow_step_id_at_start` contract's `ExistsQuery` / `StatsQuery` now + that the stamp is real + +## Dependencies + +Task 01 — the contract must be registered and activated before the writer is +meaningful. + +## Parallelism + +`sequential`. + +## Inputs + +- Spec, *Slice 1 — turn-start step stamp*, *`task_session_turns.metadata`*, and + the seven *Scenarios → Slice 1* cases; each maps to one test row +- Plan, *Area 2* +- `service_turns.go:89-98` — `runtimeConfigSnapshotMetadata` returns `nil` when + the snapshot is empty. Do **not** early-return on that nil: the spec has an + explicit scenario for a session with no runtime config whose turn is still + stamped. Compose, don't short-circuit. +- `createCompletedTurn` (line 68) is the synthetic-turn path for lifecycle + messages. It shares the same composer, which is what satisfies the spec's + "synthetic completed turn carries the same stamp" scenario — do not give it a + separate path. +- `s.tasks.GetTask(ctx, session.TaskID)` is the read; `service_turns.go:723` + already uses that seam. +- Immutability needs no code — nothing rewrites turn metadata after creation. + Add the regression test that pins it rather than adding a guard. +- Assert key **absence** with the two-value map read (`_, ok := md[key]`), never + by comparing to `""`. A test that compares values passes on a bug that writes + an empty string, which is the exact failure the spec forbids. +- Call `steptelemetry.RecordTurnStamp` only if Task 06's metrics helper already + exists; otherwise leave a plain counter out of scope here and let Task 06 add + it. Do not invent a second metrics namespace. + +## Output contract + +Summary, files changed, tests run with counts, blockers, risks, and a status +update to this file and `plan.md` in the same conversation. + +## Results + +See `plan.md` § Verification Results for the consolidated commands, outcomes, and decision record covering all six tasks (implemented and committed together). diff --git a/docs/plans/task-step-transition-ledger/task-03-ledger-schema-migration.md b/docs/plans/task-step-transition-ledger/task-03-ledger-schema-migration.md new file mode 100644 index 0000000000..3d761ecfa1 --- /dev/null +++ b/docs/plans/task-step-transition-ledger/task-03-ledger-schema-migration.md @@ -0,0 +1,91 @@ +--- +id: "03-ledger-schema-migration" +title: "Ledger schema and migration" +status: done +wave: 3 +depends_on: ["01-telemetry-activation-registry", "02-turn-start-step-stamp"] +plan: "plan.md" +spec: "../../specs/workflow/task-step-transition-ledger/spec.md" +--- + +# Task 03: Ledger schema and migration + +Create `task_step_transitions` idempotently on SQLite and Postgres, register its +contract, and prove the replay behaviour. No writer yet — this task ends with an +empty table that boots cleanly three times in a row. + +**Gated.** Do not start until Slice 1 has shipped and been measured per the +spec's *Gate between slices*. Record the measurement (share of post-activation +turns carrying the stamp, and the resulting change in the 47.0 / 30.5 / 22.5 +attribution split) in this task's `## Results` before proceeding. If the +measurement is inconclusive, the recorded default is that Slice 2 proceeds as +specified — note that and continue. + +## Acceptance + +1. `task_step_transitions` exists with the spec's exact columns, both indexes, + a cascade FK to `tasks`, a set-null FK to `task_sessions`, and **no** FK to + `workflows` or `workflow_steps`. +2. A database created before this feature gains the table on boot; pre-existing + tasks have no rows and nothing is backfilled. +3. Running the migration runner twice more succeeds both times and changes + nothing, on SQLite and on Postgres. + +## Verification + +``` +cd apps/backend && go test ./internal/task/repository/sqlite/... ./internal/telemetrycontract/... && KANDEV_TEST_POSTGRES_DSN="${KANDEV_TEST_POSTGRES_DSN}" go test -run 'Postgres|StepTransition' ./internal/task/repository/sqlite/... && make lint +``` + +Record in `## Results` whether the Postgres leg ran or skipped. + +## Files likely touched + +- `apps/backend/internal/task/repository/sqlite/base_schema.go` — new + `initStepTransitionsSchema`, appended to the `initSchema` step list after + `initSessionSchema` and before `runMigrations` +- `apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go` + (new) +- `apps/backend/internal/telemetrycontract/contract.go` — register + `task_step_transitions` at version 1 with its existence and stats queries + +## Dependencies + +Tasks 01 and 02, plus the Slice 1 measurement gate. + +## Parallelism + +`sequential`. + +## Inputs + +- Spec, *`task_step_transitions` (new)*, *Persistence guarantees*, *Migration + and activation*, and *Scenarios → Migration and activation* +- Plan, *Area 3* +- `internal/workflow/repository/sqlite.go:88-96` is the working precedent for + the dialect-split monotonic `id` (`INTEGER PRIMARY KEY AUTOINCREMENT` on + SQLite, `BIGSERIAL PRIMARY KEY` on Postgres). Copy that shape. +- This is a new **table**, so `CREATE TABLE IF NOT EXISTS` in the init block is + correct and complete. The backend guide's "columns only via `runMigrations`" + rule applies to adding a column to an existing table and does not apply here. +- **Deliberately no FK to `workflow_steps` or `workflows`.** Steps get deleted, + and the historical fact that a card was in a now-deleted step must survive + that deletion. A reviewer may read the missing FK as an oversight; the DDL + should carry a comment saying it is not. +- Nothing is backfilled with a substituted value. Pre-activation state is + absent, and absent is the correct reading. +- Model the test on `task_external_id_migration_test.go`: seed a pre-migration + row, run migrations, assert the pre-existing row is untouched and the new + objects exist, then call `NewWithDB` **twice more** and assert idempotence. +- Use `internal/db`'s `IsDuplicateColumnError` / `IsAlreadyExistsError` for any + replay classification, never local string matching (ADR 0027). + +## Output contract + +Summary, files changed, tests run with counts, the Slice 1 gate measurement, +blockers, risks, and a status update to this file and `plan.md` in the same +conversation. + +## Results + +See `plan.md` § Verification Results for the consolidated commands, outcomes, and decision record covering all six tasks (implemented and committed together). diff --git a/docs/plans/task-step-transition-ledger/task-04-ledger-writer-chokepoints.md b/docs/plans/task-step-transition-ledger/task-04-ledger-writer-chokepoints.md new file mode 100644 index 0000000000..18a1232084 --- /dev/null +++ b/docs/plans/task-step-transition-ledger/task-04-ledger-writer-chokepoints.md @@ -0,0 +1,128 @@ +--- +id: "04-ledger-writer-chokepoints" +title: "Ledger writer in the mutating transactions" +status: done +wave: 4 +depends_on: ["03-ledger-schema-migration"] +plan: "plan.md" +spec: "../../specs/workflow/task-step-transition-ledger/spec.md" +--- + +# Task 04: Ledger writer in the mutating transactions + +Add the attribution contract package and write exactly one ledger row from +inside each of the seven repository transactions that can change +`tasks.workflow_step_id`. Callers are not touched, so every row lands with +trigger `unknown` and actor `unknown` — which the spec defines as a recorded +fact, not an error. Task 05 makes the attribution real. + +This is the only task in the plan that changes committed behaviour on every step +change in the product. Scoping it this way lets the write path be reviewed for +correctness on its own, before the orchestrator and MCP edits arrive. + +## Acceptance + +1. Every committed change to `tasks.workflow_step_id` produces exactly one row + in the same transaction; a rolled-back, WIP-rejected, or conditionally-skipped + update produces none; a no-op step change (position-only reorder, re-issued + move to the current step) produces none. +2. For any task, reading its rows ordered by `(occurred_at, id)`, each row's + `from_workflow_step_id` equals the previous row's `to_workflow_step_id`, and + the last row's `to_workflow_step_id` equals the task's current + `tasks.workflow_step_id` — under concurrent moves. +3. `''` never appears in any of the four `from_*`/`to_*` columns; the same + condition stores `NULL`. + +## Verification + +``` +cd apps/backend && go test -race ./internal/steptelemetry/... ./internal/task/repository/sqlite/... && KANDEV_TEST_POSTGRES_DSN="${KANDEV_TEST_POSTGRES_DSN}" go test -race -run 'Postgres|StepTransition' ./internal/task/repository/sqlite/... && make lint +``` + +`-race` is not optional here: the chain-invariant tests run concurrent movers. +Record in `## Results` whether the Postgres leg ran or skipped. + +## Files likely touched + +- `apps/backend/internal/steptelemetry/steptelemetry.go` (new) — `Trigger` and + `ActorKind` enums, `Attribution`, `WithAttribution`, `FromContext`, + `ContractVersion`, `ContractKey` +- `apps/backend/internal/steptelemetry/steptelemetry_test.go` (new) +- `apps/backend/internal/task/repository/sqlite/step_transitions.go` (new) — + `stepTransitionTx`, `readTaskStepInTx`, `recordStepTransition` +- `apps/backend/internal/task/repository/sqlite/task.go` — `insertTaskTx` (319), + `UpdateTask` (469), `UpdateTaskIfWorkflowStepHasCapacity` (778), + `PromoteQueuedTaskIfWorkflowStepHasCapacity` (830), + `RestoreTaskMessageRollbackIfSessionState` (2043) +- `apps/backend/internal/task/repository/sqlite/workflow.go` — + `AddTaskToWorkflow` (17) and `RemoveTaskFromWorkflow` (25), both of which must + gain a transaction they do not currently have +- `apps/backend/internal/task/repository/sqlite/step_transitions_test.go` (new) +- `apps/backend/internal/task/repository/sqlite/step_transitions_chain_test.go` + (new) +- `apps/backend/internal/task/repository/sqlite/step_transitions_lifecycle_test.go` + (new) + +## Dependencies + +Task 03 — the table must exist. + +## Parallelism + +`sequential`. + +## Inputs + +- Spec, *Slice 2 — task-level transition ledger*, *Determinism, ordering, and + concurrency*, *Failure modes*, and *Scenarios → Slice 2 / Ordering, chain, and + concurrency* +- Plan, *Area 4* and *Area 5*, including the per-chokepoint table +- **Read the old step inside the write transaction.** The chain invariant holds + under concurrent moves only if the read of the old step and the write of the + new one are serialized against other writers of the same task row. Use + `readTaskStepInTx` with `FOR UPDATE` appended on Postgres + (`dialect.IsPostgres`); on SQLite the writer pool already serializes writers. + Reading the old step from the in-memory `*models.Task` the caller handed you + is the bug this invariant exists to prevent. +- **The missing-table case needs no detection code, and must not get any.** If + the `CREATE TABLE` was silently swallowed, the INSERT fails and that error + returns, rolling back the enclosing transaction — exactly the required + behaviour. The rule is negative: never log-and-continue on + `recordStepTransition`'s error, and never guard it with a table-exists probe, + which would let the step change commit with no row. +- **Three of the seven have conditional UPDATEs.** + `PromoteQueuedTaskIfWorkflowStepHasCapacity`, + `RestoreTaskMessageRollbackIfSessionState`, and `RemoveTaskFromWorkflow` can + affect zero rows. Record only when `RowsAffected() > 0`. +- **Two of the seven have no transaction today.** `AddTaskToWorkflow` and + `RemoveTaskFromWorkflow` are bare `r.db.ExecContext` calls; wrap each in + `r.db.BeginTx` with the established `defer func() { _ = tx.Rollback() }()` + shape used elsewhere in the package. +- **Genesis rows come free from the existing placement logic.** By the time + `insertTaskTx` runs, `applyAdmissionPlacement` (task.go:235) has already + rewritten `task.WorkflowStepID` to the *actual* placement, so recording from + the task struct satisfies the spec's feeder-step scenario without a special + case. A task created with no workflow writes nothing — a row with both sides + NULL is forbidden. +- `stepTransitionTx` must be satisfied by both `*sql.Tx` and `*sqlx.Tx`; the + package uses both (`insertTaskTx` takes `*sql.Tx`, `purgeTaskQueueInTx` takes + `*sqlx.Tx`). +- No unique constraint and no idempotency key. A card legitimately moving + A→B→A→B produces four rows. Engine retries are already deduplicated upstream + by the engine's `OperationID` applied-operations store. +- Archive, unarchive-in-place, cascade archive, and delete write no row because + none of them touches `workflow_step_id`. That is the specified behaviour; + add the lifecycle test that pins it rather than adding code. +- Watch the backend complexity limits (`.golangci.yml`: 80 lines / 50 statements + per function, cyclomatic 15). `UpdateTask` and + `UpdateTaskIfWorkflowStepHasCapacity` are already long — extract rather than + grow them. + +## Output contract + +Summary, files changed, tests run with counts, blockers, risks, and a status +update to this file and `plan.md` in the same conversation. + +## Results + +See `plan.md` § Verification Results for the consolidated commands, outcomes, and decision record covering all six tasks (implemented and committed together). diff --git a/docs/plans/task-step-transition-ledger/task-05-caller-attribution-wiring.md b/docs/plans/task-step-transition-ledger/task-05-caller-attribution-wiring.md new file mode 100644 index 0000000000..dca3fe9262 --- /dev/null +++ b/docs/plans/task-step-transition-ledger/task-05-caller-attribution-wiring.md @@ -0,0 +1,116 @@ +--- +id: "05-caller-attribution-wiring" +title: "Caller attribution wiring" +status: done +wave: 5 +depends_on: ["04-ledger-writer-chokepoints"] +plan: "plan.md" +spec: "../../specs/workflow/task-step-transition-ledger/spec.md" +--- + +# Task 05: Caller attribution wiring + +Give each production path that changes a task's workflow step its declared +trigger, actor kind, actor identifier, and initiating session, by wrapping the +context once near the entry point. After this task the ledger's `trigger` and +`actor_kind` columns carry real values instead of `unknown`. + +## Acceptance + +1. Each path in the spec's *Path → Trigger* table records its mapped trigger, + and a move reaching `MoveTask` through more than one path is attributed to + the **outermost** caller (`mcp_move` beats `manual_move`). +2. `session_id` is the initiating session when one genuinely initiated the + transition, and `NULL` when there is none or when several sessions are + candidates with no single initiator. +3. `actor_id` holds an identifier and nothing else: no display name, email, + title, or prompt text appears anywhere in any row. + +## Verification + +``` +cd apps/backend && go test -race ./internal/task/service/... ./internal/task/repository/sqlite/... ./internal/orchestrator/... ./internal/mcp/... && make lint +``` + +Because this task edits `internal/orchestrator/`, also run the CI-style +changed-file linter with the PR base SHA before pushing: + +``` +cd apps/backend && golangci-lint run ./... --new-from-rev="" --timeout=5m +``` + +## Files likely touched + +- `apps/backend/internal/task/service/service_workflow.go` — + `MoveTaskWithOptions` (428), `BulkMoveSelectedTasks` (947), `BulkMoveTasks` + (1049), `pullNextTaskOnVacate` (558), `promoteNextQueuedTask` (612) +- `apps/backend/internal/task/service/service_tasks.go` — `UpdateTask` (1362), + where the request carries a new `workflow_step_id` +- `apps/backend/internal/mcp/handlers/config_task_handlers.go` — + `applyMoveTaskImmediate` (176) +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` — + `executeStepTransition` (296), `applyPendingMove` (1243) +- `apps/backend/internal/orchestrator/workflow_store.go` — `ApplyTransition` + (154) +- `apps/backend/internal/orchestrator/watcher_dispatch.go`, + `source_jira.go`, `source_linear.go` — integration actor +- `apps/backend/internal/task/repository/sqlite/step_transitions_actor_test.go` + (new) +- `apps/backend/internal/orchestrator/workflow_step_ledger_test.go` (new) — + engine trigger, deferred move, and `OperationID` retry cases + +## Dependencies + +Task 04 — the writer and the `Attribution` type must exist. + +**Also confirm before starting:** the spec records that wiring +`session_step_history`'s dormant writer is separate work that "lands first" and +edits `event_handlers_workflow.go` and `workflow_store.go` — the same two files +this task edits. Check whether it has landed; if not, expect to be the second +writer into those files. + +## Parallelism + +`sequential`. + +## Inputs + +- Spec, *Trigger* and *Actor kind* tables, the *Path → Trigger* mapping (which + is fixed precisely so no implementer has to choose), and *Scenarios → Slice 2 + / Actor and privacy* +- Plan, *Area 6* +- **Outermost caller wins.** `MoveTaskWithOptions` must set `manual_move` only + when the context carries no trigger already, so the `mcp_move` set by the MCP + handler survives the inner call. An unconditional overwrite there silently + relabels every agent move as a board click. +- **`actor_id` and `session_id` deliberately duplicate for agents.** For actor + kind `agent` both hold the same session ID, so `actor_id` has one uniform + meaning across every actor kind and consumers never branch on kind to find the + actor. This is intentional, not redundancy to clean up. +- **`engine_transition` splits by origin.** It is `agent` when the trigger came + from a session's turn (`on_turn_start`, `on_turn_complete`, or an + `on_exit`/`on_enter` reached through one) and `system` when it came from a + non-session trigger such as a children-completed rollup or a scheduled + evaluation. `ApplyTransition` already receives `sessionID`; use it to decide. +- **WIP pulls record `system` with `session_id` NULL** even when the task has + live sessions. No one session initiated the pull, and the spec forbids picking + one to manufacture ownership that does not exist. +- `authn.IdentityFromContext` (`internal/auth/authn/identity.go:57`) is the + human actor source. `Identity.Synthetic` marks the implicit single-user + identity injected when auth is disabled — that is still actor kind `human` + with that identity's `UserID`, per the spec's scenario, not `system`. +- The privacy test should scan every text column of every row produced by the + full trigger matrix against a fixture whose title, description, and prompt are + unique sentinel strings. Asserting only on `actor_id` misses a name leaking + into another column. +- Anything left unwrapped records `unknown`/`unknown` and still commits. Do not + add an error path for undeclared attribution. + +## Output contract + +Summary, files changed, tests run with counts, blockers, risks, and a status +update to this file and `plan.md` in the same conversation. + +## Results + +See `plan.md` § Verification Results for the consolidated commands, outcomes, and decision record covering all six tasks (implemented and committed together). diff --git a/docs/plans/task-step-transition-ledger/task-06-writer-health-controls.md b/docs/plans/task-step-transition-ledger/task-06-writer-health-controls.md new file mode 100644 index 0000000000..d9af7b2729 --- /dev/null +++ b/docs/plans/task-step-transition-ledger/task-06-writer-health-controls.md @@ -0,0 +1,110 @@ +--- +id: "06-writer-health-controls" +title: "Writer health controls" +status: done +wave: 6 +depends_on: ["04-ledger-writer-chokepoints", "05-caller-attribution-wiring"] +plan: "plan.md" +spec: "../../specs/workflow/task-step-transition-ledger/spec.md" +--- + +# Task 06: Writer health controls + +A telemetry writer that silently stops is worse than one that was never built, +because its output still looks like data. Add the spec's three required +controls: a pinning test over the set of statements that may mutate +`tasks.workflow_step_id`, runtime counters, and a boot health line covering both +contracts. + +The pinning test is the control that matters. The realistic failure is not the +writer breaking; it is a new code path bypassing it. + +## Acceptance + +1. A new production statement that mutates `tasks.workflow_step_id` fails the + test suite until it is registered in the pinned set. +2. Writing a ledger row increments an expvar counter keyed by trigger and emits + a `telemetry.metric.*` log line naming the event; creating a turn increments + a counter keyed by whether the stamp was present and emits the same shape. +3. Boot emits one health line per registered contract key reporting object + existence, activation time, row count, and most recent `occurred_at` — so an + install whose ledger stopped growing is visible in one line. + +## Verification + +``` +cd apps/backend && go test ./internal/steptelemetry/... ./internal/telemetrycontract/... ./internal/task/repository/sqlite/... ./internal/task/service/... && make lint +``` + +Additionally confirm the pinning test actually bites, and record the result: +temporarily add an unregistered `UPDATE tasks SET workflow_step_id = ...` +statement to a new function in the repository package, re-run +`go test -run TestStepTransitionWriters ./internal/task/repository/sqlite/...`, +observe the failure, then remove it and confirm `git diff --check` is clean. + +## Files likely touched + +- `apps/backend/internal/steptelemetry/metrics_vars.go` (new) — expvar maps + `telemetry_step_transitions_total`, `telemetry_turn_stamps_total` +- `apps/backend/internal/steptelemetry/metrics.go` (new) — `RecordLedgerRow`, + `RecordTurnStamp` +- `apps/backend/internal/steptelemetry/metrics_test.go` (new) +- `apps/backend/internal/task/repository/sqlite/step_transitions.go` — call + `RecordLedgerRow` after a successful insert +- `apps/backend/internal/task/service/service_turns.go` — call + `RecordTurnStamp` on both branches of `turnStartMetadata` +- `apps/backend/internal/task/repository/sqlite/step_transition_writers_pin_test.go` + (new) +- `apps/backend/internal/telemetrycontract/health_test.go` — extend to cover + both registered contracts + +## Dependencies + +Tasks 04 and 05 — the pinned set is only meaningful against a finished writer +set, and the trigger-keyed counter needs real triggers. + +## Parallelism + +`sequential`. + +## Inputs + +- Spec, *Writer health* (the three numbered controls and the exact pinned set) + and *Scenarios → Writer health* +- Plan, *Area 4* (metrics) and *Area 1* (health line) +- The pinned set as of the spec is exactly seven: `UpdateTask`, + `UpdateTaskIfWorkflowStepHasCapacity`, + `PromoteQueuedTaskIfWorkflowStepHasCapacity`, + `RestoreTaskMessageRollbackIfSessionState`, `AddTaskToWorkflow`, + `RemoveTaskFromWorkflow`, and task creation's admission placement + (`insertTaskTx`). +- **Implement the pin with `go/parser` over the package's non-test files**, not + a line regex. Walk function declarations, collect those whose body contains a + string literal matching `INSERT INTO tasks` or an `UPDATE tasks` statement + that assigns `workflow_step_id`, and assert set equality against the + registered names. A regex over raw lines misreports on multi-line SQL literals, + which every statement in this package is. +- The failure message should name the offending function and say what to do — + register it and wire `recordStepTransition` — rather than just printing a set + diff. This test fires for someone who has never read this spec. +- Mirror `internal/office/scheduler/metrics_vars.go` and `routing_metrics.go`: + counters only, expvar maps published at package init, label strings built as + `k1=v1;k2=v2`, and each recorder emits **both** the counter bump and the + structured log. The event name goes first in the log line so an aggregation + rule matches on it without scanning free text. +- `/debug/vars` only exposes these in dev mode (`KANDEV_MOCK_AGENT=true` or + `debug.pprofEnabled`); that is the existing contract and needs no change. +- The health line for `turn.workflow_step_id_at_start` has no table of its own. + Count turns whose `metadata` carries the stamp key and use `started_at` as the + recency column; `metadata` is stored as TEXT on both dialects, so a + dialect-neutral predicate works and avoids per-dialect JSON functions. + +## Output contract + +Summary, files changed, tests run with counts, the pinning-test bite check and +its cleanup evidence, blockers, risks, and a status update to this file and +`plan.md` in the same conversation. + +## Results + +See `plan.md` § Verification Results for the consolidated commands, outcomes, and decision record covering all six tasks (implemented and committed together). diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index e385698d18..84d3ad6b18 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -107,6 +107,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [autopilot-mode](tasks/autopilot-mode.md) | draft | | [explicit-completion-signal](workflow/explicit-completion-signal/spec.md) | shipped | | [cancelled-turn-completion](workflow/cancelled-turn-completion/spec.md) | building | +| [task-step-transition-ledger](workflow/task-step-transition-ledger/spec.md) | draft | | [conditional-session-settings](workflow-session-settings/spec.md) | approved | | [prevent-agent-autostart-on-open](prevent-agent-autostart-on-open/spec.md) | draft | | [workflow-duplication](workflow-duplication/spec.md) | draft | diff --git a/docs/specs/workflow/task-step-transition-ledger/spec.md b/docs/specs/workflow/task-step-transition-ledger/spec.md new file mode 100644 index 0000000000..2eb37a1e16 --- /dev/null +++ b/docs/specs/workflow/task-step-transition-ledger/spec.md @@ -0,0 +1,661 @@ +--- +status: draft +created: 2026-08-12 +owner: nova28 +--- + +# Task step transition ledger + +## Why + +Kandev records *when a card was worked on* but not *which workflow step it was +in at the time*. Cost, token, and duration events can be joined to a task and a +session, but attributing them to a step requires reconstructing step boundaries +from the message timeline. On the reference store that reconstruction leaves +**47.0%** of spend cleanly inside one step, **30.5%** merely "dominant of two +steps in the window", and **22.5%** unattributable — so "what does Review cost" +cannot be answered without a caveat larger than the answer. + +The one table that looks like it should answer this, `session_step_history`, is +keyed by **session** while Kandev tracks step state on the **task** +(`base_migrations.go` removed `task_sessions.workflow_step_id` for exactly that +reason). A card can change step with no active session, or with several, so +attributing a task-level transition to one session manufactures ownership that +does not exist. + +## What this feature is, and is not + +**It is a forward-looking record of step boundaries.** From its activation point +onward, every durable change to a task's workflow step is recorded with its +timestamp, its cause, and its actor, so downstream analysis can bound a step +interval from data rather than infer it from chat. + +**It cannot repair the past.** No historical transition is reconstructed or +backfilled. Rows before the activation point do not exist and MUST NOT be +synthesised. + +**It does not make per-step cost exact.** Cost events bill windows that cross +step boundaries; better boundaries improve attribution but do not decide how one +window's cost divides between two steps. The "dominant of two steps" bucket +shrinks; it does not disappear. + +**It is not `workflow_step_decisions`.** That table has a live writer on the +Office approval path; its zero rows mean an unused feature, not a missing one. +The two mechanisms MUST NOT be merged. + +**It is not a replacement for `session_step_history`.** This spec neither wires, +removes, nor changes that table or the `GET /api/v1/sessions/:id/workflow/history` +endpoint that reads it. See *Relationship to `session_step_history`*. + +## What + +The feature ships as two independently valuable slices. **Slice 1 SHALL ship and +be measured before Slice 2 is built** (see *Gate between slices*). + +### Slice 1 — turn-start step stamp + +- A turn created for a session records the workflow step its task was in at the + moment the turn started. +- The stamp is present on every turn created after activation whose task has a + workflow step, and **absent** — never empty, never `0` — when the task has + none. +- The stamp is immutable once written. A step change during the turn does not + rewrite it. +- No column is added to `task_session_turns`. + +### Slice 2 — task-level transition ledger + +- Every committed change to a task's workflow step produces exactly one ledger + row, written in the same database transaction as the change. +- A row that is not committed with its transition does not exist: a rolled-back + or WIP-rejected move leaves no row. +- A move that does not change the step (position-only reorder, re-issued move to + the current step) produces **no** row. +- Each row names the task, the step it left and the step it entered, when, why + (trigger), what kind of actor caused it, and that actor's **identifier** — + never a display name. +- The initiating session is recorded when one exists and is genuinely the + initiator; it is `NULL` otherwise. A transition with no session, or with + several candidate sessions and no single initiator, MUST record `NULL` rather + than pick one. +- Each row carries the collection-contract version it was written under. + +### Cross-cutting + +- Legacy rows and unstamped turns get `NULL` / absent — **never `0`, never + `""`**. +- Both slices publish a machine-readable activation point, because the + downstream extract is a point-in-time snapshot with no schema versioning: a + column whose meaning changes mid-series is silently discontinuous. +- Both writers are observable at runtime, and the set of code paths that may + mutate a task's workflow step is pinned by a test (see *Writer health*). + +## Data model + +### `task_step_transitions` (new) + +``` +task_step_transitions + id int64 PK, monotonic per install + (SQLite AUTOINCREMENT / Postgres BIGSERIAL) + task_id string FK -> tasks.id ON DELETE CASCADE + session_id string FK -> task_sessions.id ON DELETE SET NULL, + nullable — the initiating session, when one exists + from_workflow_id string nullable — NULL only on the genesis row + from_workflow_step_id string nullable — NULL on genesis, and when the task + held no step (detached from a workflow) + to_workflow_id string nullable — NULL when the task is detached + to_workflow_step_id string nullable — NULL when the task is detached + trigger enum see Trigger below, NOT NULL + actor_kind enum see Actor kind below, NOT NULL + actor_id string nullable — an opaque identifier, never a name + contract_version int NOT NULL, starts at 1 + occurred_at timestamp NOT NULL, UTC +``` + +Indexes: `(task_id, occurred_at, id)` for interval reconstruction; +`(occurred_at)` for extract windowing. + +`from_*` and `to_*` are never both NULL — a row with neither side would record +nothing. `from_workflow_step_id` equals `to_workflow_step_id` on no row; that +combination is the no-op case, which writes nothing. + +**Empty string normalizes to NULL.** `tasks.workflow_step_id` uses `''` for "no +step" and `tasks.workflow_id` likewise. The ledger stores `NULL`, never `''`, +for the same condition, in both the `from_*` and `to_*` pairs. A consumer that +sees `''` in this table is looking at a bug. + +**There is deliberately no foreign key to `workflow_steps` or `workflows`.** +Steps and workflows are deleted; the historical fact that a card was in a +now-deleted step must survive that deletion. Consumers MUST tolerate a step ID +with no matching row. + +**Trigger** — why the step changed. Unrecognised or unattributable causes use +`unknown`; the enum is closed and new causes are added to it deliberately. + +| Value | Meaning | +|---|---| +| `task_created` | The genesis row: the task was created into a step. | +| `manual_move` | A user moved the card (HTTP/WS move, drag, board action). | +| `task_update` | A task update changed `workflow_step_id` without going through the move path. | +| `mcp_move` | An agent moved the card via `move_task_kandev`, applied immediately. | +| `mcp_deferred_move` | An agent's `move_task_kandev` applied at turn end. | +| `engine_transition` | The workflow engine applied a step action (`on_enter`, `on_exit`, `on_turn_start`, `on_turn_complete`). | +| `wip_pull` | A WIP/queue reconciler pulled or promoted the card into a vacated step. | +| `bulk_move` | A bulk move of a selection or of a whole step. | +| `unarchive_restore` | A restore/rollback returned the card to a recorded step. | +| `workflow_attached` | The task was attached to a workflow. | +| `workflow_detached` | The task was removed from a workflow (`to_*` NULL). | +| `unknown` | The path did not declare a trigger. | + +Each production path that mutates a task's workflow step maps to exactly one +trigger. The mapping is fixed so that no implementer has to choose: + +| Path | Trigger | +|---|---| +| Task creation admission placement | `task_created` | +| `Service.MoveTask` / `MoveTaskWithOptions` from an HTTP/WS board action | `manual_move` | +| `Service.MoveTask` reached from `move_task_kandev`, applied immediately | `mcp_move` | +| A `move_task_kandev` pending move applied at turn end | `mcp_deferred_move` | +| `Service.UpdateTask` where the request carries a new `workflow_step_id` | `task_update` | +| `executeStepTransition` and the engine's `ApplyTransition` | `engine_transition` | +| An explicit user cancellation completing a turn (`turnCompletionCauseUserCancellation`), reached through the same `on_turn_complete` code path as `engine_transition` | `user_cancellation` | +| Feeder pull and queued-task promotion on step vacate | `wip_pull` | +| `BulkMoveTasks` / `BulkMoveSelectedTasks` | `bulk_move` | +| `RestoreTaskMessageRollbackIfSessionState` | `unarchive_restore` | +| `AddTaskToWorkflow` | `workflow_attached` | +| `RemoveTaskFromWorkflow` | `workflow_detached` | +| Anything not in this table | `unknown` | + +A move that reaches `MoveTask` through more than one of these is attributed to +the outermost caller: `mcp_move` beats `manual_move`, because the agent, not a +board click, is what caused it. + +**Actor kind** — what kind of thing caused it. + +| Value | Meaning | +|---|---| +| `human` | An authenticated identity, or the synthetic single-user identity when auth is disabled. `actor_id` is the user ID. | +| `agent` | An agent session acting through MCP, or a session whose turn the engine reacted to. `actor_id` is the session ID. | +| `system` | The workflow engine reacting to a non-session trigger, a queue reconciler, or a scheduler, with no initiating identity. `actor_id` is `NULL`. | +| `integration` | An external watcher or automation (GitHub, GitLab, Jira, Linear, Sentry, Azure DevOps, webhook automation). `actor_id` is the watch/automation ID. | +| `unknown` | Not determinable. `actor_id` is `NULL`. | + +`actor_id` MUST be an identifier. Display names, titles, emails, and prompts +MUST NOT be written to this table. + +For actor kind `agent`, `actor_id` and `session_id` hold the same session ID. +The duplication is intentional: `actor_id` then has one uniform meaning ("the +identifier of whoever caused this") across every actor kind, and consumers do +not branch on kind to find the actor. + +An `engine_transition` is `agent` when the trigger came from a session's turn +(`on_turn_start`, `on_turn_complete`, `on_exit`/`on_enter` reached through one) +and `system` when it came from a non-session trigger such as a +children-completed rollup or a scheduled evaluation. + +A `user_cancellation` is never `agent`, even though it reaches the engine +through the same `on_turn_complete` code path an `engine_transition` does: the +turn didn't run to completion on its own, a caller forced it closed. Actor +kind for `user_cancellation` follows the same identity-on-context rule as +`manual_move` — `human` with the cancelling request's user ID when an +authenticated identity is present, `system` with no identifier when it is not +(for example, an automated caller that cancels on a session's behalf with no +request-scoped identity). This is a deliberate carve-out from the +`engine_transition` rule above, not a relaxation of it: the trigger value +itself (`user_cancellation` vs. `engine_transition`) is what distinguishes a +forced completion from a natural one, so a consumer never has to inspect +`actor_kind` to tell them apart. + +### `telemetry_activations` (new) + +A one-row-per-contract registry that makes each telemetry contract's activation +point readable from the snapshot itself. + +``` +telemetry_activations + contract_key string PK part 1 — e.g. "turn.workflow_step_id_at_start", + "task_step_transitions" + contract_version int PK part 2 + activated_at timestamp NOT NULL, UTC — first boot on which this version of + the contract became active on this install +``` + +The primary key is `(contract_key, contract_version)`, not `contract_key` alone, +so a future version bump **appends** a row rather than overwriting the first +activation. That is the whole point: a series that changed meaning mid-stream +must show two activation points, not one. + +Written once per `(key, version)`, on the boot that first makes it active; never +rewritten. It lives in the same +schema as the data it describes so that a database reset clears both together — +an activation marker that outlives its data would be worse than none. + +This spec registers exactly two keys: `turn.workflow_step_id_at_start` +(Slice 1) and `task_step_transitions` (Slice 2), each at version `1`. + +### `task_session_turns.metadata` (existing column, new key) + +`metadata` is a JSON object; today its only key is `runtime_config_snapshot` +(1,722 of 1,722 turns in the reference store). Slice 1 adds a sibling key: + +```json +{ + "runtime_config_snapshot": { "model": "opus[1m]", "mode": "auto" }, + "workflow_step_id_at_start": "e6f925e0-de99-4a29-88d7-1843d033e141" +} +``` + +- The key is present only when the task held a workflow step when the turn + started. It is otherwise **absent** — not `""`, not `null`, not `0`. +- Adding the key MUST NOT depend on `runtime_config_snapshot` being present. A + turn whose runtime snapshot is empty still carries the stamp. +- The value is a workflow step ID. It is not resolved to a step name; step names + are mutable and the ID is the join key. + +## API surface + +**None.** This feature adds no HTTP route, no WebSocket event, no MCP tool, and +no frontend surface. Its only consumers read the database. + +Existing surfaces are unchanged, including `GET /api/v1/sessions/:id/workflow/history`. + +The Go-side contract is the invariant in *What*, Slice 2: the repository +transaction that commits a change to `tasks.workflow_step_id` also commits the +ledger row. Callers supply trigger and actor; a caller that supplies neither +gets `unknown` / `unknown`, which is a recorded fact and not an error. + +## Determinism, ordering, and concurrency + +**Ordering.** Ledger rows for one task are ordered by `occurred_at` ASC, tiebroken +by `id` ASC. `id` is the authoritative sequence — two transitions can share a +timestamp, and `occurred_at` alone is not a total order. There is no third +tiebreak because `id` is unique. + +**Clock.** `occurred_at` is the host wall clock at transaction time, in UTC. It +can move backwards across an NTP correction. When `occurred_at` and `id` +disagree about the order of two rows **for the same task**, `id` is correct; +`occurred_at` is a measurement, `id` is the record. Consumers computing +durations from a backwards-stepping pair get a non-positive interval and MUST +treat it as unknown rather than clamping it to zero. + +**`id` is not a global watermark.** Postgres `BIGSERIAL` allocates from a +sequence before commit, so ids can commit out of order across concurrent +transactions. An incremental extract MUST window on `occurred_at` with an +overlap wide enough to cover its longest transaction, never on `MAX(id)` seen +last time. Within a single task the chain invariant below makes gaps detectable; +across tasks it does not. + +**Chain invariant.** For any single task, reading its rows in that order, each +row's `from_workflow_step_id` equals the previous row's `to_workflow_step_id`, +and the last row's `to_workflow_step_id` equals the task's current +`tasks.workflow_step_id`. This holds under concurrent moves; it therefore +requires that the read of the old step and the write of the new one be +serialized against other writers of the same task row, not read outside the +write transaction. + +**Concurrency.** Two callers moving the same task to different steps produce two +rows with an intact chain, in the order the transactions committed. This spec +does not change which move wins — last committed writer still wins, as today. +Neither move is rejected merely because the other raced it. + +**No-op writes.** A committed update whose `workflow_step_id` is unchanged +writes no row, whatever else it changed. This is the whole of the feature's +idempotency: a client retrying a move that already applied produces a second +committed update, a no-op step change, and therefore no second row. + +**Engine retries.** Engine transitions are already deduplicated by the engine's +`OperationID` applied-operations store, so a retried trigger does not re-apply +the transition and writes no second row. No additional idempotency key is added, +and there is no unique constraint on the ledger — a card legitimately moving +A→B→A→B produces four rows. + +**Genesis rows.** Task creation writes one row with `from_*` NULL and trigger +`task_created`, including when WIP capacity places the task in a feeder step +rather than the requested target. Without it, the first interval of every task +created after activation would have no start boundary in the ledger. + +A task created with **no workflow at all** writes no genesis row — there is no +step to record, and a row with both sides NULL is forbidden. Its first row is +the `workflow_attached` row written when it later joins a workflow. + +**Which tasks are recorded.** All of them, including ephemeral (quick-chat) and +config tasks, and including tasks whose origin excludes them from WIP counts. +Filtering by task kind is the consumer's job; excluding a class of task here +would put invisible holes in the chain invariant, and the invariant is what +makes the ledger self-checking. + +**Archiving is not a transition.** Archive, unarchive-in-place, cascade archive, +and delete do not change `tasks.workflow_step_id` and therefore write no row. +Only `unarchive_restore`, which does set a step, writes one. + +**Ledger vs. `tasks.updated_at`.** `occurred_at` is the transaction's timestamp, +so a row's `occurred_at` and its task's `updated_at` agree at write time. The +task's `updated_at` moves on later unrelated writes; the ledger row never does. + +## Permissions + +The ledger is written by the same transactions that already perform authorized +step mutations; it grants no new capability and performs no authorization of its +own. If a caller was not permitted to move the task, no move and therefore no +row occurs. + +There is no read surface, so there is no read authorization. Anyone with +database access already has it. + +## Failure modes + +| Condition | Behaviour | +|---|---| +| Ledger insert fails inside the transaction | The whole transaction rolls back: the step change does not commit either. Telemetry never silently diverges from the state it describes. | +| Migration fails at boot | Kandev's migration runner swallows migration errors (`db.MigrateLogger.Apply` logs and continues). A failed `CREATE TABLE` is therefore invisible at boot. The writer MUST detect the missing table at first write and fail the transaction rather than skip the row — a step change that cannot be recorded is preferable to a ledger that is quietly partial. The boot health line (below) reports the table's absence. | +| Trigger or actor cannot be determined | Written as `unknown`, which is a recorded fact. It is never guessed. | +| Task has several live sessions and no single initiator | `session_id` is `NULL`. | +| Task has no workflow step (detached) | `to_*` NULL with trigger `workflow_detached`; the stamp key is absent from new turns. | +| Task is deleted | Rows cascade away with the task. Kandev prefers archive over delete, and archived tasks retain their rows; a deleted task's step history is gone by design and is not recoverable. | +| Session is deleted | `session_id` becomes `NULL`; the row survives, because the transition was a task-level fact. | +| Slice 1: the task row cannot be read when a turn starts | The turn is still created; the stamp key is absent. Turn creation MUST NOT fail because telemetry could not be resolved. | + +## Persistence guarantees + +- Ledger rows and turn stamps survive restart, upgrade, and backup/restore. They + are ordinary rows in the primary database. +- Nothing about this feature is held in memory across a restart. There is no + queue, no buffer, and no deferred flush; a row exists exactly when its + transaction committed. +- `telemetry_activations` rows survive restart and upgrade, and are cleared by a + database reset together with the data they describe. +- No retention policy, no TTL, no pruning. The ledger is bounded by the number + of step changes (72 tasks and 1,722 turns on the reference store); it needs + none. +- Postgres parity is required per [ADR 0027](../../../decisions/0027-replayable-schema-migrations.md). + +## Migration and activation + +Kandev has no migration framework: schema is Go string DDL applied idempotently +at boot, and the runner swallows errors. Therefore: + +- New columns are added nullable and backfilled by a separate idempotent + statement, following the `task_session_messages.updated_at` pattern + (`ALTER TABLE … ADD COLUMN` then `UPDATE … WHERE … IS NULL`). Anything + referencing a new column — index, backfill, partial predicate — runs after the + `ADD COLUMN`, never in the `CREATE TABLE` init block. +- Nothing is backfilled with a substituted value. Pre-activation state is + absent, and absent is the correct reading. +- The migration is replay-safe: running it twice on the same database is a + no-op, and dialect-specific replay classification uses `internal/db` + (`IsDuplicateColumnError` / `IsAlreadyExistsError`), never local string + matching. +- Coverage mirrors `task_external_id_migration_test.go`: seed a pre-migration + row, run migrations, assert the pre-existing row reads NULL and the new + objects exist, then call the migration runner **twice more** and assert + idempotence — on SQLite and, env-gated, on Postgres. +- On the boot that first activates each contract, a `telemetry_activations` row + is written with that boot's UTC time and contract version `1`. It is written + once and never rewritten. + +## Writer health + +A telemetry writer that silently stops is worse than one that was never built, +because its output still looks like data. Three controls, all required: + +1. **A pinning test over the mutation set.** The production statements that may + change `tasks.workflow_step_id` are enumerated in a test. A new statement + that is not registered fails the build. This is the control that matters: + the realistic failure is not the writer breaking, it is a new code path + bypassing it. The set as of this spec is + `UpdateTask`, `UpdateTaskIfWorkflowStepHasCapacity`, + `PromoteQueuedTaskIfWorkflowStepHasCapacity`, + `RestoreTaskMessageRollbackIfSessionState`, `AddTaskToWorkflow`, + `RemoveTaskFromWorkflow`, and task creation's admission placement. +2. **Runtime counters**, following the `routing.metric.*` precedent: an expvar + counter of rows written keyed by trigger, and of turns created keyed by + whether the stamp was present. Mirrored as structured logs under a + `telemetry.metric.*` namespace so a log-aggregation rule can match the event + name without scanning free text. +3. **A boot health line** reporting, for each registered contract key: whether + its objects exist, its activation timestamp, the current row count, and the + most recent `occurred_at`. An install whose ledger stopped growing is visible + in one line. + +## Relationship to `session_step_history` + +`session_step_history` exists, has a `CreateStepTransition` writer with **zero +production callers** on `main` (verified at `db4fc039a`), and is read by +`GET /api/v1/sessions/:id/workflow/history`, which therefore returns +`{"history":[]}` on every install. Wiring that writer is separate work and lands +first; it and this ledger touch the same code region and cannot proceed in +parallel. + +This spec does not delete, deprecate, migrate, or dual-write that table. The two +answer different questions — "what did this session do" versus "what happened to +this card" — and a card can change step with no session or with several, which +is why the second question needs its own grain. + +## Downstream consumer contract + +Normative for the analysis extract, not for this repository — recorded here +because the producer's obligations only make sense against it. + +Every published per-step figure MUST carry an `attribution_basis` per row and +coverage counts by basis. Basis is resolved in this precedence: + +| Basis | Source | Precedence | +|---|---|---| +| `task_history` | A `task_step_transitions` interval containing the event | 1 (strongest) | +| `turn_start` | `task_session_turns.metadata.workflow_step_id_at_start` | 2 | +| `message_reconstruction` | `task_session_messages.metadata.workflow_step_id` on workflow-automation messages (the mechanism in use today) | 3 | +| `unknown` | No source resolves the event | 4 | + +**A partly-populated ledger MUST NOT silently replace message-timeline +reconstruction.** Both remain live; the ledger raises precedence for events it +covers and changes nothing for events it does not. A figure published without +its coverage-by-basis breakdown is a figure whose meaning changed when the +ledger activated, and that is exactly the discontinuity this contract exists to +prevent. + +Kandev's obligations that make this possible: publish the activation point +(`telemetry_activations`), version each row (`contract_version`), and leave +pre-activation state absent rather than defaulted. + +## Honest limits + +- Attribution improves **forward only**. History is not repaired. +- A cost event whose window crosses a step boundary is still not divided between + the two steps. The 30.5% "dominant of two steps" bucket shrinks as boundaries + sharpen; it does not go to zero, and this feature does not attempt it. +- Turn-level stamping records the step at turn **start**. A turn that spans a + step change is attributed to where it began, which is a choice, not a + measurement. +- Step names are not captured. A renamed step is the same step; a deleted step + leaves rows pointing at an ID with no row to join, and the consumer must + tolerate that. + +## Gate between slices + +Slice 1 ships alone and is measured before Slice 2 is built. The measurement +that opens the gate: on a store with at least two weeks of post-activation +turns, report the share of post-activation turns carrying the stamp, and the +resulting change in the 47.0 / 30.5 / 22.5 attribution split when `turn_start` +is admitted as a basis. If Slice 1 alone moves the unattributable bucket enough, +Slice 2's scope is a decision made against that number rather than against this +spec's assumption. + +If the measurement is inconclusive — too few post-activation turns, or a change +inside noise — the default is that **Slice 2 proceeds as specified**. The gate +exists to let evidence shrink Slice 2, not to let missing evidence stall it. + +The two slices can disagree: a turn's stamp reads the task's step at turn start +and can be superseded by a move that commits moments later, which the ledger +records and the stamp does not. That is expected, not an inconsistency, and is +why `task_history` outranks `turn_start` in the basis precedence. Neither writer +corrects the other. + +## Scenarios + +### Slice 1 — turn-start step stamp + +- **GIVEN** a session whose task sits in workflow step `S`, **WHEN** a turn + starts, **THEN** the turn's metadata contains `workflow_step_id_at_start` = `S`. +- **GIVEN** a session whose task has no workflow step, **WHEN** a turn starts, + **THEN** the turn's metadata contains no `workflow_step_id_at_start` key — not + an empty string, not `null`, not `0`. +- **GIVEN** a session with no runtime config to snapshot, **WHEN** a turn starts + while its task sits in step `S`, **THEN** the metadata contains + `workflow_step_id_at_start` = `S` and no `runtime_config_snapshot`. +- **GIVEN** a turn stamped with step `S`, **WHEN** the task moves to step `T` + before the turn completes, **THEN** the turn's stamp is still `S`. +- **GIVEN** a turn created before activation, **WHEN** it is read after + activation, **THEN** it carries no stamp and is not backfilled. +- **GIVEN** the task row cannot be read, **WHEN** a turn starts, **THEN** the + turn is created without a stamp and turn creation does not fail. +- **GIVEN** a synthetic completed turn created for a lifecycle message, **WHEN** + it is written while its task sits in step `S`, **THEN** it carries the same + stamp as a normal turn. + +### Slice 2 — ledger writes + +- **GIVEN** a task in step `A`, **WHEN** a user moves it to step `B`, **THEN** + exactly one ledger row exists with `from_workflow_step_id` = `A`, + `to_workflow_step_id` = `B`, trigger `manual_move`, actor kind `human`, and + `actor_id` equal to the acting user's ID. +- **GIVEN** a task in step `A` with no session at all, **WHEN** a bulk move + sends it to step `B`, **THEN** one row is written with `session_id` NULL and + trigger `bulk_move`. +- **GIVEN** a task in step `A` with two live sessions, **WHEN** a WIP pull + promotes it into step `B`, **THEN** one row is written with `session_id` NULL, + trigger `wip_pull`, and actor kind `system`. +- **GIVEN** an agent session on a task in step `A`, **WHEN** the agent calls + `move_task_kandev` and it applies immediately, **THEN** one row is written + with trigger `mcp_move`, actor kind `agent`, and `actor_id` equal to that + session's ID. +- **GIVEN** an agent's deferred `move_task_kandev`, **WHEN** it applies at turn + end, **THEN** one row is written with trigger `mcp_deferred_move`. +- **GIVEN** a workflow step whose `on_turn_complete` transitions to step `B`, + **WHEN** the turn completes, **THEN** one row is written with trigger + `engine_transition`. +- **GIVEN** a task in step `A`, **WHEN** a move to step `A` is issued, **THEN** + no row is written. +- **GIVEN** a task in step `A`, **WHEN** only its position within `A` changes, + **THEN** no row is written. +- **GIVEN** a WIP-limited target step at capacity, **WHEN** a move into it is + rejected, **THEN** no row is written and the task's step is unchanged. +- **GIVEN** the transaction that changes the step is rolled back for any reason, + **WHEN** it completes, **THEN** no row exists for it. +- **GIVEN** a task created into step `A`, **WHEN** creation commits, **THEN** one + row exists with `from_workflow_id` and `from_workflow_step_id` NULL and + trigger `task_created`. +- **GIVEN** a task created targeting a full step `B` with feeder step `A`, + **WHEN** creation commits and places it in `A`, **THEN** the genesis row's + `to_workflow_step_id` is `A`, the step it was actually placed in. +- **GIVEN** a task attached to a workflow, **WHEN** it is later removed from + that workflow, **THEN** a row is written with `to_workflow_id` and + `to_workflow_step_id` NULL and trigger `workflow_detached`. +- **GIVEN** a task whose step changed via a task update rather than the move + path, **WHEN** the update commits, **THEN** one row is written with trigger + `task_update`. +- **GIVEN** a caller that declares no trigger or actor, **WHEN** its step change + commits, **THEN** the row records trigger `unknown`, actor kind `unknown`, and + `actor_id` NULL. +- **GIVEN** a task created with no workflow, **WHEN** creation commits, **THEN** + no row is written; **WHEN** it is later attached to a workflow, **THEN** its + first row has trigger `workflow_attached` and `from_*` NULL. +- **GIVEN** an ephemeral (quick-chat) task in step `A`, **WHEN** it moves to + step `B`, **THEN** a row is written exactly as for any other task. +- **GIVEN** a task in step `A`, **WHEN** it is archived, unarchived in place, + cascade-archived, or deleted without its step changing, **THEN** no row is + written for that action. +- **GIVEN** an engine action that switches a task to a step in a **different** + workflow, **WHEN** the transition commits, **THEN** one row is written whose + `from_workflow_id` and `to_workflow_id` differ, with trigger + `engine_transition`. +- **GIVEN** a task whose step is stored as the empty string, **WHEN** it moves + into step `B`, **THEN** the row's `from_workflow_step_id` is NULL, not `""`. +- **GIVEN** a row referencing a workflow step that is later deleted, **WHEN** + the step is deleted, **THEN** the row survives unchanged with its step ID + intact. + +### Ordering, chain, and concurrency + +- **GIVEN** a task moved `A`→`B`→`A`→`B`, **WHEN** its rows are read ordered by + `(occurred_at, id)`, **THEN** four rows appear and each row's + `from_workflow_step_id` equals the previous row's `to_workflow_step_id`. +- **GIVEN** two rows for one task sharing an identical `occurred_at`, **WHEN** + they are ordered by `(occurred_at, id)`, **THEN** the order is total and + matches the order the transitions committed. +- **GIVEN** two callers concurrently moving the same task, one to `B` and one to + `C`, **WHEN** both transactions commit, **THEN** exactly two rows exist, their + chain is intact, and the later row's `to_workflow_step_id` equals the task's + final `workflow_step_id`. +- **GIVEN** an engine trigger that is retried with the same `OperationID`, + **WHEN** the retry is handled, **THEN** no second row is written. +- **GIVEN** any task, **WHEN** its last row is read, **THEN** its + `to_workflow_step_id` equals `tasks.workflow_step_id`. +- **GIVEN** two rows for one task whose `occurred_at` values run backwards + because the host clock was corrected, **WHEN** they are ordered by `id`, + **THEN** the chain invariant still holds and the ledger is not repaired or + reordered by timestamp. +- **GIVEN** a turn stamped with step `S` and a ledger row moving the task to `T` + a moment after that turn started, **WHEN** both are read, **THEN** they + disagree, and neither is treated as an error. + +### Actor and privacy + +- **GIVEN** authentication is disabled, **WHEN** the single synthetic user moves + a card, **THEN** actor kind is `human` and `actor_id` is that identity's user + ID. +- **GIVEN** a Jira or Linear watcher that moves a card, **WHEN** the move + commits, **THEN** actor kind is `integration` and `actor_id` is the watch ID. +- **GIVEN** any row, **WHEN** it is inspected, **THEN** `actor_id` holds an + identifier and no display name, email, title, or prompt text appears anywhere + in the row. + +### Migration and activation + +- **GIVEN** a database created before this feature, **WHEN** the backend boots, + **THEN** the ledger table and `telemetry_activations` exist, pre-existing + tasks have no ledger rows, and pre-existing turns carry no stamp. +- **GIVEN** a database already migrated, **WHEN** the migration runner is run + twice more, **THEN** it succeeds both times and changes nothing. +- **GIVEN** a Postgres deployment, **WHEN** the same fresh-then-replay sequence + runs, **THEN** it behaves identically to SQLite. +- **GIVEN** a first boot on which a contract activates, **WHEN** boot completes, + **THEN** `telemetry_activations` holds one row for that key with + `contract_version` = 1 and that boot's UTC time. +- **GIVEN** a subsequent boot, **WHEN** it completes, **THEN** the existing + activation row is unchanged. +- **GIVEN** an install where the ledger table is absent because its migration + silently failed, **WHEN** a step change is attempted, **THEN** the transaction + fails rather than committing a step change with no row, and the boot health + line reports the table as absent. + +### Writer health + +- **GIVEN** a new production statement that mutates `tasks.workflow_step_id`, + **WHEN** the test suite runs, **THEN** the pinning test fails until that + statement is registered. +- **GIVEN** a ledger row is written, **WHEN** metrics are read, **THEN** the + expvar counter for that trigger has incremented and a `telemetry.metric.*` + log line names the event. +- **GIVEN** any boot, **WHEN** startup completes, **THEN** one health line per + registered contract key reports object existence, activation time, row count, + and most recent `occurred_at`. + +## Out of scope + +- **Backfilling history.** No pre-activation transition is reconstructed, from + the message timeline or otherwise. +- **Dividing a cost event across a mid-turn step change.** Explicitly not + attempted; the "dominant of two steps" bucket remains. +- **`workflow_step_decisions`.** A different mechanism with a live writer. Not + merged, not extended, not read here. +- **Wiring, changing, or removing `session_step_history`** and its endpoint. + Separate work, lands first. +- **Any read API.** No HTTP route, no WS event, no MCP tool, no UI. Consumers + read the database. +- **The downstream extract and dashboard.** The consumer contract above is + recorded for the analysis side; implementing it is not part of this feature. +- **Step-name capture or step-rename history.** IDs only. +- **Retention, pruning, or archival of ledger rows.** +- **A `parent_session_id` / subagent grain.** Separate card. +- **Per-turn cost columns.** Separate card.