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 <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-13 23:51:59 +08:00
committed by GitHub
parent 509a1bb9b5
commit 26bf26fc20
57 changed files with 6586 additions and 48 deletions
@@ -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")
}
}
+14 -4
View File
@@ -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
}
@@ -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 <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 <where> ORDER BY id) w)
// Postgres: (SELECT string_agg(w.id, ',' ORDER BY w.id) FROM (SELECT id FROM tasks WHERE <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)
}
@@ -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)
}
}
+12 -4
View File
@@ -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
@@ -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 '',
@@ -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
@@ -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"`
@@ -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()
@@ -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.
@@ -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 <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(`
@@ -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)
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
+147 -14
View File
@@ -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,
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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.
@@ -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 {
@@ -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
}
@@ -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)
}
}
@@ -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 ("<parentID>|"), 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[:])
}
@@ -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)
}
}
@@ -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 {
@@ -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)
}
}
@@ -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)
}
@@ -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")
}
}
@@ -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
@@ -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"])
}
})
}
}
@@ -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 {
+29 -1
View File
@@ -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
@@ -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)
}
}
@@ -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.
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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
@@ -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.
@@ -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,
@@ -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)
}
}