From 26bf26fc208f5f0198d6c2bf73ecfd8267577071 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 13 Sep 2026 23:51:59 +0800 Subject: [PATCH] fix(office): stop duplicate and missed children-completed run dispatches (#3620) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio --- .github/scripts/lint-harness-files.py | 2 + ...adapters_runs_engine_wave_identity_test.go | 73 +++ apps/backend/internal/backendapp/main.go | 18 +- apps/backend/internal/db/dialect/aggregate.go | 28 + .../internal/db/dialect/aggregate_test.go | 102 ++++ apps/backend/internal/office/models/models.go | 16 +- .../internal/office/repository/sqlite/base.go | 13 + .../repository/sqlite/base_migrations.go | 21 + .../office/repository/sqlite/blockers.go | 27 + .../office/repository/sqlite/blockers_test.go | 77 +++ .../office/repository/sqlite/participants.go | 22 + .../office/repository/sqlite/wake_receipts.go | 68 ++- .../sqlite/wake_receipts_postgres_test.go | 80 +++ .../repository/sqlite/wake_receipts_test.go | 66 +++ ...ke_receipts_wave_identity_postgres_test.go | 152 +++++ .../wake_receipts_wave_identity_test.go | 281 +++++++++ .../sqlite/wake_wave_migration_test.go | 130 ++++ .../internal/office/scheduler/reactivity.go | 190 +++++- ...ty_children_completed_wave_foreach_test.go | 228 ++++++++ ...reactivity_children_completed_wave_test.go | 533 +++++++++++++++++ .../office/scheduler/reactivity_test.go | 6 +- apps/backend/internal/office/scheduler/run.go | 161 ++++- .../internal/office/scheduler/run_test.go | 321 ++++++++++ .../scheduler/wave_identity_race_test.go | 91 +++ .../children_completed_wave_identity_test.go | 158 +++++ .../office/service/event_subscribers.go | 10 +- .../service/scheduler_wake_reconciler.go | 10 +- .../internal/office/service/wave_identity.go | 47 ++ .../service/wave_identity_internal_test.go | 96 +++ .../office/waveidentity/waveidentity.go | 42 ++ .../office/waveidentity/waveidentity_test.go | 103 ++++ .../event_handlers_children_completed.go | 32 +- ...t_handlers_children_completed_wave_test.go | 51 ++ .../sqlite/idempotency_violation.go | 31 + .../sqlite/idempotency_violation_test.go | 74 +++ .../internal/runs/repository/sqlite/runs.go | 9 +- .../runs/service/cross_task_wave_test.go | 125 ++++ .../internal/runs/service/metrics_vars.go | 15 + apps/backend/internal/runs/service/service.go | 30 +- .../runs/service/service_postgres_test.go | 60 ++ .../internal/runs/service/service_test.go | 216 +++++++ .../service/service_wave_coalesce_test.go | 68 +++ .../sqlite/wave_member_parity_test.go | 106 ++++ .../internal/workflow/engine/adapters.go | 5 + .../internal/workflow/engine/payloads.go | 8 + .../workflow/engine/phase2_callbacks.go | 60 +- .../engine/queue_run_wave_identity_test.go | 223 +++++++ docs/plans/parent-wake-wave-identity/plan.md | 421 +++++++++++++ .../task-01-wave-identity-primitives.md | 116 ++++ .../task-02-wave-identity-persistence.md | 144 +++++ .../task-03-wire-cascade-producer.md | 157 +++++ .../task-04-wire-engine-routed-producers.md | 135 +++++ .../task-05-wire-orchestrator-producer.md | 117 ++++ .../task-06-backstop-admission.md | 177 ++++++ .../task-07-race-and-regression-coverage.md | 197 +++++++ .../requirements/parent-wake-wave-identity.md | 332 +++++++++++ .../parent-wake-wave-identity.md | 553 ++++++++++++++++++ 57 files changed, 6586 insertions(+), 48 deletions(-) create mode 100644 apps/backend/internal/backendapp/adapters_runs_engine_wave_identity_test.go create mode 100644 apps/backend/internal/db/dialect/aggregate.go create mode 100644 apps/backend/internal/db/dialect/aggregate_test.go create mode 100644 apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_postgres_test.go create mode 100644 apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_test.go create mode 100644 apps/backend/internal/office/repository/sqlite/wake_wave_migration_test.go create mode 100644 apps/backend/internal/office/scheduler/reactivity_children_completed_wave_foreach_test.go create mode 100644 apps/backend/internal/office/scheduler/reactivity_children_completed_wave_test.go create mode 100644 apps/backend/internal/office/scheduler/run_test.go create mode 100644 apps/backend/internal/office/scheduler/wave_identity_race_test.go create mode 100644 apps/backend/internal/office/service/children_completed_wave_identity_test.go create mode 100644 apps/backend/internal/office/service/wave_identity.go create mode 100644 apps/backend/internal/office/service/wave_identity_internal_test.go create mode 100644 apps/backend/internal/office/waveidentity/waveidentity.go create mode 100644 apps/backend/internal/office/waveidentity/waveidentity_test.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_children_completed_wave_test.go create mode 100644 apps/backend/internal/runs/repository/sqlite/idempotency_violation_test.go create mode 100644 apps/backend/internal/runs/service/cross_task_wave_test.go create mode 100644 apps/backend/internal/runs/service/service_wave_coalesce_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/wave_member_parity_test.go create mode 100644 apps/backend/internal/workflow/engine/queue_run_wave_identity_test.go create mode 100644 docs/plans/parent-wake-wave-identity/plan.md create mode 100644 docs/plans/parent-wake-wave-identity/task-01-wave-identity-primitives.md create mode 100644 docs/plans/parent-wake-wave-identity/task-02-wave-identity-persistence.md create mode 100644 docs/plans/parent-wake-wave-identity/task-03-wire-cascade-producer.md create mode 100644 docs/plans/parent-wake-wave-identity/task-04-wire-engine-routed-producers.md create mode 100644 docs/plans/parent-wake-wave-identity/task-05-wire-orchestrator-producer.md create mode 100644 docs/plans/parent-wake-wave-identity/task-06-backstop-admission.md create mode 100644 docs/plans/parent-wake-wave-identity/task-07-race-and-regression-coverage.md create mode 100644 docs/specs/office/requirements/parent-wake-wave-identity.md create mode 100644 docs/specs/office/system-design/parent-wake-wave-identity.md diff --git a/.github/scripts/lint-harness-files.py b/.github/scripts/lint-harness-files.py index 8264c5afd3..58cb7597b4 100644 --- a/.github/scripts/lint-harness-files.py +++ b/.github/scripts/lint-harness-files.py @@ -6,6 +6,8 @@ Usage: python3 .github/scripts/lint-harness-files.py AGENTS.md .agents/skills/fix/SKILL.md """ +from __future__ import annotations + import argparse import os import re diff --git a/apps/backend/internal/backendapp/adapters_runs_engine_wave_identity_test.go b/apps/backend/internal/backendapp/adapters_runs_engine_wave_identity_test.go new file mode 100644 index 0000000000..823ec997a9 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_runs_engine_wave_identity_test.go @@ -0,0 +1,73 @@ +package backendapp + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + runsservice "github.com/kandev/kandev/internal/runs/service" + workflowengine "github.com/kandev/kandev/internal/workflow/engine" +) + +// TestRunsServiceEngineAdapter_QueueRun_CarriesWaveIdentity pins +// runsServiceEngineAdapter.QueueRun's field-by-field copy from the engine's +// QueueRunRequest to the runs-service's QueueRunRequest: it is the only +// seam P2 (queueChildrenCompletedRun) and P3 (ParentWakeReconciler.Tick), +// both engine-routed producers, use to get a completion-wave's WaveKey/ +// WaveString into the row insertRun eventually persists as +// wake_wave_key/wake_wave_string. A future edit dropping either field from +// the copy would silently break AC-002.10 cross-producer wake equivalence +// for both producers without any other test in the repository noticing. +func TestRunsServiceEngineAdapter_QueueRun_CarriesWaveIdentity(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + officeRepo, err := officesqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init office repo: %v", err) + } + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + eb := bus.NewMemoryEventBus(log) + svc := runsservice.New(officeRepo.RunsRepository(), eb, log, nil) + + adapter := &runsServiceEngineAdapter{svc: svc} + ctx := context.Background() + + outcome, err := adapter.QueueRun(ctx, workflowengine.QueueRunRequest{ + Reason: "task_children_completed", + Payload: map[string]any{"agent_profile_id": "agent-wave-1", "task_id": "parent-1"}, + WaveKey: "task_children_completed:parent-1:deadbeef", + WaveString: "parent-1|child-1,child-2", + }) + if err != nil { + t.Fatalf("QueueRun: %v", err) + } + if outcome != workflowengine.QueueOutcomeQueued { + t.Fatalf("outcome = %q, want %q", outcome, workflowengine.QueueOutcomeQueued) + } + + var row struct { + WakeWaveKey string `db:"wake_wave_key"` + WakeWaveString string `db:"wake_wave_string"` + } + if err := officeRepo.RunsRepository().Reader().GetContext(ctx, &row, + `SELECT wake_wave_key, wake_wave_string FROM runs WHERE agent_profile_id = ?`, + "agent-wave-1", + ); err != nil { + t.Fatalf("read persisted run: %v", err) + } + if row.WakeWaveKey != "task_children_completed:parent-1:deadbeef" { + t.Errorf("wake_wave_key = %q, want %q", row.WakeWaveKey, "task_children_completed:parent-1:deadbeef") + } + if row.WakeWaveString != "parent-1|child-1,child-2" { + t.Errorf("wake_wave_string = %q, want %q", row.WakeWaveString, "parent-1|child-1,child-2") + } +} diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 572affcbf8..845bc80d67 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1687,11 +1687,19 @@ func startSchedulingRuntime( // Phase 4 (ADR-0004): wire the workflow engine's dependencies and a // dispatcher so office event subscribers route through the engine // unconditionally. - engineDispatcher := wireWorkflowEngineForOffice( + engineDispatcher, engineParticipants := wireWorkflowEngineForOffice( orchestratorSvc, runProcessorSvc, services.Task, services.Workflow, repos, runsSvc, log, ) if services.OfficeSvcs != nil { services.OfficeSvcs.Dashboard.SetWorkflowEngineDispatcher(engineDispatcher) + // For payload parity with the engine-routed producers, the + // cascade producer resolves the parent's current step directly, + // since it never goes through the engine. + services.OfficeSvcs.Scheduler.SetWorkflowStepGetter(services.Workflow) + // Same parity need for queue_run_for_each_participant: cascade must + // see the exact seats the engine's own fan-out would resolve, so it + // is wired the same engine.ParticipantStore instance. + services.OfficeSvcs.Scheduler.SetParticipantStore(engineParticipants) } // Start the runs scheduler (tick + signal listener). It drives // orchScheduler.Tick on both periodic ticks and event-driven signals. @@ -1748,7 +1756,7 @@ func wireWorkflowEngineForOffice( repos *Repositories, runsSvc *runsservice.Service, log *logger.Logger, -) *officeenginedispatcher.Dispatcher { +) (*officeenginedispatcher.Dispatcher, workflowengine.ParticipantStore) { // Build the workflow-domain adapters. participants := workflowadapters.NewParticipantAdapter(repos.Workflow) decisions := workflowadapters.NewDecisionAdapter(repos.Workflow) @@ -1786,7 +1794,7 @@ func wireWorkflowEngineForOffice( eng := orchestratorSvc.WorkflowEngine() if eng == nil { log.Warn("workflow engine not initialised; office engine dispatcher disabled") - return nil + return nil, nil } // Build the dispatcher. The session resolver is the task repo, // which exposes GetActiveTaskSessionByTaskID. @@ -1797,7 +1805,7 @@ func wireWorkflowEngineForOffice( repos.Task.SetStepEntryDispatcher(&engineStepEntryDispatcherAdapter{engineProvider: orchestratorSvc, log: log}) log.Info("step entry dispatcher wired for workflow engine") - return dispatcher + return dispatcher, participants } // workflowEngineProvider is the seam engineStepEntryDispatcherAdapter reads @@ -1885,6 +1893,8 @@ func (a *runsServiceEngineAdapter) QueueRun( Reason: req.Reason, IdempotencyKey: req.IdempotencyKey, Payload: req.Payload, + WakeWaveKey: req.WaveKey, + WakeWaveString: req.WaveString, }) return workflowengine.QueueOutcome(outcome), err } diff --git a/apps/backend/internal/db/dialect/aggregate.go b/apps/backend/internal/db/dialect/aggregate.go new file mode 100644 index 0000000000..8c30a6dd79 --- /dev/null +++ b/apps/backend/internal/db/dialect/aggregate.go @@ -0,0 +1,28 @@ +package dialect + +import "fmt" + +// OrderedIDConcat returns the SQL fragment for a scalar subquery that +// comma-joins the `id` column of `SELECT id FROM tasks WHERE `, in +// ascending id order. The two dialects place the ordering differently: +// SQLite's GROUP_CONCAT has no ORDER BY of its own, so ordering comes from +// an ORDER BY inside the subquery; PostgreSQL's string_agg takes an ORDER BY +// inside the aggregate call itself. Writing one and assuming the other +// matches is how the two dialects silently diverge on row order — see +// docs/specs/office/system-design/parent-wake-wave-identity.md, "Backstop +// admission". +// +// where is the WHERE clause body (no leading "WHERE", no trailing +// "ORDER BY") of the `tasks` subquery — the caller supplies the predicate, +// this helper only owns the aggregate/order-by placement split. +// +// SQLite: (SELECT GROUP_CONCAT(w.id, ',') FROM (SELECT id FROM tasks WHERE ORDER BY id) w) +// Postgres: (SELECT string_agg(w.id, ',' ORDER BY w.id) FROM (SELECT id FROM tasks WHERE ) w) +func OrderedIDConcat(driver, where string) string { + if IsPostgres(driver) { + return fmt.Sprintf( + "(SELECT string_agg(w.id, ',' ORDER BY w.id) FROM (SELECT id FROM tasks WHERE %s) w)", where) + } + return fmt.Sprintf( + "(SELECT GROUP_CONCAT(w.id, ',') FROM (SELECT id FROM tasks WHERE %s ORDER BY id) w)", where) +} diff --git a/apps/backend/internal/db/dialect/aggregate_test.go b/apps/backend/internal/db/dialect/aggregate_test.go new file mode 100644 index 0000000000..f59f1f715b --- /dev/null +++ b/apps/backend/internal/db/dialect/aggregate_test.go @@ -0,0 +1,102 @@ +package dialect + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/testutil" +) + +func TestOrderedIDConcat_FragmentShape(t *testing.T) { + got := OrderedIDConcat(SQLite3, "parent_id = p.id") + want := "(SELECT GROUP_CONCAT(w.id, ',') FROM (SELECT id FROM tasks WHERE parent_id = p.id ORDER BY id) w)" + if got != want { + t.Errorf("sqlite: got %q, want %q", got, want) + } + + got = OrderedIDConcat(PGX, "parent_id = p.id") + want = "(SELECT string_agg(w.id, ',' ORDER BY w.id) FROM (SELECT id FROM tasks WHERE parent_id = p.id) w)" + if got != want { + t.Errorf("pgx: got %q, want %q", got, want) + } +} + +// TestOrderedIDConcat_SQLite_OrdersAscendingByID proves the fragment +// actually orders ascending by id when executed, not just that its text +// looks right: ids are inserted out of order (and lexicographically +// non-sequential, UUID-shaped) so an unordered GROUP_CONCAT would produce a +// different string. +func TestOrderedIDConcat_SQLite_OrdersAscendingByID(t *testing.T) { + tmpDir := t.TempDir() + rawDB, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + sqlxDB := sqlx.NewDb(rawDB, SQLite3) + t.Cleanup(func() { _ = sqlxDB.Close() }) + + if _, err := sqlxDB.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, parent_id TEXT)`); err != nil { + t.Fatalf("create table: %v", err) + } + // Insertion order deliberately disagrees with ascending id order. + for _, id := range []string{ + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "0e02b2c3-58cc-4372-a567-f47ac10bd479", + "a567f47a-58cc-4372-0e02-c10bb2c3d479", + } { + if _, err := sqlxDB.Exec(`INSERT INTO tasks (id, parent_id) VALUES (?, 'parent-1')`, id); err != nil { + t.Fatalf("insert %s: %v", id, err) + } + } + + frag := OrderedIDConcat(SQLite3, "parent_id = 'parent-1'") + var got string + if err := sqlxDB.QueryRowxContext(context.Background(), "SELECT "+frag).Scan(&got); err != nil { + t.Fatalf("query: %v", err) + } + + want := "0e02b2c3-58cc-4372-a567-f47ac10bd479,a567f47a-58cc-4372-0e02-c10bb2c3d479,f47ac10b-58cc-4372-a567-0e02b2c3d479" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +// TestOrderedIDConcat_Postgres_OrdersAscendingByID is the PostgreSQL twin: +// AC-OFFICE-WAKE-WAVE-IDENTITY-001.13 names cross-dialect ordering +// divergence as a silent failure mode, so this asserts byte-identical +// ordering against the same UUID-shaped fixture the SQLite test uses, not +// sequential ids that would happen to sort the same under any collation. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestOrderedIDConcat_Postgres_OrdersAscendingByID(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + sqlxDB := testutil.OpenIsolatedPostgres(t, dsn) + + if _, err := sqlxDB.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, parent_id TEXT)`); err != nil { + t.Fatalf("create table: %v", err) + } + for _, id := range []string{ + "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "0e02b2c3-58cc-4372-a567-f47ac10bd479", + "a567f47a-58cc-4372-0e02-c10bb2c3d479", + } { + if _, err := sqlxDB.Exec(sqlxDB.Rebind( + `INSERT INTO tasks (id, parent_id) VALUES (?, 'parent-1')`), id); err != nil { + t.Fatalf("insert %s: %v", id, err) + } + } + + frag := OrderedIDConcat(PGX, "parent_id = 'parent-1'") + var got string + if err := sqlxDB.QueryRowxContext(context.Background(), "SELECT "+frag).Scan(&got); err != nil { + t.Fatalf("query: %v", err) + } + + want := "0e02b2c3-58cc-4372-a567-f47ac10bd479,a567f47a-58cc-4372-0e02-c10bb2c3d479,f47ac10b-58cc-4372-a567-0e02b2c3d479" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/apps/backend/internal/office/models/models.go b/apps/backend/internal/office/models/models.go index f32a0a63bd..e5e5b95f93 100644 --- a/apps/backend/internal/office/models/models.go +++ b/apps/backend/internal/office/models/models.go @@ -435,10 +435,18 @@ type Run struct { // after claim but a re-derivation against the freshly re-fetched // row would disagree with the derivation the claiming scheduler is // still holding in memory. - ContinuationScope string `json:"continuation_scope,omitempty" db:"continuation_scope"` - RequestedAt time.Time `json:"requested_at" db:"requested_at"` - ClaimedAt *time.Time `json:"claimed_at" db:"claimed_at"` - FinishedAt *time.Time `json:"finished_at" db:"finished_at"` + ContinuationScope string `json:"continuation_scope,omitempty" db:"continuation_scope"` + // WakeWaveKey and WakeWaveString are the completion-wave identity + // (parent-wake-wave-identity): both set together, only for + // task_children_completed runs, from one derivation per queued run. + // WakeWaveKey is the digest idx_run_wake_wave indexes; WakeWaveString + // is the plain string the backstop's candidate query compares. Empty + // for every other run reason and for every pre-upgrade row. + WakeWaveKey string `json:"wake_wave_key,omitempty" db:"wake_wave_key"` + WakeWaveString string `json:"wake_wave_string,omitempty" db:"wake_wave_string"` + RequestedAt time.Time `json:"requested_at" db:"requested_at"` + ClaimedAt *time.Time `json:"claimed_at" db:"claimed_at"` + FinishedAt *time.Time `json:"finished_at" db:"finished_at"` // Outcome records why a finished run ended (docs/specs/ // task-delivery-ledger/spec.md, "Office run outcome"): one of eight diff --git a/apps/backend/internal/office/repository/sqlite/base.go b/apps/backend/internal/office/repository/sqlite/base.go index 6793f313fc..d0088a0a67 100644 --- a/apps/backend/internal/office/repository/sqlite/base.go +++ b/apps/backend/internal/office/repository/sqlite/base.go @@ -31,6 +31,12 @@ func newParticipantUUID() string { return uuid.New().String() } // The alias is the table alias of the tasks row (e.g. "t" or "tasks"); // the resulting SQL fragment evaluates to "" when neither a runner row // nor a step primary exists. +// +// The third fallback orders by workflow_step_participants.created_at, a +// persisted, dialect-portable column, with id as a named tiebreak — the +// same ordering ResolveCurrentRunner uses for its identical tier, so both +// resolvers agree and both database engines return the same runner for the +// same data. func RunnerProjection(alias string) string { if alias == "" { alias = "tasks" @@ -417,6 +423,13 @@ func (r *Repository) createRunTables() error { -- re-deriving it against a context_snapshot a coalesced wakeup -- may have since patched. continuation_scope TEXT NOT NULL DEFAULT '', + -- Completion-wave identity (parent-wake-wave-identity): both + -- columns are set together, only for task_children_completed + -- runs, from one derivation per queued run. wake_wave_key is + -- the digest idx_run_wake_wave indexes; wake_wave_string is the + -- plain string the backstop's candidate query compares. + wake_wave_key TEXT NOT NULL DEFAULT '', + wake_wave_string TEXT NOT NULL DEFAULT '', -- causation_id (office-loop-liveness): copied from the wakeup -- request that created this run. '' means uncorrelated. causation_id TEXT NOT NULL DEFAULT '', diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go index 848ca9e11e..f559e12cb5 100644 --- a/apps/backend/internal/office/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go @@ -60,6 +60,7 @@ func (r *Repository) runMigrations() error { return err } r.migrateParentWakeReceiptColumns() + r.migrateWakeWaveColumns() r.migrate.Apply("task_workspace_groups.ownership_generation", `ALTER TABLE task_workspace_groups ADD COLUMN ownership_generation INTEGER NOT NULL DEFAULT 1`) r.migrateWorkspacePauseSkipAttribution() @@ -270,6 +271,26 @@ func (r *Repository) migrateParentWakeReceiptColumns() { ) } +// migrateWakeWaveColumns adds the completion-wave identity columns for +// databases created before parent-wake-wave-identity. No backfill: existing +// rows keep the empty defaults, stay outside the partial unique index, and +// are judged by the parent-scoped compatibility clause ListStuckParents +// still applies to a parent with no keyed run at all. +// +// Apply's errors are swallowed by design (see db.MigrateLogger.Apply) and +// surface instead through r.migrate.Err(), which runMigrations checks once +// after every migration step — explicitly discarded here rather than left +// unchecked, matching that contract for errcheck. +func (r *Repository) migrateWakeWaveColumns() { + _ = r.migrate.Apply("runs.wake_wave_key", + `ALTER TABLE runs ADD COLUMN wake_wave_key TEXT NOT NULL DEFAULT ''`) + _ = r.migrate.Apply("runs.wake_wave_string", + `ALTER TABLE runs ADD COLUMN wake_wave_string TEXT NOT NULL DEFAULT ''`) + _ = r.migrate.Apply("idx_run_wake_wave", + `CREATE UNIQUE INDEX IF NOT EXISTS idx_run_wake_wave + ON runs(wake_wave_key, agent_profile_id) WHERE wake_wave_key <> ''`) +} + // migrateProviderRouting creates the office_workspace_routing, // office_run_route_attempts, and office_provider_health tables. The // routing columns are also added with replayable ALTER statements so diff --git a/apps/backend/internal/office/repository/sqlite/blockers.go b/apps/backend/internal/office/repository/sqlite/blockers.go index 5a945d0718..9f11eb1528 100644 --- a/apps/backend/internal/office/repository/sqlite/blockers.go +++ b/apps/backend/internal/office/repository/sqlite/blockers.go @@ -293,6 +293,33 @@ func (r *Repository) ListChildStates(ctx context.Context, parentID string) ([]Ch return rows, nil } +// ListWaveMembers returns id+state for the parent's wave members, ordered +// by id: direct children that are not archived, not ephemeral, and not +// automation-origin. This is the predicate +// task/repository/sqlite.ListChildCompletionRows already applies +// (andNotAutomationOrigin) — the one predicate all four +// task_children_completed producers can share (docs/specs/office/ +// system-design/parent-wake-wave-identity.md, "Which children count"). +// Unlike ListChildStates, this excludes ephemeral and automation-origin +// children as well as archived ones, so its result set is a subset of +// ListChildStates' for the same parent. +func (r *Repository) ListWaveMembers(ctx context.Context, parentID string) ([]ChildState, error) { + var rows []ChildState + err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(` + SELECT id, COALESCE(state, '') AS state FROM tasks + WHERE parent_id = ? AND archived_at IS NULL AND is_ephemeral = 0 + AND COALESCE(origin, '') != 'automation_run' + ORDER BY id + `), parentID) + if err != nil { + return nil, err + } + if rows == nil { + rows = []ChildState{} + } + return rows, nil +} + // ChildSummary holds summary data for a completed child task. type ChildSummary struct { TaskID string `db:"id" json:"id"` diff --git a/apps/backend/internal/office/repository/sqlite/blockers_test.go b/apps/backend/internal/office/repository/sqlite/blockers_test.go index f5e1e581af..1412173f27 100644 --- a/apps/backend/internal/office/repository/sqlite/blockers_test.go +++ b/apps/backend/internal/office/repository/sqlite/blockers_test.go @@ -225,6 +225,83 @@ func TestListChildStates(t *testing.T) { } } +func TestListWaveMembers(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + insertTask(t, repo, ctx, "parent-1", "ws-1", "Parent Task", "", "") + insertTask(t, repo, ctx, "child-ordinary", "ws-1", "Ordinary", "", "") + insertTask(t, repo, ctx, "child-archived", "ws-1", "Archived", "", "") + insertTask(t, repo, ctx, "child-ephemeral", "ws-1", "Ephemeral", "", "") + insertTask(t, repo, ctx, "child-automation", "ws-1", "Automation", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET parent_id = 'parent-1', state = 'COMPLETED' + WHERE id IN ('child-ordinary', 'child-archived', 'child-ephemeral', 'child-automation') + `); err != nil { + t.Fatalf("set parent: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET archived_at = datetime('now') WHERE id = 'child-archived' + `); err != nil { + t.Fatalf("archive child: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET is_ephemeral = 1 WHERE id = 'child-ephemeral' + `); err != nil { + t.Fatalf("mark ephemeral: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET origin = 'automation_run' WHERE id = 'child-automation' + `); err != nil { + t.Fatalf("mark automation origin: %v", err) + } + + members, err := repo.ListWaveMembers(ctx, "parent-1") + if err != nil { + t.Fatalf("ListWaveMembers: %v", err) + } + if len(members) != 1 || members[0].TaskID != "child-ordinary" { + t.Fatalf("ListWaveMembers = %+v, want only child-ordinary", members) + } + if members[0].State != "COMPLETED" { + t.Errorf("state = %q, want COMPLETED", members[0].State) + } + + empty, err := repo.ListWaveMembers(ctx, "missing-parent") + if err != nil { + t.Fatalf("ListWaveMembers empty: %v", err) + } + if empty == nil { + t.Fatal("empty wave-member result is nil") + } + if len(empty) != 0 { + t.Fatalf("empty wave-member count = %d, want 0", len(empty)) + } +} + +func TestListWaveMembers_OrdersAscendingByID(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + insertTask(t, repo, ctx, "parent-1", "ws-1", "Parent Task", "", "") + insertTask(t, repo, ctx, "child-z", "ws-1", "Child Z", "", "") + insertTask(t, repo, ctx, "child-a", "ws-1", "Child A", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET parent_id = 'parent-1', state = 'COMPLETED' + WHERE id IN ('child-z', 'child-a') + `); err != nil { + t.Fatalf("set parent: %v", err) + } + + members, err := repo.ListWaveMembers(ctx, "parent-1") + if err != nil { + t.Fatalf("ListWaveMembers: %v", err) + } + if len(members) != 2 || members[0].TaskID != "child-a" || members[1].TaskID != "child-z" { + t.Fatalf("ListWaveMembers = %+v, want [child-a child-z]", members) + } +} + func TestListBlockersForTasks(t *testing.T) { repo := newTestRepo(t) ctx := context.Background() diff --git a/apps/backend/internal/office/repository/sqlite/participants.go b/apps/backend/internal/office/repository/sqlite/participants.go index 8fec5c0876..ede97c5e46 100644 --- a/apps/backend/internal/office/repository/sqlite/participants.go +++ b/apps/backend/internal/office/repository/sqlite/participants.go @@ -57,6 +57,28 @@ func (r *Repository) GetTaskWorkflowStepID(ctx context.Context, taskID string) ( return r.stepIDForTask(ctx, taskID) } +// GetTaskWorkflowID returns the task's current workflow_id. Returns "" with +// no error when the task has no workflow bound. Exposed so the cascade +// producer can workflow-scope its fan-out seat resolution the same way the +// engine's own ParticipantAdapter does for a same-task queue_run_for_each_ +// participant action (parent-wake-wave-identity payload parity). +func (r *Repository) GetTaskWorkflowID(ctx context.Context, taskID string) (string, error) { + var workflowID sql.NullString + err := r.ro.QueryRowxContext(ctx, r.ro.Rebind( + `SELECT workflow_id FROM tasks WHERE id = ?`, + ), taskID).Scan(&workflowID) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + if !workflowID.Valid { + return "", nil + } + return workflowID.String, nil +} + // GetWorkflowStepStageType returns the persisted stage type for a workflow // step. It returns an empty string when the step does not exist so callers // can apply compatibility fallbacks for older run payloads. diff --git a/apps/backend/internal/office/repository/sqlite/wake_receipts.go b/apps/backend/internal/office/repository/sqlite/wake_receipts.go index 75fb3bb6e5..f14a9a2e22 100644 --- a/apps/backend/internal/office/repository/sqlite/wake_receipts.go +++ b/apps/backend/internal/office/repository/sqlite/wake_receipts.go @@ -100,19 +100,32 @@ type StuckParentCandidate struct { // - the NOT EXISTS against runs drops a candidate with a queued or // claimed task_children_completed run (still in flight, regardless of // which child set it was requested for — wait for it to resolve rather -// than race a duplicate) or a terminal one requested at or after -// newest_child_updated_at (it already reflects the current child set, -// including a failed or cancelled wake that must remain terminal under -// the Office runtime contract). A terminal run requested *before* the -// newest child update does NOT block: that run saw a stale child set, so -// it must not be treated as having delivered this one — this is R3-A's -// fix, replacing a plain "any terminal run ever" check that let one -// finished run permanently immunize a parent against every later -// child-set change. +// than race a duplicate) or a terminal one whose wake_wave_string +// matches the parent's current wave string (parent-wake-wave-identity): +// it already delivered this exact wave, including a failed or +// cancelled one that must remain terminal under the Office runtime +// contract and be unblocked only by an explicit retry or a real +// wave-member change. A separate clause keeps a parent-scoped (not +// per-row) compatibility path for pre-upgrade rows: only when the +// parent has no task_children_completed run carrying a wave identity +// at all (wake_wave_key is empty), in any status, does a terminal run +// requested at or after newest_child_updated_at still block under the +// original timestamp rule — this is R3-A's fix, replacing a plain "any +// terminal run ever" check that let one finished run permanently +// immunize a parent against every later child-set change. The +// compatibility path is bounded and self-clearing: the first wave-keyed +// run recorded for a parent retires it for that parent from then on. // queueChildrenCompletedRun and cascadeChildrenCompleted (the // edge-triggered delivery paths) never write a receipt, so the receipt // alone cannot tell a healthy edge-delivered wake from a lost one; // evidence of delivery has to come from runs itself. +// - a second EXISTS, over the wave-member predicate (not archived, not +// ephemeral, not automation-origin — the same predicate +// ListWaveMembers applies), removes a parent with no possible wave from +// candidacy: without it such a parent would be listed every tick, +// found to have no wave, queue nothing, and be listed again. Additive +// and narrowing only — it sits +// beside the existing archived-only EXISTS, never replacing it. // - requiring a non-empty assignee_agent_profile_id drops a candidate // with no resolvable runner, and the INNER JOIN against agent_profiles // drops one whose runner is paused, stopped, pending approval, or @@ -133,6 +146,11 @@ type StuckParentCandidate struct { // redundant closing of that race window, not the primary filter). func (r *Repository) ListStuckParents(ctx context.Context, reason string, limit int) ([]StuckParentCandidate, error) { driver := r.ro.DriverName() + // Shared with the wave-member EXISTS gate below, which spells it + // literally with a "c." alias (matching the system design's worked + // SQL) — this unaliased form is what OrderedIDConcat's own + // "SELECT id FROM tasks WHERE " subquery needs. + waveMemberPredicate := "parent_id = p.id AND archived_at IS NULL AND is_ephemeral = 0 AND COALESCE(origin, '') != 'automation_run'" newestChildUpdatedAtText := dialect.SecondPrecisionText(driver, "MAX(c.updated_at)") requestedAtText := dialect.SecondPrecisionText(driver, "w.requested_at") var rows []StuckParentCandidate @@ -150,6 +168,7 @@ func (r *Repository) ListStuckParents(ctx context.Context, reason string, limit ORDER BY id ) c ), '') AS child_set_key, + p.id || '|' || COALESCE(`+dialect.OrderedIDConcat(driver, waveMemberPredicate)+`, '') AS wave_string, ( SELECT `+newestChildUpdatedAtText+` FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL @@ -163,6 +182,13 @@ func (r *Repository) ListStuckParents(ctx context.Context, reason string, limit SELECT 1 FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL ) + AND EXISTS ( + SELECT 1 FROM tasks c + WHERE c.parent_id = p.id + AND c.archived_at IS NULL + AND c.is_ephemeral = 0 + AND COALESCE(c.origin, '') != 'automation_run' + ) AND NOT EXISTS ( SELECT 1 FROM tasks c WHERE c.parent_id = p.id @@ -196,13 +222,28 @@ func (r *Repository) ListStuckParents(ctx context.Context, reason string, limit w.status IN ('queued', 'claimed') OR ( w.status IN ('finished', 'failed', 'cancelled') - AND `+requestedAtText+` >= s.newest_child_updated_at + AND w.wake_wave_string = s.wave_string ) ) ) + AND ( + EXISTS ( + SELECT 1 FROM runs w + WHERE `+dialect.JSONExtract(driver, "w.payload", "task_id")+` = s.parent_task_id + AND w.reason = ? + AND w.wake_wave_key <> '' + ) + OR NOT EXISTS ( + SELECT 1 FROM runs w + WHERE `+dialect.JSONExtract(driver, "w.payload", "task_id")+` = s.parent_task_id + AND w.reason = ? + AND w.status IN ('finished', 'failed', 'cancelled') + AND `+requestedAtText+` >= s.newest_child_updated_at + ) + ) ORDER BY s.parent_task_id LIMIT ? - `), reason, limit) + `), reason, reason, reason, limit) if err != nil { return nil, err } @@ -284,8 +325,9 @@ type childSetKeyAndGenerationRow struct { } // GetChildSetKey returns the deterministic key for the parent's current -// active child set. It reads child ids and states separately from the -// aggregate query so the same logic works on SQLite and PostgreSQL. +// active child set, formatted by formatChildSetKey — the same "id:state" +// comma-joined form ListStuckParents computes in SQL via +// childSetKeyAggregate, so the two must stay byte-identical. func (r *Repository) GetChildSetKey(ctx context.Context, parentTaskID string) (string, error) { var rows []childSetKeyRow if err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(` diff --git a/apps/backend/internal/office/repository/sqlite/wake_receipts_postgres_test.go b/apps/backend/internal/office/repository/sqlite/wake_receipts_postgres_test.go index a05838b045..d0fba1491d 100644 --- a/apps/backend/internal/office/repository/sqlite/wake_receipts_postgres_test.go +++ b/apps/backend/internal/office/repository/sqlite/wake_receipts_postgres_test.go @@ -548,3 +548,83 @@ func execPostgres( t.Fatalf("exec %q: %v", query, err) } } + +// TestPostgresListStuckParents_ThirdTierOrdersByCreatedAtNotID is the +// PostgreSQL twin of TestListStuckParents_ThirdTierOrdersByCreatedAtNotID: +// RunnerProjection's third fallback tier — the only line base.go +// behaviourally changed — must pick the same agent on both engines by +// ordering on workflow_step_participants.created_at, not a physical or +// textual row identifier. created_at and id are set in opposite order so +// a pick driven by id (the prior Postgres ordering) returns the wrong +// agent. +func TestPostgresListStuckParents_ThirdTierOrdersByCreatedAtNotID(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + ctx := context.Background() + + if _, _, err := settingsstore.Provide(db, db, nil); err != nil { + t.Fatalf("init settings store: %v", err) + } + if _, err := taskrepo.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init task repo: %v", err) + } + if _, err := workflowrepo.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init workflow repo: %v", err) + } + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init office repo: %v", err) + } + + now := time.Now().UTC() + const parentID = "pg-tier3-parent" + const recentAgentID, oldAgentID = "pg-tier3-agent-recent", "pg-tier3-agent-old" + childID := parentID + "-child-0" + + if _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, project_id, workflow_step_id, created_at, updated_at) + VALUES (?, 'ws-1', 'Parent', 'office-project', 'step-parent', ?, ?) + `), parentID, now, now); err != nil { + t.Fatalf("seed parent: %v", err) + } + if _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, parent_id, state, created_at, updated_at) + VALUES (?, 'ws-1', 'Child', ?, 'COMPLETED', ?, ?) + `), childID, parentID, now, now); err != nil { + t.Fatalf("seed child: %v", err) + } + for _, agentID := range []string{recentAgentID, oldAgentID} { + if _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO agents (id, name, created_at, updated_at) VALUES (?, ?, ?, ?) + `), agentID, agentID, now, now); err != nil { + t.Fatalf("seed agent %s: %v", agentID, err) + } + if _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, workspace_id, role, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'ws-1', '', 'idle', ?, ?) + `), agentID, agentID, agentID, agentID, now, now); err != nil { + t.Fatalf("seed agent profile %s: %v", agentID, err) + } + } + // Neither participant is at the parent's current step ('step-parent'), + // so tier 1 falls through for both; tier 3 must pick by created_at, and + // id is set in the opposite order to prove it isn't the deciding column. + if _, err := db.ExecContext(ctx, db.Rebind(` + INSERT INTO workflow_step_participants (id, step_id, task_id, role, agent_profile_id, created_at) + VALUES + ('aa-pg-tier3-1', 'other-step-1', ?, 'runner', ?, ?), + ('zz-pg-tier3-2', 'other-step-2', ?, 'runner', ?, ?) + `), parentID, recentAgentID, now, parentID, oldAgentID, now.Add(-24*time.Hour)); err != nil { + t.Fatalf("seed tier-3 runners: %v", err) + } + + candidates, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents: %v", err) + } + if len(candidates) != 1 || candidates[0].ParentTaskID != parentID { + t.Fatalf("candidates = %#v, want exactly [%s]", candidates, parentID) + } + if candidates[0].AssigneeAgentProfileID != recentAgentID { + t.Fatalf("assignee = %q, want %q (latest created_at, not largest id)", candidates[0].AssigneeAgentProfileID, recentAgentID) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/wake_receipts_test.go b/apps/backend/internal/office/repository/sqlite/wake_receipts_test.go index c6a178b953..5149a8fc42 100644 --- a/apps/backend/internal/office/repository/sqlite/wake_receipts_test.go +++ b/apps/backend/internal/office/repository/sqlite/wake_receipts_test.go @@ -99,6 +99,23 @@ func seedWakeRunAt(t *testing.T, repo *sqlite.Repository, ctx context.Context, r } } +// seedWakeRunWithWave is seedWakeRunAt plus the two wave-identity columns, +// so tests can simulate a run recorded by a wave-identity-aware producer +// (Tasks 03-05) rather than a pre-upgrade run (wake_wave_key/wake_wave_string +// left empty). +func seedWakeRunWithWave( + t *testing.T, repo *sqlite.Repository, ctx context.Context, + runID, parentID, reason, status, requestedAtExpr, waveKey, waveString string, +) { + t.Helper() + if _, err := repo.ExecRaw(ctx, fmt.Sprintf(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at, wake_wave_key, wake_wave_string) + VALUES (?, 'agent-x', ?, ?, ?, %s, ?, ?) + `, requestedAtExpr), runID, reason, fmt.Sprintf(`{"task_id":%q}`, parentID), status, waveKey, waveString); err != nil { + t.Fatalf("seed wave run: %v", err) + } +} + // insertTaskAt is insertTask with an explicit created_at/updated_at // timestamp, so tests can control a child's ordering relative to a run's // requested_at without relying on wall-clock delay between statements. @@ -696,3 +713,52 @@ func TestListStuckParents_OrdersDeterministically(t *testing.T) { } } } + +// TestListStuckParents_ThirdTierOrdersByCreatedAtNotID is RunnerProjection's +// third-tier regression test: when no participant matches the parent's +// current step and the step has no primary agent, the pick among the +// task's other runner rows must follow created_at, not the row identifier. +// created_at and id are set in opposite order here, so a pick driven by id +// (or by insertion order) returns the wrong agent. +func TestListStuckParents_ThirdTierOrdersByCreatedAtNotID(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + const parentID = "parent-tier3" + insertTask(t, repo, ctx, parentID, "ws-1", "Parent", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET project_id = 'office-project', workflow_step_id = 'step-parent' WHERE id = ? + `, parentID); err != nil { + t.Fatalf("mark parent: %v", err) + } + childID := parentID + "-child-0" + insertTask(t, repo, ctx, childID, "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET parent_id = ?, state = 'COMPLETED' WHERE id = ?`, parentID, childID, + ); err != nil { + t.Fatalf("set child state: %v", err) + } + + seedWakeAgentProfile(t, repo, ctx, "agent-recent", "idle") + seedWakeAgentProfile(t, repo, ctx, "agent-old", "idle") + + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO workflow_step_participants (id, step_id, task_id, role, agent_profile_id, created_at) + VALUES + ('aa-tier3-1', 'other-step-1', ?, 'runner', 'agent-recent', '2024-06-01 00:00:00'), + ('zz-tier3-2', 'other-step-2', ?, 'runner', 'agent-old', '2024-01-01 00:00:00') + `, parentID, parentID); err != nil { + t.Fatalf("seed tier-3 runners: %v", err) + } + + candidates, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents: %v", err) + } + if len(candidates) != 1 || candidates[0].ParentTaskID != parentID { + t.Fatalf("candidates = %#v, want exactly [%s]", candidates, parentID) + } + if candidates[0].AssigneeAgentProfileID != "agent-recent" { + t.Fatalf("assignee = %q, want %q (latest created_at, not largest id)", candidates[0].AssigneeAgentProfileID, "agent-recent") + } +} diff --git a/apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_postgres_test.go b/apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_postgres_test.go new file mode 100644 index 0000000000..1780e4a5d3 --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_postgres_test.go @@ -0,0 +1,152 @@ +package sqlite_test + +import ( + "context" + "testing" + "time" + + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/office/repository/sqlite" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/testutil" + workflowrepo "github.com/kandev/kandev/internal/workflow/repository" +) + +// TestPostgresListStuckParents_KeyedRunRetiresTimestampFallbackForParent is +// the PostgreSQL twin of TestListStuckParents_KeyedRunRetiresTimestampFallbackForParent. +// ListStuckParents' pre-upgrade compatibility clause (the +// "EXISTS wave-keyed run OR NOT EXISTS blocking pre-upgrade run" gate added +// alongside the wake_wave_string comparison) used two literal +// json_extract(w.payload, '$.task_id') expressions, which are a syntax +// error on Postgres (payload->>'task_id' is the Postgres form) — the same +// trap TestPostgresHasPriorTasklessFailedRun and TestPostgresHasInFlightRunForTask +// were written for. Running ListStuckParents itself against a real Postgres +// backend makes a dialect regression fail loudly instead of only in +// production, where ListStuckParents errors on every tick and the +// reconciler backstop silently stops finding any candidate at all. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestPostgresListStuckParents_KeyedRunRetiresTimestampFallbackForParent(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + ctx := context.Background() + + if _, _, err := settingsstore.Provide(db, db, nil); err != nil { + t.Fatalf("init settings store: %v", err) + } + if _, err := taskrepo.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init task repo: %v", err) + } + if _, err := workflowrepo.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init workflow repo: %v", err) + } + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init office repo: %v", err) + } + + const parentID = "pg-wave-parent-1" + const agentID = parentID + "-agent" + oldTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + newTime := oldTime.Add(10 * time.Minute) + + seedPostgresWaveTask(t, ctx, repo, parentID, "", oldTime) + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET project_id = 'office-project' WHERE id = ?`, parentID, + ); err != nil { + t.Fatalf("mark parent as Office task: %v", err) + } + + childID := parentID + "-child-0" + seedPostgresWaveTask(t, ctx, repo, childID, parentID, newTime) + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET state = 'COMPLETED' WHERE id = ?`, childID, + ); err != nil { + t.Fatalf("complete child: %v", err) + } + + // agent_profiles.agent_id carries an FK to agents(id) that Postgres + // enforces (unlike SQLite, which leaves it unenforced by default) — an + // empty agent_id needs a matching agents row, not NULL/omission. + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agents (id, name, created_at, updated_at) VALUES ('', '', ?, ?) + `, oldTime, oldTime); err != nil { + t.Fatalf("seed empty-id agents row: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, status, created_at, updated_at) + VALUES (?, '', ?, ?, 'idle', ?, ?) + `, agentID, agentID, agentID, oldTime, oldTime); err != nil { + t.Fatalf("seed agent profile: %v", err) + } + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO workflow_step_participants (id, step_id, task_id, role, agent_profile_id) + VALUES (?, '', ?, 'runner', ?) + `, "p-runner-"+parentID, parentID, agentID); err != nil { + t.Fatalf("seed runner: %v", err) + } + + // A pre-upgrade terminal run (no wave identity at all) requested after + // the child completed: under the timestamp-only compatibility rule + // alone this must still block the parent. + seedPostgresWaveRun(t, ctx, repo, "pg-run-pre-upgrade", agentID, "task_children_completed", + "finished", newTime.Add(time.Minute), parentID, "", "") + + before, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents (pre-upgrade run blocking): %v", err) + } + if len(before) != 0 { + t.Fatalf("before = %#v, want no candidates — the pre-upgrade terminal run must still block", before) + } + + // A wave-keyed run also exists for this parent, for an unrelated + // (already-stale) wave — it must not itself block via the wave-string + // comparison, only retire the timestamp fallback for this parent. + seedPostgresWaveRun(t, ctx, repo, "pg-run-keyed", agentID, "task_children_completed", + "finished", oldTime.Add(time.Minute), parentID, "stale-wave-key", parentID+"|some-other-child") + + after, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents (wave-keyed run retires fallback): %v", err) + } + if len(after) != 1 || after[0].ParentTaskID != parentID { + t.Fatalf("after = %#v, want exactly [%s] — a wave-keyed run must retire the timestamp "+ + "fallback for this parent even though a recent pre-upgrade terminal run exists", after, parentID) + } +} + +// seedPostgresWaveTask inserts a minimal tasks row against a real Postgres +// connection, with created_at/updated_at set explicitly (Postgres has no +// datetime('now', ...) SQLite function, so tests needing controlled +// ordering must bind an actual time.Time). +func seedPostgresWaveTask( + t *testing.T, ctx context.Context, repo *sqlite.Repository, + id, parentID string, updatedAt time.Time, +) { + t.Helper() + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO tasks (id, workspace_id, title, parent_id, created_at, updated_at) + VALUES (?, 'ws-1', 'Task', ?, ?, ?) + `, id, parentID, updatedAt, updatedAt); err != nil { + t.Fatalf("seed task %s: %v", id, err) + } +} + +// seedPostgresWaveRun inserts a runs row carrying the wave-identity +// columns (or, when waveKey/waveString are empty, a pre-upgrade run +// carrying neither), matching seedWakeRunWithWave's SQLite shape but bound +// against a real Postgres connection with parameterized timestamps. +func seedPostgresWaveRun( + t *testing.T, ctx context.Context, repo *sqlite.Repository, + runID, agentID, reason, status string, requestedAt time.Time, + taskID, waveKey, waveString string, +) { + t.Helper() + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO runs ( + id, agent_profile_id, reason, payload, status, error_message, + requested_at, wake_wave_key, wake_wave_string + ) VALUES (?, ?, ?, ?, ?, '', ?, ?, ?) + `, runID, agentID, reason, `{"task_id":"`+taskID+`"}`, status, requestedAt, waveKey, waveString); err != nil { + t.Fatalf("seed run %s: %v", runID, err) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_test.go b/apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_test.go new file mode 100644 index 0000000000..8a108342ca --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/wake_receipts_wave_identity_test.go @@ -0,0 +1,281 @@ +package sqlite_test + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/office/waveidentity" +) + +// TestListStuckParents_WaveMatchBlocksAcrossNonMemberChildEdit_UnblockedOnWaveMemberChange +// is Task 06's core wave-comparison regression test +// (AC-OFFICE-WAKE-WAVE-IDENTITY-003): a delivered wave keeps blocking across +// a child-set change that doesn't touch wave membership (an automation-origin +// child completing, or a non-state edit to an existing wave member — symptom +// B, AC-002.15/AC-003.2), and only a real wave-member change (a genuine, +// non-automation, non-archived, non-ephemeral child) makes the parent a +// candidate again. +func TestListStuckParents_WaveMatchBlocksAcrossNonMemberChildEdit_UnblockedOnWaveMemberChange(t *testing.T) { + for _, status := range []string{"finished", "failed", "cancelled"} { + t.Run(status, func(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + const parentID = "parent-1" + insertTask(t, repo, ctx, parentID, "ws-1", "Parent", "", "") + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET project_id = 'office-project' WHERE id = ?`, parentID, + ); err != nil { + t.Fatalf("mark parent as Office task: %v", err) + } + child0 := parentID + "-child-0" + insertTask(t, repo, ctx, child0, "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET parent_id = ?, state = 'COMPLETED' WHERE id = ?`, parentID, child0, + ); err != nil { + t.Fatalf("set child0 state: %v", err) + } + seedWakeAgentProfile(t, repo, ctx, parentID+"-agent", "idle") + seedRunner(t, repo, ctx, parentID) + + waveString := waveidentity.WaveString(parentID, []string{child0}) + waveKey := waveidentity.WaveKey(parentID, []string{child0}) + seedWakeRunWithWave(t, repo, ctx, "run-1", parentID, "task_children_completed", + status, "datetime('now', '+1 second')", waveKey, waveString) + + before, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents (delivered, unchanged wave): %v", err) + } + if len(before) != 0 { + t.Fatalf("status %q: delivered unchanged wave must not be a candidate: %#v", status, before) + } + + // A non-wave-member child completes: the child set changes, but + // wave membership does not. + automationChild := parentID + "-child-automation" + insertTask(t, repo, ctx, automationChild, "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET parent_id = ?, state = 'COMPLETED', origin = 'automation_run' WHERE id = ?`, + parentID, automationChild, + ); err != nil { + t.Fatalf("set automation child state: %v", err) + } + + afterNonMemberEdit, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents (after non-member child edit): %v", err) + } + if len(afterNonMemberEdit) != 0 { + t.Fatalf("status %q: a non-wave-member child edit must not unblock a matched wave: %#v", + status, afterNonMemberEdit) + } + + // The wave MEMBER itself is edited (a title/priority/label change + // in production, simulated here as a bare updated_at bump) without + // touching its state or id: this is symptom B's literal scenario + // (AC-002.15/AC-003.2). Wave membership is unchanged, so the wave + // string is unchanged, and the parent must stay blocked. + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET updated_at = datetime('now', '+2 seconds') WHERE id = ?`, child0, + ); err != nil { + t.Fatalf("edit wave-member child0's updated_at: %v", err) + } + + afterWaveMemberEdit, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents (after wave-member non-state edit): %v", err) + } + if len(afterWaveMemberEdit) != 0 { + t.Fatalf("status %q: editing a wave member's non-state field must not unblock a matched wave: %#v", + status, afterWaveMemberEdit) + } + + // A real wave member joins: wave membership itself changes. + child1 := parentID + "-child-1" + insertTask(t, repo, ctx, child1, "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET parent_id = ?, state = 'COMPLETED' WHERE id = ?`, parentID, child1, + ); err != nil { + t.Fatalf("set child1 state: %v", err) + } + + afterWaveChange, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents (after wave-member change): %v", err) + } + if len(afterWaveChange) != 1 || afterWaveChange[0].ParentTaskID != parentID { + t.Fatalf("status %q: a real wave-member change must unblock the parent: %#v", + status, afterWaveChange) + } + }) + } +} + +// TestListStuckParents_ExcludesParentWithOnlyNonWaveMemberChildren is +// AC-OFFICE-WAKE-WAVE-IDENTITY-003.9's regression test: a parent whose +// only children are archived, ephemeral, or automation-origin has no +// possible wave and must never be listed — the wave-member EXISTS gate is +// additive to (never a replacement for) the existing archived-only guard. +func TestListStuckParents_ExcludesParentWithOnlyNonWaveMemberChildren(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + // Ephemeral-only: one non-archived, non-automation child, but ephemeral. + insertTask(t, repo, ctx, "parent-ephemeral-only", "ws-1", "Parent", "", "") + insertTask(t, repo, ctx, "parent-ephemeral-only-child-0", "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET parent_id = 'parent-ephemeral-only', state = 'COMPLETED', is_ephemeral = 1 + WHERE id = 'parent-ephemeral-only-child-0' + `); err != nil { + t.Fatalf("mark ephemeral child: %v", err) + } + + // Automation-only: one non-archived, non-ephemeral child, but + // automation-origin. + insertTask(t, repo, ctx, "parent-automation-only", "ws-1", "Parent", "", "") + insertTask(t, repo, ctx, "parent-automation-only-child-0", "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET parent_id = 'parent-automation-only', state = 'COMPLETED', origin = 'automation_run' + WHERE id = 'parent-automation-only-child-0' + `); err != nil { + t.Fatalf("mark automation-origin child: %v", err) + } + + // Mixed non-member-only: one archived child, one ephemeral child, one + // automation-origin child — still no wave member among them. + insertTask(t, repo, ctx, "parent-mixed-non-member", "ws-1", "Parent", "", "") + insertTask(t, repo, ctx, "parent-mixed-non-member-child-archived", "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET parent_id = 'parent-mixed-non-member', state = 'COMPLETED', archived_at = datetime('now') + WHERE id = 'parent-mixed-non-member-child-archived' + `); err != nil { + t.Fatalf("archive mixed child: %v", err) + } + insertTask(t, repo, ctx, "parent-mixed-non-member-child-ephemeral", "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET parent_id = 'parent-mixed-non-member', state = 'COMPLETED', is_ephemeral = 1 + WHERE id = 'parent-mixed-non-member-child-ephemeral' + `); err != nil { + t.Fatalf("mark mixed ephemeral child: %v", err) + } + insertTask(t, repo, ctx, "parent-mixed-non-member-child-automation", "ws-1", "Child", "", "") + if _, err := repo.ExecRaw(ctx, ` + UPDATE tasks SET parent_id = 'parent-mixed-non-member', state = 'COMPLETED', origin = 'automation_run' + WHERE id = 'parent-mixed-non-member-child-automation' + `); err != nil { + t.Fatalf("mark mixed automation child: %v", err) + } + + for _, id := range []string{"parent-ephemeral-only", "parent-automation-only", "parent-mixed-non-member"} { + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET project_id = 'office-project' WHERE id = ?`, id, + ); err != nil { + t.Fatalf("mark %s as Office task: %v", id, err) + } + seedWakeAgentProfile(t, repo, ctx, id+"-agent", "idle") + seedRunner(t, repo, ctx, id) + } + + // Control: a genuine Office candidate with a real wave member. + seedWakeCandidate(t, repo, ctx, "ws-1", "parent-normal") + + candidates, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents: %v", err) + } + if len(candidates) != 1 || candidates[0].ParentTaskID != "parent-normal" { + t.Fatalf("candidates = %#v, want exactly [parent-normal] (no other parent has a wave member)", candidates) + } +} + +// TestListStuckParents_QueuedRunWithStaleWaveStillBlocksCandidacy is +// AC-OFFICE-WAKE-WAVE-IDENTITY-003.4's regression test: a queued or claimed +// task_children_completed run blocks its parent from candidacy regardless +// of which wave that run was queued for — unlike the finished/failed/ +// cancelled arm, the in-flight arm is not compared against the parent's +// current wave string at all, because a delivery already in flight must not +// be raced by a second one for what SQL currently reads as the parent's +// wave, however that wave has since moved. +func TestListStuckParents_QueuedRunWithStaleWaveStillBlocksCandidacy(t *testing.T) { + for _, status := range []string{"queued", "claimed"} { + t.Run(status, func(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + const parentID = "parent-1" + seedWakeCandidate(t, repo, ctx, "ws-1", parentID) + + // The in-flight run carries a wave identity for a completely + // different (stale) wave, not the parent's current one. + staleWaveString := waveidentity.WaveString(parentID, []string{"some-other-child"}) + staleWaveKey := waveidentity.WaveKey(parentID, []string{"some-other-child"}) + seedWakeRunWithWave(t, repo, ctx, "run-1", parentID, "task_children_completed", + status, "datetime('now')", staleWaveKey, staleWaveString) + + candidates, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents: %v", err) + } + if len(candidates) != 0 { + t.Fatalf("status %q: a queued/claimed run must block candidacy regardless of its wave: %#v", + status, candidates) + } + }) + } +} + +// TestListStuckParents_KeyedRunRetiresTimestampFallbackForParent is +// AC-OFFICE-WAKE-WAVE-IDENTITY-003.6's regression test: the pre-upgrade +// timestamp-comparison compatibility path is evaluated once per parent, not +// once per row. Once any wave-keyed run exists for a parent (in any +// status, for any wave), the timestamp rule is retired for that parent +// forever — even an older pre-upgrade row on the same parent, whose +// requested_at is recent enough it would otherwise still be blocking under +// the timestamp rule, no longer suppresses candidacy. +func TestListStuckParents_KeyedRunRetiresTimestampFallbackForParent(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + const ( + parentID = "parent-1" + wsID = "ws-1" + oldTime = "2026-01-01 00:00:00" + newTime = "2026-01-01 00:10:00" + ) + + insertTaskAt(t, repo, ctx, parentID, wsID, oldTime) + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET project_id = 'office-project' WHERE id = ?`, parentID, + ); err != nil { + t.Fatalf("mark parent as Office task: %v", err) + } + child0 := parentID + "-child-0" + insertTaskAt(t, repo, ctx, child0, wsID, newTime) + setChildStateAt(t, repo, ctx, parentID, child0, "COMPLETED", newTime) + seedWakeAgentProfile(t, repo, ctx, parentID+"-agent", "idle") + seedRunner(t, repo, ctx, parentID) + + // A pre-upgrade terminal run (no wave identity at all) requested after + // the child completed: under the timestamp-only rule this alone would + // still block the parent. + seedWakeRunAt(t, repo, ctx, "run-pre-upgrade", parentID, "task_children_completed", + "finished", "'2026-01-01 00:20:00'") + + // A wave-keyed run also exists for this parent, for an unrelated + // (already-stale) wave — it must not itself block via the wave-string + // comparison, only retire the timestamp fallback. + staleWaveString := waveidentity.WaveString(parentID, []string{"some-other-child"}) + staleWaveKey := waveidentity.WaveKey(parentID, []string{"some-other-child"}) + seedWakeRunWithWave(t, repo, ctx, "run-keyed", parentID, "task_children_completed", + "finished", "'2026-01-01 00:01:00'", staleWaveKey, staleWaveString) + + candidates, err := repo.ListStuckParents(ctx, "task_children_completed", 5) + if err != nil { + t.Fatalf("ListStuckParents: %v", err) + } + if len(candidates) != 1 || candidates[0].ParentTaskID != parentID { + t.Fatalf("candidates = %#v, want exactly [%s]: a keyed run must retire the timestamp "+ + "fallback for this parent even though a recent pre-upgrade terminal run exists", candidates, parentID) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/wake_wave_migration_test.go b/apps/backend/internal/office/repository/sqlite/wake_wave_migration_test.go new file mode 100644 index 0000000000..79c8a985ca --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/wake_wave_migration_test.go @@ -0,0 +1,130 @@ +package sqlite_test + +import ( + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +func TestFreshSchema_RunsHasWakeWaveColumnsAndIndex(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("create fresh schema: %v", err) + } + + assertWakeWaveDefaults(t, db, "fresh-run") + assertWakeWaveUniqueIndex(t, db) +} + +func TestWakeWaveColumnsMigration_ConvergesLegacySchemaAndReplays(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("create current schema: %v", err) + } + if _, err := db.Exec(`DROP INDEX IF EXISTS idx_run_wake_wave`); err != nil { + t.Fatalf("drop wake wave index from legacy schema: %v", err) + } + if _, err := db.Exec(`ALTER TABLE runs DROP COLUMN wake_wave_key`); err != nil { + t.Fatalf("drop wake_wave_key from legacy schema: %v", err) + } + if _, err := db.Exec(`ALTER TABLE runs DROP COLUMN wake_wave_string`); err != nil { + t.Fatalf("drop wake_wave_string from legacy schema: %v", err) + } + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at) + VALUES ('legacy-run', 'agent-1', 'task_assigned', '{}', 'queued', CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("seed legacy run: %v", err) + } + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("migrate legacy schema: %v", err) + } + assertWakeWaveDefaults(t, db, "legacy-run") + assertWakeWaveUniqueIndex(t, db) + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("replay migration: %v", err) + } + assertWakeWaveDefaults(t, db, "legacy-run") + // The index created by the first migration run must still be in force + // after replaying migrateWakeWaveColumns a second time (CREATE UNIQUE + // INDEX IF NOT EXISTS is idempotent) — re-inserting the same + // (wake_wave_key, agent_profile_id) pair assertWakeWaveUniqueIndex + // already proved unique must still be rejected. + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at, wake_wave_key, wake_wave_string) + VALUES ('wave-a-replay', 'agent-x', 'task_children_completed', '{}', 'queued', CURRENT_TIMESTAMP, 'wave-key-1', 'p|1') + `); err == nil { + t.Fatal("expected unique-index violation to survive migration replay, got none") + } +} + +func assertWakeWaveDefaults(t *testing.T, db *sqlx.DB, runID string) { + t.Helper() + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at) + VALUES (?, 'agent-defaults', 'task_assigned', '{}', 'queued', CURRENT_TIMESTAMP) + ON CONFLICT(id) DO NOTHING + `, runID); err != nil { + t.Fatalf("ensure run %q exists: %v", runID, err) + } + var key, str string + if err := db.QueryRow( + `SELECT wake_wave_key, wake_wave_string FROM runs WHERE id = ?`, runID, + ).Scan(&key, &str); err != nil { + t.Fatalf("read wake wave columns for %q: %v", runID, err) + } + if key != "" || str != "" { + t.Errorf("run %q wake wave columns = (%q, %q), want empty defaults", runID, key, str) + } +} + +func assertWakeWaveUniqueIndex(t *testing.T, db *sqlx.DB) { + t.Helper() + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at, wake_wave_key, wake_wave_string) + VALUES ('wave-a', 'agent-x', 'task_children_completed', '{}', 'queued', CURRENT_TIMESTAMP, 'wave-key-1', 'p|1') + `); err != nil { + t.Fatalf("insert first wave run: %v", err) + } + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at, wake_wave_key, wake_wave_string) + VALUES ('wave-b', 'agent-x', 'task_children_completed', '{}', 'queued', CURRENT_TIMESTAMP, 'wave-key-1', 'p|1') + `); err == nil { + t.Fatal("expected unique-index violation for duplicate (wake_wave_key, agent_profile_id), got none") + } + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at, wake_wave_key, wake_wave_string) + VALUES ('wave-c', 'agent-y', 'task_children_completed', '{}', 'queued', CURRENT_TIMESTAMP, 'wave-key-1', 'p|1') + `); err != nil { + t.Fatalf("insert wave run for a different agent should succeed: %v", err) + } + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at) + VALUES ('empty-wave-1', 'agent-x', 'task_assigned', '{}', 'queued', CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("insert first empty-wave-key run: %v", err) + } + if _, err := db.Exec(` + INSERT INTO runs (id, agent_profile_id, reason, payload, status, requested_at) + VALUES ('empty-wave-2', 'agent-x', 'task_assigned', '{}', 'queued', CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("second empty-wave-key run for same agent should succeed (partial index excludes empty key): %v", err) + } +} diff --git a/apps/backend/internal/office/scheduler/reactivity.go b/apps/backend/internal/office/scheduler/reactivity.go index b8462ab8f7..13ec33cad2 100644 --- a/apps/backend/internal/office/scheduler/reactivity.go +++ b/apps/backend/internal/office/scheduler/reactivity.go @@ -12,9 +12,11 @@ import ( "github.com/kandev/kandev/internal/office/models" "github.com/kandev/kandev/internal/office/repository/sqlite" "github.com/kandev/kandev/internal/office/shared" + "github.com/kandev/kandev/internal/office/waveidentity" "github.com/kandev/kandev/internal/runs/commentkeys" "github.com/kandev/kandev/internal/runs/dedupkeys" runsservice "github.com/kandev/kandev/internal/runs/service" + "github.com/kandev/kandev/internal/workflow/engine" ) // Canonical lowercase status values used inside the pipeline. Backend @@ -510,13 +512,197 @@ func (ss *SchedulerService) cascadeChildrenCompleted( return } } - queue(parentAssignee, RunContext{ + + waveKey, waveString, ok := ss.resolveWaveIdentity(ctx, task.ParentID) + if !ok { + return + } + + stepID, actionPayload := ss.resolveWaveActionPayload(ctx, task.ParentID, parentAssignee) + rc := RunContext{ Reason: RunReasonTaskChildrenCompleted, TaskID: task.ParentID, WorkspaceID: task.WorkspaceID, ChildTaskID: task.ID, + WorkflowStepID: stepID, IdempotencyKey: childrenCompletedIdempotencyKey(task.ParentID, parentAssignee, children), - }) + WaveKey: waveKey, + WaveString: waveString, + ExtraPayload: actionPayload, + } + queue(parentAssignee, rc) +} + +// resolveWaveIdentity performs the terminality-confirming last read: a +// wave identity is derived from, and only from, a wave-member read that +// itself observed every member terminal. It does not trust the earlier +// ListChildStates loop above, which counts every child (not just wave +// members) and is a separate, unsynchronized read. A read error, any +// non-terminal wave member, or an empty wave-member set (a parent with no +// wave members has no wave) all report ok=false: the caller queues +// nothing and the backstop delivers the wake later. +func (ss *SchedulerService) resolveWaveIdentity( + ctx context.Context, parentID string, +) (waveKey, waveString string, ok bool) { + members, err := ss.repo.ListWaveMembers(ctx, parentID) + if err != nil { + ss.logger.Debug("list wave members failed", + zap.String("parent_id", parentID), zap.Error(err)) + return "", "", false + } + if len(members) == 0 { + return "", "", false + } + ids := make([]string, 0, len(members)) + for _, m := range members { + if m.State != "COMPLETED" && m.State != "CANCELLED" { + return "", "", false + } + ids = append(ids, m.TaskID) + } + return waveidentity.WaveKey(parentID, ids), waveidentity.WaveString(parentID, ids), true +} + +// resolveWaveActionPayload resolves the parent's current workflow step id +// and, from that step's on_children_completed trigger, the +// workflow-authored payload the engine would attach to a run targeting the +// same recipient cascade wakes — so a cascade wake that never touches the +// engine still carries it. The step id is returned even when no matching +// action payload is found, since the staleness-guard equivalence between +// producers needs it regardless of whether the step also authors a +// payload. Any failure to resolve the step itself — no getter wired, no +// step bound, lookup error — returns ("", nil) and logs the omission at +// debug: the wake itself is unconditional, an optional field is not worth +// skipping it for. +// +// Cascade always wakes the parent's assignee, so a queue_run action only +// collides with it when its resolved Target is the implicit-default or +// explicit "primary" (QueueRunCallback.resolveTarget's own default case), +// and a queue_run_for_each_participant action only collides with it when +// its fanned-out seats include the parent's assignee — resolved via the +// same seat-resolution the engine itself would use +// (engine.ResolveFanOutSeats). A step authoring more than one queue_run( +// _for_each_participant) action on this trigger — one to +// "workspace.ceo_agent", say, one implicit-primary — must not have its +// non-colliding action's payload attached here: parity is only required +// between producers waking the *same* target, and the first non-empty +// payload regardless of target would silently carry the wrong recipient's +// content. +// +// Each action must also be reasoned task_children_completed, mirroring +// QueueRunCallback.Execute's and QueueRunForEachParticipantCallback. +// Execute's own reasonTaskChildrenCompleted gates: an action left at its +// default reason resolves to the trigger name in the engine, never +// task_children_completed, so it is excluded here too. A step authoring a +// second on_children_completed action for an unrelated reason must not +// have its payload attached to this wave. +func (ss *SchedulerService) resolveWaveActionPayload( + ctx context.Context, parentID, parentAssignee string, +) (stepID string, payload map[string]any) { + stepID, err := ss.repo.GetTaskWorkflowStepID(ctx, parentID) + if err != nil || stepID == "" { + ss.logger.Debug("wave payload parity: no workflow step bound", + zap.String("parent_id", parentID), zap.Error(err)) + return "", nil + } + if ss.workflowStepGetter == nil { + return stepID, nil + } + step, err := ss.workflowStepGetter.GetStep(ctx, stepID) + if err != nil || step == nil { + ss.logger.Debug("wave payload parity: step lookup failed", + zap.String("parent_id", parentID), zap.String("step_id", stepID), zap.Error(err)) + return stepID, nil + } + spec := engine.CompileStep(step) + for _, action := range spec.Events[engine.TriggerOnChildrenCompleted] { + switch action.Kind { + case engine.ActionQueueRun: + if p, ok := matchQueueRunActionPayload(action); ok { + return stepID, p + } + case engine.ActionQueueRunForEachParticipant: + if p, ok := ss.matchFanOutActionPayload(ctx, action, stepID, parentID, parentAssignee); ok { + return stepID, p + } + } + } + return stepID, nil +} + +// matchQueueRunActionPayload reports the action's payload when it is a +// task_children_completed-reasoned queue_run targeting the implicit-default +// or explicit "primary" — the target cascade's fixed-recipient wake collides +// with. See resolveWaveActionPayload for why non-colliding targets and +// other reasons are excluded. A collision is decided by reason and target +// alone, never by whether the action happens to author a payload: the +// engine dispatches this same action first (author order) regardless of +// its payload's length, so stopping here — even with a nil/empty payload — +// is what keeps cascade's selection aligned with which action actually +// wins the engine-routed producers' wave-unique insertion. Skipping past +// an empty-payload match to a later, non-empty one would let cascade +// attach content the engine path would never have selected. +func matchQueueRunActionPayload(action engine.Action) (map[string]any, bool) { + if action.QueueRun == nil { + return nil, false + } + if action.QueueRun.Reason != RunReasonTaskChildrenCompleted { + return nil, false + } + target := strings.TrimSpace(action.QueueRun.Target) + if target == "" || target == engine.TargetPrimary { + return action.QueueRun.Payload, true + } + return nil, false +} + +// matchFanOutActionPayload reports the action's payload when it is a +// task_children_completed-reasoned queue_run_for_each_participant action +// whose fanned-out seats (resolved via engine.ResolveFanOutSeats, the exact +// seat resolution QueueRunForEachParticipantCallback.Execute uses) include +// the parent's assignee — the recipient cascade's fixed-recipient wake +// collides with. Any resolution failure (no participant store wired, no +// workflow id, seat lookup error) reports no match rather than blocking the +// wake: see resolveWaveActionPayload's doc comment on the wake being +// unconditional. As with matchQueueRunActionPayload, a collision never +// depends on the action's payload being non-empty — only on reason, role, +// and resolved seat membership — so cascade stops at the same action the +// engine would dispatch first, regardless of what that action authors. +func (ss *SchedulerService) matchFanOutActionPayload( + ctx context.Context, action engine.Action, stepID, parentID, parentAssignee string, +) (map[string]any, bool) { + cfg := action.QueueRunForEachParticipant + if cfg == nil || cfg.Role == "" { + return nil, false + } + reason := cfg.Reason + if reason == "" { + reason = string(engine.TriggerOnChildrenCompleted) + } + if reason != RunReasonTaskChildrenCompleted { + return nil, false + } + if ss.participantStore == nil { + return nil, false + } + workflowID, err := ss.repo.GetTaskWorkflowID(ctx, parentID) + if err != nil { + ss.logger.Debug("wave payload parity: workflow id lookup failed", + zap.String("parent_id", parentID), zap.Error(err)) + return nil, false + } + seats, err := engine.ResolveFanOutSeats(ctx, ss.participantStore, stepID, parentID, workflowID, cfg.Role) + if err != nil { + ss.logger.Debug("wave payload parity: fan-out seat resolution failed", + zap.String("parent_id", parentID), zap.String("role", cfg.Role), zap.Error(err)) + return nil, false + } + for _, seat := range seats { + if seat.AgentProfileID == parentAssignee { + return cfg.Payload, true + } + } + return nil, false } // childrenCompletedIdempotencyKey digests the parent's child ID set diff --git a/apps/backend/internal/office/scheduler/reactivity_children_completed_wave_foreach_test.go b/apps/backend/internal/office/scheduler/reactivity_children_completed_wave_foreach_test.go new file mode 100644 index 0000000000..0839b93537 --- /dev/null +++ b/apps/backend/internal/office/scheduler/reactivity_children_completed_wave_foreach_test.go @@ -0,0 +1,228 @@ +package scheduler + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/workflow/engine" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// fakeParticipantStore is a minimal engine.ParticipantStore test double: a +// flat row set, filtered the same way the two ParticipantStore methods +// partition template (task_id="") vs per-task rows. It does not implement +// engine.WorkflowScopedParticipantStore, so gatherParticipantSlate always +// takes the plain ListTaskParticipants branch regardless of the workflowID +// argument passed to it — sufficient for these single-task, single-step +// fixtures. +type fakeParticipantStore struct { + rows []engine.ParticipantInfo + err error +} + +func (f *fakeParticipantStore) ListStepParticipants(_ context.Context, stepID, taskID string) ([]engine.ParticipantInfo, error) { + if f.err != nil { + return nil, f.err + } + var out []engine.ParticipantInfo + for _, p := range f.rows { + if p.StepID == stepID && p.TaskID == taskID { + out = append(out, p) + } + } + return out, nil +} + +func (f *fakeParticipantStore) ListTaskParticipants(_ context.Context, taskID string) ([]engine.ParticipantInfo, error) { + if f.err != nil { + return nil, f.err + } + var out []engine.ParticipantInfo + for _, p := range f.rows { + if p.TaskID == taskID { + out = append(out, p) + } + } + return out, nil +} + +// bindForEachParticipantStep wires a fake workflow step whose +// on_children_completed trigger authors one queue_run_for_each_participant +// action for role/reason/payload, plus a participant store standing in for +// the engine's own ParticipantAdapter. +func bindForEachParticipantStep( + t *testing.T, ss *SchedulerService, parentID, role, reason string, payload map[string]any, store engine.ParticipantStore, +) { + t.Helper() + ctx := context.Background() + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET workflow_step_id = 'step-x' WHERE id = ?`, parentID); err != nil { + t.Fatalf("bind parent step: %v", err) + } + cfg := map[string]any{"role": role, "payload": payload} + if reason != "" { + cfg["reason"] = reason + } + ss.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-x": { + ID: "step-x", + Events: wfmodels.StepEvents{ + OnChildrenCompleted: []wfmodels.GenericAction{ + { + Type: wfmodels.GenericActionQueueRunForEachParticipant, + Config: cfg, + }, + }, + }, + }, + }}) + ss.SetParticipantStore(store) +} + +// TestCascadeChildrenCompleted_PayloadParity_MergesForEachParticipantPayload +// is AC-002.10/.16's queue_run_for_each_participant half: a step authoring +// only a for-each-participant action (no plain queue_run) still gets its +// payload attached to the cascade wake when the fanned-out role resolves a +// seat matching the parent's assignee — the exact agent cascade always +// wakes, and therefore the exact (wave, agent) pair idx_run_wake_wave would +// collapse an engine-routed fan-out run into. +func TestCascadeChildrenCompleted_PayloadParity_MergesForEachParticipantPayload(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + store := &fakeParticipantStore{rows: []engine.ParticipantInfo{ + {ID: "seat-1", StepID: "step-x", TaskID: "parent-1", Role: "reviewer", AgentProfileID: "agent-1"}, + }} + bindForEachParticipantStep(t, ss, "parent-1", "reviewer", RunReasonTaskChildrenCompleted, + map[string]any{"escalate_to": "lead-agent"}, store) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if !strings.Contains(payload, `"escalate_to":"lead-agent"`) { + t.Fatalf("payload = %s, want it to contain the for-each-participant action's escalate_to key", payload) + } +} + +// TestCascadeChildrenCompleted_PayloadParity_ForEachParticipant_NoMatchingSeat_NoPayload +// covers the non-colliding half: the fanned-out role resolves seats, but +// none of them is the parent's assignee, so the engine-routed fan-out would +// never produce a run for the (wave, parentAssignee) pair cascade wakes — +// attaching this action's payload here would carry a foreign recipient's +// content onto cascade's wake. +func TestCascadeChildrenCompleted_PayloadParity_ForEachParticipant_NoMatchingSeat_NoPayload(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + store := &fakeParticipantStore{rows: []engine.ParticipantInfo{ + {ID: "seat-1", StepID: "step-x", TaskID: "parent-1", Role: "reviewer", AgentProfileID: "agent-2"}, + }} + bindForEachParticipantStep(t, ss, "parent-1", "reviewer", RunReasonTaskChildrenCompleted, + map[string]any{"escalate_to": "lead-agent"}, store) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if strings.Contains(payload, "lead-agent") { + t.Fatalf("payload = %s, must not contain the for-each-participant payload when no seat matches the assignee", payload) + } +} + +// TestCascadeChildrenCompleted_PayloadParity_ForEachParticipant_NoStoreWired_StillQueues +// covers the omission path (mirroring +// TestCascadeChildrenCompleted_PayloadParity_StepLookupFails_StillQueues): no +// ParticipantStore wired must not block the wake itself, it just queues +// without the merged payload. +func TestCascadeChildrenCompleted_PayloadParity_ForEachParticipant_NoStoreWired_StillQueues(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + bindForEachParticipantStep(t, ss, "parent-1", "reviewer", RunReasonTaskChildrenCompleted, + map[string]any{"escalate_to": "lead-agent"}, nil) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var queued int + var payload string + queue := func(_ string, c RunContext) { + queued++ + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if queued != 1 { + t.Fatalf("queued = %d, want 1 (no participant store must not block the wake)", queued) + } + if strings.Contains(payload, "lead-agent") { + t.Fatalf("payload = %s, must not contain the for-each-participant payload with no store wired", payload) + } +} + +// TestCascadeChildrenCompleted_PayloadParity_ForEachParticipant_IgnoresDifferentReasonAction +// mirrors the queue_run reason-matching test: a for-each-participant action +// left at its default (non-task_children_completed) reason must not have +// its payload merged onto this wave, even when a seat matches the assignee. +func TestCascadeChildrenCompleted_PayloadParity_ForEachParticipant_IgnoresDifferentReasonAction(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + store := &fakeParticipantStore{rows: []engine.ParticipantInfo{ + {ID: "seat-1", StepID: "step-x", TaskID: "parent-1", Role: "reviewer", AgentProfileID: "agent-1"}, + }} + // reason left empty: readQueueRunForEachParticipantConfig then falls + // back to the trigger name ("on_children_completed"), never + // task_children_completed. + bindForEachParticipantStep(t, ss, "parent-1", "reviewer", "", + map[string]any{"escalate_to": "lead-agent"}, store) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if strings.Contains(payload, "lead-agent") { + t.Fatalf("payload = %s, must not contain a differently-reasoned for-each-participant action's payload", payload) + } +} diff --git a/apps/backend/internal/office/scheduler/reactivity_children_completed_wave_test.go b/apps/backend/internal/office/scheduler/reactivity_children_completed_wave_test.go new file mode 100644 index 0000000000..651c5e4736 --- /dev/null +++ b/apps/backend/internal/office/scheduler/reactivity_children_completed_wave_test.go @@ -0,0 +1,533 @@ +package scheduler + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/office/waveidentity" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// TestResolveWaveIdentity_AllTerminalWaveMembers_ReturnsWaveIdentity is the +// happy path for the terminality-confirming last read +// (AC-OFFICE-WAKE-WAVE-IDENTITY-002.15): every wave member terminal in the +// read itself yields the wave identity waveidentity derives from the same +// ordered id set. +func TestResolveWaveIdentity_AllTerminalWaveMembers_ReturnsWaveIdentity(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, `INSERT INTO tasks (id, workspace_id) VALUES ('parent-1', 'ws-1')`); err != nil { + t.Fatalf("insert parent: %v", err) + } + insertChildTask(t, ss, "child-2", "parent-1", "COMPLETED") + insertChildTask(t, ss, "child-1", "parent-1", "CANCELLED") + + waveKey, waveString, ok := ss.resolveWaveIdentity(ctx, "parent-1") + if !ok { + t.Fatalf("resolveWaveIdentity ok = false, want true") + } + wantIDs := []string{"child-1", "child-2"} // ascending by id + wantKey := waveidentity.WaveKey("parent-1", wantIDs) + wantString := waveidentity.WaveString("parent-1", wantIDs) + if waveKey != wantKey { + t.Fatalf("waveKey = %q, want %q", waveKey, wantKey) + } + if waveString != wantString { + t.Fatalf("waveString = %q, want %q", waveString, wantString) + } +} + +// TestResolveWaveIdentity_NonTerminalMember_ReturnsNotOK covers a wave +// member the read itself observes as not terminal — the case +// AC-...-002.15 exists to guard: recording an identity for a set never +// observed simultaneously terminal would permanently suppress the real +// wave, because dedup is unbounded. +func TestResolveWaveIdentity_NonTerminalMember_ReturnsNotOK(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, `INSERT INTO tasks (id, workspace_id) VALUES ('parent-1', 'ws-1')`); err != nil { + t.Fatalf("insert parent: %v", err) + } + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + insertChildTask(t, ss, "child-2", "parent-1", "IN_PROGRESS") + + if _, _, ok := ss.resolveWaveIdentity(ctx, "parent-1"); ok { + t.Fatalf("resolveWaveIdentity ok = true, want false when a wave member is not terminal") + } +} + +// TestResolveWaveIdentity_NonTerminalMember_ThenTerminal_RecoversWaveIdentity +// is AC-...-002.15's recovery half: skipping a wave for a member the read +// observed non-terminal must not permanently suppress that parent's real +// wave once every member genuinely is terminal. resolveWaveIdentity itself +// is stateless (a read confirming false today says nothing about a later +// read), so this pins that guarantee end to end — including through the +// full cascadeChildrenCompleted path, so a run is actually queued, not +// just a non-empty identity returned. +func TestResolveWaveIdentity_NonTerminalMember_ThenTerminal_RecoversWaveIdentity(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + insertChildTask(t, ss, "child-2", "parent-1", "IN_PROGRESS") + + if _, _, ok := ss.resolveWaveIdentity(ctx, "parent-1"); ok { + t.Fatalf("resolveWaveIdentity ok = true, want false while child-2 is non-terminal") + } + + var queuedTo string + var captured RunContext + queued := false + queue := func(agentID string, c RunContext) { + queuedTo = agentID + captured = c + queued = true + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + if queued { + t.Fatalf("cascade queued a run while child-2 was still non-terminal") + } + + // The wave genuinely completes: child-2 reaches a terminal state too. + if _, err := ss.repo.ExecRaw(ctx, `UPDATE tasks SET state = 'COMPLETED' WHERE id = 'child-2'`); err != nil { + t.Fatalf("complete child-2: %v", err) + } + + waveKey, waveString, ok := ss.resolveWaveIdentity(ctx, "parent-1") + if !ok { + t.Fatalf("resolveWaveIdentity ok = false, want true once every member is terminal") + } + wantIDs := []string{"child-1", "child-2"} + if wantKey := waveidentity.WaveKey("parent-1", wantIDs); waveKey != wantKey { + t.Fatalf("waveKey = %q, want %q", waveKey, wantKey) + } + if wantString := waveidentity.WaveString("parent-1", wantIDs); waveString != wantString { + t.Fatalf("waveString = %q, want %q", waveString, wantString) + } + + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-2", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + if !queued { + t.Fatalf("cascade queued nothing once the wave genuinely completed") + } + if queuedTo != "agent-1" { + t.Fatalf("cascade queued to %q, want agent-1", queuedTo) + } + if captured.WaveKey != waveKey { + t.Fatalf("queued WaveKey = %q, want %q", captured.WaveKey, waveKey) + } +} + +// TestResolveWaveIdentity_NoWaveMembers_ReturnsNotOK is AC-...-001.7: a +// parent with no wave members has no wave. +func TestResolveWaveIdentity_NoWaveMembers_ReturnsNotOK(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, `INSERT INTO tasks (id, workspace_id) VALUES ('parent-1', 'ws-1')`); err != nil { + t.Fatalf("insert parent: %v", err) + } + + if _, _, ok := ss.resolveWaveIdentity(ctx, "parent-1"); ok { + t.Fatalf("resolveWaveIdentity ok = true, want false when the parent has no wave members") + } +} + +// TestCascadeChildrenCompleted_QueuesWaveIdentityOnRunContext proves the +// wave identity resolveWaveIdentity derives actually reaches the queued +// RunContext. +func TestCascadeChildrenCompleted_QueuesWaveIdentityOnRunContext(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var captured RunContext + queue := func(_ string, c RunContext) { captured = c } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + wantKey := waveidentity.WaveKey("parent-1", []string{"child-1"}) + wantString := waveidentity.WaveString("parent-1", []string{"child-1"}) + if captured.WaveKey != wantKey { + t.Fatalf("WaveKey = %q, want %q", captured.WaveKey, wantKey) + } + if captured.WaveString != wantString { + t.Fatalf("WaveString = %q, want %q", captured.WaveString, wantString) + } +} + +// TestCascadeChildrenCompleted_OnlyArchivedChild_QueuesNothing is +// AC-...-001.7 exercised through the full cascade path: the parent has a +// terminal child (so the pre-existing ListChildStates loop does not +// short-circuit), but that child is archived and therefore not a wave +// member, so the parent has no wave and no run is queued. +func TestCascadeChildrenCompleted_OnlyArchivedChild_QueuesNothing(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET archived_at = ? WHERE id = 'child-1'`, time.Now().UTC()); err != nil { + t.Fatalf("archive child-1: %v", err) + } + + var queued int + queue := func(_ string, _ RunContext) { queued++ } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if queued != 0 { + t.Fatalf("queued = %d, want 0 (parent has no wave members)", queued) + } +} + +// fakeWorkflowStepGetter is a minimal WorkflowStepGetter test double: a +// static step map, or a fixed error. +type fakeWorkflowStepGetter struct { + steps map[string]*wfmodels.WorkflowStep + err error +} + +func (f *fakeWorkflowStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + if f.err != nil { + return nil, f.err + } + return f.steps[stepID], nil +} + +// TestCascadeChildrenCompleted_PayloadParity_MergesWorkflowAuthoredPayload +// is AC-OFFICE-WAKE-WAVE-IDENTITY-002.16: a cascade wake that never +// consults the engine still attaches the same queue_run payload the +// engine would attach for the parent's current step. +func TestCascadeChildrenCompleted_PayloadParity_MergesWorkflowAuthoredPayload(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET workflow_step_id = 'step-x' WHERE id = 'parent-1'`); err != nil { + t.Fatalf("bind parent step: %v", err) + } + ss.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-x": { + ID: "step-x", + Events: wfmodels.StepEvents{ + OnChildrenCompleted: []wfmodels.GenericAction{ + { + Type: wfmodels.GenericActionQueueRun, + Config: map[string]any{ + "reason": RunReasonTaskChildrenCompleted, + "payload": map[string]any{"escalate_to": "lead-agent"}, + }, + }, + }, + }, + }, + }}) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if !strings.Contains(payload, `"escalate_to":"lead-agent"`) { + t.Fatalf("payload = %s, want it to contain the workflow-authored escalate_to key", payload) + } +} + +// TestCascadeChildrenCompleted_PayloadParity_IgnoresNonPrimaryTargetedAction +// is AC-OFFICE-WAKE-WAVE-IDENTITY-002.10's target-matching half: a step +// authoring two queue_run actions on one on_children_completed trigger — +// one targeted elsewhere, one implicit-primary — must only ever carry the +// primary-targeted action's payload into the cascade wake, which always +// wakes the parent's assignee. Picking the first non-empty payload +// regardless of target would leak a foreign recipient's content onto this +// wake. +func TestCascadeChildrenCompleted_PayloadParity_IgnoresNonPrimaryTargetedAction(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET workflow_step_id = 'step-x' WHERE id = 'parent-1'`); err != nil { + t.Fatalf("bind parent step: %v", err) + } + ss.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-x": { + ID: "step-x", + Events: wfmodels.StepEvents{ + OnChildrenCompleted: []wfmodels.GenericAction{ + { + Type: wfmodels.GenericActionQueueRun, + Config: map[string]any{ + "target": "workspace.ceo_agent", + "reason": RunReasonTaskChildrenCompleted, + "payload": map[string]any{"escalate_to": "ceo-agent"}, + }, + }, + { + Type: wfmodels.GenericActionQueueRun, + Config: map[string]any{ + "reason": RunReasonTaskChildrenCompleted, + "payload": map[string]any{"escalate_to": "lead-agent"}, + }, + }, + }, + }, + }, + }}) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if !strings.Contains(payload, `"escalate_to":"lead-agent"`) { + t.Fatalf("payload = %s, want it to contain the primary-targeted action's escalate_to key", payload) + } + if strings.Contains(payload, "ceo-agent") { + t.Fatalf("payload = %s, must not contain the non-primary-targeted action's payload", payload) + } +} + +// TestCascadeChildrenCompleted_PayloadParity_EmptyPayloadActionStillMatches +// proves a collision is decided by reason and target alone, never by +// whether the matched action happens to author a payload. A step with two +// implicit-primary, task_children_completed-reasoned queue_run actions — +// the first with no payload, the second with one — is exactly the +// ordering the engine's own step.Events[trigger] loop would dispatch +// first: the first action wins that race (whichever insert lands first +// under the wave-key unique index), so cascade selecting the *second* +// action's payload here would attach content the engine path would never +// actually have delivered for this wave. +func TestCascadeChildrenCompleted_PayloadParity_EmptyPayloadActionStillMatches(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET workflow_step_id = 'step-x' WHERE id = 'parent-1'`); err != nil { + t.Fatalf("bind parent step: %v", err) + } + ss.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-x": { + ID: "step-x", + Events: wfmodels.StepEvents{ + OnChildrenCompleted: []wfmodels.GenericAction{ + { + Type: wfmodels.GenericActionQueueRun, + Config: map[string]any{ + "reason": RunReasonTaskChildrenCompleted, + }, + }, + { + Type: wfmodels.GenericActionQueueRun, + Config: map[string]any{ + "reason": RunReasonTaskChildrenCompleted, + "payload": map[string]any{"escalate_to": "lead-agent"}, + }, + }, + }, + }, + }, + }}) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if strings.Contains(payload, "lead-agent") { + t.Fatalf("payload = %s, must not contain the second action's payload: the first, empty-payload action already matched and the engine would dispatch it first", payload) + } +} + +// TestCascadeChildrenCompleted_PayloadParity_StepLookupFails_StillQueues +// covers the omission path: a step-lookup failure must not block the wake +// itself (AC-...-004.2 — the wake is unconditional), it just queues +// without the merged payload. +func TestCascadeChildrenCompleted_PayloadParity_StepLookupFails_StillQueues(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET workflow_step_id = 'step-x' WHERE id = 'parent-1'`); err != nil { + t.Fatalf("bind parent step: %v", err) + } + ss.SetWorkflowStepGetter(&fakeWorkflowStepGetter{err: errors.New("step lookup boom")}) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var queued int + queue := func(_ string, _ RunContext) { queued++ } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if queued != 1 { + t.Fatalf("queued = %d, want 1 (a step-lookup failure must not block the wake)", queued) + } +} + +// TestCascadeChildrenCompleted_PersistsWorkflowStepID is AC-002.10's +// staleness-guard half: an engine-routed producer's persisted payload +// carries workflow_step_id, which evaluateRunStaleness uses to cancel a +// queued run whose parent has since moved to a different step. A cascade +// wake for the same wave must carry the same field, or the two producers' +// wakes are not equivalent — whichever wins the unique-index race decides +// whether the staleness guard applies at all. +func TestCascadeChildrenCompleted_PersistsWorkflowStepID(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET workflow_step_id = 'step-x' WHERE id = 'parent-1'`); err != nil { + t.Fatalf("bind parent step: %v", err) + } + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if !strings.Contains(payload, `"workflow_step_id":"step-x"`) { + t.Fatalf("payload = %s, want it to carry workflow_step_id like an engine-routed producer would", payload) + } +} + +// TestCascadeChildrenCompleted_ExtraPayloadWorkflowStepID_CannotOverride +// mirrors the existing task_id/workspace_id/child_task_id re-assertion +// tests: a workflow-authored payload key must not be able to override the +// wake's own workflow_step_id, the same envelope-field guarantee +// encodeRunContext already provides for the other identity fields. +func TestCascadeChildrenCompleted_ExtraPayloadWorkflowStepID_CannotOverride(t *testing.T) { + rc := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + WorkflowStepID: "step-x", + ExtraPayload: map[string]any{"workflow_step_id": "step-foreign"}, + } + payload, err := encodeRunContext(rc) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + if !strings.Contains(payload, `"workflow_step_id":"step-x"`) { + t.Fatalf("payload = %s, want workflow_step_id = step-x (envelope field), not step-foreign", payload) + } +} + +// TestCascadeChildrenCompleted_PayloadParity_IgnoresDifferentReasonAction +// is AC-002.16's reason-matching half, mirroring the engine's own gate +// (QueueRunCallback.Execute only attaches wave identity when +// queueRunReason(in) == reasonTaskChildrenCompleted): a step authoring a +// second primary-targeted queue_run action on this trigger with a +// different (or unset) reason must not have its payload merged onto the +// real task_children_completed wake. Without the reason check, cascade +// would attach an unrelated action's content to this wave. +func TestCascadeChildrenCompleted_PayloadParity_IgnoresDifferentReasonAction(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + setupChildrenCompletedParent(t, ss, "parent-1", "agent-1") + ctx := context.Background() + + if _, err := ss.repo.ExecRaw(ctx, + `UPDATE tasks SET workflow_step_id = 'step-x' WHERE id = 'parent-1'`); err != nil { + t.Fatalf("bind parent step: %v", err) + } + ss.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-x": { + ID: "step-x", + Events: wfmodels.StepEvents{ + OnChildrenCompleted: []wfmodels.GenericAction{ + { + Type: wfmodels.GenericActionQueueRun, + Config: map[string]any{ + "reason": "follow_up", + "payload": map[string]any{"escalate_to": "follow-up-agent"}, + }, + }, + { + Type: wfmodels.GenericActionQueueRun, + Config: map[string]any{ + "reason": RunReasonTaskChildrenCompleted, + "payload": map[string]any{"escalate_to": "lead-agent"}, + }, + }, + }, + }, + }, + }}) + + insertChildTask(t, ss, "child-1", "parent-1", "COMPLETED") + + var payload string + queue := func(_ string, c RunContext) { + encoded, err := encodeRunContext(c) + if err != nil { + t.Fatalf("encode run context: %v", err) + } + payload = encoded + } + ss.cascadeChildrenCompleted(ctx, &TaskSnapshot{ID: "child-1", WorkspaceID: "ws-1", ParentID: "parent-1"}, queue) + + if !strings.Contains(payload, `"escalate_to":"lead-agent"`) { + t.Fatalf("payload = %s, want it to contain the task_children_completed-reasoned action's escalate_to key", payload) + } + if strings.Contains(payload, "follow-up-agent") { + t.Fatalf("payload = %s, must not contain the differently-reasoned action's payload", payload) + } +} diff --git a/apps/backend/internal/office/scheduler/reactivity_test.go b/apps/backend/internal/office/scheduler/reactivity_test.go index 55cfb590d8..843886fbd5 100644 --- a/apps/backend/internal/office/scheduler/reactivity_test.go +++ b/apps/backend/internal/office/scheduler/reactivity_test.go @@ -38,10 +38,14 @@ func newReactivityTestRepo(t *testing.T) *officesqlite.Repository { CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, workspace_id TEXT DEFAULT '', + workflow_id TEXT DEFAULT '', workflow_step_id TEXT DEFAULT '', parent_id TEXT DEFAULT '', state TEXT DEFAULT '', - assignee_user_id TEXT NOT NULL DEFAULT '' + assignee_user_id TEXT NOT NULL DEFAULT '', + archived_at TIMESTAMP, + is_ephemeral INTEGER NOT NULL DEFAULT 0, + origin TEXT DEFAULT 'manual' ) `); err != nil { t.Fatalf("create tasks table: %v", err) diff --git a/apps/backend/internal/office/scheduler/run.go b/apps/backend/internal/office/scheduler/run.go index 1d6d0d62b2..a6b612dd98 100644 --- a/apps/backend/internal/office/scheduler/run.go +++ b/apps/backend/internal/office/scheduler/run.go @@ -20,7 +20,10 @@ import ( "github.com/kandev/kandev/internal/office/routing" "github.com/kandev/kandev/internal/office/service" "github.com/kandev/kandev/internal/office/shared" + runssqlite "github.com/kandev/kandev/internal/runs/repository/sqlite" runsservice "github.com/kandev/kandev/internal/runs/service" + "github.com/kandev/kandev/internal/workflow/engine" + wfmodels "github.com/kandev/kandev/internal/workflow/models" ) // ErrRoutingNotSupported is returned by TaskStarter.StartTaskWithRoute @@ -89,9 +92,17 @@ const ( // Run.Payload column so the agent runtime can pick the right // system prompt template based on the reason. type RunContext struct { - Reason string `json:"reason"` - TaskID string `json:"task_id"` - WorkspaceID string `json:"workspace_id,omitempty"` + Reason string `json:"reason"` + TaskID string `json:"task_id"` + WorkspaceID string `json:"workspace_id,omitempty"` + // WorkflowStepID is the parent's workflow step at wake time. An + // engine-routed producer's request always carries it (runs/service's + // runPayload copies it from the typed field), and + // evaluateRunStaleness reads it to cancel a queued run whose parent + // has since moved to a different step. Left empty, that guard never + // applies — so cascade must set this whenever it can be resolved, or + // the two producers' wakes for the same wave are not equivalent. + WorkflowStepID string `json:"workflow_step_id,omitempty"` ActorID string `json:"actor_id,omitempty"` ActorType string `json:"actor_type,omitempty"` // "user" | "agent" CommentID string `json:"comment_id,omitempty"` @@ -120,6 +131,20 @@ type RunContext struct { // encodeRunContext's output shape, which CoalesceRun compares for // equality and taskIDFromPayload parses. IdempotencyKey string `json:"-"` + + // WaveKey and WaveString carry a completion-wave identity onto the + // persisted run (models.Run.WakeWaveKey / WakeWaveString), not into + // the JSON payload: they gate admission via idx_run_wake_wave and + // coalescing, not agent-facing content. Empty means "no wave + // identity" — the ordinary idempotency-key path applies instead. + WaveKey string `json:"-"` + WaveString string `json:"-"` + + // ExtraPayload, when non-empty, is merged onto the JSON-encoded + // payload by encodeRunContext (workflow-authored keys win over any + // struct field of the same name). Left nil, encodeRunContext's + // output is byte-identical to a plain struct marshal. + ExtraPayload map[string]any `json:"-"` } // Run status constants. @@ -177,9 +202,34 @@ type SchedulerService struct { kandevBasePathFn func() string agentTypeResolver func(profileID string) string projectSkillDirResolver func(agentTypeID string) string + workflowStepGetter WorkflowStepGetter + participantStore engine.ParticipantStore pauseGate shared.PauseGate } +// WorkflowStepGetter resolves a workflow step by ID. Implemented by +// workflow/service.Service.GetStep; wired via SetWorkflowStepGetter so the +// cascade producer can resolve the parent's current step without an +// engine dependency, for payload parity with the engine-routed producers. +type WorkflowStepGetter interface { + GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) +} + +// SetWorkflowStepGetter wires the workflow step lookup used for payload +// parity. Left nil, cascade wakes queue without a merged action payload. +func (ss *SchedulerService) SetWorkflowStepGetter(g WorkflowStepGetter) { + ss.workflowStepGetter = g +} + +// SetParticipantStore wires the participant seat resolution used for +// queue_run_for_each_participant payload parity — the same +// engine.ParticipantStore instance the workflow engine itself uses +// (workflow/adapters.ParticipantAdapter in production). Left nil, cascade +// never attaches a for-each-participant action's payload. +func (ss *SchedulerService) SetParticipantStore(store engine.ParticipantStore) { + ss.participantStore = store +} + // NewSchedulerService creates a new SchedulerService. func NewSchedulerService( repo *sqlite.Repository, @@ -258,6 +308,25 @@ func (ss *SchedulerService) SetPauseGate(g shared.PauseGate) { func (ss *SchedulerService) QueueRun( ctx context.Context, agentInstanceID, reason, payload, idempotencyKey string, +) (runsservice.QueueOutcome, error) { + return ss.queueRun(ctx, agentInstanceID, reason, payload, idempotencyKey, "", "") +} + +// queueRun is QueueRun plus an optional completion-wave identity. A +// non-empty waveKey (a) skips CoalesceRun — a wave-carrying request is +// never coalesced in and a wave-carrying queued run is never coalesced +// into — and (b) classifies CreateRun's idx_run_wake_wave violation as an +// already-delivered wake rather than an error: this call site inserts +// directly (not through runs/service), so ReportInsertResult's own +// idx_run_idempotency-only classification doesn't cover it. A concurrent +// duplicate of this same request can just as well lose the race on +// idx_run_idempotency instead of idx_run_wake_wave — both keys identify the +// identical operation for the identical row, so either violation means the +// wake is already recorded and neither is an error, mirroring +// runs/service.QueueRun's own two-way classification. +func (ss *SchedulerService) queueRun( + ctx context.Context, + agentInstanceID, reason, payload, idempotencyKey, waveKey, waveString string, ) (runsservice.QueueOutcome, error) { agent, err := ss.guardAgentStatus(ctx, agentInstanceID) if err != nil { @@ -277,15 +346,17 @@ func (ss *SchedulerService) QueueRun( } } - coalesced, err := ss.repo.CoalesceRun(ctx, agentInstanceID, reason, CoalesceWindowSeconds, payload) - if err != nil { - return runsservice.QueueOutcomeNone, fmt.Errorf("coalesce check: %w", err) - } - if coalesced { - ss.logger.Debug("run coalesced", - zap.String("agent", agentInstanceID), - zap.String("reason", reason)) - return runsservice.QueueOutcomeCoalesced, nil + if waveKey == "" { + coalesced, err := ss.repo.CoalesceRun(ctx, agentInstanceID, reason, CoalesceWindowSeconds, payload) + if err != nil { + return runsservice.QueueOutcomeNone, fmt.Errorf("coalesce check: %w", err) + } + if coalesced { + ss.logger.Debug("run coalesced", + zap.String("agent", agentInstanceID), + zap.String("reason", reason)) + return runsservice.QueueOutcomeCoalesced, nil + } } var idemKeyPtr *string @@ -300,9 +371,17 @@ func (ss *SchedulerService) QueueRun( Status: RunStatusQueued, CoalescedCount: 1, IdempotencyKey: idemKeyPtr, + WakeWaveKey: waveKey, + WakeWaveString: waveString, RequestedAt: time.Now().UTC(), } insertErr := ss.repo.CreateRun(ctx, req) + if waveKey != "" && runssqlite.IsWakeWaveUniqueViolation(insertErr) { + runsservice.ParentWakeDedupedTotal.Add(1) + ss.logger.Debug("run skipped (wave already woken)", + zap.String("wave_key", waveKey)) + return runsservice.QueueOutcomeDeduped, nil + } outcome, err := runsservice.ReportInsertResult(runsservice.QueueSourceRuns, reason, idempotencyKey, agentInstanceID, insertErr) if err != nil { return runsservice.QueueOutcomeNone, fmt.Errorf("enqueue run: %w", err) @@ -332,15 +411,69 @@ func (ss *SchedulerService) QueueRunCtx( if err != nil { return runsservice.QueueOutcomeNone, fmt.Errorf("encode run context: %w", err) } - return ss.QueueRun(ctx, agentInstanceID, c.Reason, payload, c.IdempotencyKey) + return ss.queueRun(ctx, agentInstanceID, c.Reason, payload, c.IdempotencyKey, c.WaveKey, c.WaveString) } +// encodeRunContext JSON-encodes c. When c.ExtraPayload is empty the output +// is a plain struct marshal, byte-identical to before ExtraPayload existed. +// Otherwise ExtraPayload's keys are overlaid onto the encoded object, then +// c's own envelope fields are re-applied on top — workflow-authored content +// keys win, but a workflow-authored payload can never redirect the run's +// identity. This mirrors runs/service.runPayload's precedence for task_id +// and workflow_step_id: P1 never goes through that function (it inserts via +// ss.repo.CreateRun directly), so encodeRunContext is the only place that +// guarantee can be enforced for the cascade path. Without it, a queue_run +// action's payload.task_id would silently override task.ParentID and +// misdirect the wake to a foreign task — task_id is what +// SchedulerIntegration.extractTaskID reads to check out and budget the run. +// +// Unlike runPayload, this does not re-assert agent_profile_id: RunContext +// carries no typed recipient field to re-assert from (the run's actual +// AgentProfileID column is set separately, from queueRun's own +// agentInstanceID parameter, never from this payload). A workflow-authored +// ExtraPayload["agent_profile_id"] therefore passes through unfiltered — +// currently inert, since no reader in this codebase consults +// payload["agent_profile_id"] for dispatch or routing (both use the DB +// column instead). See +// TestQueueRunCtx_ExtraPayloadAgentProfileID_PassesThroughUnfiltered, which +// pins this as a known non-guarantee rather than an oversight. func encodeRunContext(c RunContext) (string, error) { b, err := json.Marshal(c) if err != nil { return "", err } - return string(b), nil + if len(c.ExtraPayload) == 0 { + return string(b), nil + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return "", err + } + for k, v := range c.ExtraPayload { + m[k] = v + } + m["task_id"] = c.TaskID + m["reason"] = c.Reason + if c.WorkspaceID != "" { + m["workspace_id"] = c.WorkspaceID + } else { + delete(m, "workspace_id") + } + if c.ChildTaskID != "" { + m["child_task_id"] = c.ChildTaskID + } else { + delete(m, "child_task_id") + } + if c.WorkflowStepID != "" { + m["workflow_step_id"] = c.WorkflowStepID + } else { + delete(m, "workflow_step_id") + } + merged, err := json.Marshal(m) + if err != nil { + return "", err + } + return string(merged), nil } // guardAgentStatus returns an error if the agent is paused or stopped, diff --git a/apps/backend/internal/office/scheduler/run_test.go b/apps/backend/internal/office/scheduler/run_test.go new file mode 100644 index 0000000000..63ee451ffd --- /dev/null +++ b/apps/backend/internal/office/scheduler/run_test.go @@ -0,0 +1,321 @@ +package scheduler + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// TestQueueRunCtx_WaveCarryingRequest_DedupesPastIdempotencyWindow proves +// P1's own direct-insert path (QueueRunCtx -> queueRun -> ss.repo.CreateRun) +// classifies idx_run_wake_wave violations as a delivered duplicate, not an +// error — the runs/service classification from Task 02 does not cover this +// call site, since cascadeChildrenCompleted never goes through +// runs/service. Two producers deriving the same wave for the same parent +// must still collapse to one persisted run even once the ordinary +// idempotency-key window (24h) has passed, because .002.6 requires the +// wave-key guard to stay unbounded. +func TestQueueRunCtx_WaveCarryingRequest_DedupesPastIdempotencyWindow(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + ctx := context.Background() + + first := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "task_children_completed:parent-1:agent-1:wave-a", + WaveKey: "task_children_completed:parent-1:deadbeef", + WaveString: "parent-1|child-1,child-2", + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", first); err != nil { + t.Fatalf("queue first: %v", err) + } + if got := runsCountForReason(t, ss, RunReasonTaskChildrenCompleted); got != 1 { + t.Fatalf("after first queue: runs = %d, want 1", got) + } + + ageRunsRequestedAt(t, ss, 25*time.Hour) + + // A distinct idempotency key (as if independently derived by a second + // read) but the same wave — must still dedupe via the wave key. + second := first + second.IdempotencyKey = "task_children_completed:parent-1:agent-1:wave-a-retry" + if _, err := ss.QueueRunCtx(ctx, "agent-1", second); err != nil { + t.Fatalf("queue racing: %v", err) + } + if got := runsCountForReason(t, ss, RunReasonTaskChildrenCompleted); got != 1 { + t.Fatalf("after wave-key collision: runs = %d, want 1 (wave key must dedupe)", got) + } +} + +// TestQueueRunCtx_IdempotencyIndexRace_ClassifiedAsDedupe proves queueRun's +// direct-insert path treats a losing idx_run_idempotency violation the same +// as a losing idx_run_wake_wave violation: a no-op dedupe, not an error. +// Two concurrent cascade calls for the same parent compute the identical +// idempotency key deterministically from the child id set, so on Postgres +// the loser's CreateRun can report either unique index depending on +// constraint-check order — mirroring runs/service.insertRun's own two-way +// classification (idempotency_key OR wake_wave_key) keeps this call site +// from logging a spurious "enqueue run" error for what is actually the +// expected duplicate-wake outcome. The wave key is deliberately varied +// between the two requests so only the idempotency index, never the +// wake-wave index, can be the one that fires. +func TestQueueRunCtx_IdempotencyIndexRace_ClassifiedAsDedupe(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + ctx := context.Background() + + first := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "task_children_completed:parent-1:agent-1:shared-key", + WaveKey: "task_children_completed:parent-1:wave-a", + WaveString: "parent-1|child-1,child-2", + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", first); err != nil { + t.Fatalf("queue first: %v", err) + } + if got := runsCountForReason(t, ss, RunReasonTaskChildrenCompleted); got != 1 { + t.Fatalf("after first queue: runs = %d, want 1", got) + } + + // Age the row past the windowed CheckIdempotencyKey lookup so the + // second call's pre-check passes and it actually reaches CreateRun, + // which is where the unbounded idx_run_idempotency constraint (not the + // windowed check) is what has to classify the loss. + ageRunsRequestedAt(t, ss, 25*time.Hour) + + second := first + second.WaveKey = "task_children_completed:parent-1:wave-b" + second.WaveString = "parent-1|child-1,child-2,child-3" + if _, err := ss.QueueRunCtx(ctx, "agent-1", second); err != nil { + t.Fatalf("queue racing (idempotency index): %v", err) + } + if got := runsCountForReason(t, ss, RunReasonTaskChildrenCompleted); got != 1 { + t.Fatalf("after idempotency-index collision: runs = %d, want 1 (idempotency key must dedupe)", got) + } +} + +// TestQueueRunCtx_WaveCarryingRequest_NotCoalesced is the office/scheduler +// twin of runs/service's TestQueueRun_WakeCarryingRequest_NotCoalesced: +// AC-OFFICE-WAKE-WAVE-IDENTITY-002.14 requires a wave-carrying request +// never be coalesced in, and this call site inserts directly through +// ss.repo.CoalesceRun rather than through runs/service. Both requests here +// carry their own wave identity, so CoalesceRun's row-side guard (`AND +// wake_wave_key = ”`) already excludes the first row as a merge candidate +// on its own — this test does NOT isolate queueRun's own request-side +// guard (`if waveKey == ""`). See +// TestQueueRunCtx_WaveCarryingRequest_NotCoalescedIntoNonWaveRow for the +// asymmetric setup that isolates it. +func TestQueueRunCtx_WaveCarryingRequest_NotCoalesced(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + ctx := context.Background() + + first := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "k1", + WaveKey: "wave-key-1", + WaveString: "parent-1|child-1", + } + second := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-2", + IdempotencyKey: "k2", + WaveKey: "wave-key-2", + WaveString: "parent-2|child-2", + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", first); err != nil { + t.Fatalf("queue first: %v", err) + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", second); err != nil { + t.Fatalf("queue second: %v", err) + } + + if got := runsCountForReason(t, ss, RunReasonTaskChildrenCompleted); got != 2 { + t.Fatalf("runs = %d, want 2 (wave-carrying requests must not coalesce)", got) + } +} + +// TestQueueRunCtx_WaveCarryingRequest_NotCoalescedIntoNonWaveRow isolates +// the REQUEST side of AC-OFFICE-WAKE-WAVE-IDENTITY-002.14 specifically: the +// existing QUEUED row for the same agent+reason+task carries NO wave identity, +// so CoalesceRun's own row-side guard (`AND wake_wave_key = ”`) would +// happily accept it as a merge candidate — only queueRun's own +// request-side check (`if waveKey == ""`) can prevent a merge here. +// TestQueueRunCtx_WaveCarryingRequest_NotCoalesced cannot prove this: both +// of its requests carry a wave key, so its outcome is fully explained by +// the row-side guard alone regardless of what the request-side check does. +func TestQueueRunCtx_WaveCarryingRequest_NotCoalescedIntoNonWaveRow(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + ctx := context.Background() + + first := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "k1", + ExtraPayload: map[string]any{"marker": "plain-row"}, + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", first); err != nil { + t.Fatalf("queue first: %v", err) + } + + const waveKey = "task_children_completed:parent-1:bbbb" + second := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "k2", + WaveKey: waveKey, + WaveString: "parent-1|child-2", + ExtraPayload: map[string]any{"marker": "wave-row"}, + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", second); err != nil { + t.Fatalf("queue second: %v", err) + } + + if got := runsCountForReason(t, ss, RunReasonTaskChildrenCompleted); got != 2 { + t.Fatalf("runs = %d, want 2 (a wave-carrying request must not coalesce into a non-wave-carrying row)", got) + } + + var persistedWaveKey string + row := ss.repo.ReaderDB().QueryRowx( + ss.repo.ReaderDB().Rebind(`SELECT wake_wave_key FROM runs WHERE reason = ? AND wake_wave_key <> ''`), + RunReasonTaskChildrenCompleted) + if err := row.Scan(&persistedWaveKey); err != nil { + t.Fatalf("scan persisted wave key: %v", err) + } + if persistedWaveKey != waveKey { + t.Fatalf("persisted wake_wave_key = %q, want %q (second row's wave identity must survive uncoalesced)", persistedWaveKey, waveKey) + } + + var plainPayload string + row = ss.repo.ReaderDB().QueryRowx( + ss.repo.ReaderDB().Rebind(`SELECT payload FROM runs WHERE reason = ? AND wake_wave_key = ''`), + RunReasonTaskChildrenCompleted) + if err := row.Scan(&plainPayload); err != nil { + t.Fatalf("scan plain row payload: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(plainPayload), &decoded); err != nil { + t.Fatalf("decode plain row payload: %v", err) + } + if decoded["marker"] != "plain-row" { + t.Fatalf("plain row marker = %v, want plain-row (wave request must not overwrite it)", decoded["marker"]) + } +} + +// TestQueueRunCtx_NonWaveRequest_StillCoalesces is the regression guard for +// the coalescing-skip change above: two distinct requests with no wave key +// (distinct idempotency keys, so the idempotency check doesn't intercept +// either) must still coalesce into a single queued run exactly as before. +// Both requests target the same task: CoalesceRun now also scopes by +// payload.task_id (see runs/repository/sqlite's task-id-scoping fix), so a +// cross-task pair would correctly stay uncoalesced regardless of the +// wave-key gate this test isolates — using the same task keeps that +// unrelated guard out of the result. +func TestQueueRunCtx_NonWaveRequest_StillCoalesces(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + ctx := context.Background() + + first := RunContext{Reason: RunReasonTaskBlockersResolved, TaskID: "task-1", IdempotencyKey: "k1"} + second := RunContext{Reason: RunReasonTaskBlockersResolved, TaskID: "task-1", IdempotencyKey: "k2"} + if _, err := ss.QueueRunCtx(ctx, "agent-1", first); err != nil { + t.Fatalf("queue first: %v", err) + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", second); err != nil { + t.Fatalf("queue second: %v", err) + } + + if got := runsCountForReason(t, ss, RunReasonTaskBlockersResolved); got != 1 { + t.Fatalf("runs = %d, want 1 (non-wave requests must still coalesce)", got) + } +} + +// runPayloadForReason returns the JSON payload of the sole persisted run +// matching reason, decoded into a map for field assertions. +func runPayloadForReason(t *testing.T, ss *SchedulerService, reason string) map[string]any { + t.Helper() + var payload string + row := ss.repo.ReaderDB().QueryRowx( + ss.repo.ReaderDB().Rebind(`SELECT payload FROM runs WHERE reason = ?`), reason) + if err := row.Scan(&payload); err != nil { + t.Fatalf("scan payload: %v", err) + } + var m map[string]any + if err := json.Unmarshal([]byte(payload), &m); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + return m +} + +// TestQueueRunCtx_ExtraPayloadTaskID_CannotRedirectRun is the regression +// guard for encodeRunContext's envelope re-assertion: a workflow-authored +// queue_run action's payload.task_id must never override the persisted +// run's task_id away from the cascade's own task.ParentID, since task_id +// is what SchedulerIntegration.extractTaskID reads to check out and +// budget the run. +func TestQueueRunCtx_ExtraPayloadTaskID_CannotRedirectRun(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + ctx := context.Background() + + c := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "k1", + ExtraPayload: map[string]any{ + "task_id": "foreign-task-99", + }, + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", c); err != nil { + t.Fatalf("queue: %v", err) + } + + payload := runPayloadForReason(t, ss, RunReasonTaskChildrenCompleted) + if got := payload["task_id"]; got != "parent-1" { + t.Fatalf("payload task_id = %v, want %q (workflow-authored payload must not redirect the run)", got, "parent-1") + } +} + +// TestQueueRunCtx_ExtraPayloadAgentProfileID_PassesThroughUnfiltered pins a +// documented non-guarantee: unlike task_id/workspace_id/child_task_id/ +// workflow_step_id, encodeRunContext does not re-assert agent_profile_id +// from a typed field, because RunContext carries none to re-assert from. +// A workflow-authored payload.agent_profile_id therefore survives into the +// persisted payload as-is. This is harmless today — dispatch and routing +// read the run's AgentProfileID column, never this payload key — but a +// future consumer of payload["agent_profile_id"] must not assume the same +// override-protection the other four envelope fields get. +func TestQueueRunCtx_ExtraPayloadAgentProfileID_PassesThroughUnfiltered(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + ctx := context.Background() + + c := RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "k1", + ExtraPayload: map[string]any{ + "agent_profile_id": "foreign-agent-99", + }, + } + if _, err := ss.QueueRunCtx(ctx, "agent-1", c); err != nil { + t.Fatalf("queue: %v", err) + } + + payload := runPayloadForReason(t, ss, RunReasonTaskChildrenCompleted) + if got := payload["agent_profile_id"]; got != "foreign-agent-99" { + t.Fatalf("payload agent_profile_id = %v, want %q (documented non-guarantee: this key is not re-asserted)", got, "foreign-agent-99") + } +} diff --git a/apps/backend/internal/office/scheduler/wave_identity_race_test.go b/apps/backend/internal/office/scheduler/wave_identity_race_test.go new file mode 100644 index 0000000000..6a3c0c4ba6 --- /dev/null +++ b/apps/backend/internal/office/scheduler/wave_identity_race_test.go @@ -0,0 +1,91 @@ +package scheduler + +import ( + "context" + "sync" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + runsservice "github.com/kandev/kandev/internal/runs/service" +) + +// TestWaveIdentityRace_CascadeVsEngineRoutedPath_CollapsesToOneRun is +// Task 07's race test: P1's direct-insert path (SchedulerService.QueueRunCtx +// -> queueRun -> ss.repo.CreateRun, classified by +// runssqlite.IsWakeWaveUniqueViolation) races an engine-routed path +// (runsservice.Service.QueueRun -> insertRun -> CreateRunTx, classified by +// Task 02's own errWakeWaveKeyConflict handling) for the exact same wave +// identity and target agent, through two genuinely concurrent goroutines +// started together via a shared barrier channel — not a sequential call +// pair, which would only prove the classification logic works when +// invoked twice in a row on one goroutine. idx_run_wake_wave is the only +// thing that can make this deterministic: SQLite serializes the two +// INSERTs, but which of the two goroutines reaches it first is left to +// the Go scheduler, so the outcome asserted below must hold regardless +// of which side wins. +func TestWaveIdentityRace_CascadeVsEngineRoutedPath_CollapsesToOneRun(t *testing.T) { + repo := newReactivityTestRepo(t) + ss := newChildrenCompletedTestScheduler(t, repo) + createChildrenCompletedAgent(t, repo, "agent-1") + + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + eb := bus.NewMemoryEventBus(log) + runsSvc := runsservice.New(repo.RunsRepository(), eb, log, nil) + + const ( + waveKey = "task_children_completed:parent-1:deadbeef" + waveString = "parent-1|child-1,child-2" + ) + ctx := context.Background() + start := make(chan struct{}) + + var wg sync.WaitGroup + var cascadeErr error + var engineOutcome runsservice.QueueOutcome + var engineErr error + + wg.Add(2) + go func() { + defer wg.Done() + <-start + _, cascadeErr = ss.QueueRunCtx(ctx, "agent-1", RunContext{ + Reason: RunReasonTaskChildrenCompleted, + TaskID: "parent-1", + IdempotencyKey: "task_children_completed:parent-1:agent-1:cascade", + WaveKey: waveKey, + WaveString: waveString, + }) + }() + go func() { + defer wg.Done() + <-start + engineOutcome, engineErr = runsSvc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: RunReasonTaskChildrenCompleted, + AgentProfileID: "agent-1", + TaskID: "parent-1", + IdempotencyKey: "task_children_completed:parent-1:agent-1:engine", + WakeWaveKey: waveKey, + WakeWaveString: waveString, + }) + }() + close(start) + wg.Wait() + + if cascadeErr != nil { + t.Fatalf("cascade (P1) path must never surface the losing side as an error: %v", cascadeErr) + } + if engineErr != nil { + t.Fatalf("engine-routed path must never surface the losing side as an error: %v", engineErr) + } + if engineOutcome != runsservice.QueueOutcomeQueued && engineOutcome != runsservice.QueueOutcomeDeduped { + t.Fatalf("engine-routed outcome = %q, want Queued or Deduped", engineOutcome) + } + + if got := runsCountForReason(t, ss, RunReasonTaskChildrenCompleted); got != 1 { + t.Fatalf("runs for wave %q = %d, want exactly 1 (the two producers must collapse to one row)", waveKey, got) + } +} diff --git a/apps/backend/internal/office/service/children_completed_wave_identity_test.go b/apps/backend/internal/office/service/children_completed_wave_identity_test.go new file mode 100644 index 0000000000..4e75abdecb --- /dev/null +++ b/apps/backend/internal/office/service/children_completed_wave_identity_test.go @@ -0,0 +1,158 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/office/service" + "github.com/kandev/kandev/internal/office/waveidentity" + "github.com/kandev/kandev/internal/workflow/engine" +) + +// childMoveEvent builds the task.moved event queueChildrenCompletedRun's +// caller (finalizeDone) reacts to for a child of parentID landing in Done. +func childMoveEvent(childID, parentID string) *bus.Event { + return bus.NewEvent("task.moved", "test", map[string]string{ + "task_id": childID, + "workspace_id": "ws-1", + "from_step_id": "step-1", + "to_step_id": "step-done", + "to_step_name": "Done", + "from_step_name": "In Progress", + "assignee_agent_profile_id": "worker-1", + "parent_id": parentID, + }) +} + +// TestQueueChildrenCompletedRun_DispatchesWaveIdentity is +// AC-OFFICE-WAKE-WAVE-IDENTITY-002.16's engine-seam half for P2: the edge- +// triggered path must attach the wave identity derived from its own +// terminality-confirming ListWaveMembers read. +func TestQueueChildrenCompletedRun_DispatchesWaveIdentity(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + dispatcher := &fakeDispatcher{} + svc.SetWorkflowEngineDispatcher(dispatcher) + + adoptOffice(t, svc, "ws-1") + seedStuckParent(t, svc, "ws-1", "parent-1", "worker-1") + + if err := eb.Publish(ctx, "task.moved", childMoveEvent("parent-1-child-0", "parent-1")); err != nil { + t.Fatalf("publish task.moved: %v", err) + } + + calls := dispatcher.Calls() + if len(calls) != 1 { + t.Fatalf("want 1 dispatch, got %d: %#v", len(calls), calls) + } + payload, ok := calls[0].payload.(engine.OnChildrenCompletedPayload) + if !ok { + t.Fatalf("payload type = %T, want engine.OnChildrenCompletedPayload", calls[0].payload) + } + wantIDs := []string{"parent-1-child-0", "parent-1-child-1", "parent-1-child-2"} + if want := waveidentity.WaveKey("parent-1", wantIDs); payload.WaveKey != want { + t.Fatalf("WaveKey = %q, want %q", payload.WaveKey, want) + } + if want := waveidentity.WaveString("parent-1", wantIDs); payload.WaveString != want { + t.Fatalf("WaveString = %q, want %q", payload.WaveString, want) + } +} + +// TestQueueChildrenCompletedRun_NoWaveMembers_NoDispatch is AC-...-001.7 +// exercised through the edge path: every child is ephemeral (so +// AreAllChildrenTerminal — which does not filter on is_ephemeral — still +// reports the parent ready) but none is a wave member, so no trigger is +// dispatched. +func TestQueueChildrenCompletedRun_NoWaveMembers_NoDispatch(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + dispatcher := &fakeDispatcher{} + svc.SetWorkflowEngineDispatcher(dispatcher) + + adoptOffice(t, svc, "ws-1") + seedStuckParent(t, svc, "ws-1", "parent-1", "worker-1") + svc.ExecSQL(t, `UPDATE tasks SET is_ephemeral = 1 WHERE parent_id = 'parent-1'`) + + if err := eb.Publish(ctx, "task.moved", childMoveEvent("parent-1-child-0", "parent-1")); err != nil { + t.Fatalf("publish task.moved: %v", err) + } + + if calls := dispatcher.Calls(); len(calls) != 0 { + t.Fatalf("dispatched %d calls, want 0 (parent has no wave members)", len(calls)) + } +} + +// TestParentWakeReconciler_DispatchesWaveIdentity is the P3 twin of +// TestQueueChildrenCompletedRun_DispatchesWaveIdentity. +func TestParentWakeReconciler_DispatchesWaveIdentity(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + dispatcher := &fakeDispatcher{} + svc.SetWorkflowEngineDispatcher(dispatcher) + + adoptOffice(t, svc, "ws-1") + seedStuckParent(t, svc, "ws-1", "parent-1", "worker-1") + + handler := service.NewParentWakeReconciler(service.NewSchedulerIntegration(svc, 0)) + if err := handler.Tick(ctx); err != nil { + t.Fatalf("tick: %v", err) + } + + calls := dispatcher.Calls() + if len(calls) != 1 { + t.Fatalf("want 1 dispatch, got %d: %#v", len(calls), calls) + } + payload, ok := calls[0].payload.(engine.OnChildrenCompletedPayload) + if !ok { + t.Fatalf("payload type = %T, want engine.OnChildrenCompletedPayload", calls[0].payload) + } + wantIDs := []string{"parent-1-child-0", "parent-1-child-1", "parent-1-child-2"} + if want := waveidentity.WaveKey("parent-1", wantIDs); payload.WaveKey != want { + t.Fatalf("WaveKey = %q, want %q", payload.WaveKey, want) + } +} + +// TestChildrenCompletedWaveIdentity_IdenticalAcrossEdgeAndReconcilerPaths +// is this task's acceptance bullet on derivation parity: P2 and P3 must +// derive byte-identical wave keys for the same parent and wave-member set, +// so idx_run_wake_wave actually collapses a race between them (proven +// fully in Task 07; here only derivation parity is checked). +func TestChildrenCompletedWaveIdentity_IdenticalAcrossEdgeAndReconcilerPaths(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + dispatcher := &fakeDispatcher{} + svc.SetWorkflowEngineDispatcher(dispatcher) + + adoptOffice(t, svc, "ws-1") + seedStuckParent(t, svc, "ws-1", "parent-1", "worker-1") + + if err := eb.Publish(ctx, "task.moved", childMoveEvent("parent-1-child-0", "parent-1")); err != nil { + t.Fatalf("publish task.moved: %v", err) + } + + handler := service.NewParentWakeReconciler(service.NewSchedulerIntegration(svc, 0)) + if err := handler.Tick(ctx); err != nil { + t.Fatalf("tick: %v", err) + } + + calls := dispatcher.Calls() + if len(calls) != 2 { + t.Fatalf("want 2 dispatcher calls (edge + reconciler), got %d: %#v", len(calls), calls) + } + edge, ok := calls[0].payload.(engine.OnChildrenCompletedPayload) + if !ok { + t.Fatalf("edge payload type = %T, want engine.OnChildrenCompletedPayload", calls[0].payload) + } + reconciler, ok := calls[1].payload.(engine.OnChildrenCompletedPayload) + if !ok { + t.Fatalf("reconciler payload type = %T, want engine.OnChildrenCompletedPayload", calls[1].payload) + } + if edge.WaveKey == "" || reconciler.WaveKey == "" { + t.Fatalf("expected non-empty wave keys, got edge=%q reconciler=%q", edge.WaveKey, reconciler.WaveKey) + } + if edge.WaveKey != reconciler.WaveKey { + t.Fatalf("edge and reconciler derived different wave keys: edge=%q reconciler=%q", + edge.WaveKey, reconciler.WaveKey) + } +} diff --git a/apps/backend/internal/office/service/event_subscribers.go b/apps/backend/internal/office/service/event_subscribers.go index 6527ab92a5..77f9436b40 100644 --- a/apps/backend/internal/office/service/event_subscribers.go +++ b/apps/backend/internal/office/service/event_subscribers.go @@ -1220,6 +1220,11 @@ func (s *Service) queueChildrenCompletedRun(ctx context.Context, parentID string return err } + waveKey, waveString, ok := resolveWaveIdentity(ctx, s.repo, parentID, s.logger) + if !ok { + return nil + } + // Derived the same way as ParentWakeReconciler's recovery dispatch // (wakeOperationID) so both producers land on the identical operation // id for the same parent + child set + generation. That shared id is @@ -1237,7 +1242,10 @@ func (s *Service) queueChildrenCompletedRun(ctx context.Context, parentID string // would make the wake's content depend on which producer won the race. key := wakeOperationID(parentID, childSetKey, generation) return s.dispatchEngineTrigger(ctx, parentID, engine.TriggerOnChildrenCompleted, - engine.OnChildrenCompletedPayload{}, key) + engine.OnChildrenCompletedPayload{ + WaveKey: waveKey, + WaveString: waveString, + }, key) } // handleCommentCreated loads the comment and relays it to external channels. diff --git a/apps/backend/internal/office/service/scheduler_wake_reconciler.go b/apps/backend/internal/office/service/scheduler_wake_reconciler.go index 3fcfa3c65f..b6ef551dd1 100644 --- a/apps/backend/internal/office/service/scheduler_wake_reconciler.go +++ b/apps/backend/internal/office/service/scheduler_wake_reconciler.go @@ -103,10 +103,18 @@ func (h *ParentWakeReconciler) reconcileOne( return } + waveKey, waveString, ok := resolveWaveIdentity(ctx, svc.repo, c.ParentTaskID, h.logger) + if !ok { + return + } + // The payload carries no child summaries: the prompt path derives the // child list at assembly time. Nothing here can fail, so no read for the // briefing can stop a wake that readiness already judged due. - payload := engine.OnChildrenCompletedPayload{} + payload := engine.OnChildrenCompletedPayload{ + WaveKey: waveKey, + WaveString: waveString, + } currentKey, err := svc.repo.GetChildSetKey(ctx, c.ParentTaskID) if err != nil { diff --git a/apps/backend/internal/office/service/wave_identity.go b/apps/backend/internal/office/service/wave_identity.go new file mode 100644 index 0000000000..b6fab8322f --- /dev/null +++ b/apps/backend/internal/office/service/wave_identity.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/office/waveidentity" +) + +// resolveWaveIdentity performs the terminality-confirming last read +// shared by the two engine-routed task_children_completed producers: +// the edge-triggered path (event_subscribers.go, queueChildrenCompletedRun) +// and the backstop reconciler (scheduler_wake_reconciler.go, +// ParentWakeReconciler.reconcileOne). A wave identity is derived from, and +// only from, a wave-member read that itself observed every member +// terminal — not from an earlier AreAllChildrenTerminal / GetChildSetKey +// read, which counts every child rather than just wave members. A read +// error, any non-terminal wave member, or an empty wave-member set (a +// parent with no wave members has no wave) all report ok=false: the +// caller must queue no run, and the log param (nilable) records why at +// debug. +func resolveWaveIdentity( + ctx context.Context, repo *sqlite.Repository, parentID string, log *logger.Logger, +) (waveKey, waveString string, ok bool) { + members, err := repo.ListWaveMembers(ctx, parentID) + if err != nil { + if log != nil { + log.Debug("list wave members failed", + zap.String("parent_id", parentID), zap.Error(err)) + } + return "", "", false + } + if len(members) == 0 { + return "", "", false + } + ids := make([]string, 0, len(members)) + for _, m := range members { + if m.State != "COMPLETED" && m.State != "CANCELLED" { + return "", "", false + } + ids = append(ids, m.TaskID) + } + return waveidentity.WaveKey(parentID, ids), waveidentity.WaveString(parentID, ids), true +} diff --git a/apps/backend/internal/office/service/wave_identity_internal_test.go b/apps/backend/internal/office/service/wave_identity_internal_test.go new file mode 100644 index 0000000000..c01298c51d --- /dev/null +++ b/apps/backend/internal/office/service/wave_identity_internal_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/office/repository/sqlite" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// newWaveIdentityTestRepo builds a minimal office repository (backed by +// the real task schema, since ListWaveMembers reads the tasks table +// taskrepo owns) for resolveWaveIdentity's own unit tests. +func newWaveIdentityTestRepo(t *testing.T) *sqlite.Repository { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if _, err := taskrepo.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init task repo: %v", err) + } + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init office repo: %v", err) + } + return repo +} + +func insertWaveIdentityTask(t *testing.T, repo *sqlite.Repository, ctx context.Context, id, parentID, state string) { + t.Helper() + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO tasks (id, workspace_id, title, parent_id, state, created_at, updated_at) + VALUES (?, 'ws-1', 'Task', ?, ?, datetime('now'), datetime('now')) + `, id, parentID, state); err != nil { + t.Fatalf("insert task %s: %v", id, err) + } +} + +// TestResolveWaveIdentity_NonTerminalMemberReportsNotOK is +// AC-OFFICE-WAKE-WAVE-IDENTITY-002.15's regression test for P2/P3's +// shared terminality gate: resolveWaveIdentity must report ok=false (and +// derive no wave key/string at all) when any wave member has not yet +// reached a terminal state, exactly like P1's byte-identical gate in +// cascadeChildrenCompleted (reactivity.go). Before this test, only P1's +// arm of the same invariant was pinned — a regression here (e.g. +// dropping the state check, or loosening it to only COMPLETED) would ship +// P2/P3 dispatching a wave identity for a wave that has not actually +// finished. +func TestResolveWaveIdentity_NonTerminalMemberReportsNotOK(t *testing.T) { + repo := newWaveIdentityTestRepo(t) + ctx := context.Background() + var log *logger.Logger + + const parentID = "parent-1" + insertWaveIdentityTask(t, repo, ctx, parentID, "", "TODO") + insertWaveIdentityTask(t, repo, ctx, parentID+"-child-0", parentID, "COMPLETED") + insertWaveIdentityTask(t, repo, ctx, parentID+"-child-1", parentID, "IN_PROGRESS") + + waveKey, waveString, ok := resolveWaveIdentity(ctx, repo, parentID, log) + if ok { + t.Fatalf("ok = true, want false — child-1 is not terminal, so no wave identity is derivable") + } + if waveKey != "" || waveString != "" { + t.Fatalf("waveKey=%q waveString=%q, want both empty when ok=false", waveKey, waveString) + } +} + +// TestResolveWaveIdentity_AllTerminalReportsOK is the control alongside +// TestResolveWaveIdentity_NonTerminalMemberReportsNotOK: once the +// remaining member also reaches a terminal state, resolveWaveIdentity +// derives a non-empty wave key/string and reports ok=true. +func TestResolveWaveIdentity_AllTerminalReportsOK(t *testing.T) { + repo := newWaveIdentityTestRepo(t) + ctx := context.Background() + var log *logger.Logger + + const parentID = "parent-1" + insertWaveIdentityTask(t, repo, ctx, parentID, "", "TODO") + insertWaveIdentityTask(t, repo, ctx, parentID+"-child-0", parentID, "COMPLETED") + insertWaveIdentityTask(t, repo, ctx, parentID+"-child-1", parentID, "CANCELLED") + + waveKey, waveString, ok := resolveWaveIdentity(ctx, repo, parentID, log) + if !ok { + t.Fatalf("ok = false, want true — every wave member is terminal (COMPLETED or CANCELLED)") + } + if waveKey == "" || waveString == "" { + t.Fatalf("waveKey=%q waveString=%q, want both non-empty when ok=true", waveKey, waveString) + } +} diff --git a/apps/backend/internal/office/waveidentity/waveidentity.go b/apps/backend/internal/office/waveidentity/waveidentity.go new file mode 100644 index 0000000000..c981a520e7 --- /dev/null +++ b/apps/backend/internal/office/waveidentity/waveidentity.go @@ -0,0 +1,42 @@ +// Package waveidentity derives the completion-wave identity for a +// task_children_completed wake (docs/specs/office/system-design/ +// parent-wake-wave-identity.md, "Wave identity"). Both exported functions +// are pure: given the same parent id and member id slice they always +// return the same value, do no I/O, and never fail. Callers are +// responsible for restricting memberIDs to wave members (not archived, not +// ephemeral, not automation-origin) and ordering them ascending by +// tasks.id before calling either function. +package waveidentity + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// waveKeyPrefix identifies the run reason a wave key belongs to, matching +// runs.reason for every producer of this wake. +const waveKeyPrefix = "task_children_completed" + +// WaveString is the canonical encoding of a completion wave: the parent id +// and its wave members' ids, ascending by tasks.id, joined by "|" and ",". +// The separator is "|", not a NUL byte, because PostgreSQL text columns +// cannot store U+0000; task ids are lowercase-hex UUIDs, so "|" cannot +// occur inside one. A parent with no wave members still produces a +// well-formed string ("|"), but per the requirements' +// Terminology that value is a pre-gate intermediate, not a wave string — +// callers must not persist or key on it for an empty memberIDs slice. +func WaveString(parentID string, memberIDs []string) string { + return parentID + "|" + strings.Join(memberIDs, ",") +} + +// WaveKey is the fixed-length digest encoding of the same identity +// WaveString names, indexed by the run queue's uniqueness constraint. +// It is a pure function of WaveString's output: equal wave strings always +// yield equal keys. The parent id appears again as a readable prefix so a +// stored key stays diagnosable, and so two parents with an identical +// member set never collide. +func WaveKey(parentID string, memberIDs []string) string { + sum := sha256.Sum256([]byte(WaveString(parentID, memberIDs))) + return waveKeyPrefix + ":" + parentID + ":" + hex.EncodeToString(sum[:]) +} diff --git a/apps/backend/internal/office/waveidentity/waveidentity_test.go b/apps/backend/internal/office/waveidentity/waveidentity_test.go new file mode 100644 index 0000000000..f948fba9e4 --- /dev/null +++ b/apps/backend/internal/office/waveidentity/waveidentity_test.go @@ -0,0 +1,103 @@ +package waveidentity + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" +) + +func TestWaveString_JoinsParentAndMembersWithPipeAndComma(t *testing.T) { + got := WaveString("parent-1", []string{"child-1", "child-2"}) + want := "parent-1|child-1,child-2" + if got != want { + t.Fatalf("WaveString() = %q, want %q", got, want) + } +} + +func TestWaveString_EmptyMembers(t *testing.T) { + got := WaveString("parent-1", nil) + want := "parent-1|" + if got != want { + t.Fatalf("WaveString() = %q, want %q", got, want) + } +} + +func TestWaveString_DeterministicForFixedInput(t *testing.T) { + ids := []string{"a", "b", "c"} + first := WaveString("p", ids) + second := WaveString("p", ids) + if first != second { + t.Fatalf("WaveString() not deterministic: %q != %q", first, second) + } +} + +func TestWaveString_DifferentMemberSetsDiffer(t *testing.T) { + a := WaveString("p", []string{"1", "2"}) + b := WaveString("p", []string{"1", "3"}) + if a == b { + t.Fatalf("expected different member sets to produce different wave strings, got %q for both", a) + } +} + +func TestWaveString_DifferentParentsWithSameMembersDiffer(t *testing.T) { + a := WaveString("parent-a", []string{"1", "2"}) + b := WaveString("parent-b", []string{"1", "2"}) + if a == b { + t.Fatalf("expected different parents to produce different wave strings, got %q for both", a) + } +} + +func TestWaveKey_IsPureFunctionOfWaveString(t *testing.T) { + ids := []string{"child-1", "child-2"} + key1 := WaveKey("parent-1", ids) + key2 := WaveKey("parent-1", ids) + if key1 != key2 { + t.Fatalf("WaveKey() not deterministic: %q != %q", key1, key2) + } + + // Equal wave strings must always yield equal keys (AC-...-001.11): the + // key is exactly the documented digest of the wave string, recomputed + // here independently of the production implementation. + s := WaveString("parent-1", ids) + sum := sha256.Sum256([]byte(s)) + want := fmt.Sprintf("task_children_completed:parent-1:%s", hex.EncodeToString(sum[:])) + if key1 != want { + t.Fatalf("WaveKey() = %q, want %q (digest of wave string %q)", key1, want, s) + } +} + +func TestWaveKey_HasReadablePrefixAndFixedLengthDigest(t *testing.T) { + key := WaveKey("parent-1", []string{"child-1"}) + const prefix = "task_children_completed:parent-1:" + if len(key) <= len(prefix) { + t.Fatalf("WaveKey() = %q, want prefix %q followed by a digest", key, prefix) + } + if key[:len(prefix)] != prefix { + t.Fatalf("WaveKey() = %q, want prefix %q", key, prefix) + } + digest := key[len(prefix):] + const sha256HexLen = 64 + if len(digest) != sha256HexLen { + t.Fatalf("WaveKey() digest length = %d, want %d (lowercase hex sha256)", len(digest), sha256HexLen) + } +} + +func TestWaveKey_DifferentMemberSetsDiffer(t *testing.T) { + a := WaveKey("p", []string{"1", "2"}) + b := WaveKey("p", []string{"1", "3"}) + if a == b { + t.Fatalf("expected different member sets to produce different wave keys, got %q for both", a) + } +} + +func TestWaveKey_SeparatorCannotCollideAcrossDifferentSplits(t *testing.T) { + // A pipe-joined id list must not let two different (parent, members) + // splits collide into the same wave string, the way a naive + // concatenation without a separator could. + a := WaveString("p1", []string{"ab", "c"}) + b := WaveString("p1a", []string{"b", "c"}) + if a == b { + t.Fatalf("expected distinguishable splits to produce different wave strings, got %q for both", a) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_children_completed.go b/apps/backend/internal/orchestrator/event_handlers_children_completed.go index b8539f5e2e..7c3c2098c3 100644 --- a/apps/backend/internal/orchestrator/event_handlers_children_completed.go +++ b/apps/backend/internal/orchestrator/event_handlers_children_completed.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "sort" "strings" "sync" "time" @@ -13,6 +14,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/office/dashboard" + "github.com/kandev/kandev/internal/office/waveidentity" "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/engine" @@ -354,7 +356,7 @@ func (s *Service) evaluateChildrenCompleted( // commit the transition lifecycle before accepting this operation. DeferOperationMark: true, PreloadedState: &state, - Payload: childCompletionPayload(rows), + Payload: childCompletionPayload(parent.ID, rows), }) if err != nil { s.logger.Warn("on_children_completed: workflow engine error", @@ -403,7 +405,14 @@ func (s *Service) workflowStepIsTerminal(ctx context.Context, workflowStepID str return wfmodels.IsTerminalStep(step, nextStep) } -func childCompletionPayload(rows []models.ChildCompletionRow) engine.OnChildrenCompletedPayload { +// childCompletionPayload builds the engine payload for an +// on_children_completed dispatch. rows arrive ordered by created_at (see +// ListChildCompletionRows) with terminality already confirmed by the +// caller's single read (readyChildCompletionRows) — that same read is +// what the wave identity is derived from, so this function only re-sorts +// a copy of rows ascending by id before deriving it; it performs no read +// of its own. +func childCompletionPayload(parentID string, rows []models.ChildCompletionRow) engine.OnChildrenCompletedPayload { summaries := make([]engine.ChildSummary, 0, len(rows)) for _, row := range rows { summaries = append(summaries, engine.ChildSummary{ @@ -412,7 +421,24 @@ func childCompletionPayload(rows []models.ChildCompletionRow) engine.OnChildrenC Summary: row.Title, }) } - return engine.OnChildrenCompletedPayload{ChildSummaries: summaries} + waveKey, waveString := childCompletionWaveIdentity(parentID, rows) + return engine.OnChildrenCompletedPayload{ + ChildSummaries: summaries, + WaveKey: waveKey, + WaveString: waveString, + } +} + +// childCompletionWaveIdentity derives the completion-wave identity from +// rows sorted ascending by id — a copy, so it doesn't disturb rows' +// created_at ordering, which childCompletionOperationID still depends on. +func childCompletionWaveIdentity(parentID string, rows []models.ChildCompletionRow) (waveKey, waveString string) { + ids := make([]string, len(rows)) + for i, row := range rows { + ids[i] = row.ID + } + sort.Strings(ids) + return waveidentity.WaveKey(parentID, ids), waveidentity.WaveString(parentID, ids) } func childCompletionStatus(row models.ChildCompletionRow) string { diff --git a/apps/backend/internal/orchestrator/event_handlers_children_completed_wave_test.go b/apps/backend/internal/orchestrator/event_handlers_children_completed_wave_test.go new file mode 100644 index 0000000000..032f11650c --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_children_completed_wave_test.go @@ -0,0 +1,51 @@ +package orchestrator + +import ( + "testing" + + "github.com/kandev/kandev/internal/office/waveidentity" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// TestChildCompletionPayload_DerivesWaveIdentityFromIDSortedRows is this +// task's acceptance test for P4's re-sort: ListChildCompletionRows orders +// by created_at first (see its doc comment), so feeding rows in an order +// that disagrees with ascending id must still produce the id-sorted wave +// identity (AC-OFFICE-WAKE-WAVE-IDENTITY-001.2). +func TestChildCompletionPayload_DerivesWaveIdentityFromIDSortedRows(t *testing.T) { + // Deliberately out of id order (as if ordered by an earlier created_at). + rows := []models.ChildCompletionRow{ + {ID: "child-b", State: v1.TaskStateCompleted}, + {ID: "child-a", State: v1.TaskStateCancelled}, + } + + payload := childCompletionPayload("parent-1", rows) + + wantIDs := []string{"child-a", "child-b"} + if want := waveidentity.WaveKey("parent-1", wantIDs); payload.WaveKey != want { + t.Fatalf("WaveKey = %q, want %q (rows must be re-sorted ascending by id)", payload.WaveKey, want) + } + if want := waveidentity.WaveString("parent-1", wantIDs); payload.WaveString != want { + t.Fatalf("WaveString = %q, want %q (rows must be re-sorted ascending by id)", payload.WaveString, want) + } +} + +// TestChildCompletionPayload_MatchesCascadeDerivationForSameParentAndSet is +// this task's cross-producer derivation-parity check (proven fully, +// together with the other three producers, in Task 07): for a fixed +// parent and wave-member id set, P4 must derive the exact same wave key +// waveidentity.WaveKey itself would produce directly — i.e. P4 adds no +// transformation beyond the id sort. +func TestChildCompletionPayload_MatchesCascadeDerivationForSameParentAndSet(t *testing.T) { + rows := []models.ChildCompletionRow{ + {ID: "child-1", State: v1.TaskStateCompleted}, + {ID: "child-2", State: v1.TaskStateCompleted}, + } + payload := childCompletionPayload("parent-1", rows) + + want := waveidentity.WaveKey("parent-1", []string{"child-1", "child-2"}) + if payload.WaveKey != want { + t.Fatalf("WaveKey = %q, want %q", payload.WaveKey, want) + } +} diff --git a/apps/backend/internal/runs/repository/sqlite/idempotency_violation.go b/apps/backend/internal/runs/repository/sqlite/idempotency_violation.go index 9df3d72bfd..aa342cc5ed 100644 --- a/apps/backend/internal/runs/repository/sqlite/idempotency_violation.go +++ b/apps/backend/internal/runs/repository/sqlite/idempotency_violation.go @@ -36,3 +36,34 @@ func IsIdempotencyKeyUniqueViolation(err error) bool { } return strings.Contains(err.Error(), sqliteRunIdempotencyViolationMessage) } + +// runWakeWaveIndexName is the unique index enforcing at most one runs row +// per (wake_wave_key, agent_profile_id) pair (internal/office/repository/ +// sqlite/base.go, idx_run_wake_wave). +const runWakeWaveIndexName = "idx_run_wake_wave" + +// sqliteRunWakeWaveViolationMessage is the substring go-sqlite3 puts in a +// UNIQUE-constraint error for this index. Composite-index violations list +// every participating column ("UNIQUE constraint failed: runs.wake_wave_key, +// runs.agent_profile_id"), a different shape from +// sqliteRunIdempotencyViolationMessage's single-column message — confirmed +// empirically, not assumed from the single-column case. +const sqliteRunWakeWaveViolationMessage = "UNIQUE constraint failed: runs.wake_wave_key, runs.agent_profile_id" + +// IsWakeWaveUniqueViolation reports whether err is a violation of +// idx_run_wake_wave specifically. Deliberately a sibling function rather +// than widening IsIdempotencyKeyUniqueViolation to "any unique violation": +// the two indexes have independent meanings ("same dispatch" vs "same +// wave") and a caller that needs to distinguish which one fired (the runs +// service and office/scheduler both do) would otherwise have to re-derive +// the distinction itself. +func IsWakeWaveUniqueViolation(err error) bool { + if err == nil { + return false + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" && pgErr.ConstraintName == runWakeWaveIndexName + } + return strings.Contains(err.Error(), sqliteRunWakeWaveViolationMessage) +} diff --git a/apps/backend/internal/runs/repository/sqlite/idempotency_violation_test.go b/apps/backend/internal/runs/repository/sqlite/idempotency_violation_test.go new file mode 100644 index 0000000000..b951fed9f8 --- /dev/null +++ b/apps/backend/internal/runs/repository/sqlite/idempotency_violation_test.go @@ -0,0 +1,74 @@ +package sqlite + +import ( + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func TestIsWakeWaveUniqueViolation_SQLite(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer func() { _ = db.Close() }() + db.MustExec(`CREATE TABLE runs ( + id TEXT PRIMARY KEY, + wake_wave_key TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL, + idempotency_key TEXT + )`) + db.MustExec(`CREATE UNIQUE INDEX idx_run_wake_wave ON runs(wake_wave_key, agent_profile_id) WHERE wake_wave_key <> ''`) + db.MustExec(`CREATE UNIQUE INDEX idx_run_idempotency ON runs(idempotency_key) WHERE idempotency_key IS NOT NULL`) + db.MustExec(`INSERT INTO runs (id, wake_wave_key, agent_profile_id) VALUES ('a', 'wave-1', 'agent-1')`) + + _, err = db.Exec(`INSERT INTO runs (id, wake_wave_key, agent_profile_id) VALUES ('b', 'wave-1', 'agent-1')`) + if err == nil { + t.Fatal("expected a unique-index violation, got none") + } + if !IsWakeWaveUniqueViolation(err) { + t.Errorf("IsWakeWaveUniqueViolation(%v) = false, want true", err) + } + if IsIdempotencyKeyUniqueViolation(err) { + t.Error("a wake-wave violation must not be classified as an idempotency-key violation") + } + + db.MustExec(`INSERT INTO runs (id, idempotency_key, agent_profile_id) VALUES ('c', 'idem-1', 'agent-1')`) + _, err = db.Exec(`INSERT INTO runs (id, idempotency_key, agent_profile_id) VALUES ('d', 'idem-1', 'agent-1')`) + if err == nil { + t.Fatal("expected an idempotency-key violation, got none") + } + if IsWakeWaveUniqueViolation(err) { + t.Error("an idempotency-key violation must not be classified as a wake-wave violation") + } + if !IsIdempotencyKeyUniqueViolation(err) { + t.Errorf("IsIdempotencyKeyUniqueViolation(%v) = false, want true", err) + } +} + +func TestIsWakeWaveUniqueViolation_Nil(t *testing.T) { + if IsWakeWaveUniqueViolation(nil) { + t.Error("IsWakeWaveUniqueViolation(nil) = true, want false") + } +} + +func TestIsWakeWaveUniqueViolation_UnrelatedError(t *testing.T) { + if IsWakeWaveUniqueViolation(errors.New("some other failure")) { + t.Error("IsWakeWaveUniqueViolation(unrelated error) = true, want false") + } +} + +func TestIsWakeWaveUniqueViolation_PostgresConstraintName(t *testing.T) { + matching := &pgconn.PgError{Code: "23505", ConstraintName: "idx_run_wake_wave"} + if !IsWakeWaveUniqueViolation(matching) { + t.Error("expected a matching pgconn.PgError to classify as a wake-wave violation") + } + + other := &pgconn.PgError{Code: "23505", ConstraintName: "idx_run_idempotency"} + if IsWakeWaveUniqueViolation(other) { + t.Error("a differently-named unique violation must not classify as a wake-wave violation") + } +} diff --git a/apps/backend/internal/runs/repository/sqlite/runs.go b/apps/backend/internal/runs/repository/sqlite/runs.go index c44aa584db..e0091e89dc 100644 --- a/apps/backend/internal/runs/repository/sqlite/runs.go +++ b/apps/backend/internal/runs/repository/sqlite/runs.go @@ -42,13 +42,15 @@ func (r *Repository) CreateRunTx(ctx context.Context, tx *sqlx.Tx, req *models.R id, agent_profile_id, reason, payload, status, coalesced_count, idempotency_key, context_snapshot, capabilities, input_snapshot, output_summary, failure_reason, session_id, retry_count, scheduled_retry_at, - requested_at, error_message, cancel_reason, continuation_scope, causation_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + requested_at, error_message, cancel_reason, continuation_scope, + wake_wave_key, wake_wave_string, causation_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), req.ID, req.AgentProfileID, req.Reason, req.Payload, req.Status, req.CoalescedCount, req.IdempotencyKey, req.ContextSnapshot, req.Capabilities, req.InputSnapshot, req.OutputSummary, req.FailureReason, req.SessionID, req.RetryCount, req.ScheduledRetryAt, req.RequestedAt, - req.ErrorMessage, req.CancelReason, req.ContinuationScope, req.CausationID) + req.ErrorMessage, req.CancelReason, req.ContinuationScope, + req.WakeWaveKey, req.WakeWaveString, req.CausationID) return err } @@ -539,6 +541,7 @@ func (r *Repository) CoalesceRun( WHERE agent_profile_id = ? AND reason = ? AND status = 'queued' AND requested_at > ? AND (idempotency_key IS NULL OR idempotency_key NOT LIKE ?) + AND wake_wave_key = '' %s ORDER BY requested_at DESC LIMIT 1 diff --git a/apps/backend/internal/runs/service/cross_task_wave_test.go b/apps/backend/internal/runs/service/cross_task_wave_test.go new file mode 100644 index 0000000000..00914cc668 --- /dev/null +++ b/apps/backend/internal/runs/service/cross_task_wave_test.go @@ -0,0 +1,125 @@ +package service_test + +import ( + "context" + "encoding/json" + "testing" + + runsservice "github.com/kandev/kandev/internal/runs/service" + "github.com/kandev/kandev/internal/workflow/engine" +) + +type engineRunsServiceAdapter struct { + service *runsservice.Service +} + +func (a engineRunsServiceAdapter) QueueRun( + ctx context.Context, req engine.QueueRunRequest, +) (engine.QueueOutcome, error) { + outcome, err := a.service.QueueRun(ctx, runsservice.QueueRunRequest{ + AgentProfileID: req.AgentProfileID, + TaskID: req.TaskID, + WorkflowStepID: req.WorkflowStepID, + Reason: req.Reason, + IdempotencyKey: req.IdempotencyKey, + Payload: req.Payload, + WakeWaveKey: req.WaveKey, + WakeWaveString: req.WaveString, + }) + return engine.QueueOutcome(outcome), err +} + +type crossTaskStepResolver struct{} + +func (crossTaskStepResolver) WorkflowStepIDForTask(context.Context, string) (string, error) { + return "step-target", nil +} + +func childrenCompletedQueueInput(taskID, operationID string) engine.ActionInput { + return engine.ActionInput{ + Trigger: engine.TriggerOnChildrenCompleted, + State: engine.MachineState{ + TaskID: "parent-1", + WorkflowID: "workflow-1", + }, + Step: engine.StepSpec{ID: "step-parent"}, + Action: engine.Action{ + Kind: engine.ActionQueueRun, + QueueRun: &engine.QueueRunAction{ + Target: "agent_profile_id:agent-a", + TaskID: taskID, + Reason: "task_children_completed", + Payload: map[string]any{"marker": taskID}, + }, + }, + OperationID: operationID, + Payload: engine.OnChildrenCompletedPayload{ + WaveKey: "task_children_completed:parent-1:deadbeef", + WaveString: "parent-1|child-1,child-2", + }, + } +} + +func TestQueueRunCallback_CrossTaskWaveIdentitySurvivesInEitherActionOrder(t *testing.T) { + orders := []struct { + name string + reverse bool + }{ + {name: "same-task-first"}, + {name: "cross-task-first", reverse: true}, + } + + for _, tc := range orders { + t.Run(tc.name, func(t *testing.T) { + svc, _, repo := newTestServiceWithRepo(t) + callback := engine.QueueRunCallback{ + Adapter: engineRunsServiceAdapter{service: svc}, + TaskSteps: crossTaskStepResolver{}, + } + actions := []engine.ActionInput{ + childrenCompletedQueueInput("this", "same-task"), + childrenCompletedQueueInput("task-2", "cross-task"), + } + if tc.reverse { + actions[0], actions[1] = actions[1], actions[0] + } + for _, action := range actions { + if _, err := callback.Execute(t.Context(), action); err != nil { + t.Fatalf("queue %s action: %v", action.OperationID, err) + } + } + + var rows []struct { + Payload string `db:"payload"` + WakeWaveKey string `db:"wake_wave_key"` + } + if err := repo.Reader().SelectContext(t.Context(), &rows, + `SELECT payload, wake_wave_key FROM runs WHERE agent_profile_id = ? AND reason = ?`, + "agent-a", "task_children_completed"); err != nil { + t.Fatalf("list queued runs: %v", err) + } + if len(rows) != 2 { + t.Fatalf("queued runs = %d, want 2 (same-task and cross-task actions must both survive)", len(rows)) + } + + waveByTask := make(map[string]string, len(rows)) + for _, row := range rows { + var payload map[string]any + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + t.Fatalf("decode run payload %q: %v", row.Payload, err) + } + taskID, ok := payload["task_id"].(string) + if !ok { + t.Fatalf("run payload task_id = %v, want string", payload["task_id"]) + } + waveByTask[taskID] = row.WakeWaveKey + } + if waveByTask["parent-1"] != "task_children_completed:parent-1:deadbeef" { + t.Fatalf("parent wave key = %q, want source wave identity", waveByTask["parent-1"]) + } + if waveByTask["task-2"] != "" { + t.Fatalf("cross-task wave key = %q, want empty", waveByTask["task-2"]) + } + }) + } +} diff --git a/apps/backend/internal/runs/service/metrics_vars.go b/apps/backend/internal/runs/service/metrics_vars.go index 614d7362a0..b93cef5aa8 100644 --- a/apps/backend/internal/runs/service/metrics_vars.go +++ b/apps/backend/internal/runs/service/metrics_vars.go @@ -47,6 +47,21 @@ var ( runDedupKeylessTotal = expvar.NewMap("office_run_dedup_keyless_total") ) +// ParentWakeDedupedTotal counts a task_children_completed insert rejected by +// idx_run_wake_wave — the only direct evidence the completion-wave identity +// constraint is doing work, since a run that never exists leaves no other +// trace. Incremented at both classification sites (runs/service and +// office/scheduler, which insert through the same CreateRun but classify +// its error independently) so a producer-side dedupe is as visible as an +// engine-routed one. +// +// Declared here rather than in internal/office/shared: that package started +// importing internal/runs/service for RunQueuer's QueueOutcome return type, +// so the reverse edge this counter used to need would be an import cycle. +// office/scheduler already imports this package directly (for QueueOutcome), +// so it reaches the counter the same way. +var ParentWakeDedupedTotal = expvar.NewInt("parent_wake_deduped_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 { diff --git a/apps/backend/internal/runs/service/service.go b/apps/backend/internal/runs/service/service.go index 43d9349c53..fc0da1857e 100644 --- a/apps/backend/internal/runs/service/service.go +++ b/apps/backend/internal/runs/service/service.go @@ -76,6 +76,14 @@ type QueueRunRequest struct { Reason string IdempotencyKey string Payload map[string]any + // WakeWaveKey and WakeWaveString are the completion-wave identity + // (parent-wake-wave-identity). When WakeWaveKey is non-empty, QueueRun + // never coalesces this request into an existing row, is never merged + // into by a later request, and idx_run_wake_wave deduplicates a second + // insert for the same (WakeWaveKey, AgentProfileID) into + // QueueOutcomeDeduped instead of an error. + WakeWaveKey string + WakeWaveString string } // CoalesceWindowSeconds is the default coalescing window. When two @@ -206,6 +214,18 @@ func (s *Service) QueueRun(ctx context.Context, req QueueRunRequest) (QueueOutco } row, insertErr := s.insertRun(ctx, agentInstanceID, req, payload) + // idx_run_wake_wave has no windowed pre-check the way IdempotencyKey + // does — it must stay unbounded — so this is the only place a + // wave-carrying conflict is caught. Checked before ReportInsertResult + // (which only classifies idx_run_idempotency) so the two indexes' + // independent meanings ("same dispatch" vs "same wave") stay + // distinguishable to the caller and to office_run_dedup_total's labels. + if runssqlite.IsWakeWaveUniqueViolation(insertErr) { + s.log.Debug("run skipped (wake wave index race)", + zap.String("wake_wave_key", req.WakeWaveKey)) + ParentWakeDedupedTotal.Add(1) + return QueueOutcomeDeduped, nil + } // idx_run_idempotency has no time bound, so a conflict here can come // from a row older than IdempotencyWindowHours, not just the windowed // race CheckIdempotencyKey guards against above. ReportInsertResult @@ -250,6 +270,8 @@ func (s *Service) insertRun( CoalescedCount: 1, IdempotencyKey: idemKeyPtr, RequestedAt: time.Now().UTC(), + WakeWaveKey: req.WakeWaveKey, + WakeWaveString: req.WakeWaveString, } if err := s.repo.CreateRun(ctx, row); err != nil { return nil, fmt.Errorf("enqueue run: %w", err) @@ -297,8 +319,14 @@ func runPayload(req QueueRunRequest, agentInstanceID string) map[string]any { return out } +// shouldCoalesceRun decides whether a request may be merged into an +// existing queued row. A wave-carrying request is never coalesced: +// coalescing replaces the target row's payload without moving its +// recorded identity, which would leave a run whose wave columns no +// longer describe the wake it delivers. idx_run_wake_wave, not this +// window, is what reconciles wave-carrying requests. func shouldCoalesceRun(req QueueRunRequest) bool { - return !commentkeys.HasTaskCommentPrefix(req.IdempotencyKey) + return req.WakeWaveKey == "" && !commentkeys.HasTaskCommentPrefix(req.IdempotencyKey) } // publishRunQueued emits the OfficeRunQueued bus event so the WS diff --git a/apps/backend/internal/runs/service/service_postgres_test.go b/apps/backend/internal/runs/service/service_postgres_test.go index bb17e702e4..b88e40670e 100644 --- a/apps/backend/internal/runs/service/service_postgres_test.go +++ b/apps/backend/internal/runs/service/service_postgres_test.go @@ -91,3 +91,63 @@ func TestPostgresQueueRun_DedupesOnIdempotencyIndexRace(t *testing.T) { t.Fatalf("runs with idempotency_key %q = %d, want 1", key, count) } } + +// TestPostgresQueueRun_DedupesOnWakeWaveKeyIndexRace is the PostgreSQL twin +// of TestQueueRun_DedupesOnWakeWaveKeyIndexRace. idx_run_wake_wave has no +// windowed pre-check the way idempotency_key does (AC-OFFICE-WAKE-WAVE- +// IDENTITY-002.6 requires it stay unbounded), so unlike the idempotency +// race above this needs no artificial aging step: the second insert always +// reaches the unique index. Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestPostgresQueueRun_DedupesOnWakeWaveKeyIndexRace(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := taskrepo.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init task repo: %v", err) + } + officeRepo, err := officesqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init office repo: %v", err) + } + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + eb := bus.NewMemoryEventBus(log) + svc := runsservice.New(officeRepo.RunsRepository(), eb, log, nil) + repo := officeRepo.RunsRepository() + ctx := context.Background() + + const waveKey = "task_children_completed:parent-1:deadbeef" + + first, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: waveKey, + WakeWaveString: "parent-1|child-1,child-2", + Payload: map[string]any{"agent_profile_id": "a1", "task_id": "t1"}, + }) + if err != nil { + t.Fatalf("queue first run: %v", err) + } + if first != runsservice.QueueOutcomeQueued { + t.Fatalf("first outcome = %q, want %q", first, runsservice.QueueOutcomeQueued) + } + + second, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: waveKey, + WakeWaveString: "parent-1|child-1,child-2", + Payload: map[string]any{"agent_profile_id": "a1", "task_id": "t1"}, + }) + if err != nil { + t.Fatalf("queue racing run: %v", err) + } + if second != runsservice.QueueOutcomeDeduped { + t.Fatalf("racing outcome = %q, want %q", second, runsservice.QueueOutcomeDeduped) + } + + var count int + if err := repo.Reader().GetContext(ctx, &count, + repo.Reader().Rebind(`SELECT COUNT(*) FROM runs WHERE wake_wave_key = ?`), waveKey); err != nil { + t.Fatalf("count runs: %v", err) + } + if count != 1 { + t.Fatalf("runs with wake_wave_key %q = %d, want 1", waveKey, count) + } +} diff --git a/apps/backend/internal/runs/service/service_test.go b/apps/backend/internal/runs/service/service_test.go index f7b31046e9..29d092d202 100644 --- a/apps/backend/internal/runs/service/service_test.go +++ b/apps/backend/internal/runs/service/service_test.go @@ -654,6 +654,222 @@ func TestQueueRun_DedupesOnIdempotencyIndexRace(t *testing.T) { } } +// TestQueueRun_DedupesOnWakeWaveKeyIndexRace mirrors +// TestQueueRun_DedupesOnIdempotencyIndexRace for idx_run_wake_wave: two +// requests carrying the same (WakeWaveKey, AgentProfileID) must collapse to +// one runs row, and the losing request must return QueueOutcomeDeduped, not +// an error (AC-OFFICE-WAKE-WAVE-IDENTITY-002.3/.4). +func TestQueueRun_DedupesOnWakeWaveKeyIndexRace(t *testing.T) { + svc, _, repo := newTestServiceWithRepo(t) + ctx := context.Background() + + const waveKey = "task_children_completed:parent-1:deadbeef" + + first, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: waveKey, + WakeWaveString: "parent-1|child-1,child-2", + Payload: agentInPayload("a1"), + }) + if err != nil { + t.Fatalf("queue first run: %v", err) + } + if first != runsservice.QueueOutcomeQueued { + t.Fatalf("first outcome = %q, want %q", first, runsservice.QueueOutcomeQueued) + } + + second, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: waveKey, + WakeWaveString: "parent-1|child-1,child-2", + Payload: agentInPayload("a1"), + }) + if err != nil { + t.Fatalf("queue racing run: %v", err) + } + if second != runsservice.QueueOutcomeDeduped { + t.Fatalf("racing outcome = %q, want %q", second, runsservice.QueueOutcomeDeduped) + } + + var count int + if err := repo.Reader().GetContext(ctx, &count, + `SELECT COUNT(*) FROM runs WHERE wake_wave_key = ?`, waveKey); err != nil { + t.Fatalf("count runs: %v", err) + } + if count != 1 { + t.Fatalf("runs with wake_wave_key %q = %d, want 1", waveKey, count) + } +} + +// TestQueueRun_WakeWaveKey_DifferentAgentBothQueued pins .002.7: the same +// wave fanning out to two distinct target agents must produce one run per +// agent, not a suppressed second one. +func TestQueueRun_WakeWaveKey_DifferentAgentBothQueued(t *testing.T) { + svc, _, repo := newTestServiceWithRepo(t) + ctx := context.Background() + const waveKey = "task_children_completed:parent-1:deadbeef" + + for _, agent := range []string{"a1", "a2"} { + outcome, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: waveKey, + WakeWaveString: "parent-1|child-1,child-2", + Payload: agentInPayload(agent), + }) + if err != nil { + t.Fatalf("queue run for %s: %v", agent, err) + } + if outcome != runsservice.QueueOutcomeQueued { + t.Fatalf("outcome for %s = %q, want %q", agent, outcome, runsservice.QueueOutcomeQueued) + } + } + + var count int + if err := repo.Reader().GetContext(ctx, &count, + `SELECT COUNT(*) FROM runs WHERE wake_wave_key = ?`, waveKey); err != nil { + t.Fatalf("count runs: %v", err) + } + if count != 2 { + t.Fatalf("runs with wake_wave_key %q = %d, want 2 (one per agent)", waveKey, count) + } +} + +// TestQueueRun_WakeCarryingRequest_NotCoalesced exercises .002.14 with both +// requests carrying their own wave identity, addressed to the same agent +// inside the coalescing window. Two independent rows must exist afterward. +// Both requests carrying a wave key means CoalesceRun's row-side guard +// (`AND wake_wave_key = ”`) already excludes the first row as a merge +// candidate on its own, so this test's assertion holds regardless of +// whether the request-side guard (shouldCoalesceRun) does anything at all — +// it does NOT isolate the request side despite the name. See +// TestQueueRun_WakeCarryingRequest_NotCoalescedIntoNonWaveRow for the test +// that isolates the request-side guard specifically (asymmetric setup: an +// existing non-wave-carrying row, a wave-carrying second request), and +// TestQueueRun_ExistingWaveCarryingRow_NotMergedInto for the row-side guard +// in isolation (the reverse asymmetry). +func TestQueueRun_WakeCarryingRequest_NotCoalesced(t *testing.T) { + svc, _, repo := newTestServiceWithRepo(t) + ctx := context.Background() + + first, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: "task_children_completed:parent-1:aaaa", + WakeWaveString: "parent-1|child-1", + Payload: agentInPayload("a1"), + }) + if err != nil { + t.Fatalf("queue first run: %v", err) + } + if first != runsservice.QueueOutcomeQueued { + t.Fatalf("first outcome = %q, want %q", first, runsservice.QueueOutcomeQueued) + } + + second, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: "task_children_completed:parent-2:bbbb", + WakeWaveString: "parent-2|child-2", + Payload: agentInPayload("a1"), + }) + if err != nil { + t.Fatalf("queue second run: %v", err) + } + if second != runsservice.QueueOutcomeQueued { + t.Fatalf("second outcome = %q, want %q (must not coalesce into the first parent's run)", second, runsservice.QueueOutcomeQueued) + } + + var count int + if err := repo.Reader().GetContext(ctx, &count, + `SELECT COUNT(*) FROM runs WHERE agent_profile_id = 'a1' AND reason = 'task_children_completed'`, + ); err != nil { + t.Fatalf("count runs: %v", err) + } + if count != 2 { + t.Fatalf("runs for a1 = %d, want 2 (no coalescing across distinct waves)", count) + } +} + +// TestQueueRun_ExistingWaveCarryingRow_NotMergedInto pins the other half of +// .002.14 — the row side: a *non*-wave-carrying request must not merge into +// an existing queued row that carries a wave identity, even though the +// request itself would otherwise be coalescible (shouldCoalesceRun returns +// true — no wave key of its own, no task_comment prefix) and the +// (agent, reason) pair matches. This is CoalesceRun's "AND wake_wave_key = +// ”" guard on the target row, which TestQueueRun_WakeCarryingRequest_ +// NotCoalesced does not reach because both of its requests carry a wave +// key and so never call shouldCoalesceRun's true branch. Deleting the +// guard would let this test's second request silently overwrite the +// first row's payload and identity. +func TestQueueRun_ExistingWaveCarryingRow_NotMergedInto(t *testing.T) { + svc, _, repo := newTestServiceWithRepo(t) + ctx := context.Background() + + first, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + AgentProfileID: "a1", + TaskID: "parent-1", + Reason: "task_children_completed", + IdempotencyKey: "wave-first", + WakeWaveKey: "task_children_completed:parent-1:aaaa", + WakeWaveString: "parent-1|child-1", + Payload: map[string]any{"marker": "wave-row"}, + }) + if err != nil { + t.Fatalf("queue first run: %v", err) + } + if first != runsservice.QueueOutcomeQueued { + t.Fatalf("first outcome = %q, want %q", first, runsservice.QueueOutcomeQueued) + } + + // Same agent and reason as the first row, but no wave identity of its + // own — an ordinary request that would coalesce into a plain queued + // row for the same (agent, reason) inside the window. + second, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + AgentProfileID: "a1", + TaskID: "parent-1", + Reason: "task_children_completed", + IdempotencyKey: "plain-second", + Payload: map[string]any{"marker": "plain-row"}, + }) + if err != nil { + t.Fatalf("queue second run: %v", err) + } + if second != runsservice.QueueOutcomeQueued { + t.Fatalf("second outcome = %q, want %q (must not merge into the wave-carrying row)", + second, runsservice.QueueOutcomeQueued) + } + + var count int + if err := repo.Reader().GetContext(ctx, &count, + `SELECT COUNT(*) FROM runs WHERE agent_profile_id = 'a1' AND reason = 'task_children_completed'`, + ); err != nil { + t.Fatalf("count runs: %v", err) + } + if count != 2 { + t.Fatalf("runs for a1 = %d, want 2 (the second request must not merge into the first)", count) + } + + var firstRow struct { + Payload string `db:"payload"` + WakeWaveKey string `db:"wake_wave_key"` + CoalescedCount int `db:"coalesced_count"` + } + if err := repo.Reader().GetContext(ctx, &firstRow, + `SELECT payload, wake_wave_key, coalesced_count FROM runs WHERE wake_wave_key = ?`, + "task_children_completed:parent-1:aaaa", + ); err != nil { + t.Fatalf("read first row: %v", err) + } + if firstRow.CoalescedCount != 1 { + t.Fatalf("first row coalesced_count = %d, want 1 (unmerged)", firstRow.CoalescedCount) + } + var firstPayload map[string]any + if err := json.Unmarshal([]byte(firstRow.Payload), &firstPayload); err != nil { + t.Fatalf("decode first row payload: %v", err) + } + if firstPayload["marker"] != "wave-row" { + t.Fatalf("first row marker = %v, want wave-row (the plain request must not overwrite it)", firstPayload["marker"]) + } +} + // TestQueueRun_Coalescing pins that two requests for the same // (agent, reason) inside the 5s window collapse onto a single row // with coalesced_count = 2 instead of producing two queued rows. diff --git a/apps/backend/internal/runs/service/service_wave_coalesce_test.go b/apps/backend/internal/runs/service/service_wave_coalesce_test.go new file mode 100644 index 0000000000..2d0d147d27 --- /dev/null +++ b/apps/backend/internal/runs/service/service_wave_coalesce_test.go @@ -0,0 +1,68 @@ +package service_test + +import ( + "context" + "testing" + + runsservice "github.com/kandev/kandev/internal/runs/service" +) + +// TestQueueRun_WakeCarryingRequest_NotCoalescedIntoNonWaveRow isolates the +// REQUEST side of AC-OFFICE-WAKE-WAVE-IDENTITY-002.14 specifically: the +// existing QUEUED row for the same agent+reason carries NO wave identity, +// so CoalesceRun's own row-side guard (`AND wake_wave_key = ”`) would +// happily accept it as a merge candidate — only shouldCoalesceRun's request +// side ("a wave-carrying request is never coalesced") can prevent a merge +// here. TestQueueRun_WakeCarryingRequest_NotCoalesced cannot prove this: both +// of its requests carry a wave key, so its outcome is fully explained by the +// row-side guard alone regardless of what the request-side check does. +func TestQueueRun_WakeCarryingRequest_NotCoalescedIntoNonWaveRow(t *testing.T) { + svc, _, repo := newTestServiceWithRepo(t) + ctx := context.Background() + + first, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + Payload: agentInPayload("a1"), + }) + if err != nil { + t.Fatalf("queue first run: %v", err) + } + if first != runsservice.QueueOutcomeQueued { + t.Fatalf("first outcome = %q, want %q", first, runsservice.QueueOutcomeQueued) + } + + const waveKey = "task_children_completed:parent-2:bbbb" + second, err := svc.QueueRun(ctx, runsservice.QueueRunRequest{ + Reason: "task_children_completed", + WakeWaveKey: waveKey, + WakeWaveString: "parent-2|child-2", + Payload: agentInPayload("a1"), + }) + if err != nil { + t.Fatalf("queue second run: %v", err) + } + if second != runsservice.QueueOutcomeQueued { + t.Fatalf("second outcome = %q, want %q (a wave-carrying request must not coalesce into a non-wave-carrying row)", + second, runsservice.QueueOutcomeQueued) + } + + var count int + if err := repo.Reader().GetContext(ctx, &count, + `SELECT COUNT(*) FROM runs WHERE agent_profile_id = 'a1' AND reason = 'task_children_completed'`, + ); err != nil { + t.Fatalf("count runs: %v", err) + } + if count != 2 { + t.Fatalf("runs for a1 = %d, want 2 (the wave-carrying request must persist as its own row)", count) + } + + var persistedWaveKey string + if err := repo.Reader().GetContext(ctx, &persistedWaveKey, + `SELECT wake_wave_key FROM runs WHERE agent_profile_id = 'a1' AND reason = 'task_children_completed' AND wake_wave_key <> ''`, + ); err != nil { + t.Fatalf("get persisted wave key: %v", err) + } + if persistedWaveKey != waveKey { + t.Fatalf("persisted wake_wave_key = %q, want %q (second row's wave identity must survive uncoalesced)", persistedWaveKey, waveKey) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/wave_member_parity_test.go b/apps/backend/internal/task/repository/sqlite/wave_member_parity_test.go new file mode 100644 index 0000000000..613790826f --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/wave_member_parity_test.go @@ -0,0 +1,106 @@ +package sqlite_test + +import ( + "context" + "sort" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/task/models" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// TestListWaveMembersMatchesListChildCompletionRows is Task 07's +// cross-producer parity check for AC-OFFICE-WAKE-WAVE-IDENTITY-001.8: P1, +// P2, and P3 (via office/repository/sqlite.ListWaveMembers) and P4 (via +// task/repository/sqlite.ListChildCompletionRows) each apply what is meant +// to be the identical wave-member predicate — not archived, not ephemeral, +// not automation-origin. Both queries here run against literally the same +// tasks table for the same fixture, so a future drift between the two +// predicates (each maintained independently in its own package) fails this +// test instead of silently producing four different wave identities for +// the same parent and child set. +func TestListWaveMembersMatchesListChildCompletionRows(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + taskRepo, err := taskrepo.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new task repo: %v", err) + } + officeRepo, err := officesqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new office repo: %v", err) + } + + ctx := context.Background() + const parentID = "parent-1" + const wsID = "ws-1" + + if err := taskRepo.CreateTask(ctx, &models.Task{ID: parentID, WorkspaceID: wsID, Title: "Parent"}); err != nil { + t.Fatalf("create parent: %v", err) + } + seedChild := func(id string, mutate func(*models.Task)) { + task := &models.Task{ID: id, WorkspaceID: wsID, Title: id, ParentID: parentID} + if mutate != nil { + mutate(task) + } + if err := taskRepo.CreateTask(ctx, task); err != nil { + t.Fatalf("create child %s: %v", id, err) + } + } + seedChild("child-ordinary-b", nil) + seedChild("child-ordinary-a", nil) + seedChild("child-ephemeral", func(task *models.Task) { task.IsEphemeral = true }) + seedChild("child-automation", func(task *models.Task) { task.Origin = models.TaskOriginAutomationRun }) + seedChild("child-archived", nil) + if err := taskRepo.ArchiveTask(ctx, "child-archived"); err != nil { + t.Fatalf("archive child: %v", err) + } + + waveMembers, err := officeRepo.ListWaveMembers(ctx, parentID) + if err != nil { + t.Fatalf("ListWaveMembers: %v", err) + } + completionRows, err := taskRepo.ListChildCompletionRows(ctx, parentID) + if err != nil { + t.Fatalf("ListChildCompletionRows: %v", err) + } + + waveIDs := make([]string, len(waveMembers)) + for i, m := range waveMembers { + waveIDs[i] = m.TaskID + } + completionIDs := make([]string, len(completionRows)) + for i, r := range completionRows { + completionIDs[i] = r.ID + } + // ListChildCompletionRows orders by created_at first (see its own doc + // comment) — every producer that reads it (P4) is responsible for its + // own ascending-by-id re-sort before deriving wave identity (Task 05), + // so this parity check compares the *set*, sorted the same way P4's + // childCompletionPayload does, not ListChildCompletionRows' raw order. + sort.Strings(completionIDs) + + want := []string{"child-ordinary-a", "child-ordinary-b"} + assertParityIDsEqual(t, "ListWaveMembers", waveIDs, want) + assertParityIDsEqual(t, "ListChildCompletionRows (id-sorted)", completionIDs, want) +} + +func assertParityIDsEqual(t *testing.T, label string, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s = %v, want %v", label, got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("%s = %v, want %v", label, got, want) + } + } +} diff --git a/apps/backend/internal/workflow/engine/adapters.go b/apps/backend/internal/workflow/engine/adapters.go index 66634dd558..c65ec43877 100644 --- a/apps/backend/internal/workflow/engine/adapters.go +++ b/apps/backend/internal/workflow/engine/adapters.go @@ -55,6 +55,11 @@ type QueueRunRequest struct { Reason string IdempotencyKey string Payload map[string]any + // WaveKey and WaveString carry a completion-wave identity through from + // OnChildrenCompletedPayload (parent-wake-wave-identity). Empty for + // every trigger except on_children_completed. + WaveKey string + WaveString string } // ParticipantInfo is a lightweight projection of a workflow_step_participants diff --git a/apps/backend/internal/workflow/engine/payloads.go b/apps/backend/internal/workflow/engine/payloads.go index e0644394fb..a91b05067b 100644 --- a/apps/backend/internal/workflow/engine/payloads.go +++ b/apps/backend/internal/workflow/engine/payloads.go @@ -35,8 +35,16 @@ type ChildSummary struct { } // OnChildrenCompletedPayload accompanies TriggerOnChildrenCompleted. +// +// WaveKey and WaveString are the completion-wave identity +// (parent-wake-wave-identity) the dispatching producer derived from its own +// terminality-confirming wave-member read, before the trigger was raised. +// Empty means the dispatcher could not derive one — QueueRunCallback +// then queues without a wave identity, same as before this field existed. type OnChildrenCompletedPayload struct { ChildSummaries []ChildSummary + WaveKey string + WaveString string } // OnApprovalResolvedPayload accompanies TriggerOnApprovalResolved. diff --git a/apps/backend/internal/workflow/engine/phase2_callbacks.go b/apps/backend/internal/workflow/engine/phase2_callbacks.go index c8ba498438..269a1ab12a 100644 --- a/apps/backend/internal/workflow/engine/phase2_callbacks.go +++ b/apps/backend/internal/workflow/engine/phase2_callbacks.go @@ -46,6 +46,15 @@ const ( TargetWorkspaceCEO = "workspace.ceo_agent" TaskIDThis = "this" defaultQueueReasonR = "queue_run" + + // reasonTaskChildrenCompleted is the reason string the wave-identity + // columns (runs.wake_wave_key/wake_wave_string) are scoped to: only a + // request resolving to this reason may carry them, so a differently + // reasoned action on the same on_children_completed trigger never enters + // idx_run_wake_wave's uniqueness domain. Duplicated from + // office/service.RunReasonTaskChildrenCompleted rather than imported — + // engine must not depend on office. + reasonTaskChildrenCompleted = "task_children_completed" ) // PrimaryAgentResolver resolves the task's "primary" agent profile id. The @@ -90,14 +99,23 @@ func (c QueueRunCallback) Execute(ctx context.Context, in ActionInput) (ActionRe if err != nil { return ActionResult{}, err } + reason := queueRunReason(in) + var waveKey, waveString string + // The wave belongs to the trigger task. A queue_run action can target a + // different task, which must keep the ordinary task-scoped admission path. + if reason == reasonTaskChildrenCompleted && taskID == in.State.TaskID { + waveKey, waveString = waveIdentityPayload(in.Payload) + } for _, agentID := range agentIDs { req := QueueRunRequest{ AgentProfileID: agentID, TaskID: taskID, WorkflowStepID: workflowStepID, - Reason: queueRunReason(in), + Reason: reason, IdempotencyKey: idempotencyKey(in, agentID, taskID), Payload: queueRunPayload(in, in.Action.QueueRun.Payload, taskID), + WaveKey: waveKey, + WaveString: waveString, } if _, err := c.Adapter.QueueRun(ctx, req); err != nil { return ActionResult{}, fmt.Errorf("queue_run for agent %s: %w", agentID, err) @@ -243,6 +261,17 @@ func (c QueueRunCallback) resolveParticipantRoleStepScoped(ctx context.Context, return ids, nil } +// ResolveFanOutSeats exposes roleSeatsForFanOut to callers outside the +// engine that must predict a queue_run_for_each_participant action's +// resolved seats without going through HandleTrigger — office/scheduler's +// cascade producer needs this to attach the same payload the engine-routed +// fan-out would for the same (wave, agent) pair (AC-002.10/.16). +func ResolveFanOutSeats( + ctx context.Context, store ParticipantStore, stepID, taskID, workflowID, role string, +) ([]ParticipantInfo, error) { + return roleSeatsForFanOut(ctx, store, stepID, taskID, workflowID, role) +} + // roleSeatsForFanOut gathers the same participant population the quorum // guard counts (gatherParticipantSlate — per-task rows at any step, unioned // with template rows at the evaluating step), filters to role (deliberately @@ -442,6 +471,22 @@ func queueRunPayload(in ActionInput, actionPayload map[string]any, targetTaskID return out } +// waveIdentityPayload extracts the completion-wave identity from a +// TriggerOnChildrenCompleted dispatch, or ("", "") for any other trigger +// (OnChildrenCompletedPayload's WaveKey/WaveString default to empty when +// the dispatching producer could not derive one). +func waveIdentityPayload(payload any) (waveKey, waveString string) { + switch p := payload.(type) { + case OnChildrenCompletedPayload: + return p.WaveKey, p.WaveString + case *OnChildrenCompletedPayload: + if p != nil { + return p.WaveKey, p.WaveString + } + } + return "", "" +} + func commentPayload(payload any) (OnCommentPayload, bool) { switch p := payload.(type) { case OnCommentPayload: @@ -509,6 +554,10 @@ func (c QueueRunForEachParticipantCallback) Execute(ctx context.Context, in Acti return ActionResult{}, fmt.Errorf("queue_run_for_each_participant list participants: %w", err) } reason := queueRunForEachParticipantReason(in) + var waveKey, waveString string + if reason == reasonTaskChildrenCompleted { + waveKey, waveString = waveIdentityPayload(in.Payload) + } // Collect-and-continue (AC-C1): one participant's QueueRun failure must // not abort the fan-out to their siblings — a reviewer whose queue is // briefly unavailable should not silently block every other reviewer's @@ -520,10 +569,19 @@ func (c QueueRunForEachParticipantCallback) Execute(ctx context.Context, in Acti // participant list with roleSeatsForFanOut, which already filters by // role and canonicalizes the slate, so #2907's inline `p.Role != cfg.Role` // guard is redundant here and is dropped rather than duplicated. + // + // waveKey/waveString (parent-wake-wave-identity): this is the second + // on_children_completed action callback alongside QueueRunCallback, and + // both attach the trigger's wave identity to every request they queue + // when the resolved reason is task_children_completed — idx_run_wake_wave, + // not this loop, is what collapses two participant roles resolving to the + // same agent profile into one run. var errs []error for _, p := range seats { req := QueueRunRequest{ AgentProfileID: p.AgentProfileID, + WaveKey: waveKey, + WaveString: waveString, TaskID: taskID, WorkflowStepID: in.Step.ID, Reason: reason, diff --git a/apps/backend/internal/workflow/engine/queue_run_wave_identity_test.go b/apps/backend/internal/workflow/engine/queue_run_wave_identity_test.go new file mode 100644 index 0000000000..b18649a9b3 --- /dev/null +++ b/apps/backend/internal/workflow/engine/queue_run_wave_identity_test.go @@ -0,0 +1,223 @@ +package engine + +import "testing" + +// TestQueueRunCallback_OnChildrenCompletedCopiesWaveIdentity is +// AC-OFFICE-WAKE-WAVE-IDENTITY-002.16's engine-seam half: a +// TriggerOnChildrenCompleted dispatch carrying a wave identity on its +// OnChildrenCompletedPayload must have that identity copied onto the +// QueueRunRequest the adapter receives, unchanged. +func TestQueueRunCallback_OnChildrenCompletedCopiesWaveIdentity(t *testing.T) { + q := &fakeRunQueue{} + cb := QueueRunCallback{Adapter: q, Primary: fakePrimary{id: "agent-primary"}} + in := newQueueRunInput("primary", "this") + in.Trigger = TriggerOnChildrenCompleted + in.Action.QueueRun.Reason = reasonTaskChildrenCompleted + in.Payload = OnChildrenCompletedPayload{ + WaveKey: "task_children_completed:parent-1:deadbeef", + WaveString: "parent-1|child-1,child-2", + } + + if _, err := cb.Execute(t.Context(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(q.calls) != 1 { + t.Fatalf("expected 1 queue run call, got %d", len(q.calls)) + } + got := q.calls[0] + if got.WaveKey != "task_children_completed:parent-1:deadbeef" { + t.Fatalf("WaveKey = %q, want it copied from OnChildrenCompletedPayload", got.WaveKey) + } + if got.WaveString != "parent-1|child-1,child-2" { + t.Fatalf("WaveString = %q, want it copied from OnChildrenCompletedPayload", got.WaveString) + } +} + +func TestQueueRunCallback_CrossTaskChildrenCompletedLeavesWaveIdentityEmpty(t *testing.T) { + q := &fakeRunQueue{} + cb := QueueRunCallback{ + Adapter: q, + TaskSteps: fakeTaskSteps{id: "step-target"}, + } + in := newQueueRunInput("agent_profile_id:agent-target", "task-2") + in.Trigger = TriggerOnChildrenCompleted + in.Action.QueueRun.Reason = reasonTaskChildrenCompleted + in.Payload = OnChildrenCompletedPayload{ + WaveKey: "task_children_completed:task-1:deadbeef", + WaveString: "task-1|child-1,child-2", + } + + if _, err := cb.Execute(t.Context(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(q.calls) != 1 { + t.Fatalf("expected 1 queue run call, got %d", len(q.calls)) + } + got := q.calls[0] + if got.TaskID != "task-2" { + t.Fatalf("task_id = %q, want task-2", got.TaskID) + } + if got.WorkflowStepID != "step-target" { + t.Fatalf("workflow_step_id = %q, want step-target", got.WorkflowStepID) + } + if got.WaveKey != "" || got.WaveString != "" { + t.Fatalf("WaveKey/WaveString = %q/%q, want both empty for a cross-task action", + got.WaveKey, got.WaveString) + } +} + +// TestQueueRunCallback_OtherTriggersLeaveWaveIdentityEmpty is the +// regression guard: any trigger other than on_children_completed must +// never populate WaveKey/WaveString, even though ActionInput.Payload is a +// bare `any` the callback type-asserts. +func TestQueueRunCallback_OtherTriggersLeaveWaveIdentityEmpty(t *testing.T) { + q := &fakeRunQueue{} + cb := QueueRunCallback{Adapter: q, Primary: fakePrimary{id: "agent-primary"}} + in := newQueueRunInput("primary", "this") + in.OperationID = "task_comment:c-1" + in.Payload = OnCommentPayload{CommentID: "c-1", AuthorID: "user-1"} + in.Action.QueueRun.Payload = nil + + if _, err := cb.Execute(t.Context(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(q.calls) != 1 { + t.Fatalf("expected 1 queue run call, got %d", len(q.calls)) + } + got := q.calls[0] + if got.WaveKey != "" || got.WaveString != "" { + t.Fatalf("WaveKey/WaveString = %q/%q, want both empty for a non-children-completed trigger", + got.WaveKey, got.WaveString) + } +} + +// TestQueueRunForEachParticipantCallback_OnChildrenCompletedCopiesWaveIdentity +// is QueueRunForEachParticipantCallback's half of AC-002.16: it must attach +// the trigger's wave identity to every request it queues, exactly like its +// QueueRunCallback sibling above. The participant slate seats the same +// reviewer twice (a per-task row and a step-template row for the same +// AgentProfileID) so collapseByRoleAgent's within-loop dedupe still applies +// — proving the fix does not depend on there being only one seat — while +// AC-002.13's cross-action duplicate (two different roles resolving to the +// same agent, deduped instead by idx_run_wake_wave once both requests carry +// this identical wave identity) is exercised at the runs/service layer. +func TestQueueRunForEachParticipantCallback_OnChildrenCompletedCopiesWaveIdentity(t *testing.T) { + q := &fakeRunQueue{} + parts := scopedParticipants{ + perTask: []ParticipantInfo{ + {ID: "p1", StepID: "step-work", TaskID: "task-1", Role: "reviewer", AgentProfileID: "rev-A"}, + }, + template: []ParticipantInfo{ + {ID: "p2", StepID: "step-review", TaskID: "", Role: "reviewer", AgentProfileID: "rev-A"}, + }, + } + cb := QueueRunForEachParticipantCallback{Adapter: q, Participants: parts} + in := ActionInput{ + Trigger: TriggerOnChildrenCompleted, + State: MachineState{TaskID: "task-1", WorkflowID: "wf-1"}, + Step: StepSpec{ID: "step-review"}, + OperationID: "op-1", + Action: Action{ + Kind: ActionQueueRunForEachParticipant, + QueueRunForEachParticipant: &QueueRunForEachParticipantAction{ + Role: "reviewer", + Reason: reasonTaskChildrenCompleted, + }, + }, + Payload: OnChildrenCompletedPayload{ + WaveKey: "task_children_completed:parent-1:deadbeef", + WaveString: "parent-1|child-1,child-2", + }, + } + + if _, err := cb.Execute(t.Context(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(q.calls) != 1 { + t.Fatalf("expected exactly 1 queue run call (duplicate seats collapsed), got %d", len(q.calls)) + } + got := q.calls[0] + if got.AgentProfileID != "rev-A" { + t.Fatalf("agent_profile_id = %q, want rev-A", got.AgentProfileID) + } + if got.WaveKey != "task_children_completed:parent-1:deadbeef" { + t.Fatalf("WaveKey = %q, want it copied from OnChildrenCompletedPayload", got.WaveKey) + } + if got.WaveString != "parent-1|child-1,child-2" { + t.Fatalf("WaveString = %q, want it copied from OnChildrenCompletedPayload", got.WaveString) + } +} + +// TestQueueRunCallback_OnChildrenCompletedWithOtherReason_LeavesWaveIdentityEmpty +// is AC-002.9's regression test: an on_children_completed queue_run action +// configured with a reason other than task_children_completed must not +// receive the trigger's wave identity, even though the trigger carries one. +// idx_run_wake_wave is keyed on (wake_wave_key, agent_profile_id) only, with +// no reason component, so a differently-reasoned run pulled into that domain +// could collide with the parent's real completion wake. +func TestQueueRunCallback_OnChildrenCompletedWithOtherReason_LeavesWaveIdentityEmpty(t *testing.T) { + q := &fakeRunQueue{} + cb := QueueRunCallback{Adapter: q, Primary: fakePrimary{id: "agent-primary"}} + in := newQueueRunInput("primary", "this") + in.Trigger = TriggerOnChildrenCompleted + in.Action.QueueRun.Reason = "follow_up" + in.Payload = OnChildrenCompletedPayload{ + WaveKey: "task_children_completed:parent-1:deadbeef", + WaveString: "parent-1|child-1,child-2", + } + + if _, err := cb.Execute(t.Context(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(q.calls) != 1 { + t.Fatalf("expected 1 queue run call, got %d", len(q.calls)) + } + got := q.calls[0] + if got.WaveKey != "" || got.WaveString != "" { + t.Fatalf("WaveKey/WaveString = %q/%q, want both empty for a non-task_children_completed reason", + got.WaveKey, got.WaveString) + } +} + +// TestQueueRunForEachParticipantCallback_OnChildrenCompletedWithOtherReason_LeavesWaveIdentityEmpty +// is QueueRunForEachParticipantCallback's half of the same AC-002.9 +// regression: a fan-out action on this trigger configured with a reason +// other than task_children_completed must not receive wave identity either. +func TestQueueRunForEachParticipantCallback_OnChildrenCompletedWithOtherReason_LeavesWaveIdentityEmpty(t *testing.T) { + q := &fakeRunQueue{} + parts := scopedParticipants{ + perTask: []ParticipantInfo{ + {ID: "p1", StepID: "step-work", TaskID: "task-1", Role: "reviewer", AgentProfileID: "rev-A"}, + }, + } + cb := QueueRunForEachParticipantCallback{Adapter: q, Participants: parts} + in := ActionInput{ + Trigger: TriggerOnChildrenCompleted, + State: MachineState{TaskID: "task-1", WorkflowID: "wf-1"}, + Step: StepSpec{ID: "step-review"}, + OperationID: "op-1", + Action: Action{ + Kind: ActionQueueRunForEachParticipant, + QueueRunForEachParticipant: &QueueRunForEachParticipantAction{ + Role: "reviewer", + Reason: "follow_up", + }, + }, + Payload: OnChildrenCompletedPayload{ + WaveKey: "task_children_completed:parent-1:deadbeef", + WaveString: "parent-1|child-1,child-2", + }, + } + + if _, err := cb.Execute(t.Context(), in); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(q.calls) != 1 { + t.Fatalf("expected 1 queue run call, got %d", len(q.calls)) + } + got := q.calls[0] + if got.WaveKey != "" || got.WaveString != "" { + t.Fatalf("WaveKey/WaveString = %q/%q, want both empty for a non-task_children_completed reason", + got.WaveKey, got.WaveString) + } +} diff --git a/docs/plans/parent-wake-wave-identity/plan.md b/docs/plans/parent-wake-wave-identity/plan.md new file mode 100644 index 0000000000..90b3deaaad --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/plan.md @@ -0,0 +1,421 @@ +--- +created: 2026-09-09 +status: implemented +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-001 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-002 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-003 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-004 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +legacy_specs: [] +--- + +# Implementation Plan: Parent Wake Wave Identity + +## Overview + +Regenerated 2026-09-09 against the frozen spec (round-2 amend). The prior +plan under this directory (session `d0842a54`) predates both spec amends: it +had one column/encoding, a wider wave-member predicate, one classification +site, and no coalescing exclusion or payload-parity obligation. This +supersedes it in full. + +Give the `task_children_completed` wake a single durable identity, computed +identically by all four producers, persisted on the run in two encodings, and +compared by the backstop instead of a timestamp. Implementation order: + +1. **Foundation** (Task 01): the pure wave-string/wave-key derivation and the + new wave-member repository read every producer needs. No behavior change. +2. **Persistence** (Task 02): the two `runs` columns, the partial unique + index, the second unique-violation classifier, and `runs/service`'s + coalescing exclusion. Still no producer sets these columns yet, so this is + also behavior-inert until Task 03 lands — verified by service-level tests + that construct requests directly. +3. **Producers**, one work order per identity site, in dependency order + forced by the shared engine seam: + - Task 03: P1 cascade (`office/scheduler`) — direct insert path, needs its + own classification site and its own coalescing guard, and owns the + payload-parity read (AC-...-002.16) since it never touches the engine. + - Task 04: the engine seam plus P2 edge and P3 backstop, which share it. + `OnChildrenCompletedPayload` gains the two encodings; `QueueRunCallback` + reads them off the trigger payload it already carries end-to-end + (`ActionInput.Payload` is `HandleInput.Payload` verbatim — no new + parameter threading through `HandleInput`/`ActionInput`/engine + `QueueRunRequest` is needed beyond the two new fields on that one + request struct and its `runsServiceEngineAdapter` copy). + - Task 05: P4 orchestrator, built on Task 04's seam. No new read; re-sorts + the read it already has. +4. **Admission** (Task 06): `ListStuckParents` compares wave identity instead + of timestamp, once producers actually write it. +5. **Closing coverage** (Task 07): the race test, cross-producer parity, + coalescing, read-skew, and admission suites that need every producer and + the admission rewrite to exist simultaneously. + +Tasks 03, 04 and 05 are not parallel-safe with each other even though they +touch different packages: all three add wiring in +`internal/backendapp/main.go` (03 wires a new `WorkflowStepGetter` setter for +payload parity; 04 changes `runsServiceEngineAdapter`; 05 depends on 04's +`OnChildrenCompletedPayload` fields). Land them in order. + +## Scope + +### In scope + +- `internal/office/waveidentity` (new leaf package): `WaveString` and + `WaveKey`, pure functions of `(parentID string, memberIDs []string)` + (REQ-001). +- A new office-repository read returning wave members `(id, state)` ordered + by id, applying the shared not-archived/not-ephemeral/not-automation-origin + predicate (REQ-001, REQ-002.15). +- `runs.wake_wave_key` / `runs.wake_wave_string` columns, partial unique index + `idx_run_wake_wave(wake_wave_key, agent_profile_id)`, `models.Run` fields, + `CreateRunTx` column list, a second classifier + (`IsWakeWaveUniqueViolation`), `runs/service`'s dedupe-on-conflict handling, + and its coalescing exclusion for wave-carrying requests/rows (REQ-002). +- Threading the two encodings through all four producers, including P1's own + `CreateRun` classification site (`office/scheduler.SchedulerService`) and + its coalescing guard, and the shared engine seam + (`OnChildrenCompletedPayload` → `ActionInput.Payload` → `QueueRunCallback` + → `engine.QueueRunRequest` → `runsServiceEngineAdapter` → + `runs/service.QueueRunRequest`) for P2/P3/P4 (REQ-002, REQ-004). +- The terminality-confirming wave-member read (AC-...-002.15): P1/P2/P3 read + `(id, state)` as their last child-state read before queueing and queue + nothing if any member is non-terminal; P4 already satisfies this via + `readyChildCompletionRows`. +- Payload parity (AC-...-002.16): the cascade resolves the parent's current + workflow step, reads its `on_children_completed` `queue_run` action + payload (via `engine.CompileStep`, already used by + `orchestrator.workflowStore.LoadStep`), and merges it into its own run + payload; queues without it, logging the omission, when it cannot resolve. +- `internal/office/repository/sqlite.ListStuckParents` rewrite: a + dialect-aware wave-string aggregate (new `internal/db/dialect` helper), a + wave-member existence gate, and the three-clause, status-qualified + admission arm from the system design (REQ-003). +- `parent_wake_deduped_total` expvar, incremented at both classification + sites. +- Regression coverage per the spec's Testing section. + +### Out of scope + +(Mirrors the requirements document's "Out of scope" verbatim; not repeated +here beyond a pointer.) Reopen-and-recomplete re-waking; id-set-reuse via +unarchive; task `updated_at` stamping; the queued-or-claimed arm's +whole-second comparison (`b7e29d7c`); porting `ListStuckParents`' SQLite-only +`json_extract`/aggregate expressions elsewhere in the same query to dialect +form (`53c24173`, and this work must not widen that gap); `on_children_completed` +trigger firing conditions, payload shape, or editor surface; UI. +`wakeOperationID` (P2/P3's existing operation id) is **not** deleted or +redefined — the round-2 spec keeps every producer's existing identifier +untouched, unlike the stale plan this supersedes. + +## Technical approach + +### Foundation: `internal/office/waveidentity` and the wave-member read + +New package, dependency-free beyond `crypto/sha256`, `encoding/hex`, +`strings`: + +```go +func WaveString(parentID string, memberIDs []string) string +func WaveKey(parentID string, memberIDs []string) string // = WaveKey(WaveString(...)) +``` + +`WaveString` = `parentID + "|" + strings.Join(memberIDs, ",")`. `WaveKey` = +`"task_children_completed:" + parentID + ":" + hex(sha256(waveString))`. +Callers order `memberIDs` ascending by `tasks.id` and pass only wave members; +the functions do no I/O and return no error (AC-...-001.1, .2, .10, .11). +Sibling of `internal/office/costs/modelsdev`, which `internal/orchestrator` +already imports (`orchestrator/model_info.go`) without crossing the +`orchestrator`/`office/service` boundary. + +New read, `ListWaveMembers`, in `internal/office/repository/sqlite/blockers.go` +beside `ListChildStates` (same file, same `ChildState`-shaped row): + +```go +func (r *Repository) ListWaveMembers(ctx context.Context, parentID string) ([]ChildState, error) +``` + +```sql +SELECT id, COALESCE(state, '') AS state FROM tasks +WHERE parent_id = ? AND archived_at IS NULL AND is_ephemeral = 0 + AND COALESCE(origin, '') != 'automation_run' +ORDER BY id +``` + +This is the predicate `task/repository/sqlite.ListChildCompletionRows` +already applies (`andNotAutomationOrigin`), reused here because P4 forces the +choice (design, "Which children count"). A cross-producer test in Task 01 +asserts this method and `ListChildCompletionRows` return the same id set for +one fixture parent — the equality AC-...-001.8 depends on. + +### Persistence + +- `internal/office/repository/sqlite/base.go`, `createRunTables` (~line + 329-387): add `wake_wave_key TEXT NOT NULL DEFAULT ''` and + `wake_wave_string TEXT NOT NULL DEFAULT ''` to the inline `CREATE TABLE + runs`, plus `CREATE UNIQUE INDEX IF NOT EXISTS idx_run_wake_wave ON + runs(wake_wave_key, agent_profile_id) WHERE wake_wave_key <> ''` beside + `idx_run_idempotency`. +- `internal/office/repository/sqlite/base_migrations.go`: new + `migrateWakeWaveColumns()`, following `migrateContinuationScope`'s shape + (two `r.migrate.Apply` ALTERs + one partial-index `CREATE UNIQUE INDEX IF + NOT EXISTS`, no backfill), called from `runMigrations()` next to + `migrateParentWakeReceiptColumns()`. +- `internal/office/models/models.go`, `Run` struct (~line 381): add + `WakeWaveKey string \`db:"wake_wave_key"\`` and `WakeWaveString string + \`db:"wake_wave_string"\``. +- `internal/runs/repository/sqlite/runs.go`, `CreateRunTx` (~line 31-52): add + both columns to the `INSERT INTO runs` list and bound args. +- `internal/runs/repository/sqlite/idempotency_violation.go`: add + `IsWakeWaveUniqueViolation(err error) bool`, mirroring + `IsIdempotencyKeyUniqueViolation` — PostgreSQL: typed `pgErr.ConstraintName + == "idx_run_wake_wave"`; SQLite: message substring `"UNIQUE constraint + failed: runs.wake_wave_key, runs.agent_profile_id"` (confirm go-sqlite3's + exact composite-index message empirically before hardcoding — this is the + same risk the superseded plan flagged, now scoped to one new function + instead of a rename). +- `internal/runs/service/service.go`: `QueueRunRequest` (~line 85-92) gains + `WakeWaveKey`, `WakeWaveString string`; `insertRun` (~line 249-274) sets + them on the `*models.Run{}` literal; `insertRun`'s `CreateRun` error + handling gains a second branch — `IsWakeWaveUniqueViolation(err)` returns a + second sentinel (e.g. `errWakeWaveKeyConflict`), and `QueueRun` (~line + 221-235) maps it to `QueueOutcomeDeduped` exactly like + `errIdempotencyKeyConflict`, with no error and no retry + (AC-...-002.4/.5/.6). `shouldCoalesceRun` (~line 316-318) gains `&& + req.WakeWaveKey == ""` (AC-...-002.14, first half). +- `internal/runs/repository/sqlite/runs.go`, `CoalesceRun` (~line 449-488): + add `AND (wake_wave_key = '' OR wake_wave_key IS NULL)` — actually the + column is `NOT NULL DEFAULT ''`, so `AND wake_wave_key = ''` — to the + candidate-row `WHERE`, closing AC-...-002.14's second half (a queued + wave-carrying run is never merged into, regardless of which caller + invokes `CoalesceRun`). +- `internal/office/scheduler/run.go`, `SchedulerService.QueueRun` (~line + 249-303): the same two guards for P1's own coalesce-and-insert path — skip + `ss.repo.CoalesceRun` when a wave key is supplied, and classify + `ss.repo.CreateRun`'s error with `IsWakeWaveUniqueViolation` into a nil + return + debug log (mirroring the existing `CheckIdempotencyKey` hit + handling), not the `enqueue run: %w` wrap that would make .002.4 fail for + P1 specifically (this is why Task 03 needs its own classification work, + not just Task 02's). +- `internal/office/service/wake_metrics.go`: add `parentWakeDedupedTotal = + expvar.NewInt("parent_wake_deduped_total")`, incremented at both + classification branches above. `runs/service` does not import + `office/service`; wire the increment via a small exported callback var (or + equivalent seam already used for `parent_wake_*` counters — check + `wake_metrics.go`'s existing wiring pattern before choosing) rather than a + new cross-package dependency. + +### P1 cascade (Task 03) + +`internal/office/scheduler/reactivity.go`, `cascadeChildrenCompleted` +(~line 384-421): keeps `ListChildStates` and its terminal-state loop +unchanged (narrowing that read would change cascade's readiness check, +forbidden by AC-...-004.7) and adds, after that loop confirms all-terminal: + +1. Call `ListWaveMembers(ctx, task.ParentID)` — the terminality-confirming + last read (AC-...-002.15). Any non-terminal member (a race with the + `ListChildStates` read) or a read error: queue nothing, return. +2. Empty result: queue nothing, return (AC-...-001.7; live once the + predicate is narrower than `ListChildStates`, e.g. an all-ephemeral child + set). +3. Derive `waveidentity.WaveString`/`WaveKey` from the sorted member ids. +4. Resolve payload parity: `GetTaskWorkflowStepID(ctx, task.ParentID)` + (`office/repository/sqlite/participants.go:55`, already used elsewhere in + this package's blast radius) → a new small interface, + `WorkflowStepGetter.GetStep(ctx, stepID) (*wfmodels.WorkflowStep, error)` + (satisfied by `workflow/service.Service.GetStep`, wired via a new + `SchedulerService.SetWorkflowStepGetter` setter, called once at boot next + to `SetTaskStarter`/`SetResolver`) → `engine.CompileStep` (already + exported, already used by `orchestrator/workflow_store.go:226`) → + look up the step's `TriggerOnChildrenCompleted` actions for one of kind + `queue_run` with a non-nil `Payload`. Any failure at any of these steps: + log the omission at debug and continue without a merged payload — the + wake is unconditional (AC-...-004.2) and outranks the optional payload. +5. `RunContext` (~line 90-122) gains `WaveKey string`, `WaveString string` + (JSON `"-"`, like `IdempotencyKey`) and a merge point for the resolved + action payload with the same override precedence `queueRunPayload` + applies (workflow-authored keys win over the trigger's own). Since + `RunContext` marshals as a fixed struct rather than a generic map, the + simplest implementation is: marshal `RunContext` first, then if a merged + payload map is non-empty, unmarshal to `map[string]any`, overlay the + merged keys, re-marshal — implemented inside `encodeRunContext` or a + thin wrapper around it, whichever keeps `QueueRunCtx` readable. +6. `QueueRunCtx` → `QueueRun` (~line 246-324) thread `WaveKey`/`WaveString` + as **new required parameters** (do not smuggle them through `RunContext` + alone into the low-level `QueueRun`, which today takes discrete scalar + args) onto the inserted `models.Run{WakeWaveKey, WakeWaveString}`. + `childrenCompletedIdempotencyKey` (~line 436-443) is unchanged and stays + the run's `idempotency_key` — additive, not replaced (design, + "Producers"). + +### Engine seam, P2 edge, P3 backstop (Task 04) + +- `internal/workflow/engine/payloads.go`: `OnChildrenCompletedPayload` + (~line 38) gains `WaveKey string`, `WaveString string`. Not read by + `idempotencyKey` or `queueActionDigest` (phase2_callbacks.go) — those stay + untouched, per the design's "narrow seam" decision. +- `internal/workflow/engine/adapters.go`, `QueueRunRequest` (~line 45-52): + add `WakeWaveKey`, `WakeWaveString string`. +- `internal/workflow/engine/phase2_callbacks.go`, `QueueRunCallback.Execute` + (~line 81-107): after building `req := QueueRunRequest{...}`, type-assert + `in.Payload.(OnChildrenCompletedPayload)` and copy `WaveKey`/`WaveString` + onto `req` when present. This is the only engine-package call site that + changes — `HandleInput`, `ActionInput`, and `Engine.executeCallback` + already carry `Payload any` end-to-end unmodified + (`engine.go:429-437` copies `in.Payload` verbatim into `ActionInput`), so + no new field needs to thread through those three types. +- `internal/backendapp/main.go`, `runsServiceEngineAdapter.QueueRun` + (~line 1810-1822): copy `WakeWaveKey`/`WakeWaveString` from the engine's + `QueueRunRequest` to the runs-service `QueueRunRequest`. +- `internal/office/service/event_subscribers.go`, `queueChildrenCompletedRun` + (~line 1116-1151): after `AreAllChildrenTerminal`, add the wave-member read + (`ListWaveMembers`) as the terminality confirmation; on empty or + non-terminal, queue nothing (return nil, matching the existing + `!allDone` early return). `childSetKey`/`wakeOperationID` computation is + **unchanged** — the operation id keeps its existing state-inclusive + derivation. Build `engine.OnChildrenCompletedPayload{ChildSummaries: + summaries, WaveKey: ..., WaveString: ...}` from the new read's sorted ids. +- `internal/office/service/scheduler_wake_reconciler.go`, + `reconcileOne`/`buildPayload` (~line 93-177): same addition — call + `ListWaveMembers` before dispatch (in addition to, not instead of, the + existing `GetChildSetKey` re-reads that guard the receipt write — those + keep using the state-inclusive key per the design's "Receipts" section), + reject on non-terminal/empty, and set `WaveKey`/`WaveString` on the + payload `buildPayload` returns. `wakeOperationID` is unchanged. + +### P4 orchestrator (Task 05) + +`internal/orchestrator/event_handlers_children_completed.go`: +`childCompletionOperationID` (~line 325-345) is **unchanged** — the +operation ledger keeps its existing per-row state/step/terminal/`updated_at` +derivation (AC-...-004.3). `childCompletionPayload` (~line 306-316) gains a +`parentID` parameter; before building `ChildSummaries` it copies `rows`, +sorts the copy ascending by `ID` (the source orders by `created_at, id`), and +sets `WaveKey`/`WaveString` from `waveidentity.WaveString/WaveKey(parentID, +sortedIDs)`. No new read and no new filter — `readyChildCompletionRows` +already rejects `len(rows) == 0` and already establishes terminality from +these same rows (AC-...-002.15 satisfied by construction, per the design). + +### Backstop admission (Task 06) + +`internal/office/repository/sqlite/wake_receipts.go`, `ListStuckParents` +(~line 101-175): + +- New `internal/db/dialect` helper beside `JSONExtract` (e.g. + `OrderedIDConcat(driver, table, parentCol, idCol string) string` or similar + — name it to fit the file's existing signature style), branching: + SQLite `GROUP_CONCAT(c.id, ',')` fed by an `ORDER BY id` subquery (the + `child_set_key` idiom already in this file); PostgreSQL `string_agg(c.id, + ',' ORDER BY c.id)`. Only this new fragment is dialect-branched — the + query's existing `json_extract`/unbranched `GROUP_CONCAT` stay as they are + (out of scope per `53c24173`). +- Add a wave-string CTE column: `p.id || '|' || COALESCE(, '')`, computed for every row (a pre-gate + intermediate for a parent with no members, per Terminology). +- Add the wave-member existence gate: a second `EXISTS` beside the existing + archived-only one, over `archived_at IS NULL AND is_ephemeral = 0 AND + COALESCE(origin, '') != 'automation_run'` (AC-...-003.9). +- Rewrite the second `NOT EXISTS` arm into three named clauses (design, + "Backstop admission"): `status IN ('queued','claimed')` blocks + unconditionally; `status IN ('finished','failed','cancelled') AND + wake_wave_string = ` blocks; `NOT EXISTS (... WHERE + wake_wave_key <> '') AND status IN ('finished','failed','cancelled') AND + requested_at >= newest_child_updated_at` is the parent-scoped legacy + fallback (AC-...-003.5/.6/.10) — the "no keyed run at all for this parent" + condition must be evaluated once per parent, not per row, per .003.6. +- `StuckParentCandidate` (~line 21-27) gains no new field unless the caller + needs the wave string for logging; the comparison lives entirely inside + the SQL per AC-...-003.8. + +### Observability + +Already covered under Persistence (`parent_wake_deduped_total`). + +## Tests + +| Acceptance criteria | Test | +| --- | --- | +| AC-...-001.1-.3, .5, .6, .9-.13 | `internal/office/waveidentity/waveidentity_test.go`: deterministic and order-sensitive per the caller contract; `|`-separated, not NUL; digest-of-string relationship; empty slice documented as never called; no I/O. | +| AC-...-001.4, .5 | `internal/office/scheduler/reactivity_children_completed_test.go` (existing terminal-edit and non-state-edit cases) continue to pass unmodified. | +| AC-...-001.7, .8 | New cross-producer parity test: `ListWaveMembers` and `ListChildCompletionRows` return the same id set for one fixture parent, including an ephemeral/automation-origin child; an all-excluded child set derives no wave. | +| AC-...-002.1-.3, .7-.9, .11-.13 | `internal/runs/service/service_test.go`: `TestQueueRun_DedupesOnWakeWaveKeyIndexRace` (mirrors `TestQueueRun_DedupesOnIdempotencyIndexRace`, ~line 578); a different agent and a different wave-member set each get their own row; retry (`ScheduleRetry`) is untouched by the constraint. | +| AC-...-002.4, .5 | `internal/runs/repository/sqlite/idempotency_violation_test.go` (new, pattern from the existing file) for `IsWakeWaveUniqueViolation`; `internal/runs/service/service_postgres_test.go`: Postgres twin of the race test. | +| AC-...-002.6 | Same race test asserts no time bound (insert the second request well after the first). | +| AC-...-002.10, .002.16 | Payload-parity test: cascade attaches the workflow-authored `queue_run` payload byte-for-byte matching what the engine path would attach for an equivalent step; and still queues without it when the step lookup fails. | +| AC-...-002.14 | Coalescing test: a second request for a different parent, same agent, inside the coalescing window, with a wave key, does not merge into the first parent's queued (also wave-keyed) run — two rows exist. | +| AC-...-002.15 | Read-skew test: a wave-member read returning one non-terminal member queues nothing and records no identity; a later genuine wave for the same parent is still delivered. | +| AC-...-003.1-.10 | `internal/office/repository/sqlite/wake_receipts_test.go`: delivered-and-unchanged wave is not a candidate; non-state child edit after delivery is not a candidate; wave-member change after delivery is a candidate (and an id-set-reuse-to-a-delivered-identity is listed but queues nothing downstream); queued/claimed run blocks regardless of wave; a legacy run with an empty `wake_wave_key` is judged by the timestamp rule, scoped per parent even alongside a keyed run for the same parent; a failed run's wave-scoped block survives an unrelated child edit and is lifted only by a wave-member change; a parent whose only children are excluded is not a candidate. | +| AC-...-004.1-.7 | The full race test: cascade (P1) racing one reconciler (P3) tick for the same parent through `office/scheduler`'s own `QueueRun` path (not only `runs/service`), asserting exactly one `runs` row and no failure logged for the loser. Existing `TestWakeOperationID_UnifiedAcrossEdgeAndReconcilerPaths` and the orchestrator's operation-ledger tests continue to pass unmodified, confirming P2/P3's `wakeOperationID` and P4's `childCompletionOperationID` are untouched. | + +## E2E tests + +None. No user-visible control, view, or setting (requirements document, +"Out of scope": "User interface"). The effect is observable only as the +absence of a duplicate or spurious run in the Office Runs list, which the +spec's E2E decision explicitly leaves to Go-level coverage. + +## Work orders + +- [x] [Task 01: Wave identity primitives](task-01-wave-identity-primitives.md) +- [x] [Task 02: Persist wave identity and classify its unique violation](task-02-wave-identity-persistence.md) +- [x] [Task 03: Wire cascade (P1) onto wave identity](task-03-wire-cascade-producer.md) +- [x] [Task 04: Wire engine-routed producers (P2, P3) onto wave identity](task-04-wire-engine-routed-producers.md) +- [x] [Task 05: Wire orchestrator (P4) onto wave identity](task-05-wire-orchestrator-producer.md) +- [x] [Task 06: Backstop admission compares wave identity](task-06-backstop-admission.md) +- [x] [Task 07: Race, parity, and regression coverage](task-07-race-and-regression-coverage.md) + +## Verification results + +All 7 work orders done. Full-package tests pass with `-race` across every +touched package (`office/scheduler`, `office/service`, +`office/repository/sqlite`, `orchestrator`, `runs/service`, +`runs/repository/sqlite`, `task/repository/sqlite`, `office/waveidentity`, +`db/dialect`); `golangci-lint --new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` +clean on every changed package. See each task file's own Results section +for command output and per-task detail. `KANDEV_TEST_POSTGRES_DSN` was not +provisioned in this environment, so the gated Postgres twins (Task 06's +`OrderedIDConcat`, this initiative's other dialect-sensitive coverage) +skipped rather than ran — consistent with the card's instruction not to +provision Docker/Postgres here. + +Definition of Done gauntlet: `make fmt`, `make typecheck`, `make lint` +(backend 0 issues repo-wide, web eslint clean, lint-architecture clean; +lint-harness/lint-specs required Python 3.10+ — this environment's +default `python3` is 3.9.6, incompatible with the scripts' `X | None` +type-hint syntax; ran clean under `/opt/homebrew/bin/python3.14` +instead), `make lint-format`, and `pnpm run i18n:ratchet` (no UI change, +as expected for a backend-only card) all pass. `make test` passes for +every package this initiative touched; pre-existing failures in +unrelated subsystems (npm cache path resolution, worktree/git +provisioning, agentctl process management, the dev launcher) reproduce +against unmodified code in this sandboxed environment and are unrelated +to this change. + +A final pass removed every AC-NN/task-order reference this initiative's +comments had accumulated, restating each as a direct invariant per the +repo's comment convention. + +## Risks + +- **SQLite composite-unique-index violation message shape.** + `IsWakeWaveUniqueViolation`'s SQLite branch needs the exact go-sqlite3 + message for a two-column unique index, confirmed empirically in Task 02 + before hardcoding (same risk the superseded plan flagged for a + single-column rename; now isolated to one new function). +- **Cross-package `parent_wake_deduped_total` increment.** `runs/service` + does not import `office/service`; Task 02 picks the least-invasive seam + (existing `wake_metrics.go` wiring pattern, if one already crosses this + boundary, otherwise a small callback var). +- **Payload-parity merge shape.** `RunContext` is a fixed struct, not a + generic map, so merging an arbitrary workflow-authored payload into it + needs a marshal/overlay/re-marshal step Task 03 must design carefully to + avoid disturbing `CoalesceRun`'s existing byte-for-byte payload comparison + for non-wave-carrying runs. +- **Three sequential, non-parallel producer work orders (03, 04, 05)** share + touches to `internal/backendapp/main.go`. Land them one at a time. + +## Open questions + +None outstanding — the frozen spec resolves every prior open question (see +the spec's own review history for the closed rounds). diff --git a/docs/plans/parent-wake-wave-identity/task-01-wave-identity-primitives.md b/docs/plans/parent-wake-wave-identity/task-01-wave-identity-primitives.md new file mode 100644 index 0000000000..e57065dc61 --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/task-01-wave-identity-primitives.md @@ -0,0 +1,116 @@ +--- +id: "01-wave-identity-primitives" +title: "Wave identity primitives" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-001 +acceptance_criteria: + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.1 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.2 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.3 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.5 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.6 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.7 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.8 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.9 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.10 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.11 + - AC-OFFICE-WAKE-WAVE-IDENTITY-001.13 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +--- + +# Task 01: Wave identity primitives + +## Summary + +Add the pure two-encoding derivation (`WaveString`, `WaveKey`) as a new leaf +package, and add the shared wave-member repository read every producer will +use to obtain the ordered id set those functions consume. No caller changes +yet — this is foundation only. + +## In scope + +- `internal/office/waveidentity` package: `WaveString(parentID string, + memberIDs []string) string` and `WaveKey(parentID string, memberIDs + []string) string`, per the system design's "The two encodings" section + (separator `|`, digest prefix `task_children_completed::`, + lowercase hex sha256 of the wave string). No I/O, no error return. +- `internal/office/repository/sqlite.Repository.ListWaveMembers(ctx, + parentID) ([]ChildState, error)` in `blockers.go`, beside `ListChildStates`: + `(id, state)` ordered by `id`, filtered to `archived_at IS NULL AND + is_ephemeral = 0 AND COALESCE(origin, '') != 'automation_run'` — the same + predicate `task/repository/sqlite.ListChildCompletionRows` already applies. + +## Out of scope + +- Calling either the derivation or `ListWaveMembers` from any producer + (Tasks 03-05). +- Persisting the derived values anywhere (Task 02). +- The PostgreSQL-dialect wave-string SQL aggregate used by the backstop + (Task 06) — that is a separate, SQL-side expression of the same identity, + not this Go package. + +## Acceptance + +- `waveidentity.WaveKey(p, ids) == waveidentity.WaveKey(p, ids)` for any + fixed input (determinism); different `ids` slices (same length, different + order) that represent a *different* set produce a different key, but two + calls with the *same* already-sorted slice never differ regardless of the + slice's original pre-sort arrival order (callers, not the function, sort). +- `ListWaveMembers` and `task/repository/sqlite.ListChildCompletionRows` + return the identical set of ids for one fixture parent with a mix of + ordinary, archived, ephemeral, and automation-origin children. +- `go vet ./internal/office/waveidentity/... ./internal/office/repository/sqlite/...` + is clean. + +## Verification + +```bash +cd apps/backend +go test ./internal/office/waveidentity/... -run . -v +go test ./internal/office/repository/sqlite/... -run TestListWaveMembers -v +``` + +## Files likely touched + +- `internal/office/waveidentity/waveidentity.go` (new) +- `internal/office/waveidentity/waveidentity_test.go` (new) +- `internal/office/repository/sqlite/blockers.go` +- `internal/office/repository/sqlite/blockers_test.go` (or a new + `wave_members_test.go` if the existing file is near its line-count limit) + +## Dependencies + +None. + +## Risks + +None — pure functions and a single new read-only query, no behavior change +for any existing caller. + +## Parallelism + +`sequential` + +## Inputs + +- Requirements: REQ-OFFICE-WAKE-WAVE-IDENTITY-001, full text. +- System design: "Wave identity" and "The two encodings" sections. +- `internal/office/repository/sqlite/blockers.go` (`ListChildStates` for the + row shape and query idiom). +- `internal/task/repository/sqlite/task.go` (`ListChildCompletionRows`, + `andNotAutomationOrigin`) for the predicate this must match exactly. +- `internal/office/costs/modelsdev` as the precedent leaf-package shape + `internal/orchestrator` already imports. + +## Results + +Done. `internal/office/waveidentity` (`WaveString`, `WaveKey`) plus +`Repository.ListWaveMembers` (excludes archived, ephemeral, and +automation-origin children, ordered ascending by id) landed in +`feat(office): add wave identity derivation and wave-member read`. +`go test ./internal/office/waveidentity/... ./internal/office/repository/sqlite/... -run 'WaveIdentity|WaveMembers'` green. diff --git a/docs/plans/parent-wake-wave-identity/task-02-wave-identity-persistence.md b/docs/plans/parent-wake-wave-identity/task-02-wave-identity-persistence.md new file mode 100644 index 0000000000..ecdd5eeb41 --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/task-02-wave-identity-persistence.md @@ -0,0 +1,144 @@ +--- +id: "02-wave-identity-persistence" +title: "Persist wave identity and classify its unique violation" +status: done +wave: 2 +depends_on: ["01-wave-identity-primitives"] +plan: "plan.md" +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-002 +acceptance_criteria: + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.1 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.2 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.4 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.5 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.14 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +--- + +# Task 02: Persist wave identity and classify its unique violation + +## Summary + +Add the two `runs` columns and their partial unique index, thread them +through `runs/service`'s request/insert path with dedupe-on-conflict +handling and a coalescing exclusion, and add the observability counter. No +producer sets these fields yet — verified directly against `runs/service`. + +## In scope + +- `runs.wake_wave_key` / `runs.wake_wave_string` columns (both `TEXT NOT + NULL DEFAULT ''`), `idx_run_wake_wave(wake_wave_key, agent_profile_id) + WHERE wake_wave_key <> ''`, added to `createRunTables` (fresh schema) and a + new replayable `migrateWakeWaveColumns()` migration (existing databases). +- `models.Run.WakeWaveKey` / `WakeWaveString` fields; `CreateRunTx`'s column + list and bound args. +- `internal/runs/repository/sqlite/idempotency_violation.go`: + `IsWakeWaveUniqueViolation(err error) bool`. +- `internal/runs/service/service.go`: `QueueRunRequest` gains the two + fields; `insertRun` sets them on the inserted row; a second sentinel + (parallel to `errIdempotencyKeyConflict`) maps + `IsWakeWaveUniqueViolation` to `QueueOutcomeDeduped`; `shouldCoalesceRun` + excludes wave-carrying requests. +- `internal/runs/repository/sqlite/runs.go`, `CoalesceRun`: exclude + wave-carrying rows from the merge-target candidate query. +- `parent_wake_deduped_total` expvar, incremented at this classification + site (the second site, in `office/scheduler`, is Task 03's + responsibility — this task only covers the one runs/service exercises). + +## Out of scope + +- Any producer actually populating `WakeWaveKey`/`WakeWaveString` on a + request (Tasks 03-05). +- `office/scheduler.SchedulerService.QueueRun`'s own classification site and + coalescing guard (Task 03) — `runs/service` and the office scheduler's + direct-insert path are two separate call sites into `CreateRun`, per the + system design's "Failure and recovery" section, and this task only closes + the `runs/service` one. +- `ListStuckParents` (Task 06). + +## Acceptance + +- A `QueueRun` call with a `WakeWaveKey` set inserts a row with both columns + populated; a second call with the identical `(WakeWaveKey, + AgentProfileID)` returns `QueueOutcomeDeduped`, not an error, and is not + coalesced into instead. +- A `QueueRun` call with `WakeWaveKey` set to a *different, already-queued* + wave's key is never selected as a `CoalesceRun` merge target by an + unrelated wave-carrying request for the same agent+reason inside the + coalescing window. +- `IsWakeWaveUniqueViolation` returns true for a real SQLite composite + unique-index violation on this index (confirmed empirically, not assumed + from the single-column message) and for the matching PostgreSQL + `pgconn.PgError` shape (gated test, `KANDEV_TEST_POSTGRES_DSN`). + +## Verification + +```bash +cd apps/backend +go test ./internal/office/repository/sqlite/... -run TestMigrate -v +go test ./internal/runs/service/... -run TestQueueRun -v +go test ./internal/runs/repository/sqlite/... -run TestIsWakeWaveUniqueViolation -v +KANDEV_TEST_POSTGRES_DSN=... go test ./internal/runs/service/... -run TestPostgresQueueRun -v +``` + +## Files likely touched + +- `internal/office/repository/sqlite/base.go` +- `internal/office/repository/sqlite/base_migrations.go` +- `internal/office/repository/sqlite/base_migrations_test.go` (replay + coverage, per the backend's table-rebuild/migration testing convention) +- `internal/office/models/models.go` +- `internal/runs/repository/sqlite/runs.go` +- `internal/runs/repository/sqlite/idempotency_violation.go` +- `internal/runs/repository/sqlite/idempotency_violation_test.go` (new) +- `internal/runs/service/service.go` +- `internal/runs/service/service_test.go` +- `internal/runs/service/service_postgres_test.go` +- `internal/office/service/wake_metrics.go` + +## Dependencies + +Task 01 (uses `waveidentity` types only for test fixtures, not production +code in this task). + +## Risks + +- **SQLite composite-index message shape** — confirm empirically before + hardcoding; a wrong substring silently fails classification and P1/engine + producers would see a hard error instead of a dedupe on their second + arrival. +- **`parent_wake_deduped_total` cross-package increment** — check + `wake_metrics.go`'s existing wiring before adding a new seam; prefer + reusing whatever pattern already gets a runs/service-side outcome back to + office's expvars, if one exists. + +## Parallelism + +`sequential` + +## Inputs + +- System design: "Persistence", "Coalescing: the third write path into + runs", "Failure and recovery" sections. +- `internal/runs/repository/sqlite/idempotency_violation.go` (pattern to + mirror). +- `internal/office/repository/sqlite/base_migrations.go` + (`migrateContinuationScope` as the replayable-migration precedent). +- `internal/runs/service/service_test.go:578` + `TestQueueRun_DedupesOnIdempotencyIndexRace` (pattern for the new race + test written here at the request-construction level; the full + cross-producer race test is Task 07's). + +## Results + +Done, across three commits: `feat(runs): add wake_wave_key and +wake_wave_string columns to runs` (migration-only index per the +schema-init-before-migration rule, `models.Run` fields), `feat(runs): +dedupe and skip coalescing for wave-carrying queue requests` +(`IsWakeWaveUniqueViolation`, `runs/service` classification + +`shouldCoalesceRun` guard, `office/shared.ParentWakeDedupedTotal`), and +`test(runs): add Postgres twin for the wake-wave-key dedupe race` +(env-gated, skips cleanly without `KANDEV_TEST_POSTGRES_DSN`). +`go test ./internal/runs/... ./internal/office/repository/sqlite/...` green. diff --git a/docs/plans/parent-wake-wave-identity/task-03-wire-cascade-producer.md b/docs/plans/parent-wake-wave-identity/task-03-wire-cascade-producer.md new file mode 100644 index 0000000000..496da77ce5 --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/task-03-wire-cascade-producer.md @@ -0,0 +1,157 @@ +--- +id: "03-wire-cascade-producer" +title: "Wire cascade (P1) onto wave identity" +status: done +wave: 3 +depends_on: ["01-wave-identity-primitives", "02-wave-identity-persistence"] +plan: "plan.md" +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-002 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-004 +acceptance_criteria: + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.1 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.4 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.12 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.14 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.15 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.16 + - AC-OFFICE-WAKE-WAVE-IDENTITY-004.2 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +--- + +# Task 03: Wire cascade (P1) onto wave identity + +## Summary + +`office/scheduler.cascadeChildrenCompleted` gains the terminality-confirming +wave-member read, derives and persists the wave identity on its direct +insert, gains its own unique-violation classification and coalescing guard +(the runs/service ones from Task 02 do not cover this call site), and +resolves the workflow-authored `on_children_completed` payload for parity +with the engine-routed producers. + +## In scope + +- `RunContext` gains `WaveKey`, `WaveString string` (JSON `"-"`). +- `cascadeChildrenCompleted`: after the existing `ListChildStates` + terminal-state loop, read `ListWaveMembers`; queue nothing on a read error + or any non-terminal member; queue nothing on an empty result + (AC-...-001.7); otherwise derive the wave identity from the sorted ids. +- `QueueRunCtx`/`QueueRun` (`office/scheduler/run.go`) take the wave + identity through to the inserted `models.Run`, skip + `ss.repo.CoalesceRun` when a wave key is present, and classify + `ss.repo.CreateRun`'s error with `IsWakeWaveUniqueViolation` into a nil + return + debug log (not the existing `enqueue run: %w` wrap) — + `runs/service`'s classification from Task 02 does not cover this + direct-insert call site. +- Payload parity (AC-...-002.16): a new `WorkflowStepGetter` interface + (`GetStep(ctx, stepID) (*wfmodels.WorkflowStep, error)`), a + `SchedulerService.SetWorkflowStepGetter` setter wired at boot to + `workflow/service.Service.GetStep`, and cascade logic that resolves the + parent's current step via `GetTaskWorkflowStepID` + + `WorkflowStepGetter.GetStep` + `engine.CompileStep`, finds a `queue_run` + action on `TriggerOnChildrenCompleted` with a non-nil payload, and merges + it into the run's persisted payload with the same override precedence + `engine.queueRunPayload` applies. Any failure at any step: log at debug, + queue without the merged payload (AC-...-004.2 — the wake is + unconditional). +- `parent_wake_deduped_total` increment at this task's classification site. + +## Out of scope + +- P2/P3/P4 (Tasks 04, 05). +- `runs/service`'s own classification and coalescing guard (Task 02, + already done, unrelated call site). +- Any change to `childrenCompletedIdempotencyKey` or cascade's existing + no-session behavior (AC-...-004.2's first half, already true today). + +## Acceptance + +- Two calls to `cascadeChildrenCompleted` for the same parent and the same + terminal child set produce exactly one `runs` row, and the second call + logs no failure. +- A wave-member read that observes one non-terminal member (simulated via a + fake repo) queues nothing; a subsequent call once all members are + genuinely terminal queues the wake. +- When the parent's current step declares an `on_children_completed` + `queue_run` action with a payload, that payload's keys appear in the + queued run's persisted payload; when the step lookup fails, the run is + still queued without them and a debug log records the omission. + +## Verification + +```bash +cd apps/backend +go test ./internal/office/scheduler/... -run TestCascadeChildrenCompleted -v +go test ./internal/office/scheduler/... -run TestCascade.*WavePayload -v +``` + +## Files likely touched + +- `internal/office/scheduler/reactivity.go` +- `internal/office/scheduler/reactivity_children_completed_test.go` +- `internal/office/scheduler/run.go` +- `internal/office/scheduler/run_test.go` +- `internal/backendapp/main.go` (wire `SetWorkflowStepGetter`) + +## Dependencies + +Task 01 (`waveidentity`, `ListWaveMembers`), Task 02 (`models.Run` columns, +`IsWakeWaveUniqueViolation`). + +## Risks + +- **Payload merge shape.** `RunContext` is a fixed struct, not a generic + map; merging an arbitrary action payload onto it (marshal → overlay → + re-marshal, or an equivalent) must not change `encodeRunContext`'s output + for the common case where no action payload exists — `CoalesceRun` + compares payload bytes for equality-adjacent decisions elsewhere in the + codebase, so an unconditional shape change here is a regression risk even + though `CoalesceRun` itself is skipped for wave-carrying requests. +- **`GetTaskWorkflowStepID` reads the parent's *current* step at wake time, + not at any earlier decision point** — this is intentional (the design + states this resolution "reads workflow configuration, not child task + state," so it may run after the wave-member read), but do not repurpose + this value for anything identity-bearing; it is payload content only. + +## Parallelism + +`sequential` + +## Inputs + +- System design: "Producers" (P1 cascade), "Wake equivalence between + producers" sections. +- `internal/workflow/engine/phase2_callbacks.go` (`queueRunPayload`, + `QueueRunCallback.Execute`) for the precedence and shape to match. +- `internal/orchestrator/workflow_store.go:217-232` (`LoadStep`) for the + `GetStep` + `engine.CompileStep` pattern. +- `internal/office/repository/sqlite/participants.go:55` + (`GetTaskWorkflowStepID`). + +## Results + +Done, in `feat(office): wire the cascade producer onto wave identity`. +`cascadeChildrenCompleted` now calls `resolveWaveIdentity` (its own +terminality-confirming `ListWaveMembers` read) and `resolveWaveActionPayload` +(step lookup + `engine.CompileStep`) after the existing `ListChildStates` +loop; `RunContext` carries `WaveKey`/`WaveString`/`ExtraPayload` (all +`json:"-"`); `queueRun` (the shared body behind `QueueRun`/`QueueRunCtx`) +skips `CoalesceRun` and classifies `IsWakeWaveUniqueViolation` when a wave +key is present. `SetWorkflowStepGetter` wired in +`internal/backendapp/main.go` to `services.Workflow` alongside the +existing `SetWorkflowEngineDispatcher` call. + +One deviation from the original scope note: rather than changing the +public `QueueRun`/`QueueRunCtx` signatures (`QueueRun` implements +`shared.RunQueuer`, used by other non-wave callers), the wave fields route +through a new unexported `queueRun` helper both public methods call. + +`go test ./internal/office/scheduler/...` green (18 new/changed tests +across `run_test.go` and `reactivity_children_completed_wave_test.go`, +plus `newReactivityTestRepo`'s ad hoc `tasks` fixture gained +`archived_at`/`is_ephemeral`/`origin` columns so `ListWaveMembers` can run +against it). `golangci-lint run ./internal/office/scheduler/... +./internal/backendapp/... --new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` +clean. diff --git a/docs/plans/parent-wake-wave-identity/task-04-wire-engine-routed-producers.md b/docs/plans/parent-wake-wave-identity/task-04-wire-engine-routed-producers.md new file mode 100644 index 0000000000..f885efa6c2 --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/task-04-wire-engine-routed-producers.md @@ -0,0 +1,135 @@ +--- +id: "04-wire-engine-routed-producers" +title: "Wire engine-routed producers (P2, P3) onto wave identity" +status: done +wave: 3 +depends_on: ["01-wave-identity-primitives", "02-wave-identity-persistence"] +plan: "plan.md" +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-002 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-004 +acceptance_criteria: + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.1 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.12 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.15 + - AC-OFFICE-WAKE-WAVE-IDENTITY-004.6 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +--- + +# Task 04: Wire engine-routed producers (P2, P3) onto wave identity + +## Summary + +Open the narrow engine seam that lets `OnChildrenCompletedPayload` carry the +two encodings from trigger dispatch through to the inserted run, then wire +P2 (`queueChildrenCompletedRun`) and P3 (`ParentWakeReconciler.reconcileOne`) +to read the wave members and populate that payload. `wakeOperationID` +(P2/P3's existing operation id) is unchanged. + +## In scope + +- `engine.OnChildrenCompletedPayload` gains `WaveKey`, `WaveString string`. +- `engine.QueueRunRequest` (adapters.go) gains the same two fields. +- `QueueRunCallback.Execute`: type-assert `in.Payload.(OnChildrenCompletedPayload)` + and copy the two fields onto the `QueueRunRequest` it builds, when present. + `idempotencyKey`/`queueActionDigest` are unchanged. +- `runsServiceEngineAdapter.QueueRun` (`backendapp/main.go`) copies the two + fields into the `runs/service.QueueRunRequest` it constructs. +- P2 (`event_subscribers.go`, `queueChildrenCompletedRun`): after + `AreAllChildrenTerminal`, add the `ListWaveMembers` terminality + confirmation (queue nothing on a non-terminal member, an empty set, or a + read error); build `OnChildrenCompletedPayload` with `WaveKey`/`WaveString` + from the sorted member ids. `childSetKey`/`wakeOperationID` computation is + unchanged. +- P3 (`scheduler_wake_reconciler.go`, `reconcileOne`/`buildPayload`): same + addition. The existing `GetChildSetKey`/`GetChildSetKeyTx` re-reads that + guard the receipt write are unchanged (state-inclusive key, different + purpose). + +## Out of scope + +- P1 (Task 03) and P4 (Task 05). +- `ListStuckParents` (Task 06). +- Any change to `wakeOperationID`, `formatChildSetKey`, or + `parent_child_wake_receipts` semantics (AC-...-004.6). + +## Acceptance + +- A run queued via P2 for a parent whose children are all terminal carries + the correct `WakeWaveKey`/`WakeWaveString` on the persisted row. +- A run queued via P3 for the same parent and wave derives the identical + `WakeWaveKey` as P2 would for that parent (byte-for-byte), so + `idx_run_wake_wave` actually collapses a P2/P3 race for the same wave + (proven fully in Task 07; this task's own test proves derivation parity, + not the race itself). +- A wave-member read observing a non-terminal member (P2 or P3) queues + nothing and dispatches no engine trigger. +- `TestWakeOperationID_UnifiedAcrossEdgeAndReconcilerPaths` continues to + pass unmodified. + +## Verification + +```bash +cd apps/backend +go test ./internal/workflow/engine/... -run TestQueueRunCallback -v +go test ./internal/office/service/... -run TestQueueChildrenCompletedRun -v +go test ./internal/office/service/... -run TestParentWakeReconciler -v +go test ./internal/office/service/... -run TestWakeOperationID_UnifiedAcrossEdgeAndReconcilerPaths -v +``` + +## Files likely touched + +- `internal/workflow/engine/payloads.go` +- `internal/workflow/engine/adapters.go` +- `internal/workflow/engine/phase2_callbacks.go` +- `internal/workflow/engine/phase8_test.go` (or a new test file for the + `QueueRunCallback` wave-key copy) +- `internal/backendapp/main.go` +- `internal/office/service/event_subscribers.go` +- `internal/office/service/scheduler_wake_reconciler.go` +- `internal/office/service/scheduler_wake_reconciler_test.go` + +## Dependencies + +Task 01 (`waveidentity`, `ListWaveMembers`), Task 02 (persistence, so the +copied fields land somewhere real). + +## Risks + +- **Shared engine-seam files.** This task and Task 05 both touch + `payloads.go`/`phase2_callbacks.go`'s type assertion; land this one first + so Task 05 builds on an existing seam rather than duplicating it. + +## Parallelism + +`sequential` + +## Inputs + +- System design: "Producers" (P2 edge, P3 backstop), "The engine seam is + narrow" paragraph under "The wave-member read is the last read, and it + carries state". +- `internal/workflow/engine/engine.go:417-443` (`executeCallback`) — + confirms `Payload any` already flows `HandleInput` → `ActionInput` + unmodified, so no change is needed there. + +## Results + +Done, in `feat(office): wire the engine-routed producers onto wave +identity`. `OnChildrenCompletedPayload`/`engine.QueueRunRequest` gained +`WaveKey`/`WaveString`; `QueueRunCallback.Execute` copies them via a new +`waveIdentityPayload` helper (empty for every trigger but +`on_children_completed`); `runsServiceEngineAdapter.QueueRun` forwards them +into `runs/service.QueueRunRequest.WakeWaveKey`/`WakeWaveString`. P2 and P3 +share a new `office/service.resolveWaveIdentity` helper (same terminality- +confirming shape as Task 03's `office/scheduler` one — duplicated rather +than shared across packages, consistent with this codebase's existing +`runsServiceEngineAdapter` precedent for small intentional duplication +across package boundaries). + +`go test ./internal/office/... ./internal/workflow/... ./internal/runs/... +./internal/backendapp/...` and `golangci-lint run +./internal/workflow/engine/... ./internal/office/service/... +./internal/backendapp/... --new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` +both clean. diff --git a/docs/plans/parent-wake-wave-identity/task-05-wire-orchestrator-producer.md b/docs/plans/parent-wake-wave-identity/task-05-wire-orchestrator-producer.md new file mode 100644 index 0000000000..2c0ef4cccf --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/task-05-wire-orchestrator-producer.md @@ -0,0 +1,117 @@ +--- +id: "05-wire-orchestrator-producer" +title: "Wire orchestrator (P4) onto wave identity" +status: done +wave: 3 +depends_on: ["01-wave-identity-primitives", "04-wire-engine-routed-producers"] +plan: "plan.md" +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-002 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-004 +acceptance_criteria: + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.1 + - AC-OFFICE-WAKE-WAVE-IDENTITY-004.3 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +--- + +# Task 05: Wire orchestrator (P4) onto wave identity + +## Summary + +`processOnChildrenCompleted` already reads exactly the wave-member rows +(`ListChildCompletionRows` applies the same predicate `ListWaveMembers` +does) and already confirms terminality on them before evaluating actions. +It needs no new read — only a re-sort by id (its source orders by +`created_at` first) and to set the two encodings on the +`OnChildrenCompletedPayload` the Task 04 seam now carries through. + +## In scope + +- `childCompletionPayload` (`event_handlers_children_completed.go`) gains a + `parentID string` parameter; before building `ChildSummaries`, sort a copy + of `rows` ascending by `ID` and derive `WaveKey`/`WaveString` from that + sorted id list. `childCompletionOperationID` is unchanged (keeps ordering + by the rows' existing `created_at` order and its full + state/step/terminal/`updated_at` derivation). +- Update `childCompletionPayload`'s one call site in + `evaluateChildrenCompleted` to pass `parent.ID`. + +## Out of scope + +- P1/P2/P3 (Tasks 03, 04). +- `readyChildCompletionRows`, `allChildrenTerminal`, + `annotateTerminalChildSteps` — unchanged; they already establish + terminality on the rows the wave identity is derived from + (AC-...-002.15 satisfied by construction). +- Any change to the operation ledger, `EvaluateOnly` transition lifecycle, + or reopen-driven step transition (AC-...-004.3). + +## Acceptance + +- For a fixed parent and child set, P4's derived `WakeWaveKey` is + byte-identical to what P1/P2/P3 would derive for the same parent and + wave-member set (proven together with Task 07's cross-producer test, but + this task's own unit test covers P4's re-sort in isolation: feed rows in + `created_at` order that differ from `id` order and assert the derived key + matches the id-sorted expectation). +- `TestProcessOnChildrenCompleted_DedupedAcrossReinit` (or the equivalent + existing operation-ledger test) continues to pass unmodified. + +## Verification + +```bash +cd apps/backend +go test ./internal/orchestrator/... -run TestProcessOnChildrenCompleted -v +go test ./internal/orchestrator/... -run TestChildCompletionPayload -v +``` + +## Files likely touched + +- `internal/orchestrator/event_handlers_children_completed.go` +- `internal/orchestrator/event_handlers_children_completed_test.go` (or + wherever the existing P4 tests live — confirm exact file before editing) + +## Dependencies + +Task 01 (`waveidentity`), Task 04 (the engine seam this payload now flows +through — `OnChildrenCompletedPayload`'s new fields and +`QueueRunCallback.Execute`'s type assertion must exist first). + +## Risks + +None beyond the shared-seam ordering already called out in Task 04 — this +task is a small, additive change to one existing function. + +## Parallelism + +`sequential` + +## Inputs + +- System design: "Producers" (P4 orchestrator) section, "The wave-member + read is the last read, and it carries state" (P4 paragraph). +- `internal/task/repository/sqlite/task.go:2055-2070` + (`ListChildCompletionRows`) for the `created_at, id` ordering this task + must correct for. + +## Results + +Done, in `feat(orchestrator): derive wave identity in the children-completed +producer`. `childCompletionPayload(parentID string, rows +[]models.ChildCompletionRow)` derives `WaveKey`/`WaveString` via a new +`childCompletionWaveIdentity` helper (copies row ids into a fresh slice, +`sort.Strings`, then `waveidentity.WaveKey`/`WaveString` — never mutates +`rows`, so `childCompletionOperationID`'s created_at-ordered derivation, +called earlier in `processOnChildrenCompleted`, is unaffected regardless of +call order). No new read: `readyChildCompletionRows` already applies the +same predicate `ListWaveMembers` does and already confirms terminality +before `rows` reaches `evaluateChildrenCompleted`; it also already gates on +`len(rows) == 0`, so `childCompletionPayload` never runs against zero wave +members in production (AC-...-001.7 holds by construction, no extra guard +needed). + +`go test ./internal/orchestrator/...` (full package, ~84s) and +`golangci-lint run ./internal/orchestrator/... +--new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` both clean. +`TestProcessOnChildrenCompleted_DedupedAcrossReinit` passes unmodified. diff --git a/docs/plans/parent-wake-wave-identity/task-06-backstop-admission.md b/docs/plans/parent-wake-wave-identity/task-06-backstop-admission.md new file mode 100644 index 0000000000..56817bc764 --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/task-06-backstop-admission.md @@ -0,0 +1,177 @@ +--- +id: "06-backstop-admission" +title: "Backstop admission compares wave identity" +status: done +wave: 4 +depends_on: ["01-wave-identity-primitives", "02-wave-identity-persistence"] +plan: "plan.md" +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-003 +acceptance_criteria: + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.1 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.2 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.3 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.4 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.5 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.6 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.8 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.9 + - AC-OFFICE-WAKE-WAVE-IDENTITY-003.10 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +--- + +# Task 06: Backstop admission compares wave identity + +## Summary + +Rewrite `ListStuckParents`' staleness arm to compare the parent's current +wave string (computed in SQL) against `runs.wake_wave_string`, add a +wave-member existence gate so a parent with no possible wave is never +listed, and keep a parent-scoped timestamp fallback for pre-upgrade rows +that carry no wave identity at all. + +## In scope + +- New `internal/db/dialect` helper for an ordered, comma-joined id + aggregate (SQLite `GROUP_CONCAT` over an ordered subquery; PostgreSQL + `string_agg(... ORDER BY ...)`), added beside `JSONExtract`. +- `ListStuckParents`: a wave-string CTE column + (`p.id || '|' || COALESCE(, '')`) over the wave-member predicate. +- A new `EXISTS` gate (wave-member predicate) beside the existing + archived-only `EXISTS`, removing a parent with no wave members from + candidacy (AC-...-003.9). Additive and narrowing only. +- Rewrite the second `NOT EXISTS` arm into three clauses: queued/claimed + blocks unconditionally; a terminal run whose `wake_wave_string` matches + the parent's current one blocks; only when the parent has **no** + `task_children_completed` run with `wake_wave_key <> ''` at all does the + existing `requested_at >= newest_child_updated_at` timestamp rule apply + (evaluated once per parent, not per row — AC-...-003.6). + +## Out of scope + +- The first `NOT EXISTS`/receipt-comparison arm (unchanged — receipts stay + state-inclusive, AC-...-004.6). +- Porting this query's existing `json_extract`/unbranched `GROUP_CONCAT` to + dialect form (`53c24173` — do not widen or close that gap here; only the + new wave-string fragment is dialect-branched). +- The whole-second comparison in the queued-or-claimed arm (`b7e29d7c`). + +## Acceptance + +- A parent with a delivered, unchanged wave is not a candidate; a + non-state child edit after delivery does not make it one; a real + wave-member change does. +- A parent whose only children are archived/ephemeral/automation-origin is + never listed. +- A parent with a pre-upgrade terminal run (`wake_wave_key = ''`) is judged + by the timestamp rule; once any keyed run exists for that parent, the + timestamp rule is never consulted for it again, even for an older + pre-upgrade row on the same parent. +- A failed/cancelled run whose wave string matches the current one keeps + blocking across an unrelated child edit and is unblocked only by a + wave-member change. + +## Verification + +```bash +cd apps/backend +go test ./internal/db/dialect/... -run TestOrderedIDConcat -v +go test ./internal/office/repository/sqlite/... -run TestListStuckParents -v +KANDEV_TEST_POSTGRES_DSN=... go test ./internal/office/repository/sqlite/... -run TestListStuckParents -v +``` + +## Files likely touched + +- `internal/db/dialect/json.go` (or a new file beside it for the aggregate + helper, if it doesn't fit `json.go`'s theme) +- `internal/db/dialect/dialect_test.go` +- `internal/office/repository/sqlite/wake_receipts.go` +- `internal/office/repository/sqlite/wake_receipts_test.go` + +## Dependencies + +Task 01 (wave-member predicate, matched exactly), Task 02 (`wake_wave_key`/ +`wake_wave_string` columns must exist to query against). + +## Risks + +- **Ordering divergence between Go and PostgreSQL collation** — the + requirements' AC-...-001.13 names this as a silent, PostgreSQL-only + failure mode. The gated Postgres test must assert byte-identical ordering + with a fixture using real UUID-shaped ids, not sequential test ids that + would happen to sort the same everywhere. + +## Parallelism + +`sequential` + +## Inputs + +- System design: "Backstop admission" section in full, including its + worked SQL fragments. +- `internal/office/repository/sqlite/wake_receipts.go` (current + `ListStuckParents`, `child_set_key` aggregate idiom to mirror for the new + wave-string aggregate). +- `internal/db/dialect/json.go` (signature/doc-comment style to match). + +## Results + +Done, in `feat(office): compare wave identity in the backstop reconciler`. + +`internal/db/dialect/aggregate.go` (new file, not `json.go` — a distinct +aggregate-vs-extraction theme) adds `OrderedIDConcat(driver, where string) +string`, hardcoded to `tasks.id` per "simplest implementation that fully +meets the current requirement" rather than a generic group-concat builder. +SQLite orders via an `ORDER BY` inside the `GROUP_CONCAT` subquery; +PostgreSQL via `string_agg(... ORDER BY ...)` in the aggregate call itself. +`TestOrderedIDConcat_FragmentShape` checks both dialects' exact fragment +text; `TestOrderedIDConcat_SQLite_OrdersAscendingByID` and its +`KANDEV_TEST_POSTGRES_DSN`-gated Postgres twin insert UUID-shaped ids out +of order and assert the concatenated result is byte-identical and +ascending across both dialects, per the Risks section's collation-drift +warning. + +`ListStuckParents` gained a `wave_string` CTE column +(`p.id || '|' || COALESCE(OrderedIDConcat(...), '')`) over the wave-member +predicate (not archived, not ephemeral, not automation-origin — matching +`ListWaveMembers`), plus a second `EXISTS` gate applying that same +predicate to remove parents with no possible wave from candidacy +(AC-...-003.9), added beside — not replacing — the existing archived-only +`EXISTS`. The second `NOT EXISTS` arm is now: queued/claimed blocks +unconditionally; a terminal run whose `wake_wave_string` matches the +parent's current one blocks (across any unrelated, non-wave-member child +edit, unblocked only by a genuine wave-member change); a separate +`AND (EXISTS(...) OR NOT EXISTS(...))` clause keeps the pre-upgrade +timestamp fallback but scopes it to the parent as a whole +(`wake_wave_key <> ''` for *any* row for that parent, any status, any +wave), not per-row — so a parent's first wave-keyed run retires the +timestamp rule for it permanently, even against an older, still-recent +pre-upgrade row for the same parent (AC-...-003.6). + +New tests in `wake_receipts_wave_identity_test.go`: +`TestListStuckParents_WaveMatchBlocksAcrossNonMemberChildEdit_UnblockedOnWaveMemberChange` +(table-driven over finished/failed/cancelled — delivered-unchanged-wave not +a candidate, an automation-origin child completing doesn't unblock it, a +real wave-member joining does), +`TestListStuckParents_ExcludesParentWithOnlyNonWaveMemberChildren` +(ephemeral-only, automation-origin-only, and mixed non-member-only parents +are all excluded, with a control candidate), and +`TestListStuckParents_KeyedRunRetiresTimestampFallbackForParent` (a recent +pre-upgrade terminal run that would otherwise still block under the +timestamp rule is bypassed once any wave-keyed run — even for an unrelated, +stale wave — exists for the parent). + +All 8 pre-existing `TestListStuckParents_*` tests (and their subtests) +pass unmodified, confirming the rewrite is backward-compatible with every +scenario that predates wave identity (their fixture runs never set +`wake_wave_key`, so they exercise the compatibility/timestamp-fallback +path exactly as before). + +`go build ./...`, `go test ./internal/office/repository/sqlite/... +./internal/db/dialect/...` (full packages), and `golangci-lint run +./internal/office/repository/sqlite/... ./internal/db/dialect/... +--new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` all clean. The +gated `TestOrderedIDConcat_Postgres_OrdersAscendingByID` skips on this +runner (no `KANDEV_TEST_POSTGRES_DSN`), consistent with not provisioning +Docker/Postgres for this card. diff --git a/docs/plans/parent-wake-wave-identity/task-07-race-and-regression-coverage.md b/docs/plans/parent-wake-wave-identity/task-07-race-and-regression-coverage.md new file mode 100644 index 0000000000..cdbf0b7399 --- /dev/null +++ b/docs/plans/parent-wake-wave-identity/task-07-race-and-regression-coverage.md @@ -0,0 +1,197 @@ +--- +id: "07-race-and-regression-coverage" +title: "Race, parity, and regression coverage" +status: done +wave: 5 +depends_on: + - "02-wave-identity-persistence" + - "03-wire-cascade-producer" + - "04-wire-engine-routed-producers" + - "05-wire-orchestrator-producer" + - "06-backstop-admission" +plan: "plan.md" +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-002 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-003 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-004 +acceptance_criteria: + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.3 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.14 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.15 + - AC-OFFICE-WAKE-WAVE-IDENTITY-002.16 + - AC-OFFICE-WAKE-WAVE-IDENTITY-004.1 +system_design: + - ../../specs/office/system-design/parent-wake-wave-identity.md +--- + +# Task 07: Race, parity, and regression coverage + +## Summary + +Close the loop with the tests that need every producer and the rewritten +admission query to exist at once: the cascade-vs-reconciler race through +`office/scheduler`'s own queue path, full cross-producer derivation parity, +the coalescing exclusion end-to-end, and the read-skew guarantee. + +## In scope + +- **Race test:** cascade (P1) racing one `ParentWakeReconciler` tick for the + same parent and wave, driven through `office/scheduler.QueueRun` (not only + `runs/service`) — the two classification sites this initiative added. + Asserts exactly one `runs` row and that the losing side logs no failure. + Mirrors `TestQueueRun_DedupesOnIdempotencyIndexRace` + (`runs/service/service_test.go:578`) and its Postgres twin. +- **Cross-producer parity:** for one fixture parent and child set (including + an ephemeral and an automation-origin child), all four producers derive + byte-identical `WakeWaveKey`/`WakeWaveString`. +- **Coalescing:** a second wave-carrying request for a *different* parent, + same target agent, inside the coalescing window, does not merge into the + first parent's queued (also wave-carrying) run — two rows exist, each with + its own identity. +- **Read skew:** a wave-member read that observes a non-terminal member + queues nothing; a later read of the same parent, once genuinely all + terminal, is delivered normally (not permanently suppressed). +- **Payload parity end-to-end:** the cascade's queued run and an + equivalent engine-routed run for a step with the same + `on_children_completed` `queue_run` payload carry the same resolved + payload keys. +- Confirm the "MUST NOT be weakened" tests + (`reactivity_children_completed_test.go`, + `scheduler_wake_reconciler_identity_test.go`) still pass unmodified. + +## Out of scope + +- Any further production code change — this task is test-only. A finding + here that requires a production fix routes back to the relevant earlier + work order, not a change made inline in this one. + +## Acceptance + +- The race test fails on the pre-Task-03/04 code (verify by temporarily + reverting to confirm it actually exercises the new constraint, then + restore) and passes on the finished tree. +- All new tests pass on both SQLite and PostgreSQL where a dialect twin is + required. + +## Verification + +```bash +cd apps/backend +go test ./internal/office/scheduler/... ./internal/office/service/... \ + ./internal/office/repository/sqlite/... ./internal/orchestrator/... \ + ./internal/runs/service/... ./internal/runs/repository/sqlite/... \ + -race -count=1 +KANDEV_TEST_POSTGRES_DSN=... go test ./internal/runs/service/... \ + ./internal/office/repository/sqlite/... -run Postgres -count=1 +``` + +## Files likely touched + +- `internal/office/service/scheduler_wake_reconciler_test.go` (or a new + shared-harness test file, since the race test needs both a direct + `office/scheduler.QueueRun` caller and a `ParentWakeReconciler` against + one database) +- `internal/runs/service/service_test.go` +- `internal/runs/service/service_postgres_test.go` +- A new cross-producer parity test file (exact location depends on which + package can import all four producers' derivation call sites without a + cycle — likely a `_test.go` file in `internal/office/service` with build + tags or fakes for the orchestrator piece, confirmed at implementation + time) + +## Dependencies + +Tasks 02 through 06 — this is the closing verification pass. + +## Risks + +- **Test-only task that could hide a production gap.** If the race test + cannot actually be made to race deterministically (e.g. both paths commit + through the same test transaction manager without true concurrency), the + test would pass without proving anything. Use `synctest` or explicit + goroutine synchronization (start both inserts, block one at a lock point, + release both) per the backend's testing conventions — not + `time.Sleep`-based interleaving. + +## Parallelism + +`sequential` + +## Inputs + +- System design: "Testing" section in full. +- Requirements document: "REQUIRED TESTS" section of the task's Kandev + running plan (this initiative's own card), which enumerates the same + suite from the review history. +- `internal/runs/service/service_test.go:578` and + `service_postgres_test.go:27` as the race-test pattern. + +## Results + +Done, in `test(office): close race, parity, and regression coverage for +wave identity`. + +Most of this task's Acceptance items were already covered by Tasks 02-05's +own tests, confirmed unmodified in this pass: + +- **Coalescing:** `TestQueueRunCtx_WaveCarryingRequest_NotCoalesced` + (`office/scheduler/run_test.go`, Task 03) and + `TestQueueRun_WakeWaveKey_DifferentAgentBothQueued` + (`runs/service/service_test.go`, Task 02). +- **Read skew:** `TestResolveWaveIdentity_NonTerminalMember_ReturnsNotOK` + (`office/scheduler/reactivity_children_completed_wave_test.go`, Task 03). +- **Payload parity:** `TestCascadeChildrenCompleted_PayloadParity_*` + (same file, Task 03). +- **Per-producer derivation parity:** + `TestQueueRunCallback_OnChildrenCompletedCopiesWaveIdentity` (Task 04), + `TestChildrenCompletedWaveIdentity_IdenticalAcrossEdgeAndReconcilerPaths` + (Task 04, P2 vs P3), `TestChildCompletionPayload_MatchesCascadeDerivationForSameParentAndSet` + (Task 05, P4 vs `waveidentity` directly). +- **"MUST NOT be weakened" regression suites:** + `reactivity_children_completed_test.go` (5 pre-existing tests) and + `scheduler_wake_reconciler_identity_test.go` + (`TestParentWakeReconciler_*`, 7 tests) both pass unmodified. + +This task added the two genuinely new closing pieces: + +- `internal/office/scheduler/wave_identity_race_test.go`: + `TestWaveIdentityRace_CascadeVsEngineRoutedPath_CollapsesToOneRun` races + P1's direct-insert path (`SchedulerService.QueueRunCtx` -> `queueRun` -> + `ss.repo.CreateRun`, classified by + `runssqlite.IsWakeWaveUniqueViolation`) against an engine-routed path + (`runsservice.Service.QueueRun` -> `insertRun` -> `CreateRunTx`, + classified by Task 02's `errWakeWaveKeyConflict` handling) for the same + wave identity and target agent, through two goroutines started together + via a shared barrier channel (not a sequential call pair) — both + `office/scheduler.SchedulerService` and `runsservice.Service` are wired + against the exact same in-memory SQLite DB via + `officeRepo.RunsRepository()`, so the two classification sites this + initiative added contend on the real `idx_run_wake_wave` index. + Confirmed the test has teeth: temporarily gave the engine-routed request + a different `WakeWaveKey` and watched the assertion fail (`runs = 2, want + exactly 1`), then reverted before committing. Passes reliably across + repeated `-race -count=10` runs. +- `internal/task/repository/sqlite/wave_member_parity_test.go`: + `TestListWaveMembersMatchesListChildCompletionRows` builds one shared + `*sqlx.DB`, initializes both `task/repository/sqlite.NewWithDB` (owns the + `tasks` table in production) and `office/repository/sqlite.NewWithDB` + against it, seeds one parent with ordinary/ephemeral/automation-origin/ + archived children, and asserts `ListWaveMembers` (P1/P2/P3's read) and + `ListChildCompletionRows` (P4's read, id-sorted the way + `childCompletionPayload` sorts it) return the identical wave-member id + set — the AC-...-001.8 cross-producer predicate-equivalence proof Task + 01's plan text called for but that file's own tests didn't yet include. + +`go build ./...` clean. +`go test ./internal/office/scheduler/... ./internal/office/service/... +./internal/office/repository/sqlite/... ./internal/orchestrator/... +./internal/runs/service/... ./internal/runs/repository/sqlite/... +./internal/task/repository/sqlite/... -race -count=1` — all packages pass. +`golangci-lint run ./internal/office/scheduler/... +./internal/task/repository/sqlite/... +--new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` clean. The +Postgres-gated command in Verification was not run — no +`KANDEV_TEST_POSTGRES_DSN` provisioned in this environment, consistent +with the card's own instruction not to stand up Docker/Postgres here; +every dialect-sensitive change (Task 06's `OrderedIDConcat`) already has +its own gated Postgres twin from that task. diff --git a/docs/specs/office/requirements/parent-wake-wave-identity.md b/docs/specs/office/requirements/parent-wake-wave-identity.md new file mode 100644 index 0000000000..d4a5fe53ec --- /dev/null +++ b/docs/specs/office/requirements/parent-wake-wave-identity.md @@ -0,0 +1,332 @@ +--- +status: draft +system: office +created: 2026-09-08 +owners: + - kandev +--- + +# Parent Wake Wave Identity Requirements + +## Overview + +When every direct child of a parent task reaches a terminal state, Kandev wakes +the parent's agent with a `task_children_completed` run. Four independent +producers can emit that wake for the same parent at the same moment, and each +identifies the completion event differently today. The consequences are opposite +failures of one missing primitive: + +- **Duplicate wake.** Two producers describing the same completion event mint + structurally different run identities, so the run queue's uniqueness constraint + cannot collapse them. The parent agent runs twice, pays twice, and re-reads the + same child results. +- **Spurious wake.** The backstop decides "has this parent already been woken for + its current children" by comparing timestamps, and every task write stamps + `updated_at` — so editing a finished child's title, priority, or labels makes a + delivered wake look stale and queues a needless autonomous run. + +This capability defines a single **completion wave identity**: one value naming +*which completion event* a wake belongs to, computed the same way by every +producer, persisted on the run, and compared instead of a timestamp. + +Office owns this contract because the outcome is an Office wakeup policy +decision: whether an autonomous `task_children_completed` run enters the Office +run queue. It reads and constrains, but does not own, the workflow engine's +`on_children_completed` trigger and operation ledger and the task system's +parent/child relationship and `updated_at` stamping. The task system's +[subtask completion trigger](../../tasks/requirements/subtask-completion-trigger.md) +still owns when the trigger fires and what its payload contains; this capability +constrains only the identity carried by the resulting run. + +## Terminology + +- **Completion wave:** the event of a parent's full set of wave members being + terminal at once. Iterative delegation produces one wave per round. +- **Wave member:** a direct child task of the parent that counts towards its + parent's completion wave: `archived_at` unset, `is_ephemeral` false, and + `origin` not `automation_run`. Children failing any of these are excluded from + wave identity throughout. This is the predicate the orchestrator's existing + children-completed readiness query applies; the design records why that makes it + the only one all four producers can share. +- **Wave identity:** the value naming a completion wave. Equal values mean the + same wave. It has the two encodings below, which are two spellings of one + identity rather than two identities. +- **Wave string:** the canonical encoding of a wave identity: the parent id and + its wave members' ids in ascending `tasks.id` order, joined by a fixed + separator. It is what the backstop's candidate query compares, so it must be + derivable with plain SQL string aggregation. A parent with no wave members has + no wave and no wave string; an SQL expression of the same shape evaluated per + row, before that parent is known to have any member, is an intermediate, not a + wave string, until the parent passes the wave-member gate. +- **Wave key:** the fixed-length digest of the wave string, and the encoding the + run queue's uniqueness constraint indexes, where an unbounded value would + eventually exceed an index entry limit. +- **Producer:** any code path that can cause a `task_children_completed` run to + be queued for a parent. Four exist; the design enumerates them. +- **Target agent:** the agent profile a queued run is addressed to. One wave can + legitimately fan out to several when a workflow step configures it. +- **Terminal state:** the child task states that count as done, as already + defined by the `on_children_completed` trigger. + +## Requirements + +### REQ-OFFICE-WAKE-WAVE-IDENTITY-001: One canonical completion-wave identity + +**Intent:** A wave must have exactly one name, so that agreement between +producers is a property of the definition rather than of four code paths +happening to match. + +#### Acceptance criteria + +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.1:** Wave identity shall be a pure function + of the parent task id and the set of its wave-member child task ids. No other + input shall affect it. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.2:** Child ids shall enter the derivation + ordered ascending by `tasks.id`, which is unique, so no tiebreak column is + required and none shall be introduced. A producer holding rows in any other + order shall re-sort first; arrival order is not sufficient. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.3:** Children that are not wave members + shall be excluded from the derivation. A child entering or leaving wave + membership — by archiving, unarchiving, or being created ephemeral or + automation-origin — shall change the wave identity only when it changes the + wave-member id set. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.4:** When a child moves between two terminal + states, `CANCELLED` to `COMPLETED` say, the wave identity shall not change. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.5:** When any child field other than wave + membership changes — title, description, priority, labels, assignee, workflow + step, `updated_at` — the wave identity shall not change. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.6:** When a wave member is added or removed, + the wave identity shall change. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.7:** A parent with no wave members has no + wave, and no `task_children_completed` run shall be queued for it. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.8:** For the same parent and wave-member id + set, every producer shall derive a byte-identical wave string and wave key from + the same wave-member predicate; a producer whose existing row source applies a + different predicate shall be brought onto the shared one. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.9:** Wave identity shall not depend on the + wake payload: child-summary truncation, a missing child summary, and missing or + present pull-request enrichment shall not change it. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.10:** Wave identity derivation shall have no + failure mode of its own: it shall be computable from values the caller already + holds, without a further read that can error. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.11:** The wave string and the wave key shall + be two encodings of one identity: the key shall be a pure function of the + string, so equal strings always yield equal keys, and both shall be derived once + per queued run from the same member-id set rather than computed independently at + two call sites. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.12:** The backstop's candidate query shall + derive the current wave string using only string aggregation available on both + SQLite and PostgreSQL; no hash or digest shall be required inside SQL on either + dialect. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.13:** The ascending `tasks.id` order in .2 + shall be the same total order in application code and in SQL on both dialects. + It rests on task ids being lowercase hexadecimal UUIDs, for which byte order and + collation order coincide, and shall not depend on any database locale or + collation setting. + +### REQ-OFFICE-WAKE-WAVE-IDENTITY-002: At most one wake per wave per target agent + +**Intent:** Producers race by design — the reconciler exists precisely because +an edge-triggered dispatch can be lost — so correctness must come from a durable +constraint the second arrival cannot bypass, not from producers avoiding each +other. + +#### Acceptance criteria + +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.1:** Every queued `task_children_completed` + run shall durably record the wave identity it was queued for, in both encodings. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.2:** The run queue shall enforce durable + uniqueness over (wave key, target agent). +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.3:** When two producers concurrently queue a + run for the same wave and target agent, exactly one run row shall exist + afterwards. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.4:** The producer whose insert loses that + race shall observe a deduplicated outcome, not an error; the wake shall not be + logged as a failure or retried. This holds for every producer, including one + inserting directly rather than through the shared run-admission service — + reaching the queue by an unclassified path is not an exemption. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.5:** Deduplication shall behave identically + on SQLite and PostgreSQL. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.6:** Deduplication shall not be time-bounded: + a second attempt at the same wave shall be deduplicated however long after the + first it arrives. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.7:** When one wave fans out to two distinct + target agents, each shall receive exactly one run. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.8:** A run for a different wave of the same + parent shall not be suppressed by .2. A parent that completes wave 1, is given + new children, and completes wave 2 shall be woken for both. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.9:** Runs of every other reason shall be + unaffected: recording no wave identity leaves a run outside this constraint + entirely. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.10:** The wake the surviving run delivers + shall be equivalent to the wake the losing producer would have delivered. No + producer shall depend on being the winner, and no producer's wake shall carry + context that another producer's wake for the same wave omits. The obligation + binds whenever two producers would queue for the same wave *and* the same + target agent, which is when one of the two is suppressed; producers queueing to + different target agents each deliver their own run and are not compared. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.11:** Retrying an already-queued run shall + not be rejected by .2: a retry continues an existing run and is not a second + wake for the wave. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.12:** When a producer cannot read the + parent's wave members, it shall queue no run and record no wave identity; the + backstop shall deliver that wake later. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.13:** When one wave fans out across several + workflow participants resolving to the same target agent profile, that profile + shall receive exactly one run, not one per participant. Holding two + roles is not a second wave. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.14:** A run's recorded wave identity shall + always describe the wake that run delivers. Where the run queue would otherwise + merge a later request into a queued run by replacing that run's payload, a + request carrying a wave identity shall not be merged into any run, and a queued + run carrying one shall not be merged into; both shall be admitted independently + and reconciled only by .2. One consequence is accepted: two wakes for *different* + parents addressed to the same target agent inside the merge window now produce + two runs, where one previously absorbed and discarded the other. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.15:** A producer shall record a wave + identity only for a wave-member set it has observed entirely terminal in a + single read. That read shall be the producer's last read of child task state + before queueing and shall yield each member's state as well as its id; if any + member is not terminal in it, the producer shall queue no run and the backstop + shall deliver the wake later. Recording an identity for a set never observed + simultaneously terminal would permanently suppress the real wave, because .6 + makes deduplication unbounded. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.16:** A producer that queues a wake without + consulting the workflow engine shall attach the same workflow-authored action + payload the engine would attach for the parent's current workflow step, so that + .10 holds for a workflow that authors one. Where it cannot be resolved, the + producer shall still queue the wake without it and record the omission: the wake + outranks an optional payload, and that wake is unconditional under .004.2. + +### REQ-OFFICE-WAKE-WAVE-IDENTITY-003: Backstop admission compares waves, not time + +**Intent:** The reconciler must re-deliver a genuinely lost wake and not +re-deliver a genuinely delivered one. Deciding that by timestamp makes the answer +depend on unrelated edits; deciding it by wave identity makes it depend on the +thing the wake is about. + +#### Acceptance criteria + +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.1:** When a terminal + `task_children_completed` run exists for the parent whose recorded wave identity + equals the parent's current one, the parent shall not be a candidate for + re-delivery. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.2:** When a wake has been delivered for the + current wave and a user then edits a terminal child's title, priority, or + labels, the parent shall not become a candidate and no run shall be queued. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.3:** When a wake has been delivered and the + parent's wave-member set subsequently changes to one whose wave identity has not + already been queued for that parent and target agent, the parent shall become a + candidate and a run for the new wave shall be queued. A change returning the + parent to an already-queued identity is the id-set-reuse exclusion under *Out of + scope*, suppressed by .002.6, and is not required to be re-delivered. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.4:** While a `task_children_completed` run + for the parent is queued or claimed, the parent shall not be a candidate, + regardless of which wave that run was queued for. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.5:** A parent with no + `task_children_completed` run carrying a wave identity shall continue to be + judged by the timestamp rule its runs were written under, so an upgrade queues + no re-wake storm. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.6:** Once any `task_children_completed` run + carrying a wave identity exists for a parent, that parent's admission shall be + decided by wave identity alone and .5 shall never be consulted for it again, + whichever of its runs is oldest. The compatibility rule is scoped to the parent, + not the individual run row. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.7:** A parent whose assignee cannot accept a + run, and a parent whose delivery evidence is missing, shall be admitted or + rejected exactly as today. This capability changes only how delivery *for the + current wave* is recognised, on the terms .10 states. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.8:** Admission shall decide staleness inside + the candidate query. A parent permanently ineligible because its current wave + was already delivered shall not be returned and then rejected in the caller, + where it would occupy a row of the query's fixed result limit on every tick. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.9:** A parent with no wave members shall not + be returned as a candidate: no producer will ever queue for it (.001.7), so + returning it creates exactly the permanently rejected candidate .8 forbids. This + gate shall only remove candidates, never admit one not admitted today. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.10:** Each admission clause shall name the + run statuses it applies to, and those sets shall be the ones in force today: a + queued or claimed run blocks under .4; a finished, failed, or cancelled run is + the delivery evidence .1 and .5 compare; a run of any status counts when deciding + whether a parent has left the compatibility rule in .5. A failed or cancelled + wake shall therefore keep blocking its parent and, because wave identity no + longer moves on an unrelated child edit, shall block that wave permanently rather + than until the next such edit. This is intended: the runtime contract already + requires an explicit user retry after a terminal execution failure, and the + edit-driven re-arming it replaces was an artefact of the churn being removed. + The escape hatches are an explicit retry and any wave-member change. + +### REQ-OFFICE-WAKE-WAVE-IDENTITY-004: Every producer, no other behaviour change + +**Intent:** A constraint one producer can route around is decorative; at the same +time, three of the four carry behaviour unrelated to wave identity that must +survive untouched. + +#### Acceptance criteria + +- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.1:** All four producers shall record the + canonical wave identity on every run they queue. + +- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.2:** The Office scheduler cascade shall + continue to queue a wake for a parent with no active workflow session, and its + wake shall not become conditional on the engine accepting a trigger. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.3:** The orchestrator's workflow operation + ledger identifier shall not change, and parent step transitions driven by + `on_children_completed`, including on a reopened and recompleted child set, + shall fire exactly as today. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.4:** A non-Office workflow that configures + `on_children_completed` shall observe no behaviour change other than those + .002.7, .002.13 and .002.14 define. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.5:** Upgrading shall require no backfill of + historical runs and shall queue no run as a direct consequence of the upgrade. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.6:** The parent wake receipt shall keep its + state-inclusive child-set key and its delivery-evidence fields. This capability + adds a wave identity to runs; it does not redefine the receipt. +- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.7:** The definition of when a parent's + children are *terminal enough* for a wake shall not change. Which children count + towards it, and the deliberate archived-child divergence between the + edge-triggered readiness check and the backstop's candidate query, are outside + this capability. Readiness and wave identity may disagree about which children + they count, and the wave-member predicate shall not be propagated into any + terminality predicate. Exactly two readiness-adjacent additions are in scope and + both only remove wakes: .001.7 with .003.9, and .002.15. + +## Out of scope + +- **Re-waking a parent after a child is reopened and completed again.** A child + going `COMPLETED` → `IN_PROGRESS` → `COMPLETED` presents an identical id set and + an identical state set at both wave moments, so no function of the current child + set distinguishes them. Closing this needs a monotone per-child completion epoch + on `tasks`, a separate capability with a different owner, filed as a follow-up. + Parent *step transitions* on reopen are unaffected, per .004.3; only the + redundant run is suppressed. + +- **Re-waking a parent whose wave-member id set returns to a previously delivered + value.** A child that leaves the wave-member set and later rejoins it — plainly, + archived then unarchived — returns the identity to a delivered value, which the + unbounded deduplication in .002.6 suppresses. Same root limit and same deferred + follow-up as the bullet above; .003.3 is qualified to match. Exposure is small: + unarchiving is deliberate and rare, and any later membership change still wakes + the parent. + +- **The task system's `updated_at` stamping.** Every task write stamps it + unconditionally and will continue to. This capability removes the wake + pipeline's dependence on it rather than changing it. + +- **The whole-second comparison in the backstop's in-flight run arm**, tracked as + its own card. This capability replaces only the *staleness* arm; the + queued-or-claimed arm and its resolution behaviour are untouched. + +- **The backstop candidate query's SQLite-only SQL.** Its aggregate child-set + expression and raw JSON extraction have no PostgreSQL branch; tracked as its own + follow-up. Work here shall not widen that gap — new SQL carries a dialect branch + — but closing it is not this capability's outcome. + +- **Any change to the `on_children_completed` trigger's firing conditions, + payload shape, or workflow-editor surface.** Those belong to the task system's + subtask completion trigger contract. Carrying an extra value alongside a trigger + to the action it dispatches, and *reading* an already-authored action payload, + are not changes to that contract and are not excluded. + +- **User interface.** No new control, view, or setting. The effect is visible + only as the absence of a duplicate or spurious run. diff --git a/docs/specs/office/system-design/parent-wake-wave-identity.md b/docs/specs/office/system-design/parent-wake-wave-identity.md new file mode 100644 index 0000000000..6973a99ccf --- /dev/null +++ b/docs/specs/office/system-design/parent-wake-wave-identity.md @@ -0,0 +1,553 @@ +--- +status: draft +system: office +requirements: + - REQ-OFFICE-WAKE-WAVE-IDENTITY-001 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-002 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-003 + - REQ-OFFICE-WAKE-WAVE-IDENTITY-004 +--- + +# Parent Wake Wave Identity System Design + +## Purpose and boundaries + +This design gives the `task_children_completed` wake a single durable identity, +persisted on the run row, produced by every producer, and compared by the backstop's +candidate query in place of a timestamp. Office owns the outcome: whether an +autonomous run enters the Office run queue. Adjacent contracts it reads and +constrains but does not own: + +- `internal/workflow/engine` — the `on_children_completed` trigger, the action path + turning it into a `queue_run`, the idempotency key it synthesises, and the operation + ledger. This design adds a value travelling beside that key. +- `internal/orchestrator` — the task-state and terminal-step handlers dispatching the + same trigger for every workflow, Office or not. +- `internal/task/repository/sqlite` — `tasks` and its `archived_at`, `is_ephemeral`, + `origin` and `updated_at` columns, plus `IsFromOfficePredicate`. +- `internal/runs/service` — run admission, the idempotency window, coalescing, and + unique-violation classification. +- `internal/db/dialect` — the expression helpers new cross-dialect SQL goes through. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-OFFICE-WAKE-WAVE-IDENTITY-001` | [Wave identity](#wave-identity) | +| `REQ-OFFICE-WAKE-WAVE-IDENTITY-002` | [Persistence](#persistence), [Failure and recovery](#failure-and-recovery) | +| `REQ-OFFICE-WAKE-WAVE-IDENTITY-003` | [Backstop admission](#backstop-admission) | +| `REQ-OFFICE-WAKE-WAVE-IDENTITY-004` | [Producers](#producers) | + +## Current state + +Four producers can queue a `task_children_completed` run for one parent, each +verified at `cd7823631`. + +| Producer | Entry point | Fires on | Route to the queue | Identity it uses today | +| --- | --- | --- | --- | --- | +| P1 cascade | `office/scheduler/reactivity.go` `cascadeChildrenCompleted` | inline from `ApplyTaskMutation`, via the dashboard status update | direct `QueueRun` | `childrenCompletedIdempotencyKey`: parent, agent, digest of child ids, straight to `runs.idempotency_key` | +| P2 edge | `office/service/event_subscribers.go` `queueChildrenCompletedRun` | `events.TaskMoved` into a done-category step | workflow engine | `wakeOperationID`: parent, digest of child ids, wrapped by the engine's `idempotencyKey` | +| P3 backstop | `office/service/scheduler_wake_reconciler.go` | cron sweep over `ListStuckParents` | workflow engine | same `wakeOperationID` as P2 | +| P4 orchestrator | `orchestrator/event_handlers_children_completed.go` `processOnChildrenCompleted` | task state → terminal, and terminal step moves | workflow engine | `childCompletionOperationID`: parent, then per child id, state, step, terminal flag and `updated_at` at ns resolution | + +P4 is not gated on Office: it reaches any parent with an active session whose step +configures `on_children_completed`, which shipped `office-default` does with +`reason: task_children_completed`. Its `EvaluateOnly` call suppresses persistence and +the step transition but still evaluates actions, so its `queue_run` reaches the queue. + +### The four child-set sources disagree, three ways + +The fact the design turns on, and wider than an archived-child mismatch: + +| Source | Feeds | `archived_at` | `is_ephemeral` | automation origin | ordered by | +| --- | --- | --- | --- | --- | --- | +| `ListChildStates` (`office/repository/sqlite/blockers.go`) | P1 | not filtered | not filtered | not filtered | `id` | +| `GetChildSetKey` (`office/repository/sqlite/wake_receipts.go`) | P2, P3 | `IS NULL` | not filtered | not filtered | `id` | +| `ListChildCompletionRows` (`task/repository/sqlite/task.go`) | P4 | `IS NULL` | `= 0` | excluded | `created_at`, then `id` | +| `ListStuckParents` child aggregate (`wake_receipts.go`) | admission | `IS NULL` | not filtered | not filtered | `id` | + +Two consequences. Telling a producer to reuse "the rows it already has" gives four +different answers, so the constraint would be decorative for at least one. And +`ORDER BY id` is *not* what every existing query uses — P4's source orders by +`created_at` first, so reusing its rows in arrival order derives a different string +from the same set. Separately, the backstop's staleness arm compares +`runs.requested_at` against `MAX(tasks.updated_at)` over children while +`updateTaskTx` stamps `updated_at` on every write regardless of which column changed: +symptom B's root. + +## Wave identity + +### Which children count + +The wave-member predicate is the one `ListChildCompletionRows` already applies: not +archived, not ephemeral, not automation-origin. P4 forces the choice: P1–P3 can each be narrowed cheaply, but P4's row set is the one its +readiness check consumes, so widening it would change *when the +`on_children_completed` trigger fires*, a task-system contract that is out of scope. +It is the only predicate all four can share without moving a readiness boundary, and +it is right on its merits: ephemeral and automation-origin tasks are machinery, +not delegated work. + +Two consequences: + +- **Readiness and identity now count different children, deliberately.** A parent with + a non-terminal ephemeral child is still not *ready* (readiness arms are untouched, + per .004.7) even though that child is not part of its wave. Do not "fix" this by + propagating the wave-member predicate into a readiness predicate. +- **Narrowing cannot split a wave.** Excluding a child can only merge two identities + that differed by an invisible row, never make one wave look like two, so the change + direction is safe for duplicate wakes. + +The predicate is expressed once, as a shared SQL fragment in the office repository; +`task/repository/sqlite`'s `andNotAutomationOrigin` is the precedent. + +### The two encodings + +```text +waveString(parentID, memberIDs) = parentID + "|" + strings.Join(memberIDs, ",") + +waveKey(parentID, memberIDs) = + "task_children_completed:" + parentID + ":" + + lowerhex(sha256(waveString(parentID, memberIDs))) +``` + +`memberIDs` are the ids of the parent's wave members, ascending by `tasks.id`. + +Both are pure functions of the same two inputs, with no I/O and no error return, +which is what .001.10 requires of the *derivation* — the function, not the caller: a +producer may still need a read to obtain the member ids, and .002.12 and .002.15 +govern that read. + +Why two encodings rather than one. **The backstop must compare in SQL** (.003.8) — a +candidate rejected in Go after the SELECT occupies a row of the query's `LIMIT` every +tick and starves an actionable parent behind it — and SQL here cannot hash, since +`mattn/go-sqlite3` registers no custom functions and there is no `pgcrypto` in the +tree, so the compared encoding has to be the plain string. **The uniqueness +constraint must be bounded** — a PostgreSQL btree entry caps near 2704 bytes, so a +parent with roughly seventy children would push an unhashed key past it and fail the +insert outright, so the indexed encoding has to be the digest. Neither job can use +the other's encoding, so both are persisted, derived together from one member-id +slice at one call site per producer. + +Choices a builder would otherwise have to invent, fixed here: + +- **The separator is `|`, deliberately not a NUL byte.** PostgreSQL `text` cannot + contain `U+0000` at all, so a NUL-separated string is unstorable there. A UUID + holds only hex digits and dashes, so `|` cannot occur inside an id. +- **Ordering is `ORDER BY id` ascending**, and a producer whose rows arrive + otherwise re-sorts first. `tasks.id` is the primary key, so the order is total and + there is no tiebreak to choose. +- **That order must be the *same* in Go and in SQL** (.001.13). Go sorts by byte; + PostgreSQL `ORDER BY c.id` sorts by database collation, which for most locales is + not byte order. Task ids are `uuid.New().String()` — lowercase hex, dashes at fixed + positions — and for that alphabet the two coincide, so no `COLLATE` clause is + needed. This is a premise worth stating because its failure is invisible: SQLite + (BINARY) never diverges, while on PostgreSQL a divergence would make every parent + look permanently stale and the sweep re-wake continuously. A non-UUID id source is + what would break it, and the cross-dialect test is what would catch it. +- **The digest covers ids only** — state, workflow step, terminal flag and + `updated_at` all excluded. +- **The parent id appears in the wave string and again as a readable prefix on the + wave key.** The prefix keeps the stored value diagnosable; its presence in the + digest input stops two parents with an identical child set colliding. +- **An empty `memberIDs` slice has no wave.** Producers return before deriving, so + neither function is called with one and no key of that shape is written. + +### Where the derivation lives + +A small leaf package holding the two pure functions and nothing else, imported by +`office/scheduler`, `office/service` and `orchestrator`. The constraint is *not* that +`office/scheduler` may not import `office/service` — it already does +(`executor_resolver.go`, `wake_payload.go`, `run.go`). It is the reverse: +`office/service` must not import `office/scheduler`, because that edge closes a cycle, +which rules out the scheduler as the shared home. `orchestrator` importing a leaf +office package is established practice — `orchestrator/model_info.go` imports +`internal/office/costs/modelsdev`. + +## Persistence + +### Two columns on `runs` + +```sql +ALTER TABLE runs ADD COLUMN wake_wave_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE runs ADD COLUMN wake_wave_string TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_run_wake_wave + ON runs(wake_wave_key, agent_profile_id) + WHERE wake_wave_key <> ''; +``` + +`wake_wave_key` carries the digest and is indexed; `wake_wave_string` carries the +plain string the backstop compares. Both are written in the same insert from one +derivation, so they cannot disagree; a test asserts `wake_wave_key == waveKey` of the +inputs that produced `wake_wave_string`. + +Applied through the replayable `r.migrate.Apply` path the other office column +migrations use (`runs.continuation_scope` in `base_migrations.go` is the model) and +added inline to the fresh-schema `CREATE TABLE` in `office/repository/sqlite/base.go` +so a new database and an upgraded one converge. The office repository creates `runs`, +so this is its migration to own, and both statements are written for both dialects. +They run after `runs` exists and none rewrites a row; index creation cannot fail on a +pre-existing duplicate pair, because both columns are new and default to empty, so no +existing row is inside the partial index. + +The columns live on `runs` rather than in a claim table because the constraint must +be enforced in the same transaction as the run insert, and that insert is a single +statement; a claim table would need a caller-owned transaction threaded from the +office producers through the engine's action dispatch into the runs service, and no +seam supports that. + +Both columns carry a value only for `task_children_completed` runs; every other run +leaves them empty and, the index being partial, sits outside the constraint. + +`(wake_wave_key, agent_profile_id)` rather than `wake_wave_key` alone: the engine can +fan one trigger out to several targets, which is why the receipt records an operation +id rather than one delivered run id. Constraining the pair suppresses a second run to +the same agent for one wave while leaving a genuine fan-out to *different* agents +intact. Per .002.13, a fan-out across participants resolving to the *same* profile +collapses to one run: an agent holding two roles is not two waves. + +### Coalescing: the third write path into `runs` + +`runs` is written three ways, not two, and the third is easy to miss because it is an +UPDATE. `runs/service.QueueRun` consults `CoalesceRun` **before** `insertRun`, and +`CoalesceRun` merges the request into an existing `queued` row for the same agent, +reason, and task bucket inside a time window, incrementing `coalesced_count` and +**replacing that row's payload**. `shouldCoalesceRun` excludes only +`task_comment:`-prefixed keys, so `task_children_completed` is eligible. + +When both payloads contain a task ID, `CoalesceRun` compares those IDs for every +reason. A request for a different parent therefore remains a separate row. Wave +requests skip this update path and use the durable wave identity index instead. + +Note what is *not* the mechanism: two engine-path producers deriving the same +`wakeOperationID` are already collapsed upstream by `CheckIdempotencyKey`. Coalescing +still handles different keys when the agent, reason, and task bucket match. + +The decision (.002.14): **a request carrying a wave identity is never merged, and a +queued run carrying one is never merged into.** The guard is on the wave columns, not +the reason string, so a future wave-keyed reason is covered without a second edit. +Both requests are then reconciled by `idx_run_wake_wave`, the stronger mechanism +anyway: exact and unbounded, where coalescing is approximate and windowed. + +Different parents addressed to the same agent inside the window stay in separate rows +because their task buckets differ. Requests for the same task can still coalesce when +they do not carry a wave identity. + +### Existing identifiers are untouched + +`childrenCompletedIdempotencyKey` (P1), `wakeOperationID` (P2, P3) and +`childCompletionOperationID` (P4) keep their definitions and values, and +`runs.idempotency_key` and `idx_run_idempotency` are unchanged. + +The wave key is *purely additive* — a new column beside whatever identity a producer +already computed, never a replacement — which keeps the operation ledger stable, +keeps every `parent_child_wake_receipts` row already written meaningful, and removes +any historical-operation-id migration question. None of that would hold if +`wakeOperationID` were redefined to be the wave key, since the wave-member predicate +is narrower than `GetChildSetKey`'s. The two +constraints answer different questions — "same dispatch" and "same wave" — and only +the second can be computed identically by a direct caller and by the engine. + +### Receipts + +`parent_child_wake_receipts` is unchanged and `GetChildSetKey` keeps its predicate and +state-inclusive format: the backstop uses that key for the two re-reads that keep a +concurrent child update from being masked by a previous generation's receipt, and that +check wants to notice a state change. `wakeOperationID` being unchanged, +`delivery_operation_id` keeps its meaning. + +## Producers + +All four derive from the wave-member predicate and pass the resulting pair onto +the run they queue. + +- **P1 cascade.** Keeps `ListChildStates` and its terminal-state loop as they are — + narrowing that read would change cascade's readiness check, which .004.7 forbids — + and gains a separate wave-member read. `RunContext` gains wave-key and wave-string + fields beside its `IdempotencyKey`, carried through `SchedulerService.QueueRun` into + the run row. Its no-session fallback is untouched: it still queues directly and + never consults the engine (.004.2). +- **P2 edge and P3 backstop.** Both gain the same wave-member read. + `GetChildSetKey` is *not* reused for the derivation — wider predicate, + state-inclusive — and stays where it is, serving receipts. +- **P4 orchestrator.** No new read and no new filter: its rows are already exactly + the wave members. It must re-sort by `id` before deriving, because + `ListChildCompletionRows` orders by `created_at` first. + `childCompletionOperationID` is unchanged, so the operation ledger, the + `EvaluateOnly` transition lifecycle, and the reopen-driven step transition all + behave as today. + +### The wave-member read is the last read, and it carries state + +Every producer reaches the queue through several separate, untransacted reads: a +readiness check, for P2 and P3 a receipt re-read, and now the wave-member read. +Nothing orders them, so a child can be added, archived, or leave a terminal state +between two. Unconstrained, that records a run against a member set that was **never +simultaneously terminal**, and because deduplication is unbounded (.002.6) that +identity suppresses the real wave *forever* — a lost wake, strictly worse than the +duplicate this design removes. + +The rule that closes it (.002.15) needs no new transaction seam: + +- the wave-member read returns `(id, state)` per member ordered by `id`, not ids + alone; +- it is the producer's **last** read of *child task state* before queueing; +- if any returned member is non-terminal, the producer queues nothing and returns. + +The recorded identity is then always derived from one statement's rows that the same +statement showed entirely terminal. A change landing *after* the read is harmless: it +moves the wave identity, so the backstop sees a mismatch and queues the new wave +normally. Only the never-terminal set had to be excluded. + +The confirmation can only reject, never admit, which keeps it inside .004.7 — the +earlier readiness gates are untouched and strictly wider, so a set that passes them +and fails this defers to the backstop. P4 satisfies it by construction: +`ListChildCompletionRows` returns `state` and `readyChildCompletionRows` establishes +terminality from those same rows. + +For P3 this also settles which value is authoritative: `ListStuckParents`' SQL wave +string is an **admission filter only**, never written to a run, and every producer +writes the identity from its own final wave-member read. A candidate row whose wave +string has gone stale needs no special handling — the producer's read is fresher and +its terminality confirmation decides. + +A producer deriving an **empty** member set queues nothing and returns (.001.7). This +is live, not theoretical, now the predicate is narrower than the readiness checks: a +parent whose only child is ephemeral or automation-origin passes P1's +`AreAllChildrenTerminal` gate and its `ListChildStates` loop, and only the +wave-member read reveals there is no wave. P4 already guards it +(`readyChildCompletionRows` rejects `len(rows) == 0`); P1, P2 and P3 gain the same. + +P1, P2 and P3 read through one new office-repository method returning wave-member +`(id, state)` ordered by `id`. Because P4 derives from `ListChildCompletionRows` +instead, a test asserts the two SQL sources return an identical id set for the same +parent — the equality .001.8 rests on, and the one place two queries must be kept in +step by hand. + +The engine seam is narrow: both encodings accompany an `on_children_completed` +trigger to the `queue_run` action and are written to the run row, carried by +`OnChildrenCompletedPayload` (`workflow/engine/payloads.go`). They do not participate +in `idempotencyKey`, `queueActionDigest`, or the operation ledger — +`queueActionDigest` keys off the workflow-authored *action* payload, not the trigger +payload. + +## Backstop admission + +`ListStuckParents` computes the parent's current wave string in SQL alongside the +existing state-inclusive `child_set_key`, over the wave-member predicate: + +```text +p.id || '|' || COALESCE(, '') +``` + +The aggregate needs a dialect branch, added to `internal/db/dialect` beside +`JSONExtract`: SQLite's `GROUP_CONCAT(c.id, ',')` takes ordering from an ordered +subquery, the idiom the existing `child_set_key` aggregate uses; PostgreSQL's +`string_agg(c.id, ',' ORDER BY c.id)` takes it inside the aggregate. Writing one and +hoping is how ordering silently differs between dialects. + +That expression is evaluated for **every** CTE row, including a parent with no wave +members, where it yields a well-formed `"|"` — a pre-gate intermediate, not +a wave string, since per the requirements' *Terminology* a parent with no wave +members has neither. Nothing compares it, because the gate below removes the row +first; note that a gate testing the assembled expression for emptiness would exclude +nothing, as it is never empty. + +The gate is a separate `EXISTS` over the wave-member predicate, beside the existing +`EXISTS`: + +```sql +AND EXISTS ( + SELECT 1 FROM tasks c + WHERE c.parent_id = p.id + AND c.archived_at IS NULL + AND c.is_ephemeral = 0 + AND COALESCE(c.origin, '') != 'automation_run' +) +``` + +Without it, a parent whose only child is ephemeral or automation-origin would be +listed every tick, be found to have no wave, queue nothing, and be listed again — the +permanently rejected candidate .003.8 prevents. The existing +`EXISTS (... archived_at IS NULL)` arm keeps its archived-only predicate and is not +replaced: the gate is additive and narrowing, so it can only remove candidates, never +admit one not admitted today, which keeps it inside .004.7. + +The second `NOT EXISTS` arm becomes three clauses, each naming its run statuses +explicitly per .003.10, and each status set is the one in force today: + +- `status IN ('queued','claimed')` blocks regardless of wave — unchanged, so the + sweep never races an in-flight wake; +- `status IN ('finished','failed','cancelled')` blocks when its `wake_wave_string` + equals the parent's current wave string; +- **only when the parent has no `task_children_completed` run carrying a wave + identity at all, in any status**, a terminal run blocks under the existing + `requested_at >= newest_child_updated_at` rule. + +The second clause keeps its three-status set deliberately, and that is the one place +this design changes *when* a wake is re-delivered. Today a failed or cancelled wake +blocks only until some child's `updated_at` overtakes `requested_at`, so any +unrelated edit silently re-arms it; comparing wave identity removes that churn, so a +failed wake blocks its wave until the wave changes. `ListStuckParents`' doc comment +already states the contract — a terminal failure stays terminal, needs an explicit +user retry, and must not become a cron retry loop — so this is the correct direction. +The escape hatches are an explicit retry and any wave-member change. + +The third clause is the compatibility path for pre-change rows. Its guard is +status-agnostic on purpose — it asks about *vintage*, not delivery, and a keyed run +still `queued` blocks through the first clause anyway — and it is scoped to the +**parent**, not the row (.003.6): evaluating per row would let a parent's +pre-upgrade row keep answering long after a keyed run existed. "Carrying a wave +identity" is tested as `wake_wave_key <> ''`, the same predicate the partial index +uses, so a row is inside or outside both the constraint and the compatibility path +together. The path is bounded and self-clearing: an unrelated edit can still make a +pre-upgrade parent look stale and queue one wake, that wake writes a keyed row, and +the parent is judged by wave identity ever after. `newest_child_updated_at` stays +solely for this clause. + +The reconciler's two re-reads keep using `GetChildSetKey`: they guard against a child +change between the SELECT and the dispatch, and the state-inclusive key is the +stricter guard. + +One asymmetry follows from unbounded deduplication and is why .003.3 is qualified +rather than absolute. Admission can make a parent a candidate again on any +wave-member change, but the constraint still refuses an insert for an identity +already queued, so a set that changes and changes *back* — archive, then unarchive — +lists the parent and queues nothing. That is the id-set-reuse exclusion: admission +promises candidacy, not delivery. + +## Wake equivalence between producers + +AC-OFFICE-WAKE-WAVE-IDENTITY-002.10 requires the surviving run to deliver the same +wake whichever producer won. It binds only where one run is actually suppressed — +same wave *and* same target agent — so a workflow whose `on_children_completed` +action targets `workspace.ceo_agent` while the cascade wakes the parent's assignee +raises no equivalence question. Two payload sources have to agree for the cases that +do collide. + +**Child summaries: equal, from current storage.** Prompt assembly calls +`enrichChildrenContext`, which reads `GetChildSummaries` from the task repository. +The four producers do not need to write a `children` payload key. Each surviving run +therefore receives the current stored child summaries when the scheduler builds the +prompt. + +**Workflow-authored action payload: NOT equal today; this design fixes it.** +`queueRunPayload` copies `in.Action.QueueRun.Payload` onto the run verbatim, so every +engine-path producer carries whatever the step's `on_children_completed` `queue_run` +action declares, and the cascade — never consulting the engine — has no way to. The +shipped `office-default` action declares none, so nothing diverges today; but the +workflow editor allows one, and the moment a workflow authors a payload while +targeting the parent's primary agent, the delivered context depends on which producer +won — and suppressing the loser is what makes that observable. + +So, per .002.16, the cascade resolves the parent's current workflow step, reads that +step's `on_children_completed` `queue_run` action payload, and merges it into its own +run payload with the precedence `queueRunPayload` applies. The comment-specific fields +that function also merges do not apply to this trigger and are not reproduced. That +resolution reads workflow configuration, not child task state, so it may sit after the +wave-member read without violating .002.15. + +Its failure behaviour is deliberately *not* the wave-member read's: if the authored +payload cannot be resolved, the cascade still queues the wake without it and logs the +omission. Its unconditional wake for a parent with no active workflow session is a +contract in its own right (.004.2) and outranks an optional payload. The degradation +is named rather than left to be discovered. + +## Failure and recovery + +- **Unique violation on insert — two classification sites, not one.** + `IsIdempotencyKeyUniqueViolation` + (`runs/repository/sqlite/idempotency_violation.go`) is deliberately scoped to + `idx_run_idempotency` by name, so it gains an explicit sibling for + `idx_run_wake_wave` rather than being widened to "any unique violation". The sibling + matches PostgreSQL's `pgconn.PgError` code `23505` with constraint name + `idx_run_wake_wave`, and SQLite's composite message `UNIQUE constraint failed: + runs.wake_wave_key, runs.agent_profile_id` — a different shape from the + single-column message the existing constant matches. + + Both queue paths must call it, and this is what a single-site fix gets wrong. + `office/repository/sqlite.Repository` *embeds* `*runssqlite.Repository`, so P1 and + the engine path call the very same `CreateRun`, but only `runs/service` wraps it + with classification: `office/scheduler.SchedulerService.QueueRun` calls `CreateRun` + directly and wraps the error as `enqueue run: %w`, logged by its caller as + `reactivity run failed`. Left alone, P1 losing a wave race logs a failure, which + .002.4 forbids — and P1 is the producer the race test drives. So (1) + `runs/service`'s `insertRun`/`QueueRun` gains a second sentinel beside + `errIdempotencyKeyConflict` and returns `QueueOutcomeDeduped`, covering P2–P4; and + (2) `office/scheduler.SchedulerService.QueueRun` classifies the `CreateRun` error + and returns `nil` with a debug log, as it already treats a `CheckIdempotencyKey` + hit, covering P1. +- **Lost dispatch.** Unchanged: the backstop re-derives readiness from current state + every tick and simply recognises delivery by wave rather than timestamp. +- **Retry.** `ScheduleRetry` updates in place and does not insert, so the constraint + is never consulted on a retry (.002.11). A future retry-by-reinsert would have to + carry both columns forward or clear them. +- **Coalescing.** Excluded for wave-carrying requests and rows, per *Coalescing* + above — the counterpart of the retry note: both are UPDATE paths, and one moving a + payload without moving the identity breaks .002.14. +- **Wave-member set changes mid-flight.** Handled by the terminality confirmation on + the final read, not by locking. No producer records an identity for a set it did + not observe entirely terminal, so unbounded deduplication can never suppress a wave + that was not delivered. +- **Wave-member read fails.** The producer does not dispatch (.002.12). P2 logs at + warn and defers to the backstop; P3 retries next tick; P1 already returns without + queueing on a failed child read and does the same here. P4 cannot hit this case. +- **Upgrade.** No backfill. Existing rows keep the empty defaults, stay outside the + partial index, and are judged by the parent-scoped compatibility clause. + +## Security + +No new trust boundary: both encodings contain only task ids the same caller already +reads, and neither is user-supplied or rendered. + +## Observability + +The existing `parent_wake_*` expvars and their paired `wake.metric.*` logs are +unchanged. One counter is added, `parent_wake_deduped_total`, incremented when a +`task_children_completed` insert is rejected by `idx_run_wake_wave`, at both +classification sites so a P1 dedupe is as visible as an engine-path one. It is the +only direct evidence the constraint is doing work, since a run that never exists +leaves no other trace. Parent task id is unbounded and is not a label, +matching the existing counters; it goes on the structured log. + +## Testing + +- **Derivation:** terminal-to-terminal edit stable; non-state edit stable; member + added or removed changes it; archived, ephemeral and automation-origin children + excluded; independent of insertion order; empty set never keyed; wave key is the + digest of the wave string. +- **Cross-producer:** all four derive identical encodings for one parent and child + set, including a parent with an ephemeral or automation-origin child and P4's rows + in `created_at` order. The new wave-member method and `ListChildCompletionRows` + return the same id set. +- **Dialect:** the wave-string aggregate is byte-identical on SQLite and PostgreSQL, + ordering included. +- **Race:** cascade against one backstop tick yields exactly one run row and the + loser returns success without logging a failure, mirroring the runs service's + idempotency-index race tests and their PostgreSQL twin. The cascade case must run + through `office/scheduler`'s own queue path, not only `runs/service` — those are the + two classification sites. +- **Coalescing:** a second request for a *different* parent, same agent, inside the + window does not merge into the first parent's queued run; two runs exist, each + carrying its own identity, neither differing from the wake it delivers. +- **Read skew:** a wave-member read returning a non-terminal member queues nothing + and records no identity, and a later genuine wave for that parent is still + delivered — the case otherwise suppressed permanently. +- **Admission:** a non-state child edit after delivery lists no candidate; a + wave-member change lists one; a parent whose only run predates the change still + follows the timestamp rule; a parent holding both a pre-upgrade and a keyed run is + judged by wave identity alone. A failed run whose wave string equals the current one + keeps blocking across an unrelated child edit, and a wave-member change unblocks it. +- **Gate:** a parent whose only children are ephemeral or automation-origin is not a + candidate and no producer queues for it; a parent that is a candidate today stays + one whenever it has a wave member. +- **Payload parity:** the cascade attaches the workflow-authored + `on_children_completed` action payload when the step declares one, matching the + engine path byte-for-byte, and still queues the wake without it when it cannot be + resolved. +- The two identity tests pinning the id-only decision, and the cascade's + children-completed tests, must continue to pass unmodified. + +## Related decisions + +- [ADR-0015](../../../decisions/0015-explicit-completion-signal-for-auto-advance.md)