fix(office): stop duplicate and missed children-completed run dispatches (#3620)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-13 23:51:59 +08:00
committed by GitHub
parent 509a1bb9b5
commit 26bf26fc20
57 changed files with 6586 additions and 48 deletions
@@ -0,0 +1,421 @@
---
created: 2026-09-09
status: implemented
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-001
- REQ-OFFICE-WAKE-WAVE-IDENTITY-002
- REQ-OFFICE-WAKE-WAVE-IDENTITY-003
- REQ-OFFICE-WAKE-WAVE-IDENTITY-004
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
legacy_specs: []
---
# Implementation Plan: Parent Wake Wave Identity
## Overview
Regenerated 2026-09-09 against the frozen spec (round-2 amend). The prior
plan under this directory (session `d0842a54`) predates both spec amends: it
had one column/encoding, a wider wave-member predicate, one classification
site, and no coalescing exclusion or payload-parity obligation. This
supersedes it in full.
Give the `task_children_completed` wake a single durable identity, computed
identically by all four producers, persisted on the run in two encodings, and
compared by the backstop instead of a timestamp. Implementation order:
1. **Foundation** (Task 01): the pure wave-string/wave-key derivation and the
new wave-member repository read every producer needs. No behavior change.
2. **Persistence** (Task 02): the two `runs` columns, the partial unique
index, the second unique-violation classifier, and `runs/service`'s
coalescing exclusion. Still no producer sets these columns yet, so this is
also behavior-inert until Task 03 lands — verified by service-level tests
that construct requests directly.
3. **Producers**, one work order per identity site, in dependency order
forced by the shared engine seam:
- Task 03: P1 cascade (`office/scheduler`) — direct insert path, needs its
own classification site and its own coalescing guard, and owns the
payload-parity read (AC-...-002.16) since it never touches the engine.
- Task 04: the engine seam plus P2 edge and P3 backstop, which share it.
`OnChildrenCompletedPayload` gains the two encodings; `QueueRunCallback`
reads them off the trigger payload it already carries end-to-end
(`ActionInput.Payload` is `HandleInput.Payload` verbatim — no new
parameter threading through `HandleInput`/`ActionInput`/engine
`QueueRunRequest` is needed beyond the two new fields on that one
request struct and its `runsServiceEngineAdapter` copy).
- Task 05: P4 orchestrator, built on Task 04's seam. No new read; re-sorts
the read it already has.
4. **Admission** (Task 06): `ListStuckParents` compares wave identity instead
of timestamp, once producers actually write it.
5. **Closing coverage** (Task 07): the race test, cross-producer parity,
coalescing, read-skew, and admission suites that need every producer and
the admission rewrite to exist simultaneously.
Tasks 03, 04 and 05 are not parallel-safe with each other even though they
touch different packages: all three add wiring in
`internal/backendapp/main.go` (03 wires a new `WorkflowStepGetter` setter for
payload parity; 04 changes `runsServiceEngineAdapter`; 05 depends on 04's
`OnChildrenCompletedPayload` fields). Land them in order.
## Scope
### In scope
- `internal/office/waveidentity` (new leaf package): `WaveString` and
`WaveKey`, pure functions of `(parentID string, memberIDs []string)`
(REQ-001).
- A new office-repository read returning wave members `(id, state)` ordered
by id, applying the shared not-archived/not-ephemeral/not-automation-origin
predicate (REQ-001, REQ-002.15).
- `runs.wake_wave_key` / `runs.wake_wave_string` columns, partial unique index
`idx_run_wake_wave(wake_wave_key, agent_profile_id)`, `models.Run` fields,
`CreateRunTx` column list, a second classifier
(`IsWakeWaveUniqueViolation`), `runs/service`'s dedupe-on-conflict handling,
and its coalescing exclusion for wave-carrying requests/rows (REQ-002).
- Threading the two encodings through all four producers, including P1's own
`CreateRun` classification site (`office/scheduler.SchedulerService`) and
its coalescing guard, and the shared engine seam
(`OnChildrenCompletedPayload` → `ActionInput.Payload` → `QueueRunCallback`
→ `engine.QueueRunRequest` → `runsServiceEngineAdapter` →
`runs/service.QueueRunRequest`) for P2/P3/P4 (REQ-002, REQ-004).
- The terminality-confirming wave-member read (AC-...-002.15): P1/P2/P3 read
`(id, state)` as their last child-state read before queueing and queue
nothing if any member is non-terminal; P4 already satisfies this via
`readyChildCompletionRows`.
- Payload parity (AC-...-002.16): the cascade resolves the parent's current
workflow step, reads its `on_children_completed` `queue_run` action
payload (via `engine.CompileStep`, already used by
`orchestrator.workflowStore.LoadStep`), and merges it into its own run
payload; queues without it, logging the omission, when it cannot resolve.
- `internal/office/repository/sqlite.ListStuckParents` rewrite: a
dialect-aware wave-string aggregate (new `internal/db/dialect` helper), a
wave-member existence gate, and the three-clause, status-qualified
admission arm from the system design (REQ-003).
- `parent_wake_deduped_total` expvar, incremented at both classification
sites.
- Regression coverage per the spec's Testing section.
### Out of scope
(Mirrors the requirements document's "Out of scope" verbatim; not repeated
here beyond a pointer.) Reopen-and-recomplete re-waking; id-set-reuse via
unarchive; task `updated_at` stamping; the queued-or-claimed arm's
whole-second comparison (`b7e29d7c`); porting `ListStuckParents`' SQLite-only
`json_extract`/aggregate expressions elsewhere in the same query to dialect
form (`53c24173`, and this work must not widen that gap); `on_children_completed`
trigger firing conditions, payload shape, or editor surface; UI.
`wakeOperationID` (P2/P3's existing operation id) is **not** deleted or
redefined — the round-2 spec keeps every producer's existing identifier
untouched, unlike the stale plan this supersedes.
## Technical approach
### Foundation: `internal/office/waveidentity` and the wave-member read
New package, dependency-free beyond `crypto/sha256`, `encoding/hex`,
`strings`:
```go
func WaveString(parentID string, memberIDs []string) string
func WaveKey(parentID string, memberIDs []string) string // = WaveKey(WaveString(...))
```
`WaveString` = `parentID + "|" + strings.Join(memberIDs, ",")`. `WaveKey` =
`"task_children_completed:" + parentID + ":" + hex(sha256(waveString))`.
Callers order `memberIDs` ascending by `tasks.id` and pass only wave members;
the functions do no I/O and return no error (AC-...-001.1, .2, .10, .11).
Sibling of `internal/office/costs/modelsdev`, which `internal/orchestrator`
already imports (`orchestrator/model_info.go`) without crossing the
`orchestrator`/`office/service` boundary.
New read, `ListWaveMembers`, in `internal/office/repository/sqlite/blockers.go`
beside `ListChildStates` (same file, same `ChildState`-shaped row):
```go
func (r *Repository) ListWaveMembers(ctx context.Context, parentID string) ([]ChildState, error)
```
```sql
SELECT id, COALESCE(state, '') AS state FROM tasks
WHERE parent_id = ? AND archived_at IS NULL AND is_ephemeral = 0
AND COALESCE(origin, '') != 'automation_run'
ORDER BY id
```
This is the predicate `task/repository/sqlite.ListChildCompletionRows`
already applies (`andNotAutomationOrigin`), reused here because P4 forces the
choice (design, "Which children count"). A cross-producer test in Task 01
asserts this method and `ListChildCompletionRows` return the same id set for
one fixture parent — the equality AC-...-001.8 depends on.
### Persistence
- `internal/office/repository/sqlite/base.go`, `createRunTables` (~line
329-387): add `wake_wave_key TEXT NOT NULL DEFAULT ''` and
`wake_wave_string TEXT NOT NULL DEFAULT ''` to the inline `CREATE TABLE
runs`, plus `CREATE UNIQUE INDEX IF NOT EXISTS idx_run_wake_wave ON
runs(wake_wave_key, agent_profile_id) WHERE wake_wave_key <> ''` beside
`idx_run_idempotency`.
- `internal/office/repository/sqlite/base_migrations.go`: new
`migrateWakeWaveColumns()`, following `migrateContinuationScope`'s shape
(two `r.migrate.Apply` ALTERs + one partial-index `CREATE UNIQUE INDEX IF
NOT EXISTS`, no backfill), called from `runMigrations()` next to
`migrateParentWakeReceiptColumns()`.
- `internal/office/models/models.go`, `Run` struct (~line 381): add
`WakeWaveKey string \`db:"wake_wave_key"\`` and `WakeWaveString string
\`db:"wake_wave_string"\``.
- `internal/runs/repository/sqlite/runs.go`, `CreateRunTx` (~line 31-52): add
both columns to the `INSERT INTO runs` list and bound args.
- `internal/runs/repository/sqlite/idempotency_violation.go`: add
`IsWakeWaveUniqueViolation(err error) bool`, mirroring
`IsIdempotencyKeyUniqueViolation` — PostgreSQL: typed `pgErr.ConstraintName
== "idx_run_wake_wave"`; SQLite: message substring `"UNIQUE constraint
failed: runs.wake_wave_key, runs.agent_profile_id"` (confirm go-sqlite3's
exact composite-index message empirically before hardcoding — this is the
same risk the superseded plan flagged, now scoped to one new function
instead of a rename).
- `internal/runs/service/service.go`: `QueueRunRequest` (~line 85-92) gains
`WakeWaveKey`, `WakeWaveString string`; `insertRun` (~line 249-274) sets
them on the `*models.Run{}` literal; `insertRun`'s `CreateRun` error
handling gains a second branch — `IsWakeWaveUniqueViolation(err)` returns a
second sentinel (e.g. `errWakeWaveKeyConflict`), and `QueueRun` (~line
221-235) maps it to `QueueOutcomeDeduped` exactly like
`errIdempotencyKeyConflict`, with no error and no retry
(AC-...-002.4/.5/.6). `shouldCoalesceRun` (~line 316-318) gains `&&
req.WakeWaveKey == ""` (AC-...-002.14, first half).
- `internal/runs/repository/sqlite/runs.go`, `CoalesceRun` (~line 449-488):
add `AND (wake_wave_key = '' OR wake_wave_key IS NULL)` — actually the
column is `NOT NULL DEFAULT ''`, so `AND wake_wave_key = ''` — to the
candidate-row `WHERE`, closing AC-...-002.14's second half (a queued
wave-carrying run is never merged into, regardless of which caller
invokes `CoalesceRun`).
- `internal/office/scheduler/run.go`, `SchedulerService.QueueRun` (~line
249-303): the same two guards for P1's own coalesce-and-insert path — skip
`ss.repo.CoalesceRun` when a wave key is supplied, and classify
`ss.repo.CreateRun`'s error with `IsWakeWaveUniqueViolation` into a nil
return + debug log (mirroring the existing `CheckIdempotencyKey` hit
handling), not the `enqueue run: %w` wrap that would make .002.4 fail for
P1 specifically (this is why Task 03 needs its own classification work,
not just Task 02's).
- `internal/office/service/wake_metrics.go`: add `parentWakeDedupedTotal =
expvar.NewInt("parent_wake_deduped_total")`, incremented at both
classification branches above. `runs/service` does not import
`office/service`; wire the increment via a small exported callback var (or
equivalent seam already used for `parent_wake_*` counters — check
`wake_metrics.go`'s existing wiring pattern before choosing) rather than a
new cross-package dependency.
### P1 cascade (Task 03)
`internal/office/scheduler/reactivity.go`, `cascadeChildrenCompleted`
(~line 384-421): keeps `ListChildStates` and its terminal-state loop
unchanged (narrowing that read would change cascade's readiness check,
forbidden by AC-...-004.7) and adds, after that loop confirms all-terminal:
1. Call `ListWaveMembers(ctx, task.ParentID)` — the terminality-confirming
last read (AC-...-002.15). Any non-terminal member (a race with the
`ListChildStates` read) or a read error: queue nothing, return.
2. Empty result: queue nothing, return (AC-...-001.7; live once the
predicate is narrower than `ListChildStates`, e.g. an all-ephemeral child
set).
3. Derive `waveidentity.WaveString`/`WaveKey` from the sorted member ids.
4. Resolve payload parity: `GetTaskWorkflowStepID(ctx, task.ParentID)`
(`office/repository/sqlite/participants.go:55`, already used elsewhere in
this package's blast radius) → a new small interface,
`WorkflowStepGetter.GetStep(ctx, stepID) (*wfmodels.WorkflowStep, error)`
(satisfied by `workflow/service.Service.GetStep`, wired via a new
`SchedulerService.SetWorkflowStepGetter` setter, called once at boot next
to `SetTaskStarter`/`SetResolver`) → `engine.CompileStep` (already
exported, already used by `orchestrator/workflow_store.go:226`) →
look up the step's `TriggerOnChildrenCompleted` actions for one of kind
`queue_run` with a non-nil `Payload`. Any failure at any of these steps:
log the omission at debug and continue without a merged payload — the
wake is unconditional (AC-...-004.2) and outranks the optional payload.
5. `RunContext` (~line 90-122) gains `WaveKey string`, `WaveString string`
(JSON `"-"`, like `IdempotencyKey`) and a merge point for the resolved
action payload with the same override precedence `queueRunPayload`
applies (workflow-authored keys win over the trigger's own). Since
`RunContext` marshals as a fixed struct rather than a generic map, the
simplest implementation is: marshal `RunContext` first, then if a merged
payload map is non-empty, unmarshal to `map[string]any`, overlay the
merged keys, re-marshal — implemented inside `encodeRunContext` or a
thin wrapper around it, whichever keeps `QueueRunCtx` readable.
6. `QueueRunCtx` → `QueueRun` (~line 246-324) thread `WaveKey`/`WaveString`
as **new required parameters** (do not smuggle them through `RunContext`
alone into the low-level `QueueRun`, which today takes discrete scalar
args) onto the inserted `models.Run{WakeWaveKey, WakeWaveString}`.
`childrenCompletedIdempotencyKey` (~line 436-443) is unchanged and stays
the run's `idempotency_key` — additive, not replaced (design,
"Producers").
### Engine seam, P2 edge, P3 backstop (Task 04)
- `internal/workflow/engine/payloads.go`: `OnChildrenCompletedPayload`
(~line 38) gains `WaveKey string`, `WaveString string`. Not read by
`idempotencyKey` or `queueActionDigest` (phase2_callbacks.go) — those stay
untouched, per the design's "narrow seam" decision.
- `internal/workflow/engine/adapters.go`, `QueueRunRequest` (~line 45-52):
add `WakeWaveKey`, `WakeWaveString string`.
- `internal/workflow/engine/phase2_callbacks.go`, `QueueRunCallback.Execute`
(~line 81-107): after building `req := QueueRunRequest{...}`, type-assert
`in.Payload.(OnChildrenCompletedPayload)` and copy `WaveKey`/`WaveString`
onto `req` when present. This is the only engine-package call site that
changes — `HandleInput`, `ActionInput`, and `Engine.executeCallback`
already carry `Payload any` end-to-end unmodified
(`engine.go:429-437` copies `in.Payload` verbatim into `ActionInput`), so
no new field needs to thread through those three types.
- `internal/backendapp/main.go`, `runsServiceEngineAdapter.QueueRun`
(~line 1810-1822): copy `WakeWaveKey`/`WakeWaveString` from the engine's
`QueueRunRequest` to the runs-service `QueueRunRequest`.
- `internal/office/service/event_subscribers.go`, `queueChildrenCompletedRun`
(~line 1116-1151): after `AreAllChildrenTerminal`, add the wave-member read
(`ListWaveMembers`) as the terminality confirmation; on empty or
non-terminal, queue nothing (return nil, matching the existing
`!allDone` early return). `childSetKey`/`wakeOperationID` computation is
**unchanged** — the operation id keeps its existing state-inclusive
derivation. Build `engine.OnChildrenCompletedPayload{ChildSummaries:
summaries, WaveKey: ..., WaveString: ...}` from the new read's sorted ids.
- `internal/office/service/scheduler_wake_reconciler.go`,
`reconcileOne`/`buildPayload` (~line 93-177): same addition — call
`ListWaveMembers` before dispatch (in addition to, not instead of, the
existing `GetChildSetKey` re-reads that guard the receipt write — those
keep using the state-inclusive key per the design's "Receipts" section),
reject on non-terminal/empty, and set `WaveKey`/`WaveString` on the
payload `buildPayload` returns. `wakeOperationID` is unchanged.
### P4 orchestrator (Task 05)
`internal/orchestrator/event_handlers_children_completed.go`:
`childCompletionOperationID` (~line 325-345) is **unchanged** — the
operation ledger keeps its existing per-row state/step/terminal/`updated_at`
derivation (AC-...-004.3). `childCompletionPayload` (~line 306-316) gains a
`parentID` parameter; before building `ChildSummaries` it copies `rows`,
sorts the copy ascending by `ID` (the source orders by `created_at, id`), and
sets `WaveKey`/`WaveString` from `waveidentity.WaveString/WaveKey(parentID,
sortedIDs)`. No new read and no new filter — `readyChildCompletionRows`
already rejects `len(rows) == 0` and already establishes terminality from
these same rows (AC-...-002.15 satisfied by construction, per the design).
### Backstop admission (Task 06)
`internal/office/repository/sqlite/wake_receipts.go`, `ListStuckParents`
(~line 101-175):
- New `internal/db/dialect` helper beside `JSONExtract` (e.g.
`OrderedIDConcat(driver, table, parentCol, idCol string) string` or similar
— name it to fit the file's existing signature style), branching:
SQLite `GROUP_CONCAT(c.id, ',')` fed by an `ORDER BY id` subquery (the
`child_set_key` idiom already in this file); PostgreSQL `string_agg(c.id,
',' ORDER BY c.id)`. Only this new fragment is dialect-branched — the
query's existing `json_extract`/unbranched `GROUP_CONCAT` stay as they are
(out of scope per `53c24173`).
- Add a wave-string CTE column: `p.id || '|' || COALESCE(<helper output over
the wave-member predicate>, '')`, computed for every row (a pre-gate
intermediate for a parent with no members, per Terminology).
- Add the wave-member existence gate: a second `EXISTS` beside the existing
archived-only one, over `archived_at IS NULL AND is_ephemeral = 0 AND
COALESCE(origin, '') != 'automation_run'` (AC-...-003.9).
- Rewrite the second `NOT EXISTS` arm into three named clauses (design,
"Backstop admission"): `status IN ('queued','claimed')` blocks
unconditionally; `status IN ('finished','failed','cancelled') AND
wake_wave_string = <current wave string>` blocks; `NOT EXISTS (... WHERE
wake_wave_key <> '') AND status IN ('finished','failed','cancelled') AND
requested_at >= newest_child_updated_at` is the parent-scoped legacy
fallback (AC-...-003.5/.6/.10) — the "no keyed run at all for this parent"
condition must be evaluated once per parent, not per row, per .003.6.
- `StuckParentCandidate` (~line 21-27) gains no new field unless the caller
needs the wave string for logging; the comparison lives entirely inside
the SQL per AC-...-003.8.
### Observability
Already covered under Persistence (`parent_wake_deduped_total`).
## Tests
| Acceptance criteria | Test |
| --- | --- |
| AC-...-001.1-.3, .5, .6, .9-.13 | `internal/office/waveidentity/waveidentity_test.go`: deterministic and order-sensitive per the caller contract; `|`-separated, not NUL; digest-of-string relationship; empty slice documented as never called; no I/O. |
| AC-...-001.4, .5 | `internal/office/scheduler/reactivity_children_completed_test.go` (existing terminal-edit and non-state-edit cases) continue to pass unmodified. |
| AC-...-001.7, .8 | New cross-producer parity test: `ListWaveMembers` and `ListChildCompletionRows` return the same id set for one fixture parent, including an ephemeral/automation-origin child; an all-excluded child set derives no wave. |
| AC-...-002.1-.3, .7-.9, .11-.13 | `internal/runs/service/service_test.go`: `TestQueueRun_DedupesOnWakeWaveKeyIndexRace` (mirrors `TestQueueRun_DedupesOnIdempotencyIndexRace`, ~line 578); a different agent and a different wave-member set each get their own row; retry (`ScheduleRetry`) is untouched by the constraint. |
| AC-...-002.4, .5 | `internal/runs/repository/sqlite/idempotency_violation_test.go` (new, pattern from the existing file) for `IsWakeWaveUniqueViolation`; `internal/runs/service/service_postgres_test.go`: Postgres twin of the race test. |
| AC-...-002.6 | Same race test asserts no time bound (insert the second request well after the first). |
| AC-...-002.10, .002.16 | Payload-parity test: cascade attaches the workflow-authored `queue_run` payload byte-for-byte matching what the engine path would attach for an equivalent step; and still queues without it when the step lookup fails. |
| AC-...-002.14 | Coalescing test: a second request for a different parent, same agent, inside the coalescing window, with a wave key, does not merge into the first parent's queued (also wave-keyed) run — two rows exist. |
| AC-...-002.15 | Read-skew test: a wave-member read returning one non-terminal member queues nothing and records no identity; a later genuine wave for the same parent is still delivered. |
| AC-...-003.1-.10 | `internal/office/repository/sqlite/wake_receipts_test.go`: delivered-and-unchanged wave is not a candidate; non-state child edit after delivery is not a candidate; wave-member change after delivery is a candidate (and an id-set-reuse-to-a-delivered-identity is listed but queues nothing downstream); queued/claimed run blocks regardless of wave; a legacy run with an empty `wake_wave_key` is judged by the timestamp rule, scoped per parent even alongside a keyed run for the same parent; a failed run's wave-scoped block survives an unrelated child edit and is lifted only by a wave-member change; a parent whose only children are excluded is not a candidate. |
| AC-...-004.1-.7 | The full race test: cascade (P1) racing one reconciler (P3) tick for the same parent through `office/scheduler`'s own `QueueRun` path (not only `runs/service`), asserting exactly one `runs` row and no failure logged for the loser. Existing `TestWakeOperationID_UnifiedAcrossEdgeAndReconcilerPaths` and the orchestrator's operation-ledger tests continue to pass unmodified, confirming P2/P3's `wakeOperationID` and P4's `childCompletionOperationID` are untouched. |
## E2E tests
None. No user-visible control, view, or setting (requirements document,
"Out of scope": "User interface"). The effect is observable only as the
absence of a duplicate or spurious run in the Office Runs list, which the
spec's E2E decision explicitly leaves to Go-level coverage.
## Work orders
- [x] [Task 01: Wave identity primitives](task-01-wave-identity-primitives.md)
- [x] [Task 02: Persist wave identity and classify its unique violation](task-02-wave-identity-persistence.md)
- [x] [Task 03: Wire cascade (P1) onto wave identity](task-03-wire-cascade-producer.md)
- [x] [Task 04: Wire engine-routed producers (P2, P3) onto wave identity](task-04-wire-engine-routed-producers.md)
- [x] [Task 05: Wire orchestrator (P4) onto wave identity](task-05-wire-orchestrator-producer.md)
- [x] [Task 06: Backstop admission compares wave identity](task-06-backstop-admission.md)
- [x] [Task 07: Race, parity, and regression coverage](task-07-race-and-regression-coverage.md)
## Verification results
All 7 work orders done. Full-package tests pass with `-race` across every
touched package (`office/scheduler`, `office/service`,
`office/repository/sqlite`, `orchestrator`, `runs/service`,
`runs/repository/sqlite`, `task/repository/sqlite`, `office/waveidentity`,
`db/dialect`); `golangci-lint --new-from-rev=cd78236315f28982848de4938d56f7722c7f632f`
clean on every changed package. See each task file's own Results section
for command output and per-task detail. `KANDEV_TEST_POSTGRES_DSN` was not
provisioned in this environment, so the gated Postgres twins (Task 06's
`OrderedIDConcat`, this initiative's other dialect-sensitive coverage)
skipped rather than ran — consistent with the card's instruction not to
provision Docker/Postgres here.
Definition of Done gauntlet: `make fmt`, `make typecheck`, `make lint`
(backend 0 issues repo-wide, web eslint clean, lint-architecture clean;
lint-harness/lint-specs required Python 3.10+ — this environment's
default `python3` is 3.9.6, incompatible with the scripts' `X | None`
type-hint syntax; ran clean under `/opt/homebrew/bin/python3.14`
instead), `make lint-format`, and `pnpm run i18n:ratchet` (no UI change,
as expected for a backend-only card) all pass. `make test` passes for
every package this initiative touched; pre-existing failures in
unrelated subsystems (npm cache path resolution, worktree/git
provisioning, agentctl process management, the dev launcher) reproduce
against unmodified code in this sandboxed environment and are unrelated
to this change.
A final pass removed every AC-NN/task-order reference this initiative's
comments had accumulated, restating each as a direct invariant per the
repo's comment convention.
## Risks
- **SQLite composite-unique-index violation message shape.**
`IsWakeWaveUniqueViolation`'s SQLite branch needs the exact go-sqlite3
message for a two-column unique index, confirmed empirically in Task 02
before hardcoding (same risk the superseded plan flagged for a
single-column rename; now isolated to one new function).
- **Cross-package `parent_wake_deduped_total` increment.** `runs/service`
does not import `office/service`; Task 02 picks the least-invasive seam
(existing `wake_metrics.go` wiring pattern, if one already crosses this
boundary, otherwise a small callback var).
- **Payload-parity merge shape.** `RunContext` is a fixed struct, not a
generic map, so merging an arbitrary workflow-authored payload into it
needs a marshal/overlay/re-marshal step Task 03 must design carefully to
avoid disturbing `CoalesceRun`'s existing byte-for-byte payload comparison
for non-wave-carrying runs.
- **Three sequential, non-parallel producer work orders (03, 04, 05)** share
touches to `internal/backendapp/main.go`. Land them one at a time.
## Open questions
None outstanding — the frozen spec resolves every prior open question (see
the spec's own review history for the closed rounds).
@@ -0,0 +1,116 @@
---
id: "01-wave-identity-primitives"
title: "Wave identity primitives"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-001
acceptance_criteria:
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.1
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.2
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.3
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.5
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.6
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.7
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.8
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.9
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.10
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.11
- AC-OFFICE-WAKE-WAVE-IDENTITY-001.13
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
---
# Task 01: Wave identity primitives
## Summary
Add the pure two-encoding derivation (`WaveString`, `WaveKey`) as a new leaf
package, and add the shared wave-member repository read every producer will
use to obtain the ordered id set those functions consume. No caller changes
yet — this is foundation only.
## In scope
- `internal/office/waveidentity` package: `WaveString(parentID string,
memberIDs []string) string` and `WaveKey(parentID string, memberIDs
[]string) string`, per the system design's "The two encodings" section
(separator `|`, digest prefix `task_children_completed:<parentID>:`,
lowercase hex sha256 of the wave string). No I/O, no error return.
- `internal/office/repository/sqlite.Repository.ListWaveMembers(ctx,
parentID) ([]ChildState, error)` in `blockers.go`, beside `ListChildStates`:
`(id, state)` ordered by `id`, filtered to `archived_at IS NULL AND
is_ephemeral = 0 AND COALESCE(origin, '') != 'automation_run'` — the same
predicate `task/repository/sqlite.ListChildCompletionRows` already applies.
## Out of scope
- Calling either the derivation or `ListWaveMembers` from any producer
(Tasks 03-05).
- Persisting the derived values anywhere (Task 02).
- The PostgreSQL-dialect wave-string SQL aggregate used by the backstop
(Task 06) — that is a separate, SQL-side expression of the same identity,
not this Go package.
## Acceptance
- `waveidentity.WaveKey(p, ids) == waveidentity.WaveKey(p, ids)` for any
fixed input (determinism); different `ids` slices (same length, different
order) that represent a *different* set produce a different key, but two
calls with the *same* already-sorted slice never differ regardless of the
slice's original pre-sort arrival order (callers, not the function, sort).
- `ListWaveMembers` and `task/repository/sqlite.ListChildCompletionRows`
return the identical set of ids for one fixture parent with a mix of
ordinary, archived, ephemeral, and automation-origin children.
- `go vet ./internal/office/waveidentity/... ./internal/office/repository/sqlite/...`
is clean.
## Verification
```bash
cd apps/backend
go test ./internal/office/waveidentity/... -run . -v
go test ./internal/office/repository/sqlite/... -run TestListWaveMembers -v
```
## Files likely touched
- `internal/office/waveidentity/waveidentity.go` (new)
- `internal/office/waveidentity/waveidentity_test.go` (new)
- `internal/office/repository/sqlite/blockers.go`
- `internal/office/repository/sqlite/blockers_test.go` (or a new
`wave_members_test.go` if the existing file is near its line-count limit)
## Dependencies
None.
## Risks
None — pure functions and a single new read-only query, no behavior change
for any existing caller.
## Parallelism
`sequential`
## Inputs
- Requirements: REQ-OFFICE-WAKE-WAVE-IDENTITY-001, full text.
- System design: "Wave identity" and "The two encodings" sections.
- `internal/office/repository/sqlite/blockers.go` (`ListChildStates` for the
row shape and query idiom).
- `internal/task/repository/sqlite/task.go` (`ListChildCompletionRows`,
`andNotAutomationOrigin`) for the predicate this must match exactly.
- `internal/office/costs/modelsdev` as the precedent leaf-package shape
`internal/orchestrator` already imports.
## Results
Done. `internal/office/waveidentity` (`WaveString`, `WaveKey`) plus
`Repository.ListWaveMembers` (excludes archived, ephemeral, and
automation-origin children, ordered ascending by id) landed in
`feat(office): add wave identity derivation and wave-member read`.
`go test ./internal/office/waveidentity/... ./internal/office/repository/sqlite/... -run 'WaveIdentity|WaveMembers'` green.
@@ -0,0 +1,144 @@
---
id: "02-wave-identity-persistence"
title: "Persist wave identity and classify its unique violation"
status: done
wave: 2
depends_on: ["01-wave-identity-primitives"]
plan: "plan.md"
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-002
acceptance_criteria:
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.1
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.2
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.4
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.5
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.14
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
---
# Task 02: Persist wave identity and classify its unique violation
## Summary
Add the two `runs` columns and their partial unique index, thread them
through `runs/service`'s request/insert path with dedupe-on-conflict
handling and a coalescing exclusion, and add the observability counter. No
producer sets these fields yet — verified directly against `runs/service`.
## In scope
- `runs.wake_wave_key` / `runs.wake_wave_string` columns (both `TEXT NOT
NULL DEFAULT ''`), `idx_run_wake_wave(wake_wave_key, agent_profile_id)
WHERE wake_wave_key <> ''`, added to `createRunTables` (fresh schema) and a
new replayable `migrateWakeWaveColumns()` migration (existing databases).
- `models.Run.WakeWaveKey` / `WakeWaveString` fields; `CreateRunTx`'s column
list and bound args.
- `internal/runs/repository/sqlite/idempotency_violation.go`:
`IsWakeWaveUniqueViolation(err error) bool`.
- `internal/runs/service/service.go`: `QueueRunRequest` gains the two
fields; `insertRun` sets them on the inserted row; a second sentinel
(parallel to `errIdempotencyKeyConflict`) maps
`IsWakeWaveUniqueViolation` to `QueueOutcomeDeduped`; `shouldCoalesceRun`
excludes wave-carrying requests.
- `internal/runs/repository/sqlite/runs.go`, `CoalesceRun`: exclude
wave-carrying rows from the merge-target candidate query.
- `parent_wake_deduped_total` expvar, incremented at this classification
site (the second site, in `office/scheduler`, is Task 03's
responsibility — this task only covers the one runs/service exercises).
## Out of scope
- Any producer actually populating `WakeWaveKey`/`WakeWaveString` on a
request (Tasks 03-05).
- `office/scheduler.SchedulerService.QueueRun`'s own classification site and
coalescing guard (Task 03) — `runs/service` and the office scheduler's
direct-insert path are two separate call sites into `CreateRun`, per the
system design's "Failure and recovery" section, and this task only closes
the `runs/service` one.
- `ListStuckParents` (Task 06).
## Acceptance
- A `QueueRun` call with a `WakeWaveKey` set inserts a row with both columns
populated; a second call with the identical `(WakeWaveKey,
AgentProfileID)` returns `QueueOutcomeDeduped`, not an error, and is not
coalesced into instead.
- A `QueueRun` call with `WakeWaveKey` set to a *different, already-queued*
wave's key is never selected as a `CoalesceRun` merge target by an
unrelated wave-carrying request for the same agent+reason inside the
coalescing window.
- `IsWakeWaveUniqueViolation` returns true for a real SQLite composite
unique-index violation on this index (confirmed empirically, not assumed
from the single-column message) and for the matching PostgreSQL
`pgconn.PgError` shape (gated test, `KANDEV_TEST_POSTGRES_DSN`).
## Verification
```bash
cd apps/backend
go test ./internal/office/repository/sqlite/... -run TestMigrate -v
go test ./internal/runs/service/... -run TestQueueRun -v
go test ./internal/runs/repository/sqlite/... -run TestIsWakeWaveUniqueViolation -v
KANDEV_TEST_POSTGRES_DSN=... go test ./internal/runs/service/... -run TestPostgresQueueRun -v
```
## Files likely touched
- `internal/office/repository/sqlite/base.go`
- `internal/office/repository/sqlite/base_migrations.go`
- `internal/office/repository/sqlite/base_migrations_test.go` (replay
coverage, per the backend's table-rebuild/migration testing convention)
- `internal/office/models/models.go`
- `internal/runs/repository/sqlite/runs.go`
- `internal/runs/repository/sqlite/idempotency_violation.go`
- `internal/runs/repository/sqlite/idempotency_violation_test.go` (new)
- `internal/runs/service/service.go`
- `internal/runs/service/service_test.go`
- `internal/runs/service/service_postgres_test.go`
- `internal/office/service/wake_metrics.go`
## Dependencies
Task 01 (uses `waveidentity` types only for test fixtures, not production
code in this task).
## Risks
- **SQLite composite-index message shape** — confirm empirically before
hardcoding; a wrong substring silently fails classification and P1/engine
producers would see a hard error instead of a dedupe on their second
arrival.
- **`parent_wake_deduped_total` cross-package increment** — check
`wake_metrics.go`'s existing wiring before adding a new seam; prefer
reusing whatever pattern already gets a runs/service-side outcome back to
office's expvars, if one exists.
## Parallelism
`sequential`
## Inputs
- System design: "Persistence", "Coalescing: the third write path into
runs", "Failure and recovery" sections.
- `internal/runs/repository/sqlite/idempotency_violation.go` (pattern to
mirror).
- `internal/office/repository/sqlite/base_migrations.go`
(`migrateContinuationScope` as the replayable-migration precedent).
- `internal/runs/service/service_test.go:578`
`TestQueueRun_DedupesOnIdempotencyIndexRace` (pattern for the new race
test written here at the request-construction level; the full
cross-producer race test is Task 07's).
## Results
Done, across three commits: `feat(runs): add wake_wave_key and
wake_wave_string columns to runs` (migration-only index per the
schema-init-before-migration rule, `models.Run` fields), `feat(runs):
dedupe and skip coalescing for wave-carrying queue requests`
(`IsWakeWaveUniqueViolation`, `runs/service` classification +
`shouldCoalesceRun` guard, `office/shared.ParentWakeDedupedTotal`), and
`test(runs): add Postgres twin for the wake-wave-key dedupe race`
(env-gated, skips cleanly without `KANDEV_TEST_POSTGRES_DSN`).
`go test ./internal/runs/... ./internal/office/repository/sqlite/...` green.
@@ -0,0 +1,157 @@
---
id: "03-wire-cascade-producer"
title: "Wire cascade (P1) onto wave identity"
status: done
wave: 3
depends_on: ["01-wave-identity-primitives", "02-wave-identity-persistence"]
plan: "plan.md"
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-002
- REQ-OFFICE-WAKE-WAVE-IDENTITY-004
acceptance_criteria:
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.1
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.4
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.12
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.14
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.15
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.16
- AC-OFFICE-WAKE-WAVE-IDENTITY-004.2
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
---
# Task 03: Wire cascade (P1) onto wave identity
## Summary
`office/scheduler.cascadeChildrenCompleted` gains the terminality-confirming
wave-member read, derives and persists the wave identity on its direct
insert, gains its own unique-violation classification and coalescing guard
(the runs/service ones from Task 02 do not cover this call site), and
resolves the workflow-authored `on_children_completed` payload for parity
with the engine-routed producers.
## In scope
- `RunContext` gains `WaveKey`, `WaveString string` (JSON `"-"`).
- `cascadeChildrenCompleted`: after the existing `ListChildStates`
terminal-state loop, read `ListWaveMembers`; queue nothing on a read error
or any non-terminal member; queue nothing on an empty result
(AC-...-001.7); otherwise derive the wave identity from the sorted ids.
- `QueueRunCtx`/`QueueRun` (`office/scheduler/run.go`) take the wave
identity through to the inserted `models.Run`, skip
`ss.repo.CoalesceRun` when a wave key is present, and classify
`ss.repo.CreateRun`'s error with `IsWakeWaveUniqueViolation` into a nil
return + debug log (not the existing `enqueue run: %w` wrap) —
`runs/service`'s classification from Task 02 does not cover this
direct-insert call site.
- Payload parity (AC-...-002.16): a new `WorkflowStepGetter` interface
(`GetStep(ctx, stepID) (*wfmodels.WorkflowStep, error)`), a
`SchedulerService.SetWorkflowStepGetter` setter wired at boot to
`workflow/service.Service.GetStep`, and cascade logic that resolves the
parent's current step via `GetTaskWorkflowStepID` +
`WorkflowStepGetter.GetStep` + `engine.CompileStep`, finds a `queue_run`
action on `TriggerOnChildrenCompleted` with a non-nil payload, and merges
it into the run's persisted payload with the same override precedence
`engine.queueRunPayload` applies. Any failure at any step: log at debug,
queue without the merged payload (AC-...-004.2 — the wake is
unconditional).
- `parent_wake_deduped_total` increment at this task's classification site.
## Out of scope
- P2/P3/P4 (Tasks 04, 05).
- `runs/service`'s own classification and coalescing guard (Task 02,
already done, unrelated call site).
- Any change to `childrenCompletedIdempotencyKey` or cascade's existing
no-session behavior (AC-...-004.2's first half, already true today).
## Acceptance
- Two calls to `cascadeChildrenCompleted` for the same parent and the same
terminal child set produce exactly one `runs` row, and the second call
logs no failure.
- A wave-member read that observes one non-terminal member (simulated via a
fake repo) queues nothing; a subsequent call once all members are
genuinely terminal queues the wake.
- When the parent's current step declares an `on_children_completed`
`queue_run` action with a payload, that payload's keys appear in the
queued run's persisted payload; when the step lookup fails, the run is
still queued without them and a debug log records the omission.
## Verification
```bash
cd apps/backend
go test ./internal/office/scheduler/... -run TestCascadeChildrenCompleted -v
go test ./internal/office/scheduler/... -run TestCascade.*WavePayload -v
```
## Files likely touched
- `internal/office/scheduler/reactivity.go`
- `internal/office/scheduler/reactivity_children_completed_test.go`
- `internal/office/scheduler/run.go`
- `internal/office/scheduler/run_test.go`
- `internal/backendapp/main.go` (wire `SetWorkflowStepGetter`)
## Dependencies
Task 01 (`waveidentity`, `ListWaveMembers`), Task 02 (`models.Run` columns,
`IsWakeWaveUniqueViolation`).
## Risks
- **Payload merge shape.** `RunContext` is a fixed struct, not a generic
map; merging an arbitrary action payload onto it (marshal → overlay →
re-marshal, or an equivalent) must not change `encodeRunContext`'s output
for the common case where no action payload exists — `CoalesceRun`
compares payload bytes for equality-adjacent decisions elsewhere in the
codebase, so an unconditional shape change here is a regression risk even
though `CoalesceRun` itself is skipped for wave-carrying requests.
- **`GetTaskWorkflowStepID` reads the parent's *current* step at wake time,
not at any earlier decision point** — this is intentional (the design
states this resolution "reads workflow configuration, not child task
state," so it may run after the wave-member read), but do not repurpose
this value for anything identity-bearing; it is payload content only.
## Parallelism
`sequential`
## Inputs
- System design: "Producers" (P1 cascade), "Wake equivalence between
producers" sections.
- `internal/workflow/engine/phase2_callbacks.go` (`queueRunPayload`,
`QueueRunCallback.Execute`) for the precedence and shape to match.
- `internal/orchestrator/workflow_store.go:217-232` (`LoadStep`) for the
`GetStep` + `engine.CompileStep` pattern.
- `internal/office/repository/sqlite/participants.go:55`
(`GetTaskWorkflowStepID`).
## Results
Done, in `feat(office): wire the cascade producer onto wave identity`.
`cascadeChildrenCompleted` now calls `resolveWaveIdentity` (its own
terminality-confirming `ListWaveMembers` read) and `resolveWaveActionPayload`
(step lookup + `engine.CompileStep`) after the existing `ListChildStates`
loop; `RunContext` carries `WaveKey`/`WaveString`/`ExtraPayload` (all
`json:"-"`); `queueRun` (the shared body behind `QueueRun`/`QueueRunCtx`)
skips `CoalesceRun` and classifies `IsWakeWaveUniqueViolation` when a wave
key is present. `SetWorkflowStepGetter` wired in
`internal/backendapp/main.go` to `services.Workflow` alongside the
existing `SetWorkflowEngineDispatcher` call.
One deviation from the original scope note: rather than changing the
public `QueueRun`/`QueueRunCtx` signatures (`QueueRun` implements
`shared.RunQueuer`, used by other non-wave callers), the wave fields route
through a new unexported `queueRun` helper both public methods call.
`go test ./internal/office/scheduler/...` green (18 new/changed tests
across `run_test.go` and `reactivity_children_completed_wave_test.go`,
plus `newReactivityTestRepo`'s ad hoc `tasks` fixture gained
`archived_at`/`is_ephemeral`/`origin` columns so `ListWaveMembers` can run
against it). `golangci-lint run ./internal/office/scheduler/...
./internal/backendapp/... --new-from-rev=cd78236315f28982848de4938d56f7722c7f632f`
clean.
@@ -0,0 +1,135 @@
---
id: "04-wire-engine-routed-producers"
title: "Wire engine-routed producers (P2, P3) onto wave identity"
status: done
wave: 3
depends_on: ["01-wave-identity-primitives", "02-wave-identity-persistence"]
plan: "plan.md"
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-002
- REQ-OFFICE-WAKE-WAVE-IDENTITY-004
acceptance_criteria:
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.1
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.12
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.15
- AC-OFFICE-WAKE-WAVE-IDENTITY-004.6
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
---
# Task 04: Wire engine-routed producers (P2, P3) onto wave identity
## Summary
Open the narrow engine seam that lets `OnChildrenCompletedPayload` carry the
two encodings from trigger dispatch through to the inserted run, then wire
P2 (`queueChildrenCompletedRun`) and P3 (`ParentWakeReconciler.reconcileOne`)
to read the wave members and populate that payload. `wakeOperationID`
(P2/P3's existing operation id) is unchanged.
## In scope
- `engine.OnChildrenCompletedPayload` gains `WaveKey`, `WaveString string`.
- `engine.QueueRunRequest` (adapters.go) gains the same two fields.
- `QueueRunCallback.Execute`: type-assert `in.Payload.(OnChildrenCompletedPayload)`
and copy the two fields onto the `QueueRunRequest` it builds, when present.
`idempotencyKey`/`queueActionDigest` are unchanged.
- `runsServiceEngineAdapter.QueueRun` (`backendapp/main.go`) copies the two
fields into the `runs/service.QueueRunRequest` it constructs.
- P2 (`event_subscribers.go`, `queueChildrenCompletedRun`): after
`AreAllChildrenTerminal`, add the `ListWaveMembers` terminality
confirmation (queue nothing on a non-terminal member, an empty set, or a
read error); build `OnChildrenCompletedPayload` with `WaveKey`/`WaveString`
from the sorted member ids. `childSetKey`/`wakeOperationID` computation is
unchanged.
- P3 (`scheduler_wake_reconciler.go`, `reconcileOne`/`buildPayload`): same
addition. The existing `GetChildSetKey`/`GetChildSetKeyTx` re-reads that
guard the receipt write are unchanged (state-inclusive key, different
purpose).
## Out of scope
- P1 (Task 03) and P4 (Task 05).
- `ListStuckParents` (Task 06).
- Any change to `wakeOperationID`, `formatChildSetKey`, or
`parent_child_wake_receipts` semantics (AC-...-004.6).
## Acceptance
- A run queued via P2 for a parent whose children are all terminal carries
the correct `WakeWaveKey`/`WakeWaveString` on the persisted row.
- A run queued via P3 for the same parent and wave derives the identical
`WakeWaveKey` as P2 would for that parent (byte-for-byte), so
`idx_run_wake_wave` actually collapses a P2/P3 race for the same wave
(proven fully in Task 07; this task's own test proves derivation parity,
not the race itself).
- A wave-member read observing a non-terminal member (P2 or P3) queues
nothing and dispatches no engine trigger.
- `TestWakeOperationID_UnifiedAcrossEdgeAndReconcilerPaths` continues to
pass unmodified.
## Verification
```bash
cd apps/backend
go test ./internal/workflow/engine/... -run TestQueueRunCallback -v
go test ./internal/office/service/... -run TestQueueChildrenCompletedRun -v
go test ./internal/office/service/... -run TestParentWakeReconciler -v
go test ./internal/office/service/... -run TestWakeOperationID_UnifiedAcrossEdgeAndReconcilerPaths -v
```
## Files likely touched
- `internal/workflow/engine/payloads.go`
- `internal/workflow/engine/adapters.go`
- `internal/workflow/engine/phase2_callbacks.go`
- `internal/workflow/engine/phase8_test.go` (or a new test file for the
`QueueRunCallback` wave-key copy)
- `internal/backendapp/main.go`
- `internal/office/service/event_subscribers.go`
- `internal/office/service/scheduler_wake_reconciler.go`
- `internal/office/service/scheduler_wake_reconciler_test.go`
## Dependencies
Task 01 (`waveidentity`, `ListWaveMembers`), Task 02 (persistence, so the
copied fields land somewhere real).
## Risks
- **Shared engine-seam files.** This task and Task 05 both touch
`payloads.go`/`phase2_callbacks.go`'s type assertion; land this one first
so Task 05 builds on an existing seam rather than duplicating it.
## Parallelism
`sequential`
## Inputs
- System design: "Producers" (P2 edge, P3 backstop), "The engine seam is
narrow" paragraph under "The wave-member read is the last read, and it
carries state".
- `internal/workflow/engine/engine.go:417-443` (`executeCallback`) —
confirms `Payload any` already flows `HandleInput` → `ActionInput`
unmodified, so no change is needed there.
## Results
Done, in `feat(office): wire the engine-routed producers onto wave
identity`. `OnChildrenCompletedPayload`/`engine.QueueRunRequest` gained
`WaveKey`/`WaveString`; `QueueRunCallback.Execute` copies them via a new
`waveIdentityPayload` helper (empty for every trigger but
`on_children_completed`); `runsServiceEngineAdapter.QueueRun` forwards them
into `runs/service.QueueRunRequest.WakeWaveKey`/`WakeWaveString`. P2 and P3
share a new `office/service.resolveWaveIdentity` helper (same terminality-
confirming shape as Task 03's `office/scheduler` one — duplicated rather
than shared across packages, consistent with this codebase's existing
`runsServiceEngineAdapter` precedent for small intentional duplication
across package boundaries).
`go test ./internal/office/... ./internal/workflow/... ./internal/runs/...
./internal/backendapp/...` and `golangci-lint run
./internal/workflow/engine/... ./internal/office/service/...
./internal/backendapp/... --new-from-rev=cd78236315f28982848de4938d56f7722c7f632f`
both clean.
@@ -0,0 +1,117 @@
---
id: "05-wire-orchestrator-producer"
title: "Wire orchestrator (P4) onto wave identity"
status: done
wave: 3
depends_on: ["01-wave-identity-primitives", "04-wire-engine-routed-producers"]
plan: "plan.md"
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-002
- REQ-OFFICE-WAKE-WAVE-IDENTITY-004
acceptance_criteria:
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.1
- AC-OFFICE-WAKE-WAVE-IDENTITY-004.3
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
---
# Task 05: Wire orchestrator (P4) onto wave identity
## Summary
`processOnChildrenCompleted` already reads exactly the wave-member rows
(`ListChildCompletionRows` applies the same predicate `ListWaveMembers`
does) and already confirms terminality on them before evaluating actions.
It needs no new read — only a re-sort by id (its source orders by
`created_at` first) and to set the two encodings on the
`OnChildrenCompletedPayload` the Task 04 seam now carries through.
## In scope
- `childCompletionPayload` (`event_handlers_children_completed.go`) gains a
`parentID string` parameter; before building `ChildSummaries`, sort a copy
of `rows` ascending by `ID` and derive `WaveKey`/`WaveString` from that
sorted id list. `childCompletionOperationID` is unchanged (keeps ordering
by the rows' existing `created_at` order and its full
state/step/terminal/`updated_at` derivation).
- Update `childCompletionPayload`'s one call site in
`evaluateChildrenCompleted` to pass `parent.ID`.
## Out of scope
- P1/P2/P3 (Tasks 03, 04).
- `readyChildCompletionRows`, `allChildrenTerminal`,
`annotateTerminalChildSteps` — unchanged; they already establish
terminality on the rows the wave identity is derived from
(AC-...-002.15 satisfied by construction).
- Any change to the operation ledger, `EvaluateOnly` transition lifecycle,
or reopen-driven step transition (AC-...-004.3).
## Acceptance
- For a fixed parent and child set, P4's derived `WakeWaveKey` is
byte-identical to what P1/P2/P3 would derive for the same parent and
wave-member set (proven together with Task 07's cross-producer test, but
this task's own unit test covers P4's re-sort in isolation: feed rows in
`created_at` order that differ from `id` order and assert the derived key
matches the id-sorted expectation).
- `TestProcessOnChildrenCompleted_DedupedAcrossReinit` (or the equivalent
existing operation-ledger test) continues to pass unmodified.
## Verification
```bash
cd apps/backend
go test ./internal/orchestrator/... -run TestProcessOnChildrenCompleted -v
go test ./internal/orchestrator/... -run TestChildCompletionPayload -v
```
## Files likely touched
- `internal/orchestrator/event_handlers_children_completed.go`
- `internal/orchestrator/event_handlers_children_completed_test.go` (or
wherever the existing P4 tests live — confirm exact file before editing)
## Dependencies
Task 01 (`waveidentity`), Task 04 (the engine seam this payload now flows
through — `OnChildrenCompletedPayload`'s new fields and
`QueueRunCallback.Execute`'s type assertion must exist first).
## Risks
None beyond the shared-seam ordering already called out in Task 04 — this
task is a small, additive change to one existing function.
## Parallelism
`sequential`
## Inputs
- System design: "Producers" (P4 orchestrator) section, "The wave-member
read is the last read, and it carries state" (P4 paragraph).
- `internal/task/repository/sqlite/task.go:2055-2070`
(`ListChildCompletionRows`) for the `created_at, id` ordering this task
must correct for.
## Results
Done, in `feat(orchestrator): derive wave identity in the children-completed
producer`. `childCompletionPayload(parentID string, rows
[]models.ChildCompletionRow)` derives `WaveKey`/`WaveString` via a new
`childCompletionWaveIdentity` helper (copies row ids into a fresh slice,
`sort.Strings`, then `waveidentity.WaveKey`/`WaveString` — never mutates
`rows`, so `childCompletionOperationID`'s created_at-ordered derivation,
called earlier in `processOnChildrenCompleted`, is unaffected regardless of
call order). No new read: `readyChildCompletionRows` already applies the
same predicate `ListWaveMembers` does and already confirms terminality
before `rows` reaches `evaluateChildrenCompleted`; it also already gates on
`len(rows) == 0`, so `childCompletionPayload` never runs against zero wave
members in production (AC-...-001.7 holds by construction, no extra guard
needed).
`go test ./internal/orchestrator/...` (full package, ~84s) and
`golangci-lint run ./internal/orchestrator/...
--new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` both clean.
`TestProcessOnChildrenCompleted_DedupedAcrossReinit` passes unmodified.
@@ -0,0 +1,177 @@
---
id: "06-backstop-admission"
title: "Backstop admission compares wave identity"
status: done
wave: 4
depends_on: ["01-wave-identity-primitives", "02-wave-identity-persistence"]
plan: "plan.md"
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-003
acceptance_criteria:
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.1
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.2
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.3
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.4
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.5
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.6
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.8
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.9
- AC-OFFICE-WAKE-WAVE-IDENTITY-003.10
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
---
# Task 06: Backstop admission compares wave identity
## Summary
Rewrite `ListStuckParents`' staleness arm to compare the parent's current
wave string (computed in SQL) against `runs.wake_wave_string`, add a
wave-member existence gate so a parent with no possible wave is never
listed, and keep a parent-scoped timestamp fallback for pre-upgrade rows
that carry no wave identity at all.
## In scope
- New `internal/db/dialect` helper for an ordered, comma-joined id
aggregate (SQLite `GROUP_CONCAT` over an ordered subquery; PostgreSQL
`string_agg(... ORDER BY ...)`), added beside `JSONExtract`.
- `ListStuckParents`: a wave-string CTE column
(`p.id || '|' || COALESCE(<helper>, '')`) over the wave-member predicate.
- A new `EXISTS` gate (wave-member predicate) beside the existing
archived-only `EXISTS`, removing a parent with no wave members from
candidacy (AC-...-003.9). Additive and narrowing only.
- Rewrite the second `NOT EXISTS` arm into three clauses: queued/claimed
blocks unconditionally; a terminal run whose `wake_wave_string` matches
the parent's current one blocks; only when the parent has **no**
`task_children_completed` run with `wake_wave_key <> ''` at all does the
existing `requested_at >= newest_child_updated_at` timestamp rule apply
(evaluated once per parent, not per row — AC-...-003.6).
## Out of scope
- The first `NOT EXISTS`/receipt-comparison arm (unchanged — receipts stay
state-inclusive, AC-...-004.6).
- Porting this query's existing `json_extract`/unbranched `GROUP_CONCAT` to
dialect form (`53c24173` — do not widen or close that gap here; only the
new wave-string fragment is dialect-branched).
- The whole-second comparison in the queued-or-claimed arm (`b7e29d7c`).
## Acceptance
- A parent with a delivered, unchanged wave is not a candidate; a
non-state child edit after delivery does not make it one; a real
wave-member change does.
- A parent whose only children are archived/ephemeral/automation-origin is
never listed.
- A parent with a pre-upgrade terminal run (`wake_wave_key = ''`) is judged
by the timestamp rule; once any keyed run exists for that parent, the
timestamp rule is never consulted for it again, even for an older
pre-upgrade row on the same parent.
- A failed/cancelled run whose wave string matches the current one keeps
blocking across an unrelated child edit and is unblocked only by a
wave-member change.
## Verification
```bash
cd apps/backend
go test ./internal/db/dialect/... -run TestOrderedIDConcat -v
go test ./internal/office/repository/sqlite/... -run TestListStuckParents -v
KANDEV_TEST_POSTGRES_DSN=... go test ./internal/office/repository/sqlite/... -run TestListStuckParents -v
```
## Files likely touched
- `internal/db/dialect/json.go` (or a new file beside it for the aggregate
helper, if it doesn't fit `json.go`'s theme)
- `internal/db/dialect/dialect_test.go`
- `internal/office/repository/sqlite/wake_receipts.go`
- `internal/office/repository/sqlite/wake_receipts_test.go`
## Dependencies
Task 01 (wave-member predicate, matched exactly), Task 02 (`wake_wave_key`/
`wake_wave_string` columns must exist to query against).
## Risks
- **Ordering divergence between Go and PostgreSQL collation** — the
requirements' AC-...-001.13 names this as a silent, PostgreSQL-only
failure mode. The gated Postgres test must assert byte-identical ordering
with a fixture using real UUID-shaped ids, not sequential test ids that
would happen to sort the same everywhere.
## Parallelism
`sequential`
## Inputs
- System design: "Backstop admission" section in full, including its
worked SQL fragments.
- `internal/office/repository/sqlite/wake_receipts.go` (current
`ListStuckParents`, `child_set_key` aggregate idiom to mirror for the new
wave-string aggregate).
- `internal/db/dialect/json.go` (signature/doc-comment style to match).
## Results
Done, in `feat(office): compare wave identity in the backstop reconciler`.
`internal/db/dialect/aggregate.go` (new file, not `json.go` — a distinct
aggregate-vs-extraction theme) adds `OrderedIDConcat(driver, where string)
string`, hardcoded to `tasks.id` per "simplest implementation that fully
meets the current requirement" rather than a generic group-concat builder.
SQLite orders via an `ORDER BY` inside the `GROUP_CONCAT` subquery;
PostgreSQL via `string_agg(... ORDER BY ...)` in the aggregate call itself.
`TestOrderedIDConcat_FragmentShape` checks both dialects' exact fragment
text; `TestOrderedIDConcat_SQLite_OrdersAscendingByID` and its
`KANDEV_TEST_POSTGRES_DSN`-gated Postgres twin insert UUID-shaped ids out
of order and assert the concatenated result is byte-identical and
ascending across both dialects, per the Risks section's collation-drift
warning.
`ListStuckParents` gained a `wave_string` CTE column
(`p.id || '|' || COALESCE(OrderedIDConcat(...), '')`) over the wave-member
predicate (not archived, not ephemeral, not automation-origin — matching
`ListWaveMembers`), plus a second `EXISTS` gate applying that same
predicate to remove parents with no possible wave from candidacy
(AC-...-003.9), added beside — not replacing — the existing archived-only
`EXISTS`. The second `NOT EXISTS` arm is now: queued/claimed blocks
unconditionally; a terminal run whose `wake_wave_string` matches the
parent's current one blocks (across any unrelated, non-wave-member child
edit, unblocked only by a genuine wave-member change); a separate
`AND (EXISTS(...) OR NOT EXISTS(...))` clause keeps the pre-upgrade
timestamp fallback but scopes it to the parent as a whole
(`wake_wave_key <> ''` for *any* row for that parent, any status, any
wave), not per-row — so a parent's first wave-keyed run retires the
timestamp rule for it permanently, even against an older, still-recent
pre-upgrade row for the same parent (AC-...-003.6).
New tests in `wake_receipts_wave_identity_test.go`:
`TestListStuckParents_WaveMatchBlocksAcrossNonMemberChildEdit_UnblockedOnWaveMemberChange`
(table-driven over finished/failed/cancelled — delivered-unchanged-wave not
a candidate, an automation-origin child completing doesn't unblock it, a
real wave-member joining does),
`TestListStuckParents_ExcludesParentWithOnlyNonWaveMemberChildren`
(ephemeral-only, automation-origin-only, and mixed non-member-only parents
are all excluded, with a control candidate), and
`TestListStuckParents_KeyedRunRetiresTimestampFallbackForParent` (a recent
pre-upgrade terminal run that would otherwise still block under the
timestamp rule is bypassed once any wave-keyed run — even for an unrelated,
stale wave — exists for the parent).
All 8 pre-existing `TestListStuckParents_*` tests (and their subtests)
pass unmodified, confirming the rewrite is backward-compatible with every
scenario that predates wave identity (their fixture runs never set
`wake_wave_key`, so they exercise the compatibility/timestamp-fallback
path exactly as before).
`go build ./...`, `go test ./internal/office/repository/sqlite/...
./internal/db/dialect/...` (full packages), and `golangci-lint run
./internal/office/repository/sqlite/... ./internal/db/dialect/...
--new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` all clean. The
gated `TestOrderedIDConcat_Postgres_OrdersAscendingByID` skips on this
runner (no `KANDEV_TEST_POSTGRES_DSN`), consistent with not provisioning
Docker/Postgres for this card.
@@ -0,0 +1,197 @@
---
id: "07-race-and-regression-coverage"
title: "Race, parity, and regression coverage"
status: done
wave: 5
depends_on:
- "02-wave-identity-persistence"
- "03-wire-cascade-producer"
- "04-wire-engine-routed-producers"
- "05-wire-orchestrator-producer"
- "06-backstop-admission"
plan: "plan.md"
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-002
- REQ-OFFICE-WAKE-WAVE-IDENTITY-003
- REQ-OFFICE-WAKE-WAVE-IDENTITY-004
acceptance_criteria:
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.3
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.14
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.15
- AC-OFFICE-WAKE-WAVE-IDENTITY-002.16
- AC-OFFICE-WAKE-WAVE-IDENTITY-004.1
system_design:
- ../../specs/office/system-design/parent-wake-wave-identity.md
---
# Task 07: Race, parity, and regression coverage
## Summary
Close the loop with the tests that need every producer and the rewritten
admission query to exist at once: the cascade-vs-reconciler race through
`office/scheduler`'s own queue path, full cross-producer derivation parity,
the coalescing exclusion end-to-end, and the read-skew guarantee.
## In scope
- **Race test:** cascade (P1) racing one `ParentWakeReconciler` tick for the
same parent and wave, driven through `office/scheduler.QueueRun` (not only
`runs/service`) — the two classification sites this initiative added.
Asserts exactly one `runs` row and that the losing side logs no failure.
Mirrors `TestQueueRun_DedupesOnIdempotencyIndexRace`
(`runs/service/service_test.go:578`) and its Postgres twin.
- **Cross-producer parity:** for one fixture parent and child set (including
an ephemeral and an automation-origin child), all four producers derive
byte-identical `WakeWaveKey`/`WakeWaveString`.
- **Coalescing:** a second wave-carrying request for a *different* parent,
same target agent, inside the coalescing window, does not merge into the
first parent's queued (also wave-carrying) run — two rows exist, each with
its own identity.
- **Read skew:** a wave-member read that observes a non-terminal member
queues nothing; a later read of the same parent, once genuinely all
terminal, is delivered normally (not permanently suppressed).
- **Payload parity end-to-end:** the cascade's queued run and an
equivalent engine-routed run for a step with the same
`on_children_completed` `queue_run` payload carry the same resolved
payload keys.
- Confirm the "MUST NOT be weakened" tests
(`reactivity_children_completed_test.go`,
`scheduler_wake_reconciler_identity_test.go`) still pass unmodified.
## Out of scope
- Any further production code change — this task is test-only. A finding
here that requires a production fix routes back to the relevant earlier
work order, not a change made inline in this one.
## Acceptance
- The race test fails on the pre-Task-03/04 code (verify by temporarily
reverting to confirm it actually exercises the new constraint, then
restore) and passes on the finished tree.
- All new tests pass on both SQLite and PostgreSQL where a dialect twin is
required.
## Verification
```bash
cd apps/backend
go test ./internal/office/scheduler/... ./internal/office/service/... \
./internal/office/repository/sqlite/... ./internal/orchestrator/... \
./internal/runs/service/... ./internal/runs/repository/sqlite/... \
-race -count=1
KANDEV_TEST_POSTGRES_DSN=... go test ./internal/runs/service/... \
./internal/office/repository/sqlite/... -run Postgres -count=1
```
## Files likely touched
- `internal/office/service/scheduler_wake_reconciler_test.go` (or a new
shared-harness test file, since the race test needs both a direct
`office/scheduler.QueueRun` caller and a `ParentWakeReconciler` against
one database)
- `internal/runs/service/service_test.go`
- `internal/runs/service/service_postgres_test.go`
- A new cross-producer parity test file (exact location depends on which
package can import all four producers' derivation call sites without a
cycle — likely a `_test.go` file in `internal/office/service` with build
tags or fakes for the orchestrator piece, confirmed at implementation
time)
## Dependencies
Tasks 02 through 06 — this is the closing verification pass.
## Risks
- **Test-only task that could hide a production gap.** If the race test
cannot actually be made to race deterministically (e.g. both paths commit
through the same test transaction manager without true concurrency), the
test would pass without proving anything. Use `synctest` or explicit
goroutine synchronization (start both inserts, block one at a lock point,
release both) per the backend's testing conventions — not
`time.Sleep`-based interleaving.
## Parallelism
`sequential`
## Inputs
- System design: "Testing" section in full.
- Requirements document: "REQUIRED TESTS" section of the task's Kandev
running plan (this initiative's own card), which enumerates the same
suite from the review history.
- `internal/runs/service/service_test.go:578` and
`service_postgres_test.go:27` as the race-test pattern.
## Results
Done, in `test(office): close race, parity, and regression coverage for
wave identity`.
Most of this task's Acceptance items were already covered by Tasks 02-05's
own tests, confirmed unmodified in this pass:
- **Coalescing:** `TestQueueRunCtx_WaveCarryingRequest_NotCoalesced`
(`office/scheduler/run_test.go`, Task 03) and
`TestQueueRun_WakeWaveKey_DifferentAgentBothQueued`
(`runs/service/service_test.go`, Task 02).
- **Read skew:** `TestResolveWaveIdentity_NonTerminalMember_ReturnsNotOK`
(`office/scheduler/reactivity_children_completed_wave_test.go`, Task 03).
- **Payload parity:** `TestCascadeChildrenCompleted_PayloadParity_*`
(same file, Task 03).
- **Per-producer derivation parity:**
`TestQueueRunCallback_OnChildrenCompletedCopiesWaveIdentity` (Task 04),
`TestChildrenCompletedWaveIdentity_IdenticalAcrossEdgeAndReconcilerPaths`
(Task 04, P2 vs P3), `TestChildCompletionPayload_MatchesCascadeDerivationForSameParentAndSet`
(Task 05, P4 vs `waveidentity` directly).
- **"MUST NOT be weakened" regression suites:**
`reactivity_children_completed_test.go` (5 pre-existing tests) and
`scheduler_wake_reconciler_identity_test.go`
(`TestParentWakeReconciler_*`, 7 tests) both pass unmodified.
This task added the two genuinely new closing pieces:
- `internal/office/scheduler/wave_identity_race_test.go`:
`TestWaveIdentityRace_CascadeVsEngineRoutedPath_CollapsesToOneRun` races
P1's direct-insert path (`SchedulerService.QueueRunCtx` -> `queueRun` ->
`ss.repo.CreateRun`, classified by
`runssqlite.IsWakeWaveUniqueViolation`) against an engine-routed path
(`runsservice.Service.QueueRun` -> `insertRun` -> `CreateRunTx`,
classified by Task 02's `errWakeWaveKeyConflict` handling) for the same
wave identity and target agent, through two goroutines started together
via a shared barrier channel (not a sequential call pair) — both
`office/scheduler.SchedulerService` and `runsservice.Service` are wired
against the exact same in-memory SQLite DB via
`officeRepo.RunsRepository()`, so the two classification sites this
initiative added contend on the real `idx_run_wake_wave` index.
Confirmed the test has teeth: temporarily gave the engine-routed request
a different `WakeWaveKey` and watched the assertion fail (`runs = 2, want
exactly 1`), then reverted before committing. Passes reliably across
repeated `-race -count=10` runs.
- `internal/task/repository/sqlite/wave_member_parity_test.go`:
`TestListWaveMembersMatchesListChildCompletionRows` builds one shared
`*sqlx.DB`, initializes both `task/repository/sqlite.NewWithDB` (owns the
`tasks` table in production) and `office/repository/sqlite.NewWithDB`
against it, seeds one parent with ordinary/ephemeral/automation-origin/
archived children, and asserts `ListWaveMembers` (P1/P2/P3's read) and
`ListChildCompletionRows` (P4's read, id-sorted the way
`childCompletionPayload` sorts it) return the identical wave-member id
set — the AC-...-001.8 cross-producer predicate-equivalence proof Task
01's plan text called for but that file's own tests didn't yet include.
`go build ./...` clean.
`go test ./internal/office/scheduler/... ./internal/office/service/...
./internal/office/repository/sqlite/... ./internal/orchestrator/...
./internal/runs/service/... ./internal/runs/repository/sqlite/...
./internal/task/repository/sqlite/... -race -count=1` — all packages pass.
`golangci-lint run ./internal/office/scheduler/...
./internal/task/repository/sqlite/...
--new-from-rev=cd78236315f28982848de4938d56f7722c7f632f` clean. The
Postgres-gated command in Verification was not run — no
`KANDEV_TEST_POSTGRES_DSN` provisioned in this environment, consistent
with the card's own instruction not to stand up Docker/Postgres here;
every dialect-sensitive change (Task 06's `OrderedIDConcat`) already has
its own gated Postgres twin from that task.
@@ -0,0 +1,332 @@
---
status: draft
system: office
created: 2026-09-08
owners:
- kandev
---
# Parent Wake Wave Identity Requirements
## Overview
When every direct child of a parent task reaches a terminal state, Kandev wakes
the parent's agent with a `task_children_completed` run. Four independent
producers can emit that wake for the same parent at the same moment, and each
identifies the completion event differently today. The consequences are opposite
failures of one missing primitive:
- **Duplicate wake.** Two producers describing the same completion event mint
structurally different run identities, so the run queue's uniqueness constraint
cannot collapse them. The parent agent runs twice, pays twice, and re-reads the
same child results.
- **Spurious wake.** The backstop decides "has this parent already been woken for
its current children" by comparing timestamps, and every task write stamps
`updated_at` — so editing a finished child's title, priority, or labels makes a
delivered wake look stale and queues a needless autonomous run.
This capability defines a single **completion wave identity**: one value naming
*which completion event* a wake belongs to, computed the same way by every
producer, persisted on the run, and compared instead of a timestamp.
Office owns this contract because the outcome is an Office wakeup policy
decision: whether an autonomous `task_children_completed` run enters the Office
run queue. It reads and constrains, but does not own, the workflow engine's
`on_children_completed` trigger and operation ledger and the task system's
parent/child relationship and `updated_at` stamping. The task system's
[subtask completion trigger](../../tasks/requirements/subtask-completion-trigger.md)
still owns when the trigger fires and what its payload contains; this capability
constrains only the identity carried by the resulting run.
## Terminology
- **Completion wave:** the event of a parent's full set of wave members being
terminal at once. Iterative delegation produces one wave per round.
- **Wave member:** a direct child task of the parent that counts towards its
parent's completion wave: `archived_at` unset, `is_ephemeral` false, and
`origin` not `automation_run`. Children failing any of these are excluded from
wave identity throughout. This is the predicate the orchestrator's existing
children-completed readiness query applies; the design records why that makes it
the only one all four producers can share.
- **Wave identity:** the value naming a completion wave. Equal values mean the
same wave. It has the two encodings below, which are two spellings of one
identity rather than two identities.
- **Wave string:** the canonical encoding of a wave identity: the parent id and
its wave members' ids in ascending `tasks.id` order, joined by a fixed
separator. It is what the backstop's candidate query compares, so it must be
derivable with plain SQL string aggregation. A parent with no wave members has
no wave and no wave string; an SQL expression of the same shape evaluated per
row, before that parent is known to have any member, is an intermediate, not a
wave string, until the parent passes the wave-member gate.
- **Wave key:** the fixed-length digest of the wave string, and the encoding the
run queue's uniqueness constraint indexes, where an unbounded value would
eventually exceed an index entry limit.
- **Producer:** any code path that can cause a `task_children_completed` run to
be queued for a parent. Four exist; the design enumerates them.
- **Target agent:** the agent profile a queued run is addressed to. One wave can
legitimately fan out to several when a workflow step configures it.
- **Terminal state:** the child task states that count as done, as already
defined by the `on_children_completed` trigger.
## Requirements
### REQ-OFFICE-WAKE-WAVE-IDENTITY-001: One canonical completion-wave identity
**Intent:** A wave must have exactly one name, so that agreement between
producers is a property of the definition rather than of four code paths
happening to match.
#### Acceptance criteria
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.1:** Wave identity shall be a pure function
of the parent task id and the set of its wave-member child task ids. No other
input shall affect it.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.2:** Child ids shall enter the derivation
ordered ascending by `tasks.id`, which is unique, so no tiebreak column is
required and none shall be introduced. A producer holding rows in any other
order shall re-sort first; arrival order is not sufficient.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.3:** Children that are not wave members
shall be excluded from the derivation. A child entering or leaving wave
membership — by archiving, unarchiving, or being created ephemeral or
automation-origin — shall change the wave identity only when it changes the
wave-member id set.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.4:** When a child moves between two terminal
states, `CANCELLED` to `COMPLETED` say, the wave identity shall not change.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.5:** When any child field other than wave
membership changes — title, description, priority, labels, assignee, workflow
step, `updated_at` — the wave identity shall not change.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.6:** When a wave member is added or removed,
the wave identity shall change.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.7:** A parent with no wave members has no
wave, and no `task_children_completed` run shall be queued for it.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.8:** For the same parent and wave-member id
set, every producer shall derive a byte-identical wave string and wave key from
the same wave-member predicate; a producer whose existing row source applies a
different predicate shall be brought onto the shared one.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.9:** Wave identity shall not depend on the
wake payload: child-summary truncation, a missing child summary, and missing or
present pull-request enrichment shall not change it.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.10:** Wave identity derivation shall have no
failure mode of its own: it shall be computable from values the caller already
holds, without a further read that can error.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.11:** The wave string and the wave key shall
be two encodings of one identity: the key shall be a pure function of the
string, so equal strings always yield equal keys, and both shall be derived once
per queued run from the same member-id set rather than computed independently at
two call sites.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.12:** The backstop's candidate query shall
derive the current wave string using only string aggregation available on both
SQLite and PostgreSQL; no hash or digest shall be required inside SQL on either
dialect.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-001.13:** The ascending `tasks.id` order in .2
shall be the same total order in application code and in SQL on both dialects.
It rests on task ids being lowercase hexadecimal UUIDs, for which byte order and
collation order coincide, and shall not depend on any database locale or
collation setting.
### REQ-OFFICE-WAKE-WAVE-IDENTITY-002: At most one wake per wave per target agent
**Intent:** Producers race by design — the reconciler exists precisely because
an edge-triggered dispatch can be lost — so correctness must come from a durable
constraint the second arrival cannot bypass, not from producers avoiding each
other.
#### Acceptance criteria
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.1:** Every queued `task_children_completed`
run shall durably record the wave identity it was queued for, in both encodings.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.2:** The run queue shall enforce durable
uniqueness over (wave key, target agent).
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.3:** When two producers concurrently queue a
run for the same wave and target agent, exactly one run row shall exist
afterwards.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.4:** The producer whose insert loses that
race shall observe a deduplicated outcome, not an error; the wake shall not be
logged as a failure or retried. This holds for every producer, including one
inserting directly rather than through the shared run-admission service —
reaching the queue by an unclassified path is not an exemption.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.5:** Deduplication shall behave identically
on SQLite and PostgreSQL.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.6:** Deduplication shall not be time-bounded:
a second attempt at the same wave shall be deduplicated however long after the
first it arrives.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.7:** When one wave fans out to two distinct
target agents, each shall receive exactly one run.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.8:** A run for a different wave of the same
parent shall not be suppressed by .2. A parent that completes wave 1, is given
new children, and completes wave 2 shall be woken for both.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.9:** Runs of every other reason shall be
unaffected: recording no wave identity leaves a run outside this constraint
entirely.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.10:** The wake the surviving run delivers
shall be equivalent to the wake the losing producer would have delivered. No
producer shall depend on being the winner, and no producer's wake shall carry
context that another producer's wake for the same wave omits. The obligation
binds whenever two producers would queue for the same wave *and* the same
target agent, which is when one of the two is suppressed; producers queueing to
different target agents each deliver their own run and are not compared.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.11:** Retrying an already-queued run shall
not be rejected by .2: a retry continues an existing run and is not a second
wake for the wave.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.12:** When a producer cannot read the
parent's wave members, it shall queue no run and record no wave identity; the
backstop shall deliver that wake later.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.13:** When one wave fans out across several
workflow participants resolving to the same target agent profile, that profile
shall receive exactly one run, not one per participant. Holding two
roles is not a second wave.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.14:** A run's recorded wave identity shall
always describe the wake that run delivers. Where the run queue would otherwise
merge a later request into a queued run by replacing that run's payload, a
request carrying a wave identity shall not be merged into any run, and a queued
run carrying one shall not be merged into; both shall be admitted independently
and reconciled only by .2. One consequence is accepted: two wakes for *different*
parents addressed to the same target agent inside the merge window now produce
two runs, where one previously absorbed and discarded the other.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.15:** A producer shall record a wave
identity only for a wave-member set it has observed entirely terminal in a
single read. That read shall be the producer's last read of child task state
before queueing and shall yield each member's state as well as its id; if any
member is not terminal in it, the producer shall queue no run and the backstop
shall deliver the wake later. Recording an identity for a set never observed
simultaneously terminal would permanently suppress the real wave, because .6
makes deduplication unbounded.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-002.16:** A producer that queues a wake without
consulting the workflow engine shall attach the same workflow-authored action
payload the engine would attach for the parent's current workflow step, so that
.10 holds for a workflow that authors one. Where it cannot be resolved, the
producer shall still queue the wake without it and record the omission: the wake
outranks an optional payload, and that wake is unconditional under .004.2.
### REQ-OFFICE-WAKE-WAVE-IDENTITY-003: Backstop admission compares waves, not time
**Intent:** The reconciler must re-deliver a genuinely lost wake and not
re-deliver a genuinely delivered one. Deciding that by timestamp makes the answer
depend on unrelated edits; deciding it by wave identity makes it depend on the
thing the wake is about.
#### Acceptance criteria
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.1:** When a terminal
`task_children_completed` run exists for the parent whose recorded wave identity
equals the parent's current one, the parent shall not be a candidate for
re-delivery.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.2:** When a wake has been delivered for the
current wave and a user then edits a terminal child's title, priority, or
labels, the parent shall not become a candidate and no run shall be queued.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.3:** When a wake has been delivered and the
parent's wave-member set subsequently changes to one whose wave identity has not
already been queued for that parent and target agent, the parent shall become a
candidate and a run for the new wave shall be queued. A change returning the
parent to an already-queued identity is the id-set-reuse exclusion under *Out of
scope*, suppressed by .002.6, and is not required to be re-delivered.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.4:** While a `task_children_completed` run
for the parent is queued or claimed, the parent shall not be a candidate,
regardless of which wave that run was queued for.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.5:** A parent with no
`task_children_completed` run carrying a wave identity shall continue to be
judged by the timestamp rule its runs were written under, so an upgrade queues
no re-wake storm.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.6:** Once any `task_children_completed` run
carrying a wave identity exists for a parent, that parent's admission shall be
decided by wave identity alone and .5 shall never be consulted for it again,
whichever of its runs is oldest. The compatibility rule is scoped to the parent,
not the individual run row.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.7:** A parent whose assignee cannot accept a
run, and a parent whose delivery evidence is missing, shall be admitted or
rejected exactly as today. This capability changes only how delivery *for the
current wave* is recognised, on the terms .10 states.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.8:** Admission shall decide staleness inside
the candidate query. A parent permanently ineligible because its current wave
was already delivered shall not be returned and then rejected in the caller,
where it would occupy a row of the query's fixed result limit on every tick.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.9:** A parent with no wave members shall not
be returned as a candidate: no producer will ever queue for it (.001.7), so
returning it creates exactly the permanently rejected candidate .8 forbids. This
gate shall only remove candidates, never admit one not admitted today.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-003.10:** Each admission clause shall name the
run statuses it applies to, and those sets shall be the ones in force today: a
queued or claimed run blocks under .4; a finished, failed, or cancelled run is
the delivery evidence .1 and .5 compare; a run of any status counts when deciding
whether a parent has left the compatibility rule in .5. A failed or cancelled
wake shall therefore keep blocking its parent and, because wave identity no
longer moves on an unrelated child edit, shall block that wave permanently rather
than until the next such edit. This is intended: the runtime contract already
requires an explicit user retry after a terminal execution failure, and the
edit-driven re-arming it replaces was an artefact of the churn being removed.
The escape hatches are an explicit retry and any wave-member change.
### REQ-OFFICE-WAKE-WAVE-IDENTITY-004: Every producer, no other behaviour change
**Intent:** A constraint one producer can route around is decorative; at the same
time, three of the four carry behaviour unrelated to wave identity that must
survive untouched.
#### Acceptance criteria
- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.1:** All four producers shall record the
canonical wave identity on every run they queue.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.2:** The Office scheduler cascade shall
continue to queue a wake for a parent with no active workflow session, and its
wake shall not become conditional on the engine accepting a trigger.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.3:** The orchestrator's workflow operation
ledger identifier shall not change, and parent step transitions driven by
`on_children_completed`, including on a reopened and recompleted child set,
shall fire exactly as today.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.4:** A non-Office workflow that configures
`on_children_completed` shall observe no behaviour change other than those
.002.7, .002.13 and .002.14 define.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.5:** Upgrading shall require no backfill of
historical runs and shall queue no run as a direct consequence of the upgrade.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.6:** The parent wake receipt shall keep its
state-inclusive child-set key and its delivery-evidence fields. This capability
adds a wave identity to runs; it does not redefine the receipt.
- **AC-OFFICE-WAKE-WAVE-IDENTITY-004.7:** The definition of when a parent's
children are *terminal enough* for a wake shall not change. Which children count
towards it, and the deliberate archived-child divergence between the
edge-triggered readiness check and the backstop's candidate query, are outside
this capability. Readiness and wave identity may disagree about which children
they count, and the wave-member predicate shall not be propagated into any
terminality predicate. Exactly two readiness-adjacent additions are in scope and
both only remove wakes: .001.7 with .003.9, and .002.15.
## Out of scope
- **Re-waking a parent after a child is reopened and completed again.** A child
going `COMPLETED` → `IN_PROGRESS` → `COMPLETED` presents an identical id set and
an identical state set at both wave moments, so no function of the current child
set distinguishes them. Closing this needs a monotone per-child completion epoch
on `tasks`, a separate capability with a different owner, filed as a follow-up.
Parent *step transitions* on reopen are unaffected, per .004.3; only the
redundant run is suppressed.
- **Re-waking a parent whose wave-member id set returns to a previously delivered
value.** A child that leaves the wave-member set and later rejoins it — plainly,
archived then unarchived — returns the identity to a delivered value, which the
unbounded deduplication in .002.6 suppresses. Same root limit and same deferred
follow-up as the bullet above; .003.3 is qualified to match. Exposure is small:
unarchiving is deliberate and rare, and any later membership change still wakes
the parent.
- **The task system's `updated_at` stamping.** Every task write stamps it
unconditionally and will continue to. This capability removes the wake
pipeline's dependence on it rather than changing it.
- **The whole-second comparison in the backstop's in-flight run arm**, tracked as
its own card. This capability replaces only the *staleness* arm; the
queued-or-claimed arm and its resolution behaviour are untouched.
- **The backstop candidate query's SQLite-only SQL.** Its aggregate child-set
expression and raw JSON extraction have no PostgreSQL branch; tracked as its own
follow-up. Work here shall not widen that gap — new SQL carries a dialect branch
— but closing it is not this capability's outcome.
- **Any change to the `on_children_completed` trigger's firing conditions,
payload shape, or workflow-editor surface.** Those belong to the task system's
subtask completion trigger contract. Carrying an extra value alongside a trigger
to the action it dispatches, and *reading* an already-authored action payload,
are not changes to that contract and are not excluded.
- **User interface.** No new control, view, or setting. The effect is visible
only as the absence of a duplicate or spurious run.
@@ -0,0 +1,553 @@
---
status: draft
system: office
requirements:
- REQ-OFFICE-WAKE-WAVE-IDENTITY-001
- REQ-OFFICE-WAKE-WAVE-IDENTITY-002
- REQ-OFFICE-WAKE-WAVE-IDENTITY-003
- REQ-OFFICE-WAKE-WAVE-IDENTITY-004
---
# Parent Wake Wave Identity System Design
## Purpose and boundaries
This design gives the `task_children_completed` wake a single durable identity,
persisted on the run row, produced by every producer, and compared by the backstop's
candidate query in place of a timestamp. Office owns the outcome: whether an
autonomous run enters the Office run queue. Adjacent contracts it reads and
constrains but does not own:
- `internal/workflow/engine` — the `on_children_completed` trigger, the action path
turning it into a `queue_run`, the idempotency key it synthesises, and the operation
ledger. This design adds a value travelling beside that key.
- `internal/orchestrator` — the task-state and terminal-step handlers dispatching the
same trigger for every workflow, Office or not.
- `internal/task/repository/sqlite` — `tasks` and its `archived_at`, `is_ephemeral`,
`origin` and `updated_at` columns, plus `IsFromOfficePredicate`.
- `internal/runs/service` — run admission, the idempotency window, coalescing, and
unique-violation classification.
- `internal/db/dialect` — the expression helpers new cross-dialect SQL goes through.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-OFFICE-WAKE-WAVE-IDENTITY-001` | [Wave identity](#wave-identity) |
| `REQ-OFFICE-WAKE-WAVE-IDENTITY-002` | [Persistence](#persistence), [Failure and recovery](#failure-and-recovery) |
| `REQ-OFFICE-WAKE-WAVE-IDENTITY-003` | [Backstop admission](#backstop-admission) |
| `REQ-OFFICE-WAKE-WAVE-IDENTITY-004` | [Producers](#producers) |
## Current state
Four producers can queue a `task_children_completed` run for one parent, each
verified at `cd7823631`.
| Producer | Entry point | Fires on | Route to the queue | Identity it uses today |
| --- | --- | --- | --- | --- |
| P1 cascade | `office/scheduler/reactivity.go` `cascadeChildrenCompleted` | inline from `ApplyTaskMutation`, via the dashboard status update | direct `QueueRun` | `childrenCompletedIdempotencyKey`: parent, agent, digest of child ids, straight to `runs.idempotency_key` |
| P2 edge | `office/service/event_subscribers.go` `queueChildrenCompletedRun` | `events.TaskMoved` into a done-category step | workflow engine | `wakeOperationID`: parent, digest of child ids, wrapped by the engine's `idempotencyKey` |
| P3 backstop | `office/service/scheduler_wake_reconciler.go` | cron sweep over `ListStuckParents` | workflow engine | same `wakeOperationID` as P2 |
| P4 orchestrator | `orchestrator/event_handlers_children_completed.go` `processOnChildrenCompleted` | task state → terminal, and terminal step moves | workflow engine | `childCompletionOperationID`: parent, then per child id, state, step, terminal flag and `updated_at` at ns resolution |
P4 is not gated on Office: it reaches any parent with an active session whose step
configures `on_children_completed`, which shipped `office-default` does with
`reason: task_children_completed`. Its `EvaluateOnly` call suppresses persistence and
the step transition but still evaluates actions, so its `queue_run` reaches the queue.
### The four child-set sources disagree, three ways
The fact the design turns on, and wider than an archived-child mismatch:
| Source | Feeds | `archived_at` | `is_ephemeral` | automation origin | ordered by |
| --- | --- | --- | --- | --- | --- |
| `ListChildStates` (`office/repository/sqlite/blockers.go`) | P1 | not filtered | not filtered | not filtered | `id` |
| `GetChildSetKey` (`office/repository/sqlite/wake_receipts.go`) | P2, P3 | `IS NULL` | not filtered | not filtered | `id` |
| `ListChildCompletionRows` (`task/repository/sqlite/task.go`) | P4 | `IS NULL` | `= 0` | excluded | `created_at`, then `id` |
| `ListStuckParents` child aggregate (`wake_receipts.go`) | admission | `IS NULL` | not filtered | not filtered | `id` |
Two consequences. Telling a producer to reuse "the rows it already has" gives four
different answers, so the constraint would be decorative for at least one. And
`ORDER BY id` is *not* what every existing query uses — P4's source orders by
`created_at` first, so reusing its rows in arrival order derives a different string
from the same set. Separately, the backstop's staleness arm compares
`runs.requested_at` against `MAX(tasks.updated_at)` over children while
`updateTaskTx` stamps `updated_at` on every write regardless of which column changed:
symptom B's root.
## Wave identity
### Which children count
The wave-member predicate is the one `ListChildCompletionRows` already applies: not
archived, not ephemeral, not automation-origin. P4 forces the choice: P1–P3 can each be narrowed cheaply, but P4's row set is the one its
readiness check consumes, so widening it would change *when the
`on_children_completed` trigger fires*, a task-system contract that is out of scope.
It is the only predicate all four can share without moving a readiness boundary, and
it is right on its merits: ephemeral and automation-origin tasks are machinery,
not delegated work.
Two consequences:
- **Readiness and identity now count different children, deliberately.** A parent with
a non-terminal ephemeral child is still not *ready* (readiness arms are untouched,
per .004.7) even though that child is not part of its wave. Do not "fix" this by
propagating the wave-member predicate into a readiness predicate.
- **Narrowing cannot split a wave.** Excluding a child can only merge two identities
that differed by an invisible row, never make one wave look like two, so the change
direction is safe for duplicate wakes.
The predicate is expressed once, as a shared SQL fragment in the office repository;
`task/repository/sqlite`'s `andNotAutomationOrigin` is the precedent.
### The two encodings
```text
waveString(parentID, memberIDs) = parentID + "|" + strings.Join(memberIDs, ",")
waveKey(parentID, memberIDs) =
"task_children_completed:" + parentID + ":" +
lowerhex(sha256(waveString(parentID, memberIDs)))
```
`memberIDs` are the ids of the parent's wave members, ascending by `tasks.id`.
Both are pure functions of the same two inputs, with no I/O and no error return,
which is what .001.10 requires of the *derivation* — the function, not the caller: a
producer may still need a read to obtain the member ids, and .002.12 and .002.15
govern that read.
Why two encodings rather than one. **The backstop must compare in SQL** (.003.8) — a
candidate rejected in Go after the SELECT occupies a row of the query's `LIMIT` every
tick and starves an actionable parent behind it — and SQL here cannot hash, since
`mattn/go-sqlite3` registers no custom functions and there is no `pgcrypto` in the
tree, so the compared encoding has to be the plain string. **The uniqueness
constraint must be bounded** — a PostgreSQL btree entry caps near 2704 bytes, so a
parent with roughly seventy children would push an unhashed key past it and fail the
insert outright, so the indexed encoding has to be the digest. Neither job can use
the other's encoding, so both are persisted, derived together from one member-id
slice at one call site per producer.
Choices a builder would otherwise have to invent, fixed here:
- **The separator is `|`, deliberately not a NUL byte.** PostgreSQL `text` cannot
contain `U+0000` at all, so a NUL-separated string is unstorable there. A UUID
holds only hex digits and dashes, so `|` cannot occur inside an id.
- **Ordering is `ORDER BY id` ascending**, and a producer whose rows arrive
otherwise re-sorts first. `tasks.id` is the primary key, so the order is total and
there is no tiebreak to choose.
- **That order must be the *same* in Go and in SQL** (.001.13). Go sorts by byte;
PostgreSQL `ORDER BY c.id` sorts by database collation, which for most locales is
not byte order. Task ids are `uuid.New().String()` — lowercase hex, dashes at fixed
positions — and for that alphabet the two coincide, so no `COLLATE` clause is
needed. This is a premise worth stating because its failure is invisible: SQLite
(BINARY) never diverges, while on PostgreSQL a divergence would make every parent
look permanently stale and the sweep re-wake continuously. A non-UUID id source is
what would break it, and the cross-dialect test is what would catch it.
- **The digest covers ids only** — state, workflow step, terminal flag and
`updated_at` all excluded.
- **The parent id appears in the wave string and again as a readable prefix on the
wave key.** The prefix keeps the stored value diagnosable; its presence in the
digest input stops two parents with an identical child set colliding.
- **An empty `memberIDs` slice has no wave.** Producers return before deriving, so
neither function is called with one and no key of that shape is written.
### Where the derivation lives
A small leaf package holding the two pure functions and nothing else, imported by
`office/scheduler`, `office/service` and `orchestrator`. The constraint is *not* that
`office/scheduler` may not import `office/service` — it already does
(`executor_resolver.go`, `wake_payload.go`, `run.go`). It is the reverse:
`office/service` must not import `office/scheduler`, because that edge closes a cycle,
which rules out the scheduler as the shared home. `orchestrator` importing a leaf
office package is established practice — `orchestrator/model_info.go` imports
`internal/office/costs/modelsdev`.
## Persistence
### Two columns on `runs`
```sql
ALTER TABLE runs ADD COLUMN wake_wave_key TEXT NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN wake_wave_string TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_wake_wave
ON runs(wake_wave_key, agent_profile_id)
WHERE wake_wave_key <> '';
```
`wake_wave_key` carries the digest and is indexed; `wake_wave_string` carries the
plain string the backstop compares. Both are written in the same insert from one
derivation, so they cannot disagree; a test asserts `wake_wave_key == waveKey` of the
inputs that produced `wake_wave_string`.
Applied through the replayable `r.migrate.Apply` path the other office column
migrations use (`runs.continuation_scope` in `base_migrations.go` is the model) and
added inline to the fresh-schema `CREATE TABLE` in `office/repository/sqlite/base.go`
so a new database and an upgraded one converge. The office repository creates `runs`,
so this is its migration to own, and both statements are written for both dialects.
They run after `runs` exists and none rewrites a row; index creation cannot fail on a
pre-existing duplicate pair, because both columns are new and default to empty, so no
existing row is inside the partial index.
The columns live on `runs` rather than in a claim table because the constraint must
be enforced in the same transaction as the run insert, and that insert is a single
statement; a claim table would need a caller-owned transaction threaded from the
office producers through the engine's action dispatch into the runs service, and no
seam supports that.
Both columns carry a value only for `task_children_completed` runs; every other run
leaves them empty and, the index being partial, sits outside the constraint.
`(wake_wave_key, agent_profile_id)` rather than `wake_wave_key` alone: the engine can
fan one trigger out to several targets, which is why the receipt records an operation
id rather than one delivered run id. Constraining the pair suppresses a second run to
the same agent for one wave while leaving a genuine fan-out to *different* agents
intact. Per .002.13, a fan-out across participants resolving to the *same* profile
collapses to one run: an agent holding two roles is not two waves.
### Coalescing: the third write path into `runs`
`runs` is written three ways, not two, and the third is easy to miss because it is an
UPDATE. `runs/service.QueueRun` consults `CoalesceRun` **before** `insertRun`, and
`CoalesceRun` merges the request into an existing `queued` row for the same agent,
reason, and task bucket inside a time window, incrementing `coalesced_count` and
**replacing that row's payload**. `shouldCoalesceRun` excludes only
`task_comment:`-prefixed keys, so `task_children_completed` is eligible.
When both payloads contain a task ID, `CoalesceRun` compares those IDs for every
reason. A request for a different parent therefore remains a separate row. Wave
requests skip this update path and use the durable wave identity index instead.
Note what is *not* the mechanism: two engine-path producers deriving the same
`wakeOperationID` are already collapsed upstream by `CheckIdempotencyKey`. Coalescing
still handles different keys when the agent, reason, and task bucket match.
The decision (.002.14): **a request carrying a wave identity is never merged, and a
queued run carrying one is never merged into.** The guard is on the wave columns, not
the reason string, so a future wave-keyed reason is covered without a second edit.
Both requests are then reconciled by `idx_run_wake_wave`, the stronger mechanism
anyway: exact and unbounded, where coalescing is approximate and windowed.
Different parents addressed to the same agent inside the window stay in separate rows
because their task buckets differ. Requests for the same task can still coalesce when
they do not carry a wave identity.
### Existing identifiers are untouched
`childrenCompletedIdempotencyKey` (P1), `wakeOperationID` (P2, P3) and
`childCompletionOperationID` (P4) keep their definitions and values, and
`runs.idempotency_key` and `idx_run_idempotency` are unchanged.
The wave key is *purely additive* — a new column beside whatever identity a producer
already computed, never a replacement — which keeps the operation ledger stable,
keeps every `parent_child_wake_receipts` row already written meaningful, and removes
any historical-operation-id migration question. None of that would hold if
`wakeOperationID` were redefined to be the wave key, since the wave-member predicate
is narrower than `GetChildSetKey`'s. The two
constraints answer different questions — "same dispatch" and "same wave" — and only
the second can be computed identically by a direct caller and by the engine.
### Receipts
`parent_child_wake_receipts` is unchanged and `GetChildSetKey` keeps its predicate and
state-inclusive format: the backstop uses that key for the two re-reads that keep a
concurrent child update from being masked by a previous generation's receipt, and that
check wants to notice a state change. `wakeOperationID` being unchanged,
`delivery_operation_id` keeps its meaning.
## Producers
All four derive from the wave-member predicate and pass the resulting pair onto
the run they queue.
- **P1 cascade.** Keeps `ListChildStates` and its terminal-state loop as they are —
narrowing that read would change cascade's readiness check, which .004.7 forbids —
and gains a separate wave-member read. `RunContext` gains wave-key and wave-string
fields beside its `IdempotencyKey`, carried through `SchedulerService.QueueRun` into
the run row. Its no-session fallback is untouched: it still queues directly and
never consults the engine (.004.2).
- **P2 edge and P3 backstop.** Both gain the same wave-member read.
`GetChildSetKey` is *not* reused for the derivation — wider predicate,
state-inclusive — and stays where it is, serving receipts.
- **P4 orchestrator.** No new read and no new filter: its rows are already exactly
the wave members. It must re-sort by `id` before deriving, because
`ListChildCompletionRows` orders by `created_at` first.
`childCompletionOperationID` is unchanged, so the operation ledger, the
`EvaluateOnly` transition lifecycle, and the reopen-driven step transition all
behave as today.
### The wave-member read is the last read, and it carries state
Every producer reaches the queue through several separate, untransacted reads: a
readiness check, for P2 and P3 a receipt re-read, and now the wave-member read.
Nothing orders them, so a child can be added, archived, or leave a terminal state
between two. Unconstrained, that records a run against a member set that was **never
simultaneously terminal**, and because deduplication is unbounded (.002.6) that
identity suppresses the real wave *forever* — a lost wake, strictly worse than the
duplicate this design removes.
The rule that closes it (.002.15) needs no new transaction seam:
- the wave-member read returns `(id, state)` per member ordered by `id`, not ids
alone;
- it is the producer's **last** read of *child task state* before queueing;
- if any returned member is non-terminal, the producer queues nothing and returns.
The recorded identity is then always derived from one statement's rows that the same
statement showed entirely terminal. A change landing *after* the read is harmless: it
moves the wave identity, so the backstop sees a mismatch and queues the new wave
normally. Only the never-terminal set had to be excluded.
The confirmation can only reject, never admit, which keeps it inside .004.7 — the
earlier readiness gates are untouched and strictly wider, so a set that passes them
and fails this defers to the backstop. P4 satisfies it by construction:
`ListChildCompletionRows` returns `state` and `readyChildCompletionRows` establishes
terminality from those same rows.
For P3 this also settles which value is authoritative: `ListStuckParents`' SQL wave
string is an **admission filter only**, never written to a run, and every producer
writes the identity from its own final wave-member read. A candidate row whose wave
string has gone stale needs no special handling — the producer's read is fresher and
its terminality confirmation decides.
A producer deriving an **empty** member set queues nothing and returns (.001.7). This
is live, not theoretical, now the predicate is narrower than the readiness checks: a
parent whose only child is ephemeral or automation-origin passes P1's
`AreAllChildrenTerminal` gate and its `ListChildStates` loop, and only the
wave-member read reveals there is no wave. P4 already guards it
(`readyChildCompletionRows` rejects `len(rows) == 0`); P1, P2 and P3 gain the same.
P1, P2 and P3 read through one new office-repository method returning wave-member
`(id, state)` ordered by `id`. Because P4 derives from `ListChildCompletionRows`
instead, a test asserts the two SQL sources return an identical id set for the same
parent — the equality .001.8 rests on, and the one place two queries must be kept in
step by hand.
The engine seam is narrow: both encodings accompany an `on_children_completed`
trigger to the `queue_run` action and are written to the run row, carried by
`OnChildrenCompletedPayload` (`workflow/engine/payloads.go`). They do not participate
in `idempotencyKey`, `queueActionDigest`, or the operation ledger —
`queueActionDigest` keys off the workflow-authored *action* payload, not the trigger
payload.
## Backstop admission
`ListStuckParents` computes the parent's current wave string in SQL alongside the
existing state-inclusive `child_set_key`, over the wave-member predicate:
```text
p.id || '|' || COALESCE(<ordered group concat of wave-member c.id>, '')
```
The aggregate needs a dialect branch, added to `internal/db/dialect` beside
`JSONExtract`: SQLite's `GROUP_CONCAT(c.id, ',')` takes ordering from an ordered
subquery, the idiom the existing `child_set_key` aggregate uses; PostgreSQL's
`string_agg(c.id, ',' ORDER BY c.id)` takes it inside the aggregate. Writing one and
hoping is how ordering silently differs between dialects.
That expression is evaluated for **every** CTE row, including a parent with no wave
members, where it yields a well-formed `"<parentID>|"` — a pre-gate intermediate, not
a wave string, since per the requirements' *Terminology* a parent with no wave
members has neither. Nothing compares it, because the gate below removes the row
first; note that a gate testing the assembled expression for emptiness would exclude
nothing, as it is never empty.
The gate is a separate `EXISTS` over the wave-member predicate, beside the existing
`EXISTS`:
```sql
AND EXISTS (
SELECT 1 FROM tasks c
WHERE c.parent_id = p.id
AND c.archived_at IS NULL
AND c.is_ephemeral = 0
AND COALESCE(c.origin, '') != 'automation_run'
)
```
Without it, a parent whose only child is ephemeral or automation-origin would be
listed every tick, be found to have no wave, queue nothing, and be listed again — the
permanently rejected candidate .003.8 prevents. The existing
`EXISTS (... archived_at IS NULL)` arm keeps its archived-only predicate and is not
replaced: the gate is additive and narrowing, so it can only remove candidates, never
admit one not admitted today, which keeps it inside .004.7.
The second `NOT EXISTS` arm becomes three clauses, each naming its run statuses
explicitly per .003.10, and each status set is the one in force today:
- `status IN ('queued','claimed')` blocks regardless of wave — unchanged, so the
sweep never races an in-flight wake;
- `status IN ('finished','failed','cancelled')` blocks when its `wake_wave_string`
equals the parent's current wave string;
- **only when the parent has no `task_children_completed` run carrying a wave
identity at all, in any status**, a terminal run blocks under the existing
`requested_at >= newest_child_updated_at` rule.
The second clause keeps its three-status set deliberately, and that is the one place
this design changes *when* a wake is re-delivered. Today a failed or cancelled wake
blocks only until some child's `updated_at` overtakes `requested_at`, so any
unrelated edit silently re-arms it; comparing wave identity removes that churn, so a
failed wake blocks its wave until the wave changes. `ListStuckParents`' doc comment
already states the contract — a terminal failure stays terminal, needs an explicit
user retry, and must not become a cron retry loop — so this is the correct direction.
The escape hatches are an explicit retry and any wave-member change.
The third clause is the compatibility path for pre-change rows. Its guard is
status-agnostic on purpose — it asks about *vintage*, not delivery, and a keyed run
still `queued` blocks through the first clause anyway — and it is scoped to the
**parent**, not the row (.003.6): evaluating per row would let a parent's
pre-upgrade row keep answering long after a keyed run existed. "Carrying a wave
identity" is tested as `wake_wave_key <> ''`, the same predicate the partial index
uses, so a row is inside or outside both the constraint and the compatibility path
together. The path is bounded and self-clearing: an unrelated edit can still make a
pre-upgrade parent look stale and queue one wake, that wake writes a keyed row, and
the parent is judged by wave identity ever after. `newest_child_updated_at` stays
solely for this clause.
The reconciler's two re-reads keep using `GetChildSetKey`: they guard against a child
change between the SELECT and the dispatch, and the state-inclusive key is the
stricter guard.
One asymmetry follows from unbounded deduplication and is why .003.3 is qualified
rather than absolute. Admission can make a parent a candidate again on any
wave-member change, but the constraint still refuses an insert for an identity
already queued, so a set that changes and changes *back* — archive, then unarchive —
lists the parent and queues nothing. That is the id-set-reuse exclusion: admission
promises candidacy, not delivery.
## Wake equivalence between producers
AC-OFFICE-WAKE-WAVE-IDENTITY-002.10 requires the surviving run to deliver the same
wake whichever producer won. It binds only where one run is actually suppressed —
same wave *and* same target agent — so a workflow whose `on_children_completed`
action targets `workspace.ceo_agent` while the cascade wakes the parent's assignee
raises no equivalence question. Two payload sources have to agree for the cases that
do collide.
**Child summaries: equal, from current storage.** Prompt assembly calls
`enrichChildrenContext`, which reads `GetChildSummaries` from the task repository.
The four producers do not need to write a `children` payload key. Each surviving run
therefore receives the current stored child summaries when the scheduler builds the
prompt.
**Workflow-authored action payload: NOT equal today; this design fixes it.**
`queueRunPayload` copies `in.Action.QueueRun.Payload` onto the run verbatim, so every
engine-path producer carries whatever the step's `on_children_completed` `queue_run`
action declares, and the cascade — never consulting the engine — has no way to. The
shipped `office-default` action declares none, so nothing diverges today; but the
workflow editor allows one, and the moment a workflow authors a payload while
targeting the parent's primary agent, the delivered context depends on which producer
won — and suppressing the loser is what makes that observable.
So, per .002.16, the cascade resolves the parent's current workflow step, reads that
step's `on_children_completed` `queue_run` action payload, and merges it into its own
run payload with the precedence `queueRunPayload` applies. The comment-specific fields
that function also merges do not apply to this trigger and are not reproduced. That
resolution reads workflow configuration, not child task state, so it may sit after the
wave-member read without violating .002.15.
Its failure behaviour is deliberately *not* the wave-member read's: if the authored
payload cannot be resolved, the cascade still queues the wake without it and logs the
omission. Its unconditional wake for a parent with no active workflow session is a
contract in its own right (.004.2) and outranks an optional payload. The degradation
is named rather than left to be discovered.
## Failure and recovery
- **Unique violation on insert — two classification sites, not one.**
`IsIdempotencyKeyUniqueViolation`
(`runs/repository/sqlite/idempotency_violation.go`) is deliberately scoped to
`idx_run_idempotency` by name, so it gains an explicit sibling for
`idx_run_wake_wave` rather than being widened to "any unique violation". The sibling
matches PostgreSQL's `pgconn.PgError` code `23505` with constraint name
`idx_run_wake_wave`, and SQLite's composite message `UNIQUE constraint failed:
runs.wake_wave_key, runs.agent_profile_id` — a different shape from the
single-column message the existing constant matches.
Both queue paths must call it, and this is what a single-site fix gets wrong.
`office/repository/sqlite.Repository` *embeds* `*runssqlite.Repository`, so P1 and
the engine path call the very same `CreateRun`, but only `runs/service` wraps it
with classification: `office/scheduler.SchedulerService.QueueRun` calls `CreateRun`
directly and wraps the error as `enqueue run: %w`, logged by its caller as
`reactivity run failed`. Left alone, P1 losing a wave race logs a failure, which
.002.4 forbids — and P1 is the producer the race test drives. So (1)
`runs/service`'s `insertRun`/`QueueRun` gains a second sentinel beside
`errIdempotencyKeyConflict` and returns `QueueOutcomeDeduped`, covering P2–P4; and
(2) `office/scheduler.SchedulerService.QueueRun` classifies the `CreateRun` error
and returns `nil` with a debug log, as it already treats a `CheckIdempotencyKey`
hit, covering P1.
- **Lost dispatch.** Unchanged: the backstop re-derives readiness from current state
every tick and simply recognises delivery by wave rather than timestamp.
- **Retry.** `ScheduleRetry` updates in place and does not insert, so the constraint
is never consulted on a retry (.002.11). A future retry-by-reinsert would have to
carry both columns forward or clear them.
- **Coalescing.** Excluded for wave-carrying requests and rows, per *Coalescing*
above — the counterpart of the retry note: both are UPDATE paths, and one moving a
payload without moving the identity breaks .002.14.
- **Wave-member set changes mid-flight.** Handled by the terminality confirmation on
the final read, not by locking. No producer records an identity for a set it did
not observe entirely terminal, so unbounded deduplication can never suppress a wave
that was not delivered.
- **Wave-member read fails.** The producer does not dispatch (.002.12). P2 logs at
warn and defers to the backstop; P3 retries next tick; P1 already returns without
queueing on a failed child read and does the same here. P4 cannot hit this case.
- **Upgrade.** No backfill. Existing rows keep the empty defaults, stay outside the
partial index, and are judged by the parent-scoped compatibility clause.
## Security
No new trust boundary: both encodings contain only task ids the same caller already
reads, and neither is user-supplied or rendered.
## Observability
The existing `parent_wake_*` expvars and their paired `wake.metric.*` logs are
unchanged. One counter is added, `parent_wake_deduped_total`, incremented when a
`task_children_completed` insert is rejected by `idx_run_wake_wave`, at both
classification sites so a P1 dedupe is as visible as an engine-path one. It is the
only direct evidence the constraint is doing work, since a run that never exists
leaves no other trace. Parent task id is unbounded and is not a label,
matching the existing counters; it goes on the structured log.
## Testing
- **Derivation:** terminal-to-terminal edit stable; non-state edit stable; member
added or removed changes it; archived, ephemeral and automation-origin children
excluded; independent of insertion order; empty set never keyed; wave key is the
digest of the wave string.
- **Cross-producer:** all four derive identical encodings for one parent and child
set, including a parent with an ephemeral or automation-origin child and P4's rows
in `created_at` order. The new wave-member method and `ListChildCompletionRows`
return the same id set.
- **Dialect:** the wave-string aggregate is byte-identical on SQLite and PostgreSQL,
ordering included.
- **Race:** cascade against one backstop tick yields exactly one run row and the
loser returns success without logging a failure, mirroring the runs service's
idempotency-index race tests and their PostgreSQL twin. The cascade case must run
through `office/scheduler`'s own queue path, not only `runs/service` — those are the
two classification sites.
- **Coalescing:** a second request for a *different* parent, same agent, inside the
window does not merge into the first parent's queued run; two runs exist, each
carrying its own identity, neither differing from the wake it delivers.
- **Read skew:** a wave-member read returning a non-terminal member queues nothing
and records no identity, and a later genuine wave for that parent is still
delivered — the case otherwise suppressed permanently.
- **Admission:** a non-state child edit after delivery lists no candidate; a
wave-member change lists one; a parent whose only run predates the change still
follows the timestamp rule; a parent holding both a pre-upgrade and a keyed run is
judged by wave identity alone. A failed run whose wave string equals the current one
keeps blocking across an unrelated child edit, and a wave-member change unblocks it.
- **Gate:** a parent whose only children are ephemeral or automation-origin is not a
candidate and no producer queues for it; a parent that is a candidate today stays
one whenever it has a wave member.
- **Payload parity:** the cascade attaches the workflow-authored
`on_children_completed` action payload when the step declares one, matching the
engine path byte-for-byte, and still queues the wake without it when it cannot be
resolved.
- The two identity tests pinning the id-only decision, and the cascade's
children-completed tests, must continue to pass unmodified.
## Related decisions
- [ADR-0015](../../../decisions/0015-explicit-completion-signal-for-auto-advance.md)