feat: add task-level workflow step transition ledger (#2623)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-15 23:59:40 +08:00
committed by GitHub
parent 875d476f9c
commit fe991d0b77
67 changed files with 7364 additions and 120 deletions
@@ -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
@@ -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) {
@@ -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
@@ -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=<target's own session> —
// 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)
}
}
+31 -2
View File
@@ -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 {
@@ -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)
@@ -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
@@ -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)
@@ -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)
}
}
@@ -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)
}
}
@@ -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) {
@@ -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)
}
@@ -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
}
@@ -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) {
@@ -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")
@@ -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"`
}
@@ -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",
@@ -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
@@ -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)
}
}
@@ -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
@@ -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))
}
@@ -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)
}
}
@@ -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)
}
@@ -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, ""
}
@@ -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)
}
}
@@ -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
}
@@ -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.
@@ -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
@@ -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 (
@@ -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)
@@ -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)
}
}
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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
@@ -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)
}
@@ -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,
@@ -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) {
@@ -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
@@ -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")
}
}
@@ -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
@@ -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
}
@@ -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 <table> 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
}
@@ -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)
}
@@ -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 <table> 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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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"}))
@@ -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 (
<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);
```
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.
@@ -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).
@@ -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).
@@ -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).
@@ -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).
@@ -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="<base-sha>" --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).
@@ -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).
+1
View File
@@ -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 |
@@ -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.