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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -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
|
||||
|
||||
+228
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user