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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+134
@@ -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).
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user