86639f2ce485fce2ef933fcad296ad2bee86587f
2532 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
86639f2ce4 |
fleet: planning drain + archive writers 12 → 4 — one stale row starves planning, and a finaliser that wrote an undeclared column (#2742)
**Claimed on #2733 before starting.** `in-process-runtime.ts` + `task-artifacts-ops.ts` — **12 → 4**. ## 1. The planning drain: one stale row stops planning for the whole project FN-8470's own note on this code says it: **one orphan earlier in created_at FIFO prevented every later planning continuation from dispatching.** So on a renamed board the literal terminal pair did not mis-handle one card — an archived or completed card's stale work item read as live, stayed in the due set, and **starved the drain behind it**. The two classifiers take an **optional** terminal set, which is this file's own injection idiom (the specification-complete reaction already takes a `resolveIr` dependency so the pure passes are testable without constructing a runtime that would attach to the real project registry). **Optional is load-bearing:** a *required* parameter would have compiled at every existing caller and then answered "not terminal" for everything. That is the silent direction, and both halves are asserted in the test. ## 2. `moveToDoneImpl` writes `task.column` directly This is the store's own finaliser, not a `moveTask` caller — so its literal is **not** caught by `moveTask`'s unknown-column validation the way every converted call site in this program is. It silently persisted `done` on a board that does not declare it, and then emitted `to: "done"` to every listener. **This is one of the few sites where a literal writes bad state rather than merely failing to act.** A workflow declaring no complete lane now throws instead of inventing one — #2733's rule: a missing field on a resolved struct *is* an answer, and `?? legacy` discards it. ## 3. The unarchive destination — three decisions in four lines, all literal | pre-archive column | lands in | |---|---| | unusable / archived | the **complete** lane | | the **wip** or **review** lane | the **hold** lane (its worktree and session are long gone) | | anything else | back where it was | The second is the expensive one: a card archived *from* the wip lane was restored straight back *into* it **with no worktree**, and the scheduler then counts it as a live holder **occupying a slot**. Made async — its one production caller already is, and the sync alternative is the PostgreSQL no-op documented in #2703. ## Also - **The mission-error requeue** (guard *and* destination in one change): an errored mission task stayed in the wip lane holding a slot, because the guard never matched. - **The planner-chat retention cutoff on archive** — the quiet direction of this defect class: nothing breaks, data that should be deleted simply accumulates, and the only symptom is storage growth nobody attributes to a column name. ## The live defect is not where the census points `reliability-metrics.ts`'s 6 guards are **pure historical readers** over activity-log entries, and **the dashboard does not call them**. The live path is `server.ts`'s `getTaskMovedCountsByDay({ toColumn: "in-review" })` — a **SQL query filter**, the class the census counts separately. So the operator's reliability panel reads zero on a renamed board because of a *query* literal, and converting the six guards the census reports **would change nothing an operator sees**. Converting historical readers also risks reinterpreting past events under today's traits, which is a different decision from converting a live guard — I am not making it inside a vocabulary sweep. Worth generalising for the fleet: **a file's census count and its live exposure are different numbers.** This is the second file where the reported guards are the inert copy and the real one is a query (`executor.ts:5805` was the first). ## Verification `pnpm test:gate` **10 / 158 / 487 / 71** · 31/31 continuation suites · 8/8 archive PG suites · in-process-runtime PG suite green · 5 new cases, **2 red on revert** · `tsc` clean in core and engine · `pnpm lint` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b0b9d1b373 |
fleet: store.ts 12 → 11 + names the sync-dependency-loop class blocking ~10 sites across 3 clusters (#2709)
Claiming **`packages/core/src/store.ts`** (12). One conversion and a
triage — because **10 of the 12 share a single blocking shape** that is
worth naming once rather than rediscovering per file.
## Census before/after
| | before | after |
|---|---:|---:|
| `store.ts` column guards | **12** | **11** |
Baseline re-recorded; `--strict` exits 0.
## Converted: 1
**1386** — the in-review guard inside `withTaskLock(id, async () => …)`.
Already async, and `this` **is** the store, so
`resolveTaskLifecycleColumns(this, task.id)` resolves the review role
with `in-review` as the fallback. Import added; nothing else in the
method changes.
## The blocking class — 6 sites, and it is not specific to this file
**1772, 1791 ×2, 1874 ×2, 1916, 1917, 1933** all read **another task's**
column — a dependency's, a blocker's, an overlap candidate's — inside
**synchronous callbacks over a prefetched `taskById` map**:
```ts
const unresolvedDeps = (task.dependencies ?? []).filter((depId) => {
const dep = taskById.get(depId);
return dep && !dep.deletedAt && dep.column !== "done" && dep.column !== "archived";
});
```
This is not a substitution. Each dependency may belong to a **different
workflow**, so the role must be resolved *per dep* — N async resolutions
inside a sync `filter`, on a path that deliberately prefetches into a
map precisely to avoid per-item I/O.
Two honest options:
1. **Prefetch lifecycle columns alongside `taskById`** and pass a
resolved map into these predicates. Keeps them synchronous, one
resolution per distinct workflow rather than per dep. This is the one
I'd argue for.
2. Accept per-dep resolution and make the callbacks async — changes the
shape of dependency evaluation.
Both are design changes with real cost, so this is flagged rather than
guessed.
**The same shape appears in at least two other clusters I've worked**:
`TaskDetailModal`'s `overlapBlockerTask.column` (#2696) and
`register-task-workflow-routes`' dependency-summary pair (#2700), both
flagged for this exact reason. **Worth one decision covering all three**
rather than three separate judgement calls by three workers.
## Also flagged: 3
**1610** and **1739** — enclosing-scope async-ness and store access not
established at those points, so not guessed. **1933** belongs to the
sync-filter family above.
## Verification
`pnpm test:gate` **GREEN** (158 + 487 + 10 + 71) · `pnpm lint` clean ·
core `tsc` clean · `--strict` exits 0.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Updated failed pre-merge review bypass validation to support custom
workflow boards.
* Tasks can now bypass the step when placed in the board’s configured
review lane.
* Improved error messages to identify the correct review column when
bypassing is not allowed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3066948110 |
fleet: async-comments-attachments.ts NOT converted — the archived gate lives in three encodings (52 sites), pinned by a guard that fails on each (#2724)
Claimed the **async-comments-attachments** cluster (9) and did not convert it. This PR is the evidence for why, as a guard rather than a note — **no production change, census unmoved.** ## What the cluster actually is Every other file in the backlog converts on its own: resolve the task's lifecycle columns, compare against the role. `archived` doesn't, and not by a matter of degree. **Measured in `packages/core` — one rule, three encodings, 52 sites:** | encoding | sites | files | what it decides | |---|---:|---:|---| | TypeScript `=== "archived"` | **37** | 23 | what code does with a row it already has | | Drizzle `ne(tasks.column, 'archived')` | **7** | 6 | which rows a query returns | | raw `` sql`…column != 'archived'` `` | **8** | 5 | same — and invisible to both scans above | Convert only the TypeScript half and a board whose archived lane is renamed **splits**: `getLiveTaskColumn` correctly reports the task archived (it resolved the role) while `readLiveTaskRows` still hands it back as live. A document write is rejected by its gate while its parent is listed as live — a state neither gate alone can produce today. And nothing would catch it. **Every builtin workflow spells that column `archived`**, so all three encodings agree by accident on every board we ship. ## Why the SQL sides aren't just converted too `ne(tasks.column, ...)` needs the resolved id as a **query-build value**, so the IR must be resolved *before* the query — including inside the `for update` document/artifact transactions, which today receive a `db`/`tx` handle and **no store and no workflow reader**. One raw site is a hand-written `SELECT` string, so its comparison isn't even a Drizzle expression that could take a bound value without rewriting the query. The two real options are: thread a resolver into the persistence layer, or **declare `archived` a non-renameable system column** and mark all 52 sites deliberate. Both are decisions with blast radius. Neither is a fleet conversion, so I didn't pick one. ## I was wrong about the shape, and that's the strongest part I wrote the third case as an assertion that **no** raw `sql` template compares a column to `'archived'` — I assumed two encodings. **It failed on the first run with five files.** Nothing in the repo was counting them: they aren't comparisons (invisible to the column census) and aren't `eq`/`ne` calls (invisible to the Drizzle scan). A partial conversion doesn't have to miss one encoding — it can miss two. That case is now an inventory, with a note on why asserting absence was the wrong invariant: an absence assertion has to be deleted by whoever adds the next raw template, and deleting a red guard is how a class of sites stops being tracked. ## Injection proof — all three run | injected | result | |---|---| | convert one TS comparison in `async-comments-attachments.ts` | fails: **"TypeScript encoding changed"** | | remove one Drizzle predicate in `async-lifecycle.ts` | fails: **"Drizzle encoding changed"** | | remove one raw template in `reads.ts` | fails: **"Raw-sql encoding changed"** | Each failure carries the split-brain explanation and the two real options, so the next worker hits the reason rather than a bare count mismatch. ## Two scan bugs I fixed in my own guard - **It audited documentation.** The raw-template scan reported three sites in `async-archive-lineage.ts` that were prose in a JSDoc block. Now comment-stripped through the census's shared `stripComments` rather than a second implementation. - **It missed half the cluster.** Requiring a receiver (`x.column === "archived"`) misses the list paths that hold the value in a bare local (`if (column === null || column === "archived") return []`) — **four of the eight sites in the target file**, measured. The matcher now accepts a bare identifier named `column`. Counts here are per file, not per line: line numbers churn on unrelated edits, and a ratchet that cries wolf gets deleted. ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · new guard **2/2** · `pnpm lint` clean · core `tsc` clean · census `--strict` exits 0 with the **baseline untouched** — this PR converts nothing and claims nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7c408ef650 |
fleet: merge path 10 → 2 — a merged PR never advanced its task on a renamed board (#2733)
**Claimed on #2728 before starting.** The merge path: `merge-queue-ops-2.ts` + `merger.ts` — **10 → 2**, both survivors flagged with reasons. ## A merged PR never advanced its task on a renamed board `applyPrMergedTransition` is what moves a card when GitHub reports a PR merged. Every guard in it was a default-lineage literal, and they all failed **in the same direction**: | guard | renamed board | |---|---| | `column === "done"` → skip as already-done | never matched, so a complete card was re-processed | | `column !== "in-review"` → bail `wrong-column` | always matched, so a card **sitting in review** bailed | Net effect: **a PR merged on GitHub never advances its Fusion task.** The operator sees a merged PR whose card sits in review forever — which reads as a broken webhook, so it gets debugged in the wrong place entirely. That is the most expensive property of this defect class: it does not just fail, it misdirects. One snapshot now covers the pre-check, the deliberate **re-read** (a merge can land between checks), and the **move target**. The target is asserted in the test alongside the guards, because converting guards alone would admit the card and then move it to a column the board does not declare. ## merger.ts - **The orphan-stash liveness guard** classified every finished task as unfinished on a renamed board, so orphaned stashes were never cleaned up. Unioned with the legacy ids: too strict here leaves clutter, too loose **discards a stash whose task is still running**, so over-inclusion is the safe direction. - **The worktree-conflict scan** filters by worktree *path* before resolving lanes. The naive order — resolve, then filter — is exactly what made the github-tracking reconciler scan proportional to task history (#2714 review). Lesson transferred rather than re-learned. - The deprecated `aiMergeTask` already-finalized guard. ## Two flagged, not converted **`merge-queue-ops-2`'s sync enqueue guard** runs inside `store.db.transactionImmediate`. A synchronous lane resolution reads `getTaskWorkflowSelectionImpl`, which returns `undefined` **unconditionally in PostgreSQL mode** — so a "conversion" there would drop the census by one and behave exactly as the literal (the finding from #2703). Converting it properly means making the path async or pushing the trait read into SQL: store architecture, not a call site. Left literal **with that note**, so the next worker does not turn it into a false green. `merger.ts`'s last comparison is the same class. ## Pre-existing red, reported not folded **22 failures in `packages/dashboard/src/__tests__/routes-github.test.ts`** — spec revise/rebuild and approve/reject-plan, all asserting moves to **`triage`, the column U11 deleted**. Verified by reverting my diff and re-running: identical 22. Same stale-literal-in-a-test class as the two assertions #2720 fixed, and it is 22 tests pinning a column that does not exist — worth someone owning deliberately rather than as a rider here. ## Verification census **10 → 2** · `pnpm test:gate` **487 / 71** · 23/23 across three merger suites · 4 new cases, **2 red on revert** · `tsc` clean in core and engine · `pnpm lint` clean. ## Also examined and deliberately left alone - **`live-agent-count.ts` (6 guards)** — every literal there is the *documented degradation path* for a task shape that was not enriched, and both production callers already enrich (`useExecutorStats`, `fn project`). Converting them converts nothing; deleting them removes the fallback that fixtures rely on. The invariant that matters is **caller enrichment**, which is not a literal at all. - **`task-merge.ts` (6 guards)** — `getTaskMergeBlocker` is a **pure** function with no store; its callers inject `resolveTask`. Resolving lanes needs a matching injected resolver, which is an interface change across every caller. Also worth a decision first: its dependency check accepts `in-review` as satisfied while the store's `blockedBy` computation (#2720) does not — **two definitions of "dependency satisfied" in one codebase**, and I am not settling that one silently inside a vocabulary sweep. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ab715cbd39 |
fleet: default-workflow-hooks.ts 7 → 0 — every duration display read ZERO on a renamed board (#2734)
Claiming `default-workflow-hooks.ts` (7 → **0**), verified free against every open PR's diff first. ## Not a vocabulary tidy — three silent zeroes This file's header names it for the default workflow, but the store runs it on the flag-ON path for **every** workflow: the trait registry resolves each hook by **trait id**, not by workflow. `reopen-semantics-by-role.test.ts` already documents that exact hazard for the reopen predicates. The **timing, completion and in-review hooks had the same defect** and were not part of that conversion. On a renamed board, with nothing thrown and nothing logged: - **`applyTimingEffects`** accrues `cumulativeActiveMs` while a card sits in the WIP lane. With the lane named, the exit test never fires — so **no active time is ever accrued**, and `productivity-analytics.ts`, `task-timing.ts` and every duration display read **zero**. - **`applyCompletionTimingEffects`** never stamps `executionCompletedAt`, so a finished card looks unfinished to anything reading that field. - **`applyInReviewEnterEffects`** returns early, leaving the recovery counters set. The file already had the idiom — `ctx.lifecycleColumns`, `planningColumnsOf`, `liveWorkColumnsOf` with `LEGACY_` fallbacks — so this adds no abstraction. One deliberate detail: `applyTimingEffects` resolves the WIP lane **once into a local** rather than reading it twice. The exit test and the re-entry test have to agree about which column is WIP, or a rename makes the accounting count an interval twice, or not at all. ## A test that would have lied to me I wrote the new cases through `applyDefaultWorkflowMoveEffects` first, and **all three failed on the DEFAULT lineage too**. The dispatcher resolves hooks by trait, and neither test IR declares the `timing` trait, so those hooks never ran at all. That failure looks exactly like a conversion bug. Going through the dispatcher would have been testing the trait registry's wiring rather than this change — so the cases call the converted functions directly, and the reason is recorded in the test. ## Revert proof — all three, each naming the renamed lineage | reverted | failure | |---|---| | the `in-progress` literals | `renamed lineage accrued no active time: expected undefined to be 300000` | | the `done` literal | `renamed lineage did not stamp completion: expected undefined to be '2026-07-30T00:00:00.000Z'` | | the `in-review` literal | `renamed lineage kept its recovery counter: expected 3 to be undefined` | Every case runs on **both** lineages and the default one passes either way — which is the point of running it. ## A finding I did not act on **`evaluateMergeBlockerGuard` appears exactly once in the repo — its own definition.** And the file header says it is "implemented as the `evaluateDefaultWorkflowGuards` reader", which does not exist either. The merge-blocker guard hook is **defined and never consulted**. I converted it (trailing optional lifecycle param, matching `DefaultWorkflowMoveContext`) but did not delete it: the header states this file is a deliberate parallel of `store.ts`'s flag-off path so the two can be parity-checked, which makes removing it a scope call for whoever owns that convergence — not something to decide inside a conversion. ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **22 passed** across `default-workflow-hooks` + `reopen-semantics-by-role` · core `tsc` clean · `pnpm lint` clean · census `--strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
61b82a2737 |
fleet: pure lifecycle predicates 17 → 5 — a monitoring signal that went quiet, and a blocker that waited forever (#2745)
**Claimed on #2742 before starting.** Four pure modules — **17 → 5**, every survivor flagged with a reason. All four are **pure functions with no store**, so the fix shape is the injected-set contract established in #2728, not an in-function resolve. ## Three failures that never error | predicate | what a renamed board got | |---|---| | `getTaskAgeStalenessSignal` | `undefined` for **every** card — age-staleness reported nothing | | `isStaleBlockedByBlocker` | "not stale" for a blocker that was finished, paused in review, or retry-exhausted | | `areAllDependenciesDone` | "not satisfied" for a dependency that had landed | The first is the one to sit with: **a monitoring signal that goes quiet is indistinguishable from health.** The board looks fine while cards sit for days, and nobody investigates a metric that isn't alarming. The signal also chose its *threshold pair* by wip-vs-review, so both halves were literal. The second means the blocked card **waited forever**, silently — "not stale" is the answer that produces no event. The third is the **third place** "satisfied" is asked. It now gives the same answer as the store's `blockedBy` computation (#2720) and the merge blocker: complete or archived, unioned with the legacy ids. Three surfaces, one rule — which is exactly why I refused to settle it inside a vocabulary sweep the first two times it came up. ## Optional is load-bearing Both halves are asserted for every predicate: supplying lanes makes a renamed board work, **omitting them preserves every existing caller**. A *required* parameter would have compiled at every call site and then answered "not active" / "not stale" / "not satisfied" for everything. That is the silent direction, and **no type checker catches it** — which is the argument for optional-plus-legacy-default over a clean signature. The restart-recovery classifiers (with-progress / no-progress / merge-active) take the same set, and **the combiner threads it to all three**, so a caller cannot convert the outer question and leave an inner one literal. `isInReviewMissingWorktreeSessionStartFailure` is deliberately untouched — #2728 converts it and duplicating that would conflict. ## The five that remain - **3 are the ternary trait-fallback branches** (`lanes ? … : legacy`) — the documented degradation path the census counts by design, not unconverted guards. I am not marking them `DELIBERATE-LITERAL` to move the number; that marker means "a lifecycle literal reviewed and kept", and mislabelling to flatter a count is how the instrument stops meaning anything. - **`recoverInterruptedRuns`' filter sits behind a `listTasks({ column: "in-progress" })` query.** The query is the live filter, so converting the redundant predicate moves the census and changes nothing an operator sees. **Third file** where the reported guard is the inert copy and the real one is a query. - **`resolveWorkflowBypassGuards` is sync and receives only column strings** — no task, no store. Converting it means adding lanes to `MoveTaskOptions` and threading them from the moves path, which another worker owns. Marked `DELIBERATE-LITERAL` as an explicit hand-off, with the consequence named: on a renamed board the operator's drag out of the wip lane was rejected by the transition validator, so **a card could not be cancelled from the board at all** (AGENTS.md's Move-Task hard-cancel contract). ## Verification `pnpm test:gate` **10 / 158 / 487 / 71** · 9 new cases, **5 red on revert** · 13/13 with the archive PG suite · `tsc` clean in core and engine · `pnpm lint` clean · census **17 → 5**. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b9f3fb0ff2 |
docs(core): resolveReviewColumns is the BROAD set — and one of its consumers must NOT migrate onto it (#2750)
## A flaw in the helper I merged in #2730 Found by trying to do the migration I had been advocating for three rounds. One **name** was answering two questions: | | question | answer | |---|---|---| | **broad** | "is this card in a lane where review happens?" | every `mergeOrchestration` lane + every `mergeBlocker`/`humanReview` lane — **this function** | | **narrow** | "is this card in *the* review lane the engine acts on?" | `resolveLifecycleColumns().review` = `columnsWithFlag(ir, "mergeOrchestration")[0]` — **one** lane | The narrow answer is what the executor, the scheduler and `project-engine` act on. **A caller that admits on the broad set and then MOVES the card moves cards the engine does not consider in review.** ## The correction I owe I have been arguing across #2722, #2723 and #2728 that the inline review unions should converge on this helper. For the notifier that is right — over-admission there just means an extra notification. For `register-task-workflow-routes.ts` it is **wrong**. That resolver is deliberately narrower (#2723): its re-engagement *moves* the card, so admitting a second merge lane is a state change the engine will not agree with. Its local copy is **not drift from this helper — it is the other question.** Migrating it would reintroduce precisely the over-admission that PR's review round reasoned away. I was about to make that change. Reading both implementations side by side is the only thing that stopped me, and "consolidate the duplicates" would have looked like an obvious cleanup to the next person too. ## What this PR does Nothing to behaviour. It writes the distinction down **at the helper**, where a consumer reaching for "the review columns" will see it, and pins the difference with a test. The test needs a board declaring `mergeOrchestration` **twice** — no default lineage does, which is exactly why the two answers look identical everywhere else and why the conflation survived review. **Mutation: narrowing this helper to the first merge lane — the consolidation someone would reasonably attempt — fails the test.** ## Verification 30 trait tests green · core `tsc` clean · lint clean (0 errors) · gate green (487 + 158 + 10 + 71). No census movement. ## Not done here Migrating the notifier and the CLI copies onto this helper. Those genuinely should converge, but both live in open PRs (#2722, #2728/#2736) with live review threads; switching them under their authors mid-flight is worse than letting them adopt it once this distinction is documented. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Clarified the distinction between broad review-capable lanes and the workflow’s primary review lane. * Documented how review lane selection affects workflow state handling. * **Tests** * Added coverage confirming that review detection includes all matching merge lanes. * Verified lifecycle review selection continues to use only the primary merge lane. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
b6b28c3b0b |
fleet: task-age-staleness 4 → 0 + the claim guard — an agent could claim a finished card, and no card was ever 'stale' (#2746)
Two core clusters. 6 converted, 2 flagged. ## Two silent failures **No card was ever stale.** `task-age-staleness.ts` applies its signal only to the mid-flight and review lanes — a card in a hold or terminal lane is waiting or finished, not stale. Both lanes were named by id, so on a renamed board the signal returned `undefined` for **every** card and the stale-card warning never appeared anywhere on the board. **An agent could claim a finished card.** `claimTaskForAgent`'s terminal guard was `column === "done" || column === "archived"`. On a renamed board neither matched, so the claim **succeeded** and the agent began work on completed output. ## The threshold selectors are a separate literal, and half-converting is worse than neither `task-age-staleness` has two independent uses of `in-progress`: the **lane gate** that decides whether the signal applies, and the **threshold selectors** that pick which warning/critical numbers to measure against. Converting only the gate admits a renamed-WIP card and then measures it against the **review** threshold — a wrong number, silently. Both are converted, and each is revert-proofed on its own: | reverted | result | |---|---| | the lane gate | 3 of the new cases fail (`expected undefined to be defined`) | | the threshold selectors | the threshold case fails — a renamed WIP card gets the review threshold | No new seam for either: the staleness signal already took a `context` object, and its one production caller (`task-store/reads.ts`) already holds a **per-pass IR cache** for precisely this kind of resolution. ## Cost stated rather than hidden The `reads.ts` resolution is **unconditional**, where the hold-column read directly beside it is gated on `task.paused`. That asymmetry is deliberate: the lanes this needs are exactly what decides whether the signal applies at all, so there is no cheaper gate available ahead of it. With the shared per-pass cache that is a struct build per card, not an IR read. ## Flagged and left counted `formatCurrentTaskLine` is a pure formatter over `Pick<Task, "column">` whose output **prints** the column name for a human reader — same class as `github-tracking-comments.ts:165`. It also degrades gracefully: the "(not active — X)" wording is lost on a renamed board, but "(X)" is still accurate, just less specific. Threading a resolution into a string builder to pick a word is the wrong trade. ## The recurring blind spot, fourth time **None of the 12 existing staleness cases could have caught this** — `lifecycle` is optional and they all omit it, so they assert the legacy fallback. Same for the reconciler's 33 (#2737) and `TaskReviewTab`'s 45 (#2744). This is now a consistent property of the optional-flags seam: **the existing suite stays green through the conversion and through a broken one.** Every file in this program needs at least one case that supplies flags, or the conversion is untested in both directions. Worth making an explicit review criterion rather than something each worker rediscovers. ## Verification `pnpm test:gate` **GREEN** (158 + 10 + 487 + 71) · **31 passed** across staleness / routing-policy / dispatch suites · core `tsc` clean · `pnpm lint` clean · census `--strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2fe82cb644 |
feat(core): resolveReviewColumns — the shared answer four consumers each invented separately (#2730)
## The pattern `resolveLifecycleColumns().review` is a **single id derived from one flag** (`mergeOrchestration`). The domain is not that shape — a lane can host human review without orchestrating a merge, and a board may declare more than one review lane. So every consumer asking *"is this card in review"* re-derived its own answer, and they drifted: | PR | surface | how it diverged | |---|---|---| | #2713 | routes | terminal columns needed membership; fixed there only | | #2722 | notifier | a `humanReview`-only lane resolved to nothing — the operator's review notification **never fired**, silently | | #2723 | routes | the union was broader than core's single id | | #2728 | CLI | `fn task retry` refused a card `POST /tasks/:id/retry` accepted — the same operator action answering differently per surface | Four files, four patches, **two of them mine** — plus a fifth site inside #2722 that my own first pass missed, which is exactly the enumeration failure I had been pointing out in other people's PRs. Each recurrence cost a review round, and the next consumer would have invented a fifth answer. ## The change ```ts export function resolveReviewColumns(ir: WorkflowIr): string[] ``` **Additive on purpose.** `.review` is untouched, so nothing that reads it changes behaviour. This is the *missing* helper, not a reshaping of the existing one — `.review` stays correct for its own question ("which single lane hosts the merge gate"); this answers the other one ("is this card **already** in a review lane"). **Monotonic**, which #2723's review round argued about at length: a lane carrying **both** `humanReview` and `mergeOrchestration` is included. Adding a trait must never *remove* a lane from this set, or a card stops counting as in review because its column gained an unrelated capability. **Returns empty rather than defaulting to `in-review`.** The legacy fallback belongs to the caller, which knows whether refusing or admitting is the safe direction for its own guard — the notifier and the retry gate fail in opposite directions, and baking one choice in here would make one of them wrong. ## Verification Six tests, both directions: human-review-only lane, multiple lanes, monotonicity, de-duplication, empty result, and agreement with the shipped coding workflow. One asserts the divergence directly — `resolveReviewColumns` finds the lane while `.review` returns `undefined`. **Mutation: dropping the two extra flags fails 2 of 26.** 26 core trait tests green · core `tsc` clean · lint clean · gate green (487 + 158 + 10 + 71). **No census movement** — this adds capability and converts nothing. ## Follow-up, not done here Migrating the four consumers onto it. Each is an open PR with its own review thread, and switching them under their authors mid-flight would be worse than letting them adopt it. The helper is the prerequisite; adoption is theirs. |
||
|
|
ddba730a59 |
fix(core): 8 reds across 4 files — incl. a real FN-8603 contract violation and a ratchet row pinning deleted code (#2725)
## Measured
Full `@fusion/core` suite: **8 failed / 6 files → 1 failed / 1 file**
(4611 passed). `pnpm typecheck` exit 0 across every package, `pnpm lint`
clean, gate **726**.
The one remaining failure is **not mine to fix** — see the last section.
## Four causes; two are product-side, not test drift
**1. A real FN-8603 contract violation.** `tool-output-budget.ts:116`
had a bare `console.warn`, breaking the rule that production diagnostics
route through the shared logger so severity markers and `FUSION_DEBUG`
gating survive. `log-severity-spam-contract` caught it exactly as
designed. Now `createLogger("tool-output-budget")`, kept at `warn` — an
invalid operator-supplied budget is a real misconfiguration, not routine
chatter.
**2. A ratchet row pinning deleted code.** The manifest pinned a `local
reattached project ${project.id}` demotion in `central-core.ts` whose
call site was deleted by `5ae6332563` ("collapse dead SQLite dual-path
code"). Verified absent from **all** of `packages/core/src`, not merely
moved. A manifest row for deleted code can only ever fail — it ratchets
nothing — so it is removed with that provenance recorded in place.
**3. An intentional settings overlap.** `agentToolOutputMaxChars` now
appears in both scopes. Admitted to the parity list because
`settings-schema.ts:462` states the intent outright: *"Project settings
participate in the existing effective-settings merge, allowing a
project-specific tool-output cap … to override global policy."* Placed
in `GLOBAL_SETTINGS_KEYS` order, as that test requires.
**4. `maxPostReviewFixes` 3 → 10 — the third file pinning the stale 3.**
Driven off the exported `DEFAULT_MAX_POST_REVIEW_FIXES` rather than a
fourth literal copy. That constant exists *because* the declaration
default and two inline `3`s had already drifted apart once; adding
another copy would guarantee a fourth drift.
## duplicate-guard: a narrow seam instead of a rebuilt mock
Its 3 failures were `Cannot read properties of undefined (reading
'projectId')` — the fake modelled the **deleted SQLite path**
(`db.prepare().all()`) and recovered the window by parsing a captured
cutoff string. It broke when the query moved to `asyncLayer` + Drizzle.
Rebuilding a Drizzle chain to recover a number the policy already
returns would be mock-the-world for no gain, so the window policy is now
one exported pure function — `resolveFingerprintWindowMs`, the
**byte-identical** expression — that both the store query and the tests
call. Two side benefits: the ±5s timing tolerance is gone (exact
assertions), and the `Math.max(1, …)` floor now has coverage the old
cutoff-parsing shape could not see.
**Load-bearing, verified by mutation:** restoring the old 5-minute
ceiling fails 3 of them; deleting the floor fails the new case.
## The remaining failure is a deliberately-deferred product decision
`agent-logs-and-monitor.pg.test.ts > aggregateActivityAnalytics …`
expects funnel stage `todo` count 2 and gets 0. This is **already
diagnosed and deferred by another worker**, in
`activity-analytics.ts:604`:
> *"The merged column landing in `triage` while the `todo` stage stays
empty is a SEPARATE and larger question — it makes the funnel show a
phantom 100% drop between Triage and Todo on every default board since
U11 — and it is deliberately not settled here. Changing which stage the
Planning column reports would retroactively alter how historical
analytics read… Flagged for a product decision on PR #2669."*
The merged Planning column carries `["intake","hold","reset-on-entry"]`
and `stageForTraits` prefers the earliest stage, so `intake` wins.
Either fix — remapping the stage, or changing the expectation — silently
settles how historical analytics read. I left it alone rather than pick
a side inside a test-repair PR.
## Two "flaky" files that are NOT flaky — and I nearly mislabelled them
`pg-test-harness-template-concurrency.pg.test.ts` and
`moves-intake-only-hard-cancel.pg.test.ts` each failed in one full-suite
run and not another, which reads as flake and would have earned a
quarantine entry plus a 14-day deletion clock under the standing rule.
Measured in isolation instead:
| File | alongside other PG suites | alone |
|---|---|---|
| `pg-test-harness-template-concurrency` | fails intermittently | **4
passed, 3/3 runs** |
| `moves-intake-only-hard-cancel` | failed once | **2 passed** |
So this is **shared-PG-template contention between concurrently running
suites**, not an inherent flake in either test. Quarantining them would
have started a deletion clock on healthy coverage and hidden a real
harness-parallelism interaction. Flagged for whoever owns the PG
harness; no quarantine entry added.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved duplicate-detection window handling with consistent defaults,
limits, and minimum values.
* Invalid tool output limits now produce standardized warning messages
while preserving fallback behavior.
* Updated settings and workflow validation to accurately reflect
supported configuration defaults and scopes.
* **Tests**
* Strengthened coverage for duplicate-detection windows and
configuration parity.
* Removed an outdated logging severity expectation tied to a
no-longer-applicable message.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
||
|
|
8f2cddc5fd |
fleet: update-task-deps.ts 7 → 0 — settles "dependency satisfied", and the store was writing a column U11 deleted (#2720)
**Claim announced on #2714 before starting.** `packages/core/src/task-store/update-task-deps.ts` — **7 → 0**. This one settles an open question and turned up a **live bug on the shipped default board**, not just on renamed ones. ## 1. What "dependency satisfied" means — settled, not guessed I flagged this in three files rather than swapping it three times independently (`executor.ts:12325`, `register-task-workflow-routes.ts:3995`, here). The answer has to be the same everywhere or the scheduler and the store disagree about which cards are blocked. Settled in the store, where `blockedBy` is actually written: - **SATISFIED** = the dependency's own board's **complete** or **archived** column. Archived counts: it is finished work the operator filed away, and reading it as unsatisfied blocks every dependent forever with no recourse short of editing the graph. - **NOT review** — a card in review is not done; its branch has not landed. - **Unioned with the legacy ids**, because a row can outlive the column it is stored in. On a renamed board the old literals matched nothing, so **every** dependency read as unresolved and `blockedBy` was pinned to the first one permanently — dependents never unblocked after the work landed. ## 2. The re-specification move was writing a DELETED column — on the default board `hasNewDependencies && column === "todo"` set `column = "triage"`. **U11 (#2515) deleted `triage`**, keeping `todo` as the merged Planning column. Measured, not assumed: ``` resolveDefaultWorkflowIr() columns: todo[intake,hold,reset-on-entry] in-progress[wip,…] in-review[merge,…] done[complete] archived[archived] ``` So the store has been writing a column the shipped board does not declare. And the emitted event hardcoded `from: "todo", to: "triage"` — **`task:moved` is what the GitHub tracking poster, the auto-merge handoff and the executor's listeners react to**, so every subscriber was being told about a column that does not exist. Now the guard reads the hold lane, the target is the intake lane, the log line names the real column, and when intake === hold (the default lineage post-U11) there is **no move and no event** — announcing a move into the column the card already occupies re-runs reset-on-entry effects in every listener. ## 3. Two existing suites taught me more than the conversion did **`refine-duplicate-task.pg.test.ts` proved the union is required, in one run.** My first version compared only the resolved lanes and refused a row sitting in `done` on a board declaring `published`: *"Cannot refine KB-001: task is in 'done', must be in 'published' or 'editorial-review'"*. That row is real, and refusing an operator action on it is worse than accepting one extra column name. Over-inclusion is the safe direction for "may I refine this?" — the same reasoning as the executor's `resolveTerminalColumnsFor`. **Two assertions expected `"triage"`.** They were not protecting behaviour; they were protecting a stale literal that outlived its column. Updated **with the measurement in the file**, because a silently-changed expectation is indistinguishable from a broken one. ## Revert proof 1 of 3 new PostgreSQL cases reddens when the union is removed. Driven through the real store on PostgreSQL because `blockedBy` is persisted and resolution reads the workflow selection from the database — a mocked store would prove neither. ## Verification `pnpm test:gate` **487 / 71** · 13/13 in the two pre-existing dependency/refine suites · 3/3 new · `tsc -p packages/core` clean · `pnpm lint` clean · census `--strict` exit 0 (**7 → 0** for this file). Changeset: none. `@fusion/core` is private, and while item 2 is an operator-visible fix on the default board, it lands as internal behaviour with no API change — say the word if you want one anyway for the release notes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8fb7c933d5 |
docs(core): pin the LifecycleColumns arity contract — two shipped bugs came from it (#2721)
Found by **auditing for the pattern** after hitting it twice, rather than waiting for a third instance. ## The contract, previously undocumented `LifecycleColumns` names **one** column per role even when a workflow declares several. Nothing validates that a trait appears at most once — `columnsWithFlag` returns an array and `resolveLifecycleColumns` takes its head. So a workflow may legitimately declare two intake lanes, or split `mergeBlocker` and `humanReview` across a merge lane and a separate sign-off lane, and the struct names only one of each. That makes the fields safe for one question and unsafe for another: | | question | correct? | |---|---|---| | **safe** | *"where should this card **go**?"* | a move target must be exactly one column | | **unsafe** | *"is this card **already** there?"* | membership — use `columnsWithFlag(...).includes(...)` | ## Two shipped bugs, both the unsafe use Both in #2713, both reading like ordinary conversions: - a task in a **second terminal column** was rejected with a 409 - a task in a **human-review lane split from the merge lane** was classified as outside review entirely, suppressing comment re-engagement I fixed the first, did not generalise, and hit the second one review round later. That is the actual failure mode this PR addresses — not the individual bugs, which are already fixed. ## Two call sites left for their owners Named in the doc comment rather than changed here, since they belong to other clusters. Both are correct **only while their workflow declares one column per trait**: - `packages/engine/src/self-healing.ts` — `columns.intake` / `columns.hold` - `packages/core/src/builtin-workflows.ts` — `lifecycle.intake` ## The test asserts the contrast, not just the arity A reader who sees only *"it returns one id"* learns the wrong lesson. The test builds a workflow with **two intake and two complete columns** and shows that exactly one qualifying column **fails an equality check** against the struct while the **membership form gets it right** — the shipped-bug shape, reproduced in miniature. ## Verification `workflow-lifecycle-traits.test.ts` **20 → 22**. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0. `tsc -p packages/core/tsconfig.json` clean. `pnpm lint` clean. Docs + test only — no behaviour change, no census movement, no changeset. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f6e460acdf |
fleet: moves.ts 15 → 2 (hot path; one hoisted resolution, net one FEWER than before) (#2705)
Claiming **`packages/core/src/task-store/moves.ts`** — 15 guards, largest unclaimed. This is the core move path, every task move in the system, so the conversion is built to add **no work** to it. ## Census before/after | | before | after | |---|---:|---:| | `moves.ts` column guards | **15** | **2** | | repo backlog (this branch vs `origin/main`) | 693 | **679** | Baseline re-recorded; `--strict` exits 0. *(Backlog figures don't compose across my open fleet PRs — each branch carries only its own reductions. 693 → 679 is this branch against main as measured, not a running total.)* ## Zero added cost, and actually one fewer resolution than before `moveTaskInternal` **already** resolves the workflow IR unconditionally at line 400 — the `useWorkflow` gate is gone — and already derived a lifecycle from it ~400 lines later for the trait hooks. So one hoisted `moveLifecycle` immediately after the IR resolution serves all 14 guards, and the later local now **aliases** it instead of resolving a second time. Net effect on the hot path: **one fewer `resolveLifecycleColumns` call than before this PR.** ## Converted: 14 6× `toColumn === "done"` → complete · 4× `fromColumn === "in-review"` → review · 1× `toColumn === "in-review"` → review · 2× `toColumn === "todo"` → hold · 1× `toColumn === "in-progress"` → wip Every site keeps its legacy id as the fallback. `undefined` here means no IR on this path or a v1 column-less IR, and a move must behave **exactly** as before when there is no basis to resolve from — this is the transaction that arbitrates capacity, so "unchanged when unresolvable" is the requirement, not a nicety. ## Flagged, not converted: 1 **Line 309** — `task.column === "archived"` in the handoff-invariant check. It sits in a different function that runs **before** any IR resolution, so converting it would mean *adding* a resolution to a path that currently has none. That is a cost on the handoff path rather than the free reuse everything else here gets, so it wants a deliberate decision rather than my inclusion. ## Verification — the paths that matter, not just typecheck Because this is the move transaction, `tsc` + lint is not sufficient evidence: - **`pnpm test:gate` GREEN** — 158 + 10 + 487 + 71 - `workflow-capacity-invariant` + `move-path-equivalence` **7/7** (the in-transaction capacity gate lives in this file) - `handoff-to-review-atomicity` **4/4** - `store-movement` + `move-task-preserve-status` + `task-move-hard-cancel-ordering` + `transition-pending-and-status-clear` **16/16** - `pnpm lint` clean · core `tsc` clean · `--strict` exits 0 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
af058b0276 |
test(core): two delete suites still modelled the deleted SQLite path (15 red → 0) + a possibly-lost dependents gate (#2697)
## What was red `task-delete-caller-attribution` (13 failed) and `task-delete-nonblocking-cleanup` (2 failed) on clean `origin/main` — **15 failed / 8 passed**. Every case threw the same thing: ``` TypeError: store.deleteTaskBackend is not a function ``` `deleteTaskImpl` / `deleteTaskIfImpl` are now **thin delegators** onto the PostgreSQL backend; the SQLite arms both fakes modelled were deleted (`FNXC:SqliteDualPathCleanup 2026-07-26`). One fake was matching raw SQL strings (`UPDATE tasks SET "column" = 'archived'`). Nothing reached the logic under test. Rewritten onto the PG path using the same three mocked persistence seams `task-delete-notice.test.ts` already uses — that file is green on main, so this is the established pattern here, not a new one. The **real** backend impls are wired in rather than stubbed, so the delegation is what carries the behaviour. ## Measured | Check | Before | After | |---|---|---| | the two files | 15 failed / 8 passed | **21 + 3 passed** | | all 6 core delete/archive files | — | **67 passed** | | `pnpm test:gate` | — | **726 passed** | | `pnpm lint`, core `tsc --noEmit` | — | clean | **Load-bearing, not decorative:** removing the lineage gate from `deleteTaskBackendImpl` makes the gate test fail. My first attempt at that proof was worthless and I caught it — the patch didn't apply because the block appears twice in the file, so the "3 passed" I got back proved nothing. Retargeted to the occurrence inside `deleteTaskBackendImpl` and it failed correctly. ## Deleted rather than repaired `task-delete-nonblocking-cleanup`'s two original cases asserted that delete **schedules branch cleanup**. That behaviour no longer exists: `_scheduleDeleteBranchCleanup` has exactly **one** reference in the tree — its own definition — and the only live `store.cleanupBranchForTask` call is the **archive** path (`archive-lifecycle-2.ts:306`). Reshaping the fake until those passed would have pinned behaviour the product does not have. The file now covers the gates that *did* survive: idempotent re-delete, the lineage-child gate, and exactly one `task:deleted` audit row. The dead `_scheduleDeleteBranchCleanup` is left in place — deleting source is a separate change from fixing tests, and it is already unreferenced so it is inert. Flagged for whoever wants the cleanup. ## FLAGGED, not guessed — a possibly-lost safety gate The removed version asserted `TaskHasDependentsError` when deleting a task that other live tasks depend on. **That error is neither imported nor thrown anywhere in `archive-lifecycle-2.ts`** — the PG backend raises only `TaskHasLineageChildrenError`, `TaskNotFoundError`, and `TaskSelfDeleteError`. Two readings, and it is a product question rather than a test fix: 1. the delete path now **rewrites** dependency references (there is a `removeDependencyReferences` option and a `rewriteDependentsForRemoval` impl) and blocking was dropped deliberately; or 2. the gate was **lost in the cutover**, and a task can now be soft-deleted out from under its dependents. Asserting either shape would encode a guess, so the question is recorded in-file next to the tests. If it is (2), that is a data-integrity regression worth its own fix — I did not want to bury it in a test-repair PR. ## One of my own assumptions, corrected in-file I asserted `deleted.deletedAt` was populated on the returned task and it failed. The PG backend returns the **pre-delete snapshot** — its own comment says so, because the lifecycle emit and the audit row both need the previous column. The comment now records that, so nobody "fixes" the impl to satisfy the wrong expectation. Census unchanged (722) — test files are not scanned. |
||
|
|
e9e63d8e0f |
consolidate/capacity: --strict was red on main (my #2621), 14 stale baselines, routines seeding a deleted column, worktrees-off audit (#2652)
Capacity unit consolidation. Three coherent themes, small commits inside. ## Census before/after (`node scripts/lifecycle-column-census.mjs`) | | before | after | |---|---:|---:| | triage column guards (the bar) | 10 | **10** | | `--strict` on main | ❌ **RED** | ✅ green | | baseline staleness | 14 files stale | **0** | This branch does **not** move the triage bar — its remaining 10 are moves.ts (dies with the flag), the dashboard cluster, and one deliberate site. It fixes the instrument that measures the bar, plus a live defect the comparison count cannot see. --- ## 1. `--strict` was RED on clean `origin/main`, and it was my fault ``` packages/dashboard/src/routes/register-task-workflow-routes.ts: 22 -> 23 ``` My merged #2621 added a v1-IR pre-WIP fallback answering a greptile P1 and shipped no marker or baseline update, so the program's measuring instrument has been failing on main since it landed. Fixed **at the site** with a `DELIBERATE-LITERAL` marker, not by bumping the baseline. That branch runs only when the IR declares no columns and no nodes, so there is no role to resolve — `resolveLifecycleColumns` returns nothing and the legacy pre-implementation ids are the only pre-WIP signal that exists there. It is *unconvertible*, not unfinished; the sibling `else` two lines down is the trait path for every IR that can answer. A rise that is genuinely correct belongs where a reader will see it. ## 2. The baseline was stale for 14 files — a hole, not cosmetics A stale allowance lets converted guards return while the check stays green. Measured gaps: ``` self-healing.ts allows 126, tree has 111 executor.ts allows 112, tree has 104 moves.ts allows 44, tree has 39 default-workflow-hooks allows 25, tree has 7 mission-feature-sync allows 5, tree has 0 MissionControlPanel allows 4, tree has 0 (+8 more) ``` **Only two of the fourteen are mine.** The other twelve are already-merged conversions by other workers where nobody re-recorded. Re-recorded all fourteen here rather than waiting for twelve PRs, because until it happens the ratchet is not holding the 779 it exists to hold. Flagging it plainly: those drops are other people's work being locked in, not mine being claimed. ## 3. Routines created tasks into the column U11 deleted The routine editor's "Target Column" defaulted to `triage`. That value is submitted as the create step's `taskColumn`, and an **explicit** column bypasses the workflow entry-column resolution added for column-less creates (#2589) — so every routine saved with the untouched default seeded its tasks into a column the board does not declare. Defaulting to `todo` would be the same mistake one column over: a custom workflow declaring no `todo` is seeded into an undeclared column just as surely, because an explicit column overrides entry resolution whatever its value. So the default sends **nothing** and each workflow's own intake resolution decides. The `triage` **option** is removed too, not merely un-defaulted — fixing the initializer alone left the operator able to pick the deleted column one click away, and it was the option labelled "Planning", the name the merged `todo` column now displays. Removing it retires that label inversion as well. Found by scanning **membership** forms rather than comparisons: the comparison census cannot see a `?? "triage"` default, so no count showed this and nobody was looking. Revert-proof — restoring the default fails with *"the default must not name a column at all"*. ## 4. "Worktrees off is INERT" had one unaudited reader The constraint was that `maxWorktrees` become genuinely inert, "not set very high and not skipped by convention". `resolveWorktreeCapacityLimit` returns `null` for that, and its unit tests can only prove the **resolver** is right — they cannot see a second reader, which is the only way the constraint breaks. Audited every `maxWorktrees` read that bounds anything. **Exactly two:** `scheduler.ts` (the admission gate, via the resolver, single call site, optional gate snapshot) and `self-healing.ts`'s `enforceWorktreeCap` — `(settings.maxWorktrees ?? 4) * 2`, a **raw** read. The second is **not a bug** and is left alone: it bounds worktree *directories on disk* and only removes *idle* ones. Worktrees still exist in OFF mode, so that bound must keep applying or idle directories accumulate unbounded. Recorded consequence: in OFF mode the number still governs disk retention while gating no admission — an edge you scoped out. The note says explicitly **not** to unify the two readers: routing hygiene through the resolver returns `null` in OFF mode and silently removes the disk bound, which is a leak dressed as a simplification. New ratchet requires every file bounding on `maxWorktrees` to be named with a reason, and rejects a **stale** allowlist entry. Proven by injecting `active >= (settings.maxWorktrees ?? 4)` into `hybrid-executor.ts`. --- ## Deliberately NOT included - **My own census script.** #2633 landed the canonical one, and it is better than mine — an AST classifier *plus* an independent text classifier with `--compare`, and a baseline that fails on unrecorded **drops** as well as rises. Mine only caught rises. I deleted mine rather than ship a second measuring instrument; three copies of "strip comments" is the drift shape this program keeps paying for, so the worktree ratchet now imports #2633's `stripComments`. - **My TaskContextMenu fix.** Superseded, and by a better answer: main's `isPureIntakeColumn` (intake *without* hold) keeps the merged Planning column shown and suppresses only a bare Ideas capture, which resolves the exact hold-lane objection coderabbit raised against my version. I briefly clobbered that merged work by checking my old file out wholesale, caught it in the diff, and reverted. ## Verification `pnpm lint` clean · core + dashboard `tsc` clean · census suite 23/23 · worktree ratchet 8/8 · RoutineEditor 49/49 · `routes-task-retry-planning-column` 16/16 · `lifecycle-column-census --strict` exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Added after review (all four greptile threads were real, and two of them mattered) **The routine fix was half a fix.** `routine-runner.ts:515` *and* `cron-runner.ts:982` both did `column: (step.taskColumn as Column) || "triage"` **after** the step is read, so every routine — including ones saved through the fixed editor — still created tasks into the deleted column. Both now omit it. **The advanced steps editor MANUFACTURED the defect.** `ScheduleStepsEditor.tsx` had three `triage` defaults: the new-step template (`:64`), the per-step initializer (`:95`), and the select still offering it (`:344`). So the path I had *not* fixed produced the bug by default, on fresh data. Template names no column; initializer coerces a persisted `triage`; `triage` removed from the options; empty submits `undefined`. **Four pre-existing tests pinned the defect** and are rewritten to the corrected invariant rather than appeased: | test | asserted | |---|---| | `cron-runner`: "defaults column to triage when taskColumn is not set" | `column: "triage"` | | `ScheduleStepsEditor`: "adds a create-task step..." | `taskColumn` toBe `"triage"` | | `ScheduleStepsEditor`: "allows saving create-task step..." | the legacy column is **resubmitted** | | plus the explicit-column case added beside each, so the fix cannot swallow a deliberate choice | **The allowlist hole was the worst finding.** `AUDITED_BOUNDS` was keyed by FILE, so every bounding expression in an allowlisted file was exempt — a second raw bound in `scheduler.ts` stayed green, the one case that ratchet exists for. Per-expression now, and making it so **immediately surfaced a real second bound the file-level version was hiding** (`maxWorktreesGate.used >= maxWorktreesGate.limit`, safe by construction since the snapshot is `undefined` in OFF mode). Proven by injection. ## Found while re-reading my own deletion, not reported A **rendered tooltip** still named a deleted cap. The "Queued to plan" badge read *"planning starts when a concurrency slot frees up (maxConcurrent / globalMaxConcurrent)"*. The cross-project cap is gone — capacity is two numbers per project — so it told operators their planning waited on a limiter they can no longer find a setting for. Names the surviving dimension only now. ## Coding (Ideas): enforcing #2651 rather than repeating it I took the unowned coding-ideas IR merge, concluded it must not be done, then found **#2651 had already implemented, reverted and documented exactly that** — with better grounding than my own argument. It added no test, so nothing stops the next person reaching the same dead end. So this ships their reasoning as a ratchet, not a second opinion: triage discovery keys on the column's `autoTriage`, so a merged column is either never scanned (cards sit on a bootstrap stub until the **capacity hold** releases them, sending **unplanned** work into in-progress — worse than stalling) or scanning wins and the manual gate is gone. Their scope caveat is kept: `autoTriage` is a general trait field, so only *this preset's* collapse is dead, not manual intake as a concept. The registry does not reject the merged shape, which is why prose was not enough. ## Verification (re-run) `pnpm lint` clean · core + engine + dashboard-app `tsc` clean · `lifecycle-column-census --strict` exits 0 ("every file matches its baseline exactly") · routine-runner 24/24 · cron-runner 156/156 · ScheduleStepsEditor 41/41 · RoutineEditor 49/49 · worktree + coding-ideas 12/12. TaskCard has 2 failures **pre-existing on main** — confirmed identical with my changes stashed. --- ## Bears directly on the closing bar: this PR already removes the 67-guard ratchet slack Measured on current `origin/main` with the census itself: ``` tree total: 787 baseline total: 854 SLACK: 67 FILES ABOVE BASELINE (1): +1 packages/dashboard/src/routes/register-task-workflow-routes.ts (22 -> 23) FILES BELOW BASELINE: 13, totalling 68 unrecorded conversions -18 core/default-workflow-hooks.ts (25->7) -15 engine/self-healing.ts (126->111) -8 engine/executor.ts (112->104) -5 core/task-store/moves.ts (44->39) -5 engine/mission-feature-sync.ts (5->0) -4 core/live-agent-count.ts (10->6) ``` **The slack is not regression — it is 13 files of merged conversions nobody re-recorded**, against exactly **one** rise. This PR re-records the baseline **854 → 782 across 140 files**, which closes it. **And the "+3 that slipped in" is +1, and it is mine.** `register-task-workflow-routes.ts 22 → 23` is the v1-IR pre-WIP fallback my #2621 added; it is justified (that branch runs only when the IR declares no columns or nodes, so there is no role to resolve) but it shipped with no marker and no baseline update — which is why `--strict` has been **red on main since it merged**. Fixed here at the site with a `DELIBERATE-LITERAL` marker rather than by bumping the baseline, because a rise that is genuinely correct belongs where a reader will see it. Sequencing note for the auto-lowering change: if this lands first, that work is purely the mechanism (auto-lower, or fail with tighten instructions) rather than a cleanup, and the two re-records will not collide in the same file. Also worth carrying into that mechanism, from building the same guard here: **`--update` must refuse to RAISE.** An earlier version of mine wrote current counts verbatim, so a developer who added a literal and ran the documented update command locked the regression in as the new ceiling — the mirror of the high-water problem. Lowering can be unattended; raising should be a hand edit with the reason recorded. ## Third piece of residue from my own deletion `updateGlobalConcurrency` in the dashboard API client PUT to `/api/global-concurrency`, a route removed when the machine-wide cap went. Zero callers; the only reference was the `legacy.ts` barrel re-export. Deleted both. `fetchGlobalConcurrency` **survives on purpose** — the GET route remains and serves live utilization telemetry to the footer and Command Center; nothing gates on it. That is the third: after the second raw `maxWorktrees` reader and the "Queued to plan" tooltip. A deletion is not finished when the enforcement goes — the client, the label and the tooltip outlive it. --- ## Re-greened the dashboard API tests: 117 failures on main, ONE root cause These would have polluted the closing verification pass, and nobody owned them. `api()` builds headers via `new Headers(...)` and returns `Object.fromEntries(headers.entries())` — and `Headers.entries()` **lowercases every key**, so the object reaching `fetch` is `content-type`, not `Content-Type`. `ab87d0d80` then added `x-fusion-client: dashboard-ui` for run-audit attribution. Both changes are correct; neither is visible at a call site, so **114 assertions across 7 files** kept asserting the old shape and went red together. Fixed by naming the shape **once** in `app/test/apiRequestHeaders.ts` rather than patching 114 literals — restating a shared fact 114 times is what made a two-line client change look like 117 failures. Deliberately not a loose `objectContaining`: these tests are the only thing pinning that the attribution header is sent *at all*. **117 → 4.** The remaining 4 are unrelated pre-existing CSS failures (`task-detail-modal-tablet-width` ×3, `space-token-defined` ×1) — confirmed identical on clean main with my changes stashed. ### A gap this surfaced, recorded not papered over Three routes failed in the *opposite* direction — they send the old shape because they call `fetch()` **directly**, bypassing `api()`, so they never get the attribution header. `client.ts` claims the opposite: > "Applied once here rather than per-call so no future mutation route has to remember it." That does not hold for a route that bypasses the helper it is applied in. **Measured in `app/api/`: 8 files make direct `fetch()` calls and 7 include mutations (POST/DELETE)** — among them `ai-sessions.ts`'s DELETE, which is the same class as the four-delete incident the header was added for. So the attribution fix has a hole in exactly its motivating case. Not fixed here: routing those onto `api()` is a behaviour change across the API layer and belongs to its owner, not to a test re-green. Those assertions use a separate `API_JSON_HEADERS_NO_ATTRIBUTION` constant so the gap stays **visible** — if a route is later moved onto `api()`, its test fails and points at the note explaining why. --- ## This branch takes the triage bar 10 → 5, and makes `--strict` green `node scripts/lifecycle-column-census.mjs` on this branch reports **triage 5**, against **10** on `origin/main`. The five removed are the ScheduleStepsEditor template/initializer/option and the RoutineEditor default/option — the automation paths that were creating tasks into the deleted column. **`--strict` was also RED on clean main, twice over, and both causes were the same mistake:** a thorough written rationale the tool cannot read, because the marker was not where the census looks. The census reads a comparison node's **leading comments**; a `DELIBERATE-LITERAL` in the JSDoc above the enclosing function or declaration does not reach the comparison inside it. | site | why it is legitimate | why the tool could not see it | |---|---|---| | `columnRoles.ts:80` `isHoldColumnRole` | degrades to `columnId === "todo"` only when a column has **no resolved traits** — identical in kind to `LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS` directly above, which escapes counting only because a Set is a membership form | rationale written, **no marker token** | | `MissionControlPanel.tsx` ×3 | the SDLC funnel **alias table** — maps `to-do`/`ready`/`review`/`shipped` onto one display stage with an explicit `other` bucket, and nothing branches on it | marker in the JSDoc; the comparisons are arrow bodies **inside the array literal**, which it does not reach | The second only surfaced because converting the `triage` stage to a Set removed its count and exposed the siblings — red gate, justification sitting three lines above, unreachable. Both are markers, no behaviour change. Neither is a conversion candidate: resolving the funnel table to traits would **drop the non-column aliases it exists to accept**. **For the auto-lowering work:** the marker-placement rule is now the recurring trap — three instances, three different authors, including me. A marker that does not register is indistinguishable from no marker, and the failure mode is a red gate with a written explanation nobody can act on. If the census accepted a marker anywhere in the enclosing declaration's comments, none of the three would have happened. Baseline re-recorded per the tool's own instruction ("Re-record the baseline in the SAME PR that lowered the count"). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Task “Actions” menus no longer appear on bare cards in the Planning column. - Routines, scheduled tasks, and create-task steps now respect each board’s configured workflow intake column instead of using a retired default. - Legacy tasks saved with the retired intake column are migrated to automatic workflow resolution. - Target-column selection now offers only “Automatic (workflow intake)” and “Planning,” removing the obsolete option. - Capacity/planning messaging and related UI tooltip text were clarified; concurrency cap updates are managed per project. - **Tests** - Added/updated coverage for workflow intake resolution, create-task target column behavior (including legacy coercion), capacity safeguards, and API request consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aa02db5782 |
fleet: scheduler.ts 28 → 27 + make the column-role predicates reachable from the other 80% of the backlog (#2690)
## Census **722 → 721**; `packages/engine/src/scheduler.ts` **28 → 27**. Exactly the one site converted. Baseline re-recorded in the same commit — `--strict` flagged the stale allowance itself, and `in-progress` went 138 → 137. ## The unblocker (commit 1) The role helpers live in `packages/dashboard/app/utils/columnRoles.ts`, a dashboard-**app** module. Measured against the census: | Location | Guards | Share | Helpers importable? | |---|---:|---:|---| | `packages/engine/**` | 316 | 43% | no | | `packages/dashboard/app/**` | 150 | 20% | **yes** | | `packages/core/**` | 148 | 20% | no | | `packages/dashboard/src/**` | 78 | 10% | no | | `packages/cli/**` | 24 | 3% | no | **Only 20% of the backlog can call them at all.** #2685 widens the helper *set* correctly; that is coverage, not location. `packages/core/src/column-roles.ts` is the same flags-first / legacy-id-fallback predicate placed where the other 80% can reach it — core already exports `resolveColumnFlags`, so no new resolution machinery comes with it. **Semantics are mirrored from #2685, not invented**, so the two sets cannot answer the same question differently: `complete` EXCLUDES `archived`; `wip` keys on `countsTowardWip` (the same flag capacity arithmetic uses); `review` accepts `mergeBlocker` OR `humanReview`. One addition — `isTerminalColumnRole` for the `!== "done" && !== "archived"` union, the most repeated shape in the backlog. 10 tests cover both modes of all 8 predicates, including the **degraded no-flags fallback** — the half with no coverage when these lived only in the dashboard app — plus the two cases that prove the predicate does something rather than nothing: a renamed column carrying the right trait answers yes, and a legacy id carrying the WRONG trait answers no. ## A trap every fleet worker converting engine code will hit A new core export must be added to **both** `index.ts` and `index.gate.ts`. The `engine-core` gate project resolves `@fusion/core` to a bundle built from `index.gate.ts` (`scripts/build-engine-core-gate-bundle.mjs`). An export present only in `index.ts` is `undefined` at runtime under the gate: 13 `scheduler-workflow-cutover` tests failed with `isWipColumnRole is not a function`, in a file that does not mock `@fusion/core` at all. The symptom points at the consumer, the cause is the barrel. I nearly mis-attributed this. Baseline first: `scheduler-workflow-cutover` is **42 passed on clean main**, so the 13 were mine — not pre-existing. That measurement is the only reason I looked at the barrel instead of "fixing" the tests. ## The conversion (commit 2) `scheduler.ts:1690`'s `isWipColumnTask` was a hand-rolled copy of `isWipColumnRole` — it stored only `countsTowardWip` as a boolean and re-implemented flags-first-then-legacy-id inline. It now stores the resolved flags object and lets the shared predicate decide. Behaviour is identical in all four states: column present with the flag true or false (flags win), column absent from a resolved IR, and IR resolution failed (both defer to the legacy id). | Check | Result | |---|---| | `scheduler-workflow-cutover` | **42 passed** before and after | | 21 scheduler/capacity/hold-release files | **372 passed** | | `pnpm test:gate` | **726 passed** | | `pnpm lint` | clean | ## Flagged and skipped, not guessed **`scheduler.ts:1736` — a latent legacy-vocabulary defect, not a conversion.** `if (task.column !== "in-progress") continue;` gates the file-scope-lease loop on the literal, ~40 lines below capacity arithmetic that is trait-aware. On a renamed WIP column the loop silently does nothing while capacity counts the same cards correctly. Converting it *changes behaviour* on renamed boards (from wrong to right), which the fleet rules put out of scope — so it is flagged here for whoever owns that fix. It is the same class as U10's six legacy-vocabulary defects. **`hold-release.ts:343`** — already marked `DELIBERATE-LITERAL`. It is the legacy half of FN-5719's dual-accept pair; converting it would make both halves compute the same answer, deleting the compatibility signal *and* its divergence detector while looking like a cleanup. Untouched. **`task-merge.ts:254`** — the documented fallback for callers that have not proven lane identity; trait-aware callers pass `skipColumnIdentityCheck`. Untouched. **The other 14 `scheduler.ts` sites** have no flags in scope (e.g. `isLegacyDependencySatisfied(dep: Task | undefined)`, `shouldHoldActiveFileScopeLease(...)` — task-only pure functions). Threading an IR in changes signatures and call graphs: behaviour change, out of scope. This is why the cluster is 28 → 27 and not 28 → 0, and the reachability measurement behind it is #2687. No changeset: `@fusion/*` are private and no `@runfusion/fusion` behaviour changes. |
||
|
|
dc50425e98 |
docs: correct 104 future-dated FNXC timestamps across 61 files (#2680)
## What The FNXC convention exists so a reader can place a note against the change that motivated it. A stamp dated *after* the edit landed defeats exactly that. This is program-wide drift, not one author's slip — I contributed to it in my own commits this week, which is how I noticed it. ## Measured, on this tree **104 stamps across 61 files** dated later than the day they were written, from one day ahead to **2026-10-19 (81 days)**: | count | date | count | date | count | date | |---|---|---|---|---|---| | 50 | 2026-07-31 | 6 | 2026-08-05 | 3 | 2026-08-13 | | 17 | 2026-08-01 | 1 | 2026-08-07 | 1 | 2026-08-19 | | 7 | 2026-08-02 | 1 | 2026-08-12 | 2 | 2026-08-26 | | 11 | 2026-08-03 | | | 3 | 2026-10-19 | An earlier number I circulated was ~70. That came from a narrower pathspec and was wrong; **104** is the measurement. ## How Each stamp is rewritten to the date of the commit that introduced **that line**, via per-line `git blame` — deliberately *not* stamped uniformly with today's date. A uniform stamp swaps a wrong date for a different wrong date and flattens the ordering that makes these comments navigable; blame preserves it. Times of day are untouched, and a blame date in the future is clamped rather than trusted. ## Why the verification is listed A docs sweep across 61 files is precisely where a stray edit hides, so the safety claims are mechanical rather than asserted: - every changed line begins with a comment marker — **no code touched**; - **no test asserts an FNXC date later than today**, so no `toContain` assertion on embedded source text can be silently invalidated (several such assertions do exist); - CSS files, which carry several of those assertions, are outside the pathspec. ## Verified lint clean · merge gate green (487 + 158 + 10 + 71) · `census --strict` exit 0 · tsc clean for core, engine, and dashboard (`tsconfig.app.json`). **No behavior change.** Comment text only. ## Not done here A guard preventing recurrence. A check that rejects an FNXC stamp dated after the commit would stop this returning, but it needs a decision about where it runs (lint rule vs. gate) and it is a behavior change to CI — it does not belong riding inside the sweep it would police. |
||
|
|
30e287a29e |
fix(test): decouple a second logger assertion from log formatting (#2681)
Second instance of the defect fixed in #2675 — found by applying the same attribution pass to the **core** suite's long-red files rather than counting them. ## The defect `withSeverityMarker` (`logger.ts:31`) deliberately wraps every message in a machine-readable severity marker so the TUI log pane can colour by level: an `fnlvl=<level>` marker plus a `[core-merge-policy]` subsystem tag ahead of the real text. This case pinned the raw string with `toHaveBeenCalledWith`, so it broke when that convention landed. It was coupled to log **formatting**, not to the behaviour it exists to check. ## Two instances is a pattern `toHaveBeenCalledWith` on a logger is brittle **by construction** in this codebase, because decorating the message is the logger's entire job. Any assertion pinning an exact logged string will break the next time the format changes — and both instances found so far were long-red, i.e. nobody noticed they had stopped testing anything. Worth a lint rule or a shared helper if a third appears. I have not added one for two instances. ## What is preserved Warn-once semantics and the requirement that the warning names both the legacy value and its replacement are unchanged and still fully asserted. Only the exact-prefix coupling is removed. **Mutation-verified:** deleting the `severityAuditLog.warn` call in `merge-policy.ts` fails with `expected "warn" to be called 1 times, but got 0 times`. A contains-check that passed because it matched nothing would be worse than the brittle assertion it replaces. ## Still red in the core suite, not addressed here Two neighbours in the same cluster, both needing an owner's context rather than a guess: - `settings-parity.test.ts` — `expected [ 'testMode', 'voiceInput', …(15) ] to deeply equal [ …(14) ]`. A settings key now appears in **both** global and project scope without being listed as intentional. That is either a real scoping mistake or a stale allowlist, and the difference matters. - `workflow-ir-settings.test.ts` — `expected 10 to strictly equal 3` on the moved-key catalog. Both are genuine signals, not noise. Flagging rather than guessing, same as the funnel decision on #2674. ## Verification `settings-defaults.test.ts` **39/40 → 40/40**. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0. `pnpm lint` clean. Test-only; no changeset. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bc782d8d92 |
U12: resolve the move-path compatibility flag — trait hooks unconditional, legacy branch deleted (#2655)
U12's headline goal. The raw `experimentalFeatures.workflowColumns` flag gated **every task move**; its six seams are now unconditional and the flag, its last two readers, and the 124-line inline legacy branch are deleted. ## Deleted, not converted The flag-OFF branch goes with the gate. Converting a branch we intended to delete would have left a second definition of every column side effect alive to drift — the defect this program has spent its length removing. Both readers flip in **one commit** because they are not separable: the preflight in `workflow-task-create-ops.ts` computes the `movePolicyPreflight` that `moves.ts` consumes and validates. Un-gating either alone either evaluates workflow move policies — with their plugin-gate side effects — whose result is ignored, or validates against a preflight that was never computed. ## Evidence, not assertion **Equivalence (precondition 1).** `moves-flag-equivalence.test.ts` (commit 1) ran the same journey under both flag states against live PostgreSQL and diffed the persisted row: **identical across 128 fields** plus an equal timing shape, over `todo → in-progress → in-review → todo → in-progress`. Mutation-verified both ways — stamping the flag-ON branch, and diverging the reopen hook, each fail it. **And there was stronger evidence already on main that isn't mine.** U2b's `move-path-equivalence.pg.test.ts` ran *every* scenario once per path and has been green across ~10 of them: `preserveStatus`, `preservePause`, timing accounting, `preserveProgress`, `preserveWorktree`, engine-source rehome, `in-progress → todo`. Two independently built harnesses agreeing is the best evidence this question has had. **The flag was read by nothing in production.** `experimentalFeatures` is global-only and no module writes it, so this path had never run for any project without a stale persisted value. That is also why a green suite was never evidence on its own — both paths were individually valid and only one was live. ## Two claims of mine this PR corrects **1. Seam 2 does not introduce new rejections.** I said in #2639 and in the census that with the flag off there is *no* target validation, so flipping would add refusals. Reproduced the opposite: a move to an undeclared column already rejects on the legacy path with `Invalid transition: … Valid targets: …`. I found it because the discriminator I wrote to prove "the flag is the cause" failed. **2. My first equivalence test proved nothing.** It used `updateSettings`; `experimentalFeatures` is **global-only**, so `getSettingsFast()` filtered the write out and `useWorkflow` was false in *both* runs. Caught by stamping the flag-ON branch and watching the test stay green. It now writes via `updateGlobalSettings` and **asserts the flag took effect** before the journey. U2b's harness carries the same warning independently — `MUST be updateGlobalSettings, NOT updateSettings`. ## The user-visible change Move rejections now report **workflow-resolved** targets instead of the hardcoded legacy adjacency table. Concretely: `Valid targets: in-progress, triage, archived` becomes `Valid targets: archived, in-progress`. That is the fix, not a regression — the legacy table still advertised `triage`, a column the default lineage stopped declaring at #2515, so an operator following the old message was told to move somewhere impossible. Likewise a move *into* `triage` is now refused rather than stranding the card in a column with no trait flags, invisible to every trait-driven sweep until reconciliation re-homes it. `live-move-path-undeclared-target.test.ts` characterised exactly that defect and carried `it.todo("should REFUSE a move into a column the task's workflow does not declare (U2b)")` — **this fulfils it.** ## Test migration | file | change | |---|---| | `move-path-equivalence.pg.test.ts` | deleted — every scenario ran once per path; purpose fully discharged | | `workflow-capacity-invariant.pg.test.ts` | `setPath("inline"\|"hooks")` → `assertMovePathLive()`; the probe is **kept** so capacity cannot pass because moves were broken for an unrelated reason | | `store-movement.pg.test.ts` | asserts the refusal **and** that the legitimate backward move still works, so it reads as a narrowing | | `raw-workflow-columns-flag-census.test.ts` | deleted per its own instructions — it was built to fail in both directions and fired exactly as designed: `expected [] to deeply equal [3 readers]` | | `moves-workflow-flag-seams.test.ts` | deleted — it pinned the six seams this removes | ## Verification Full core suite: **33 failed / 10 files — byte-identical to main's baseline**, with **zero** files failing exclusively on this branch. I measured the baseline by checking out `origin/main` and running the same command, because the first comparison I made was by count alone and would have blamed the flip for 8 files that were already red. `pnpm lint` clean. `pnpm test:gate` green (10 / 132 / 482 / 71). `tsc -p packages/core/tsconfig.json` clean. Core builds. ## Left in place deliberately The `workflowColumns` settings key stays schema-tolerated and is already in `HIDDEN_EXPERIMENTAL_FEATURE_KEYS`, so an upgraded project carrying a stale value renders nothing and loads cleanly. Removing it from the schema would risk rejecting those projects for no benefit now that nothing reads it. --- ## Rebased onto current main — and `triage` reaches ZERO | metric | before | after | |---|---:|---:| | `moves.ts` column guards | 39 | **15** | | repo column total | 745 | **741** | | **`triage` column guards** | 1 | **ABSENT (0)** | `triage` is now absent from `byColumnId` entirely: no unconverted `triage` guard remains anywhere in production source. Combined with #2664 (the last one, in `TaskContextMenu`) this closes bar item 1. The census behaved exactly as designed on the rebase: the flip *deletes* guards, so `--strict` reported `moves.ts: allows 39, tree has 15` rather than leaving a stale allowance, and the re-record lands in this PR's diff. ## Verification on the rebased tree - Full core suite: **33 failed / 10 files — identical to main's baseline**, zero files failing exclusively on this branch (measured by checking out `origin/main` and diffing the failing-file sets, not by comparing counts). - `pnpm test:gate` green (10 / 158 / 487 / 71). - `pnpm check:lifecycle-columns` exits 0. - `pnpm lint` clean, `@fusion/core` builds. ## Two review fixes carried in this PR **P1 — optionless engine moves lost their bypass.** `resolveWorkflowBypassGuardsImpl` did `void moveSource;` — it discarded the resolved parameter and re-read `options?.moveSource`, so `moveTask(id, target)` resolved to `"engine"` at the call site and computed `bypassGuards === false`. Latent while the flag gated validation; with the gate gone, an internal executor/merger/recovery move made without an options object would be judged as a user move. **P2 — the absence signal.** Emitting `workflowId` unconditionally would have stamped `builtin:coding` onto every task with no explicit selection, reporting a fallback as authoritative. Now emits the selection directly, so absent still means "not resolved here". <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Task moves are now validated against the task’s declared workflow. - Invalid destinations are rejected with a clear error, and tasks remain in their original column. - Valid backward moves continue to work as expected. - Move behavior and lifecycle updates are now handled consistently across workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b752f9014d |
fix(core): a hold-only column vanished from the SDLC funnel (+ un-red the census on main) (#2674)
Two things, both small, one urgent. ## 1. Hold-only columns disappeared from the funnel `hold` was absent from `TRAIT_TO_STAGE`, so a column whose only pre-implementation trait is `hold` — a renamed board's wait-for-capacity lane — resolved to `OTHER` and vanished from the SDLC funnel. Measured before the fix: ``` stageForTraits(["hold"]) === "other" ``` The default lineage hid it: its Planning column also carries `intake` and `reset-on-entry`, so it always matched something. Only a board that names its wait lane separately was affected — **exactly the custom shape this trait mapping exists to support**. Revert check: removing the entry gives `expected 'other' to be 'todo'`. ## What I deliberately did NOT fix, and why The merged default Planning column carries `["intake","hold","reset-on-entry"]`, and `stageForTraits` prefers the earliest stage in flow order — so `intake` wins and it still resolves to `triage`. The `todo` stage therefore stays empty on every default board since U11, and the funnel shows a **phantom 100% drop between Triage and Todo**. That is a real defect. It is also not a reversible call: changing which stage Planning reports would retroactively alter how historical analytics read. Flagged on #2669 for a product decision. Adding `hold` does not touch it — `intake` still outranks — and a second test **pins the current behaviour** so the larger question gets answered deliberately rather than drifted into by a future edit to this map. ## 2. `check:lifecycle-columns` is RED on pristine `origin/main` — again ``` census exit on pristine main = 1 packages/engine/src/executor.ts: allows 87, tree has 85 ``` `executor.ts` is a file this PR does not touch, so a merge lowered the count without re-recording and the blocking PR check is failing for **every open PR**. The re-record is mechanical and is included here to unblock it — called out explicitly because it is unrelated to the funnel fix and should not ride along unexplained. This is the second time the baseline has gone stale on main this way. The rule works (`--strict` caught it immediately); what is missing is that it caught it *after* the merge. Worth considering whether the census should run on the merge queue rather than only on PR head — otherwise a PR that is green when opened can still land a stale baseline. ## Verification `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0 after the re-record. `tsc -p packages/core/tsconfig.json` clean. `pnpm lint` clean. `sdlc-funnel-default-columns.test.ts` 8/8. No changeset: the funnel entry is a correctness fix with no user-facing API change, and the baseline re-record is internal. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved SDLC funnel classification for hold-only columns, placing them in the Todo stage instead of Other. * Preserved correct Planning column behavior when hold-related traits are combined. * **Tests** * Added coverage for hold-related funnel stage mapping and trait ordering. * Updated lifecycle column census baselines to reflect current results. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe7e68bc13 |
fix(core): wedge notifications could never be resolved on PostgreSQL (42P18) (#2669)
Not a U12 change — found while **attributing** the pre-existing live-PG
failures during U12's closing verification, and it turned out to be a
product bug rather than a stale test.
## The defect
`resolveWedgeNotification` builds its UPDATE with:
```ts
jsonb_build_object('status', 'resolved', 'transitionedAt', ${transitionedAt})
```
`jsonb_build_object` is variadic `"any"`, so there is no signature for
PostgreSQL to resolve the bind parameter against. It rejects the
statement at **parse time**:
```
42P18: could not determine data type of parameter $1
```
Parse-time is the important part: this failed on **every call**, not on
unusual data. Wedge notifications could not be resolved at all in
PostgreSQL mode.
Casting the parameter to `::text` fixes it.
## Evidence
- `store-wedge-resolution.pg.test.ts` goes **0/7 → 7/7**. That suite has
been red on `main`.
- **Causally verified, not assumed:** removing the cast reproduces
`42P18` exactly. The fix is the cast, not something incidental to the
edit.
- Checked the rest of `packages/core` for the same shape — this is the
only `jsonb_build_object` call site, so there is no second instance
hiding.
## Why it survived
The failure is in a live-PG suite that was already red, so it read as
part of the ambient noise. I only found it because the closing
verification required me to attribute each failing suite to a cause
rather than count them — and "these 4 fail on main too" is an
attribution of *whose*, not of *what*.
Worth flagging for whoever owns the remaining three
(`agent-logs-and-monitor`, `central-archive-secrets`,
`workflow-settings-project-identity`): the same reasoning applies. A
suite failing on main is not evidence that the code is fine.
## Verification
`pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm
check:lifecycle-columns` exits 0. `tsc -p packages/core/tsconfig.json`
clean. `pnpm lint` clean.
Independent of #2655; either order merges.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a099813e94 |
fix(test): decouple the audit-emitter assertion from log formatting (last of the 4 red PG suites) (#2675)
Last of the four long-red live-PG suites. **This one is a stale test — the only one of the four that is.** ## The cause `withSeverityMarker` (`logger.ts:31`) deliberately wraps every message in a machine-readable severity marker so the TUI log pane can colour by level. The emitted string carries a `fnlvl=warn` marker and a `[core-async-secrets-store]` subsystem tag ahead of the real text. The assertion pinned the raw message with `toHaveBeenCalledWith`, so it broke when that convention landed. It was coupled to log **formatting**, not to the behaviour it exists to check. ## The fix Rewritten to assert what it actually cares about: exactly one warning, whose message **contains** the subsystem-tagged text, carrying the underlying cause. Both halves of the behaviour stay pinned — the `resolves.toMatchObject` above proves the secret is still created when the audit emitter fails, and this proves the failure is surfaced rather than swallowed. **Mutation-verified rather than assumed green:** deleting the `severityAuditLog.warn` call in `async-secrets-store.ts` fails with `expected "warn" to be called 1 times, but got 0 times`. A `stringContaining` assertion that passes because it matches nothing would be worse than the brittle one it replaces. ## The four, complete | suite | verdict | |---|---| | `store-wedge-resolution` | **product bug** — `42P18`, total runtime failure of wedge resolution in PG (#2669) | | `workflow-settings-project-identity` | **stale docs** — resolver contradicted its own documented order (#2671) | | `agent-logs-and-monitor` | **real defect** — funnel mis-bucketing from the U11 merge; half fixed in #2674, half needs a product call | | `central-archive-secrets` | **stale test** — this PR | **Three of four were real problems**, sitting behind "pre-existing, fails on main too". That phrase answers *whose* problem it is, not *what* is wrong. ## Census, again `check:lifecycle-columns` is **still** exiting 1 on `origin/main` — `executor.ts: allows 87, tree has 85` — the same staleness flagged on #2674. Re-recorded here too, because the blocking check stays red for every open PR until some PR carries it, and I do not know which of #2674 / this one lands first. ## Verification `pnpm test:gate` green (10 / 158 / 487 / 71). Suite **14/15 → 15/15**. `pnpm check:lifecycle-columns` exits 0 after the re-record. `pnpm lint` clean. No changeset: test-only plus an internal baseline. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
632d10a9b4 |
fix(engine): a completed-blocked guard was inert on renamed boards — plus one owner for the terminal pair (#2568)
Two commits: a behaviour-preserving extraction, then the behaviour change. ## ⚠️ Stack note worth acting on **#2550 and #2554 both report MERGED, but their content is not on `main`.** They merged into their *base branches*, and the bottom of that stack (**#2544**) is still open. Nothing in this chain has reached `main` yet. Nothing is lost — everything is in `origin/feature/workflow-e2e-merge-rebound`, which is why this PR targets it. But "merged" reads as "landed" and here it doesn't. **Merging #2544 flows the whole chain down.** ## The bug `parkCompletedBlockedTask` opens with *"is this card already finished?"* and answered it with: ```ts if (task.column === "done" || task.column === "archived") return false; ``` On a renamed board neither matches, so **the guard was inert** — and the very next branch (`if (task.column !== "todo")`) would then have **moved a completed card back out of its own terminal column**. A guard that never fires does not fail a test. This one was found by tracing the last ledger site, not by anything going red. ## Why a shared owner, not a local fix `merger-ai`'s `isAlreadyFinalizedColumn` held the **only** copy of the per-role terminal-pair rule — a P1 learned the hard way (PR #2471 review): a per-**set** fallback collapses to one element for a workflow declaring `complete` but no `archived`, silently dropping the archived half of every already-finished check. Executor's guard was the raw literal pair, so **whoever converted it next would have re-made exactly that mistake** — the lesson lived in a comment in another file. Hence `resolveTerminalColumns(ir)` in core: one owner, one place for the rule. ## Evidence, and its limits **Commit 1 (extraction) is proven behaviour-preserving**: `workflow-already-finalized-live-e2e` is unchanged and green through the delegation, and the per-set mutation **still fails** through the shared helper. **Commit 2 (the fix) is unproven at the call site, and I'm labelling it rather than implying otherwise.** `parkCompletedBlockedTask` is private and reached only from inside executor dispatch — I could not drive it end to end. So the shared helper gets its **own** tests, in both partial-role directions, precisely because its other consumer can't vouch for it. The call site is a one-line delegation to a tested function. Weaker evidence than the rest of this unit's work. Saying so, because quietly counting it as proven is the exact failure this unit exists to catch. ## Census 417 → 416. That ratchet (#2557) is a **ceiling**, so it stays green without coordination; lower the pin when convenient. ## Verification - E2E suites 10/10; helper unit tests 5/5 - core + engine `tsc --noEmit` clean - `pnpm test:gate` green (414 + 10 + 71) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Note on the conflict status (2026-07-31) GitHub reports this PR `CONFLICTING / DIRTY`. **It is not.** Three independent checks: - `git rebase origin/main` on the pushed head reports *"up to date"* and leaves the SHA unchanged — the branch is already on top of main. - `git merge-tree` against the merge base produces **zero** conflict markers. - `origin/main` is unchanged at the commit this was rebased onto. The remote SHA matches the local head, so the push landed. The `mergeable` field is a **stale computation** — it goes stale after a force-push and doesn't always recompute. This branch has now been rebased and force-pushed four times against that cached value. Worth guarding at the source: the auto-retry treats `mergeable` as ground truth, so a stale value generates conflict notices indefinitely. Confirming with a trial rebase or `git merge-tree` before dispatching distinguishes "actually conflicting" from "GitHub hasn't recomputed" — one command, and it ends the loop. Verification on the current head: merge gate green (487 + 158 + 10), engine + core tsc clean, lint clean, 20 tests in the affected suite, zero unresolved threads. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8d84cee11e |
fix(core): the workflow-settings identity resolver contradicted its own docs (2 long-red tests) (#2671)
Second of the four long-red live-PG suites, after #2669. This one is **stale documentation making a stale test look like a code bug** — behaviour is unchanged. ## What was wrong `getWorkflowSettingsProjectIdImpl` documented a three-step resolution order: ``` (a) store.asyncLayer?.projectId — central-registry id (PG) (b) store.db.getProjectIdentity()?.id — legacy SQLite identity (c) store.rootDir — last-resort key ``` The code does (a), then returns `rootDir`. **Step (b) was removed** by `FNXC:SqliteDualPathCleanup 2026-07-26-14:15` — but the doc block kept describing it, and a comment three lines above the return still said *"Only the true legacy (non-backend) path consults the SQLite identity"*, which has been false for every caller since. ## Which side was wrong — settled by construction, not judgement In my triage on #2669 I said I would not guess between "the test is stale" and "the code lost a needed branch", because the two have opposite consequences and the stale comments made the intent unreadable from outside. That was the right call then; it is now answerable: `dbImpl` **throws unconditionally and ignores its store argument** (`task-id-integrity.ts:58`): ```ts export function dbImpl(_store: TaskStore): Database { throw new Error("TaskStore.db: SQLite Database is not available in backend mode …"); } ``` There is no mode in which `store.db` yields a usable SQLite handle. Step (b) is unreachable **by construction**, not merely unused — so the code is right and the documentation was wrong. ## Why the tests passed review originally They build a store double whose `getProjectIdentity()` **returns** a value: ```ts db: { getProjectIdentity() { return { id: "legacy_identity_id" }; } } ``` Production cannot produce that shape. The double made an unreachable branch look testable, which is how the assertion survived the cleanup that deleted the branch. Rewritten to the shipped contract. A neighbouring case that already asserted `rootDir` *when the stub throws* was passing all along — the two forms of the same store disagreed inside one file. ## Verification Suite **7/9 → 9/9**. `pnpm test:gate` green (10 / 158 / 487 / 71). `pnpm check:lifecycle-columns` exits 0. `tsc -p packages/core/tsconfig.json` clean. `pnpm lint` clean. No changeset: no behaviour change, and no user-visible effect. ## Remaining from the four - ✅ `store-wedge-resolution` — real product bug, fixed in #2669 - ✅ `workflow-settings-project-identity` — this PR - ⬜ `agent-logs-and-monitor` — `expected +0 to be 2` on an aggregation - ⬜ `central-archive-secrets` — an assertion on `warn` arguments Two of four were real problems hiding behind "pre-existing". The other two are still unruled-out. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dca20496f4 |
consolidate/u7: plugins to zero + 8 executor rebound guards + resume lanes (supersedes #2607, #2635, #2640) (#2644)
Consolidation branch for U7, per the new one-branch working mode. **Supersedes #2607, #2635, #2640** — the three of my PRs that were stuck on review threads. My other seven (#2602, #2605, #2606, #2611, #2621, #2628, #2633) are green with **zero unresolved threads** and are deliberately left alone for the merge sweep. ## What is in here, file by file | file | change | guards before → after | |---|---|---| | `plugins/…/glasses/src/agent-actions.ts` | gates, destinations and degraded-resolution refusal all resolve from the task's own workflow | 2 → 0 | | `plugins/…/glasses/src/quick-capture.ts` | accepted capture columns come from the board; default no longer names the deleted column | 1 → 0 | | `plugins/…/glasses/src/settings.ts` | quick-capture default was `triage`, the column #2515 removed | (assignment, uncounted) | | `plugins/…/dependency-graph/src/GraphTaskNode.tsx` | redundant column condition deleted | 1 → 0 | | `packages/engine/src/executor.ts` | 8 rebound guards compare the resolved column; 4 resume-eligibility literals share one resolver | 151 → 143 (+4 off-bar) | | `packages/engine/src/__tests__/` | 4 new suites, 26 cases | — | `plugins/` reaches **zero** column guards with this branch. ## The three threads it closes **#2607 — five findings, all mine, all the same rule.** I kept *qualifying* a legacy-id fallback instead of removing it: | attempt | rule | hole review found | |---|---|---| | 1 | fall back to `todo` when the role is missing | moved cards to phantom columns | | 2 | …only if the workflow **declares** `todo` | aliased **review** lane named `todo` | | 3 | …and only if no other role is assigned to it | **traitless** parking column named `todo` | The qualifications were the mistake. Once `resolveLanes` returns a lane set the workflow *has* a column vocabulary, so "no column carries the hold trait" is a complete answer — refuse. `destination()` is two lines now, with no aliasing surface left to qualify. Plus a sixth, which is a genuinely different state: **degraded resolution is indistinguishable from the default board.** `resolveWorkflowIrForTask` is total by design — a missing definition silently returns the *default* coding IR — so a card on a custom board whose definition could not be read resolved to `todo`/`in-progress`. `undefined` lanes cannot express that (it means "no workflow at all", where the legacy ids *are* the answer). The actions now refuse with 409. #2618 would replace this check with resolver provenance; it is not merged, so this does not depend on it. **#2635 — "seven rebound sites remain untested."** Fair; my "same shape" note was an assertion, not coverage. Seven of the eight need a live graph run to reach, so the *shape* is pinned instead: a static check that no guard in front of a rebound move compares against a column literal, with a vacuity case (the same detection run against the original shape) and a match-count floor (≥8), because a guard reporting success on zero matches is worse than no guard. **#2640 — duplicate workflow resolution.** Framed as I/O; it is also a correctness bug. Eligibility and re-entry are two halves of one decision and resolved the workflow separately, so a workflow edit landing between them has the halves reading *different boards*. Now one caller-owned memo per decision — caller-owned because a process-lifetime cache would have to guess when a mid-flight workflow edit invalidates it. ## Behavioural findings, not tidying - **The last-resort recovery for completed-but-stranded work did not exist off the default lineage.** `promotedFromPlannerColumn` was false on a renamed board, so finished work resting in planning was never promoted; the code fell through to a review handoff that role adjacency rejects, and the card stayed stuck with its work complete. - **Rebound guards could not see the column their own move targeted.** U5b converted the move target; the eight `column !== "todo"` checks in front of it were left literal, so on a renamed board the engine moved a card into the column it was already in — and `moveTaskInternal` runs reset-on-entry on every real move, so at the `preserveProgress: false` site it reset step progress a second time. - **The FN-1404 `task:move` audit row was lying**, recording `to: "todo"` while the move target was resolved. A run-audit trail that disagrees with the move it describes is worse than none. Not a comparison, so no census counts it. - **A task interrupted by an engine pause never resumed on a renamed board** (off-bar, `in-review`/`in-progress` literals): four comparisons decided one question and had to agree; two of them disagreed on a renamed board, so re-entry silently never fired. ## Revert proofs, isolated per site | reverted | result | |---|---| | `destination()` back to attempt 3 | 3 of 38 fail | | degraded-resolution refusals removed | 2 of 42 fail | | capture set back to the legacy five | 2 of 3 fail (renamed-board suite) | | forward exclusions → literals | 1 of 14 fails | | missing-wip refusal removed | 2 of 14 fail | | `promotedFromPlannerColumn` → literals | 3 of 7 fail | | promotion target → `"in-progress"` | 3 of 7 fail | | one rebound guard → `!== "todo"` | 1 of 3 fails (static shape) | | resume lanes → legacy trio | 1 of 5 fails | Every conversion is paired with a negative — a forward move, a not-a-planner-lane card, a default-lineage card, an unresolvable workflow — so neither "always fire" nor "never fire" can pass for "resolve the role". ## Commit discipline Twelve commits, each one thing: the code move (`resolvePlannerLanes` out of `triage.ts`) is separate from every behavior change, and each review fix is its own commit with its own revert proof. ## Verification - `pnpm test:gate` **71/71** - 162/162 across the glasses plugin's 19 files; 26/26 across the four new engine suites - engine + glasses typecheck clean; `pnpm lint` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Engine recovery and retries now work correctly with renamed or customized workflow columns. * Tasks in manual-intake columns are no longer automatically planned. * Agent actions and quick capture now respect each board’s declared columns and lifecycle stages. * Awaiting-approval tasks are recognized regardless of their current column. * Command Center SDLC funnel stages now accurately reflect customized workflows. * **Documentation** * Added guidance for safely changing workflow-column logic and interpreting lifecycle-column checks. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
76e92f33c4 |
fix(core): review badges were silent on renamed boards — the same P1 as #2470, one role over (#2586)
Independent of my other open PRs. ## The defect PR #2470's review caught `getStalePausedTodoSignal` gaining a `holdColumn` parameter in B1 while **both** hydration sites in `reads.ts` omitted it — a correct guard comparing against the literal, so the badge was silent for a paused card in a renamed hold column. **That P1 was fixed for `holdColumn` and not for its sibling.** `getStalePausedReviewSignal` and `getInReviewStalledSignal` both take `reviewColumn`, and **all six call sites in the same file** left it defaulted to `"in-review"`. So on a renamed board (`checking`) both review badges were silent — the identical defect, in the identical file, one role over, *after* the pattern had already been found, written down, and fixed next door. ## The transferable part: this class is invisible to the census My column-literal census (#2557) cannot see this. The literal lives in a **parameter default**, and the offending call site **contains no literal at all** — it's defined by what it *omits*. The audit that finds it is different in kind: *"for every role-parameterised signal, does each caller pass the role?"* — run across the **callers**, not the definitions. Result on `reads.ts`: ``` PASSES holdColumn x2 <- fixed by #2470 OMITS reviewColumn x6 <- never fixed ``` ## Why threading differs per path Not one helper call, because the three list paths differ: - `listTasksImpl` / `searchTasksImpl` map **asynchronously** → resolve inline through a per-pass IR cache - `listTasksModifiedSinceImpl` maps **synchronously** → pre-resolve into a Map beside the existing `holdColumnByTaskId`, which exists for exactly the same reason One IR per workflow per pass in all three. ## Evidence Proven against a real store through the **real hydration paths** (`listTasks` and `listTasksModifiedSince`), mirroring the sibling renamed-hold suite because the defect lives in hydration rather than in the pure signal. **Mutation-verified:** reverting the threading fails the two renamed cases and leaves the negative and the builtin regression floor green. The fixture asserts itself — an unpaused or unaged card produces no signal for reasons unrelated to the column, which would let the suite pass while testing nothing. ## Verification - new suite 4/4 - full core PG: **1048 passed / 3 failed** — the same three that reproduce with this change stashed - core `tsc --noEmit` clean; `pnpm test:gate` green (414 + 10 + 71) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
174eb22534 |
cleanup: delete the dead sync capacity-pool helper rather than document it (#2656)
Follow-up to the #2653 audit, and the one item there that is better deleted than described. ## Why delete rather than annotate `resolveEffectiveWorkflowIdSync` **has no callers.** Verified across every `.ts`/`.tsx` in `packages` (excluding `dist`): only its own impl, the `store.ts` import and public method, and one comment naming it. Not exported from the core index, not referenced by any test. It is also **wrong**. It reads `getTaskWorkflowSelection` — the sync selection reader that has returned `undefined` unconditionally since the PG cutover — so it always resolved `resolveCapacityPoolId(undefined)`: the default pool for every task, regardless of workflow. The binding capacity path reads the selection asynchronously inside its transaction and does not use this. That combination is the argument. A dead function is clutter; a dead function that returns a **plausible wrong answer** is a trap. The next person to need "which capacity pool is this task in?" would find a public method with exactly the right name, call it, and get default-pool behavior with no signal that anything degraded. #2653 documents it, but documentation loses to autocomplete. ## Provenance of the claim greptile's P2 on #2653 corrected my first draft, which called this a live capacity collapse — it isn't, precisely because nothing calls it. I verified the no-callers claim myself before accepting, and this PR is the logical end of that correction: if it is unreachable, it should not exist. ## Removed - the impl in `task-store-helpers.ts` - the `resolveEffectiveWorkflowIdSync` public method on `TaskStore` - the import specifier in `store.ts` - the now-unused `resolveCapacityPoolId` import (its only use was the deleted function) - updated the `workflow-definitions.ts` comment that named it ## Verification core / engine / dashboard typechecks clean · eslint clean on both touched files · `pnpm --filter @fusion/core build` exit 0 · **`pnpm test:gate` green (487 + 71)**. The engine and dashboard typechecks are the ones that matter here: removing a public method from `TaskStore` would surface immediately in any consumer that called it, and neither reports anything. ## Census Unchanged (776 / triage 5) — no guards added or converted. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f8c053c3fa |
fix(core): TAKING comments-ops.ts — re-triage on renamed planner lanes (3 triage guards → 0) (#2612)
**Claiming `packages/core/src/task-store/comments-ops.ts`** from the shared backlog so nobody collides. ## Guard count | scope | before | after | |---|---|---| | `comments-ops.ts` | **3** | **0** | | repo-wide `column === / !== "triage"` in `packages/*/src` (excl. tests) | **26** | **23** | ## Why this file, and why it matters more than its size `addComment`'s post-comment **re-triage** decides, from the card's column, whether a user comment should invalidate an approved spec or send already-planned work back for re-specification. It asked with three legacy literals: ```ts task.column === "todo" || task.column === "triage" task.column === "triage" && status === "awaiting-approval" hasRealPrompt && (todo || (triage && status !== "awaiting-approval")) ``` On a renamed board none match, so a user comment on planned work does **nothing**: no approval invalidation, no re-specification, no error. **The operator types a correction and the agent never sees it.** This is the surface a human actually touches, which makes it the worst place in the program for a silent guard. Now resolved per task via `resolveLifecycleColumns`, fail-soft to the legacy pair — this phase is documented best-effort (*"failures are logged but never fail the comment add"*), so an unresolvable workflow must behave exactly as before rather than skip re-triage. ## Red-green, not green-only The suite was written **first** and failed **3 of 6** against the literals — precisely the three renamed cases — while the two negatives and the default-vocabulary floor passed throughout. Both negatives earn their place: re-triaging a **WIP** card would discard an in-flight session, and the **author gate** (agent comments must not re-triage) has to survive the conversion. ## Fixture guards its own preconditions `PROMPT.md` is written where the guard reads it rather than relying on task creation's side effects. `hasRealPrompt` gates two of the three branches, so a bootstrap stub would make those cases pass for the wrong reason — the trap that has produced two vacuous tests in this program already. ## Verification - new suite 6/6; `store-comments` 14/14 - full core PG: **1050 passed / 3 failed** — the same three that reproduce with this change stashed (`central-archive-secrets`, `workflow-settings-project-identity`) - core `tsc --noEmit` clean; `pnpm test:gate` green (482 + 132 + 10) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved comment-driven re-triage for workflows with renamed planning columns. - Comments on planned or awaiting-approval tasks now correctly move eligible tasks to “Needs re-plan.” - Prevented re-triage for tasks actively in progress. - Preserved existing re-triage behavior for standard workflow columns. - Non-user comments no longer incorrectly trigger re-triage. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
642a4fa264 |
consolidate/u12 — U12 consolidation: 4 live defects, the AST ratchet fail-closed, and the moves.ts flag scoped (#2647)
One branch, one PR, per the consolidation directive. Contents file-by-file below. **Supersedes #2625** (its overlapping conversions landed via U11's #2624/#2626/#2636; only the parts nobody else did are folded here). **#2630 and #2639 stay open** — both green with zero threads, per rule 3. ## Four live defects, each measured **1. Every planning card renders an actions menu.** `TaskContextMenu.tsx` still had `shouldShowActionsMenu: task.column !== "triage"` on main *after* the rest of that file was converted. Since #2515 removed the id, the condition is TRUE for every card, so the suppression stopped applying anywhere — including on cards whose menu is empty, the orphaned click target the Surface Enumeration rule exists to catch. Found **twice independently**: by reading the guard, and again by the invariance test below, which failed on main with `shouldShowActionsMenu` true on one lineage and false on another. That is the argument for an invariance property over per-site conversion — the file had already been converted "2 → 1" and the survivor was the live one. **2. Worktree upcoming-work list empty on renamed boards.** `groupByWorktree` filtered `t.column === "todo"`. On the default board the id and the role coincide so every existing test passed; renamed, it matched nothing and a whole panel read as idle. **3. Hold-lane FIFO ordering lost on renamed boards.** `sortTasksForDisplayColumn` gated priority-then-FIFO on `column === "todo"`, degrading to the generic id-ordered sort elsewhere. Cards simply appear in the wrong order, silently. **4. The AST ratchet still failed open** — fourth time in that file, third found by review. `receiverName` understood only one-level property access and bare identifiers, so `task["column"]`, `metadataColumn(entry, "to")`, ternaries, `(task!.column)` and backtick literals were dropped. **Measured on main: `in-progress` 196 → 197, `in-review` 211 → 213** — three real guards nobody counted, including `metadataColumn(entry, "to") === "in-review"` in `reliability-metrics.ts`. Now walks wrappers, resolves calls to the callee name, and emits a `<SyntaxKind>` **sentinel** for anything unnameable: counted *and* trips the classification guard, so a human judges it instead of it vanishing. ## Per-file guard counts | file | before | after | |---|---:|---:| | `app/components/TaskContextMenu.tsx` | 1 | **0** | | `app/utils/worktreeGrouping.ts` | 1 | **0** | | `app/components/taskSorting.ts` | 1 | **0** | The other dashboard files I had converted reached 0 via U11's PRs; where our work overlapped I took theirs during the rebase, including two places where theirs was **stronger** than mine — they deleted Column's unreachable quick-create arm outright (with fixtures migrated) where I had converted it, and they verified the same `isPreExecutionHoldColumn` degraded-set asymmetry I did, independently. ## Flip precondition: the moves.ts flag is scoped, not flipped `move-target-declared-census.test.ts` answers precondition 2 with measurement. 41 engine `moveTask` calls have literal targets — `todo` 27, `in-progress` 7, `done` 6, `archived` 1 — and **all four are declared by the default lineage**, so the default board is not the exposure. `triage` appears only in a comment noting `replan-target.ts` used to hardcode it. My own grep had said `todo=29`; the AST says 27, because grep counts comments. The exposure is **custom** lineages: 20 of the 41 carry no `recoveryRehome` and would reject with unknown-column post-flip; 21 are exempt via the #1411 carve-out, which makes that carve-out load-bearing. I did not flip the flag. It is six seams, not the `789`/`837` pair every summary including mine described, and seam 2 turns on *new refusals* rather than swapping equivalent implementations — a green suite says nothing about that. #2639 pins the blast radius. ## Tests - `column-role-id-invariance.test.tsx` — hold traits fixed, vary only the column id across MERGED / LEGACY / RENAMED; every decision must agree. Drives the real consumers, so a component keeping an inline comparison fails it. Includes a unanimous-and-**false** case so it can't be satisfied by a predicate hardwired to true. **This is the test that caught defect 1 on main.** - `worktreeGrouping.test.ts` — includes two cards both in a column named `staging`, one hold and one not, asserting opposite answers. That assertion is impossible under a board-wide column-id set, which is why hold resolution is keyed per task via `getEffectiveTaskWorkflowId` (#2625 review). - `taskSorting.test.ts` — discriminates on the **tiebreak**, not priority: both branches sort by priority, so my first version passed for the wrong reason. Equal-priority cards whose `createdAt` order disagrees with their id order. - `no-hardcoded-lifecycle-columns.test.ts` — 16 detector cases: 11 shapes counted, 4 legitimate ignored, one asserting the sentinel path. Revert checks, all run: menu suppression → diff names the field; worktree → `expected [] to include 'FN-50'`; sort → `FN-2, FN-9` instead of `FN-9, FN-2`; ratchet → the 3 recovered guards disappear. ## One site that should never be converted `MissionControlPanel.tsx:46` — `{ id: "triage", match: (c) => c === "triage" || c === "signal" || c === "backlog" }` is a deliberate name-similarity heuristic for the SDLC funnel; it matches synonyms and folds unknown columns into an "other" bucket so custom columns still contribute. Converting it changes what the funnel displays. Like the `live-agent-count` fallbacks, it belongs in a documented floor — **the ratchet's target is that floor, not zero.** `DocumentsView.tsx:73` is convertible but the file has no column flags at all, so a real fix means plumbing board-workflow metadata into a view that doesn't fetch it — its own unit of work. ## Verification `pnpm lint` clean. `pnpm test:gate` green (10 / 482 / 71). `tsc -p packages/dashboard/tsconfig.app.json` and `packages/core/tsconfig.json` clean. Core ratchet + seam suites 24/24. Dashboard target suites 37/38 — the one failure is the pre-existing `"Back to In Progress"` label casing, confirmed identical on the base. --- ## Added after the initial push **5. `TaskCard` lost inline editing on renamed boards; `TaskDetailModal` kept it.** Still live on main: the modal resolved field editability from traits in U10/R8, the card used a hardcoded `{triage, todo}` set with **no trait path at all** — even though `taskColumnFlags` was already in scope. On a renamed board the title was editable in the modal and the pencil was missing from the card. Body moved unchanged into `isFieldEditableColumnRole` so the two surfaces cannot drift again. The veto traits are the substance: a column can legally carry `hold` **and** a WIP or review trait, and a plain `intake || hold` check would let an operator rewrite a description while a session executes against it. Coverage gap **measured, not assumed**: mutating `canEdit` back to the hardcoded set left `TaskCard*` at the same failure count as the unmutated run — nothing caught it. The four render cases assert the real `aria-label`; that mutation now fails with `Unable to find an accessible element ... name 'Edit task'`. **6. The ratchet's target is a documented FLOOR, not zero** — and this changes the completion bar. Zero is not reachable, and chasing it means breaking working code. Two categories are permanent, now protected as positive assertions so a future sweep cannot "finish the job" by deleting them: - `MissionControlPanel.tsx`'s `FUNNEL_STAGES` is a deliberate **name-similarity** heuristic — it matches `signal`, `backlog`, `to-do`, `ready`, `shipped` and folds unrecognised columns into an "other" bucket so a custom board still contributes counts. It is not asking whether a column has the intake trait; it buckets arbitrary column *names* for display. Asserted on the **synonym list**, because the synonyms are what prove it is name matching — if they disappear the site has changed character and the exemption stops applying. - `live-agent-count.ts`'s no-flags arm is reachable (a remote store is deliberately given an empty flag map; a card in an undeclared column has no flags at all) and deleting the literal makes such a card match **no** arm, so the queued total silently under-reports a stranded card. A count with an undocumented floor invites someone to drive it to zero. **Not done, and why:** `DocumentsView.tsx:73` is convertible but that file has no column flags anywhere, so a real fix means plumbing board-workflow metadata into a view that does not fetch it — its own unit of work, not something to smuggle into a conversion. **Re-verified after these commits:** `pnpm lint` clean, `pnpm test:gate` green (10 / 482 / 71), `tsc` clean on core and `tsconfig.app.json`, core ratchet suite 26/26, `columnRoles` 10/10, `TaskCard.test.tsx` 384/386 (the 2 are pre-existing CSS assertions). `TaskDetail*` is 130 failed / 551 passed **both with and without** this change — verified by stashing, so pre-existing and unrelated. --- ## Flag resolution: preconditions 1 and 2 are now DISCHARGED. Precondition 3 is blocked, and by evidence. **Precondition 1 — the side-effect equivalence proof — done.** `moves-flag-equivalence.test.ts` runs the same journey under both flag states against live PG and diffs the persisted row. **Result: identical** — whole-row equality across 128 fields plus an equal timing shape, over `todo → in-progress → in-review → todo → in-progress`. That test was **wrong twice** before it meant anything, and both times it was passing: 1. **It proved nothing.** `experimentalFeatures` is **global-only**, and `moves.ts` reads `getSettingsFast()`, which filters global-only keys out of the project layer. My `updateSettings` write was silently discarded, `useWorkflow` was false in *both* runs, and the "proof" compared the legacy path against itself. Found by stamping the flag-ON branch and observing the test still passed. Now written via `updateGlobalSettings`, and the helper **asserts the flag took effect** before the journey runs. 2. **The journey was forward-only**, so it never reached the reopen hook's field resets (`status`, `error`, `blockedBy`, pause clearing) — a mutation there passed. Extended with a backward move and a re-entry. Mutation-verified after both fixes: stamping seam 3, and diverging the reopen hook, each fail the comparison. **Precondition 2 — done, and its answer is a blocker.** The census says the default board is safe: all 41 literal engine move targets are declared by the default lineage. But **20 of those 41 carry no `recoveryRehome`**, so on a custom lineage that does not declare `todo` / `in-progress` / `done`, seam 2 would start rejecting them with unknown-column. That is a user-facing break on custom boards, not a theoretical one, and it is not fixed by the equivalence proof — seam 2 adds *new refusals* rather than swapping implementations. **So the flip is one step away, and the step is not mine to take alone:** those 20 call sites need to resolve their target from the task's workflow (or justify `recoveryRehome`), and they live across engine lanes in `moves.ts` caller territory — U2b/MAIN. Flipping before that trades a dormant flag for broken custom boards. What remains for precondition 3 once those land: flip both readers **atomically** (`moves.ts` + `workflow-task-create-ops.ts`, since the latter computes the preflight the former consumes), delete the flag-OFF branch with its guards, and drop the settings key. --- ## CORRECTION: seam 2 is not a blocker. My earlier claim was wrong. I stated in #2639 and above that "with the flag off there is **no** target-column validation on the move path", so flipping would introduce new refusals. **That is not what happens.** Reproduced against live PG: the identical custom-lineage move rejects with the flag **OFF** as well — ``` Error: Invalid transition: 'backlog' -> 'todo'. Valid targets: building ``` Transition validation is already in force on the flag-OFF path. So for the shape in question — an engine move to a column the task's own workflow does not declare — **the move already fails today**, and seam 2 introduces no new break for it. The 20 census sites lacking `recoveryRehome` are broken on a custom lineage *now*, not broken by the flip. I found this because the discriminator I added to prove "the flag is the cause" failed. Had I written the test to my assumption it would have passed and the false claim would have shipped — the same way the equivalence test passed while proving nothing until I tried to make it fail. **Revised precondition status:** | precondition | status | |---|---| | 1 — side-effect equivalence | **discharged** — identical rows, mutation-verified both directions | | 2 — seam-2 exposure census | **discharged, and it is not a blocker** — the rejection predates the flag | | 3 — flip both readers atomically, delete the flag-OFF branch, drop the settings key | **the remaining work** | So the flip is no longer gated on fixing 20 engine call sites. What it is still gated on is precondition 3 being done atomically across `moves.ts` and `workflow-task-create-ops.ts` (the latter computes the preflight the former consumes), which is `moves.ts` caller territory. Three cases now cover seam 2: the flag-ON rejection, the flag-OFF rejection (asserting the error *message*, so a change in which guard rejects stays visible rather than reading as agreement), and the #1411 `recoveryRehome` carve-out succeeding — pinning why that carve-out is load-bearing and must not be tidied away. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3aa942ee5f |
capacity: spawned agents count against the project agent count (#2579)
Two configurable numbers per project. `maxSpawnedAgentsPerParent` (5) and `maxSpawnedAgentsGlobal` (20) were a **third and fourth** limiter with private budgets invisible to both. ## This closes a hole, not just knobs A spawned child **is** an agent and gets **its own git worktree** (branched from the parent’s — the tool’s own description says so), but children were counted by **neither** capacity gate. A fan-out could put up to 20 extra worktrees on disk while the scheduler believed the project was at its configured limit. The operator’s two numbers were simply wrong about what was running. ## The old caps also measured the wrong thing `totalSpawnedCount` decrements on child cleanup, but the per-parent **set** is cleared only when the **parent task** ends. So `maxSpawnedAgentsPerParent` throttled *cumulative* spawns across a task’s life rather than *concurrent* ones — a long-running task could exhaust its budget with five children that had all long since finished, and the operator had no way to see why. ## Fix `fn_spawn_agent` gates on the same project agent count every other lane uses (`computeTopLevelConcurrencyClaimedFromStore`) plus live children. One number, one answer, no private budget that can disagree with the board. The refusal names **Max Concurrent Tasks** — a control the operator actually has. The old messages pointed at settings that no longer exist, which is worse than no message: it sends someone hunting for a knob that is not there. ## Verification **Revert-proof, measured:** restoring the private budgets turns **3 of the 4** new cases red — a project at 1/1 could still spawn, which is precisely the hole. `executor.ts` restored byte-identical. `pnpm lint` clean · core + engine `tsc` clean · `pnpm test:gate` green (414 + 10 + 71) · new suite 4/4 · `settings-default-descriptions` 4/4. There was no spawn-capacity test before this; the file is new. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Spawned agents now count toward the project’s **Max Concurrent Tasks** capacity. * Agent spawning is blocked when capacity is reached, including concurrent spawn attempts. * **Bug Fixes** * Prevented over-allocation during simultaneous agent spawns. * Restored available capacity when agent creation fails. * **Changes** * Removed separate per-parent and global spawned-agent limits. * Updated settings to reflect the revised capacity controls. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
15b21dead1 |
fix(dashboard): reconcile task state through live API (#2595)
## Summary - add a project-scoped live API route for updating individual task checklist steps - add an atomic live API route for resolving stale durable wedge episodes - prevent operator repair tooling from opening a second embedded store that can diverge from the running dashboard backend ## Why Legacy graph-native workflow runs can retain successful `workflowStepResults` while their narrative checklist remains at 0/N. The existing `fn task update` fallback may open a separate embedded store, producing split-brain writes that do not accumulate in the live dashboard backend. There was also no API surface for the existing atomic wedge-episode resolver. ## Verification - `pnpm exec vitest run src/routes/__tests__/register-task-workflow-routes.step-update.test.ts` — 5/5 passing - `pnpm build` in `packages/dashboard` — passing - full managed runtime workspace build — passing - deployed to the managed local runtime and used to reconcile six legacy review-deadlock tasks - live board audit: zero `in-review-stall-deadlock` paused reasons - exact local and Tailscale dashboard roots: HTTP 200 with 16,926-byte bodies <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added live API endpoints to update individual task checklist steps with validation (step index and allowed status values). * Added an endpoint to reconcile/resolve stale task “wedge” episodes, resolving only the matching active episode and returning conflicts on mismatches. * **Tests** * Expanded route tests for step updates and wedge resolution, including consistent 404 behavior for soft-deleted and missing tasks, plus conflict and invalid-input cases. * Expanded PostgreSQL coverage for wedge resolution persistence and concurrent episode replacement scenarios. * **Bug Fixes** * Improved task-lookup error handling so soft-deleted tasks are consistently treated as “not found” (HTTP 404). <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
8d3b8262c0 |
U2b: the second move-path divergence — legal targets differ, not just message shape (blocks the useWorkflow flip) (#2638)
Tests only. Advances U2b's equivalence proof **without touching `moves.ts`**, whose edit order is still being agreed between U12 and MAIN. ## Why this one decides the sequencing The equivalence suite already records one divergence: rejection **type and message** differ. That is a shape difference and easy to absorb. This second one is a difference in **which moves are legal**, and it is workflow-dependent. U11 removed `triage` from the default coding lineage, so rows left there sit in a column their own workflow no longer declares. #2515 added an escape hatch to `resolveAllowedColumns` so such a card has a legal move — its workflow's rebound target — instead of `Valid targets: none`. **That hatch lives inside the `useWorkflow` block, so it only runs on the hooks path.** Mutation-verified in #2597: stubbing it back to `[]` left an operator-move test green, because the inline path answers from the legacy `VALID_TRANSITIONS` map instead, whose `triage` row happens to permit the move for unrelated reasons. So **flipping `useWorkflow` changes move validation for every stranded card**, not just side-effect routing. ## What that means for "flip the flag, then delete the flag-OFF branch" That plan is not the mechanical cleanup it looks like, and KTD-6's Phase A escalation correction already ruled on this exact shape once: > Deleting the inline branch would have swapped every project onto an untravelled code path and called it a cleanup. > > The convergence is its own unit with an equivalence proof (**U2b**), and it **blocks Phase B**. Nothing downstream may assume the trait-hook path runs until it lands. Flipping the flag and deleting the other branch *is* that deletion, reached from the other side. The tests won't catch a divergence because both paths have tests and only one of them runs — which is precisely why U2b was scoped as a proof rather than a refactor. **Recommended order:** U2b's equivalence proof completes → U12 flips `useWorkflow` → the flag-OFF branch and its 5 guards go away wholesale. That still gets the "strictly less work" outcome, just after the proof instead of instead of it. ## A note on how this is asserted Deliberately a **positive assertion about the inline path**, not a comparison of two target lists. The two paths do not reach the same rejection — inline enumerates the legacy table and reports it in the message; hooks throws the typed unknown-column rejection first. That asymmetry **is** the divergence. Comparing two lists would hide it behind two empty arrays and read as equivalence, which is the failure mode this suite exists to prevent. 11 tests green; lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1d0f21b428 |
U12 R12: lifecycle-column literal ratchet — and the raw count's floor is not zero (#2630)
The anti-regression ratchet U12 R12 calls for. Counts lifecycle-column **literal comparisons** in production source and fails when the count rises. ## Two jobs **1. Ratchet.** A converted guard cannot silently come back as a literal. Ceilings only go down. **Mutation-verified both ways:** adding one `t.column === "triage"` fails with *"rose to 49 (ceiling 48)"*; lowering the ceiling to 47 fails with *"rose to 48 (ceiling 47)"*. The number is exact, not approximately right. **2. Honest denominator — the finding.** `triage` is overloaded in this codebase: a column id, an **agent role**, a **session purpose**, a **prompt-template family**, and a **CLI glyph key**. A raw grep counts them together, which makes "reach zero" unreachable by construction — converting `role === "triage"` in `agent-prompts.ts` would break the planning agent's prompt-template resolution, and the failure would look nothing like a column bug. | | count | |---|---:| | raw `triage` matches | 72 | | **not a column at all** | **10** | | genuine column comparisons | **48** | The 10: `agent-prompts.ts` ×3 (`role`), `usage-limit-detector.ts` ×2 (`agentType`), `skill-resolver.ts` (`sessionPurpose`), `tool-availability.ts` (`surface`), `cli/commands/task.ts` (a glyph key), plus two in comments. Ceilings recorded for all four ids — **`in-progress` (133) and `in-review` (200) were untracked entirely.** ## A measurement error of mine that writing this caught A grep over `packages/<pkg>/src` **misses `packages/dashboard/app`**, where the board components live. That undercounted `triage` as 43 in an earlier audit of mine when it was 62. The source roots are now listed explicitly in code so the number cannot drift with someone's glob. ## The classifier is under test, not trusted A ratchet that matched nothing would pass forever while measuring nothing — the failure mode this program keeps finding. Three self-tests prevent it: - it asserts **positively** that `agent-prompts` / `skill-resolver` / `tool-availability` are excluded, so a classifier change that swallowed them would fail rather than quietly shrink the number; - it asserts the classifier still **matches** real column comparisons; - it pins `live-agent-count.ts`'s two sites as **permanent** no-flags fallbacks, with the reason, so a future edit that deletes them has to argue with it rather than silently drop stranded cards from the footer's queued total. The classifier keys on the **left-hand side naming a column** — deliberately syntactic, so a reader can audit it against the source without running anything, and conservative: an unrecognised shape counts **as** a column comparison, erring toward demanding conversion rather than excusing it. 7 tests green; lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bcb9782d3f |
fix(test): re-green task-delete-notice after the SQLite-arm deletion (21 → 0) (#2637)
**Unowned work, picked up.** `task-delete-notice.test.ts` was **21 failed / 13 passed** on main → now **34 passed**. Test-only. `pnpm test:gate` green, `pnpm lint` clean. ## Cause `deleteTaskImpl` and `deleteTaskIfImpl` are now **thin delegators**. The SQLite arms were deleted in the PG cutover (`FNXC:SqliteDualPathCleanup 2026-07-26`) and both forward unconditionally to `store.deleteTaskBackend` / `store.deleteTaskIf` — `deleteTaskImpl` is literally *"throw if self-delete; return `store.deleteTaskBackend()`"*, with **no `backendMode` branch left**. The suite drove them against a `makeSqliteStore` fake providing neither method, so every case threw `store.deleteTaskBackend is not a function` **before reaching any notice logic**. All 21 failures were measuring a crash, not a decision. ## Fixes - the PG fake gains `deleteTaskBackend` / `deleteTaskIf`, wired to the **real backend impls** rather than stubbed — so the delegating paths still prove the delegation preserves the notice decision instead of asserting against a mock; - plus `withTaskLock` (`deleteTaskIf` wraps the conditional delete in the per-task lock), running the body inline so the predicate and short-circuit paths execute for real; - every remaining `makeSqliteStore` call site retargeted, and the now-dead factory **deleted** so it cannot rot back in. ## Corrected a claim the file was making The header's Surface Enumeration said the three paths prove *"the behavior cannot depend on backend mode"*. **There is one backend now.** The enumeration is still worth driving — a caller reaching the public entry point must get the same notice as one reaching the backend directly, and these paths prove exactly that — but it is a different claim, and the file now states it, with the two paths renamed from `(SQLite)` to what they actually are. ## A bug in my own patch, caught by re-running My first edit inserted the method assignments **after** the `return`, so they were unreachable and the symptom didn't change. I only found it because the failure count stayed identical and I checked the file instead of assuming the edit had landed. Worth noting because "the patch applied" and "the patch took effect" are different facts, and this session has now produced three variants of that same mistake. ## Deliberately not fixed here `task-delete-caller-attribution` (13 failed) and `task-delete-nonblocking-cleanup` (2 failed) share the root cause, but their `makeDeleteStore` fake carries `backendMode: false` and lacks the PG surface the real backend impl needs (`asyncLayer` / `transactionImmediate` / `rowToTask` …). Wiring them means either building that surface out or re-pointing the suites at `deleteTaskBackendImpl` directly — a judgement about what those suites are *for*, and worth making deliberately rather than folding into this fix. Whoever owns the PG cutover cleanup will know which; the diagnosis above is the whole of it. Verified no collateral: `task-merge` and `legacy-adoption` unaffected (166 passed across the three files). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7397cea2dc |
U12: pin the move-path flag blast radius (6 seams, not 1) before flipping it (#2639)
Per the sequencing agreed in-thread — **U12 resolves the flag first, then the flag-OFF branch is deleted wholesale** — this is the step before the flip, not the flip. ## The flag is six switches, not one `moves.ts:363` reads the raw compatibility flag nothing in production source writes, and gates the hottest lifecycle path in the system. Every summary so far has under-scoped it, mine included: I described it as the `789`/`837` pair. Measured, it is six decision points: | seam | what flipping turns on | |---|---| | 392 | resolves the task's workflow IR — `undefined` when off, so **every IR-dependent guard below is inert** | | 489 | typed **rejections**: unknown-column and adjacency validation | | 789 | column side effects route through the trait hooks instead of the inline legacy block (timing, reset-on-entry, abort-on-exit, `merge.onEnter`) | | 1092 | writes the transition-pending marker that **capacity counting reads** | | 1330 | runs **plugin hooks** on column change | | 1395 | records `workflowId` on the emitted move payload | ## The risk is seam 2, and it is not an equivalence question With the flag off there is **no target-column validation on the move path at all**. Flipping introduces new refusals for moves that succeed today, on the path every engine lane uses. That is not "do the two implementations agree" — it is new behaviour, and a green suite is not evidence about it. `recoveryRehome` already carves out legacy targets (#1411); nothing proves the other callers are covered. ## What this PR asserts - **The seam count.** Mutation-checked, not assumed: replacing one gate with `if (true)` fails with `expected 5 to be 6`. (My first mutation attempt silently didn't apply — `str.replace` with no assert — so the anchor is verified now.) - **All six read ONE flag.** If a seam were rewritten to consult settings directly, a flip would move five behaviours and leave one behind, and nothing else in the suite would notice because both states are individually valid. - **The flag-OFF branch is still inline**, so the delete-with-the-branch step has a test naming the plan if someone converts its guards instead. - **The atomically-coupled second reader is named.** `workflow-task-create-ops.ts` computes the `movePolicyPreflight` that `moves.ts` consumes, so un-gating either alone either evaluates workflow move policies whose result is ignored, or validates against a preflight never computed. Comments cannot inflate the count — it is an AST walk. Parse failure fails loudly via `parseDiagnostics` rather than a try/catch, since `createSourceFile` is error-tolerant and a partial tree would undercount and read as "seams were removed". ## Preconditions for the flip, recorded in the file header 1. An equivalence proof for seam 3 across timing, reset-on-entry, abort-on-exit and `merge.onEnter`. **Neither implementation is the observed baseline** — they have never both run in production. 2. A census of the moves seam 2 would newly reject. 3. Both raw-flag readers flipped atomically. ## Why not just flip it here Because I cannot honestly claim the equivalence proof from a source read, and the flip is on every task move. Landing the blast radius as a test first means the flip PR has something to be proven against, and it means the next person cannot under-scope it the way this has been under-scoped four times. ## Verification `pnpm lint` clean, `pnpm test:gate` green (10 / 482 / 71), `tsc -p packages/core/tsconfig.json` clean, suite 5/5. No changeset: test-only, no behaviour change. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
50ebf3c543 |
TAKING cli/project.ts (fn project reported 0 running agents) + two test fixes — dashboard conversions WITHDRAWN in favour of #2626 and #2636 (#2631)
Three app-cluster conversions plus the evidence that they behave on a renamed AND a merged board. ## Per-file guard counts | file | before | after | note | |---|---|---|---| | `packages/cli/src/commands/project.ts` | 0 | 0 | not a comparison site — see below | | `packages/dashboard/app/components/TaskContextMenu.tsx` | 2 | 2 | **count does not move — deliberate, see below** | | `packages/dashboard/app/components/Column.tsx` | 2 | 2 | **count does not move — deliberate, see below** | **Read this before scoring the PR against the bar.** You said a claim that does not move your number is not done, so I am telling you up front that *this PR does not move it*, and why. Both dashboard conversions are **fallback-preserving**: ```ts const isIntakeColumn = columnFlags ? columnFlags.intake === true : column === "triage"; ``` The literal survives as the no-flags branch, so the grep still counts it. That is the shape the sibling code already uses (`isPreExecutionHoldColumn`, same file, converted earlier in the program), and dropping the fallback would make an unresolved-column render *lose* the affordance a second way. What changes is the **behaviour when flags exist** — which is what the mutation results below measure. If you want these to zero out the count, the fallback has to go, and that is a separate decision about whether an unresolved column should fail open or closed. Say the word and I will do it as a follow-up; I did not make that call unilaterally because it is not reversible from a rendering standpoint. `cli/project.ts` was never a comparison site at all — it fed **raw rows** to `isRunningAgentTaskShape`, so the helper's own internal legacy fallback kicked in and `fn project` reported **0 running agents** on any renamed board. Fixed by resolving the IR per task before counting. Nothing to subtract. ## Two of the three had a test that looked like coverage and was not - **`Column.tsx`** — the quick-create gate is `workflowMode || isIntakeColumn`. Every pre-existing intake case in `Column.test.tsx` *also* passes `workflowMode`, so the `||` short-circuited and **none of them ever reached the trait lookup**. Added cases that omit `workflowMode`, the only path where the conversion changes the answer. - **`TaskContextMenu.tsx`** — the intake suppression was asserted only for the legacy `triage` id, the one board shape where a broken conversion still returns the right answer. Mutation-verified rather than asserted: | mutation | result | |---|---| | `isIntakeColumn` → `column === "triage"` | **2 of 88 fail** (exactly the renamed and merged cases) | | menu suppression → `task.column !== "triage"` | **1 of 12 fail** | ## A pre-existing red I fixed on the way past `uses VALID_TRANSITIONS and in-review back-to-progress labels` was **already failing on origin/main**. #2521 correctly moved the "Back to X" label onto the host's `columnLabel` function; this file's stub is `(column) => column`, so the hardcoded `"Back to In Progress"` expectation was left over from the pre-#2521 hardcode and nothing had updated it. Matching the raw id would have made it pass while proving nothing, so instead that one case gets a display-like label function — the assertion now fails both if the "Back to" prefix regresses **and** if the label stops routing through `columnLabel`. Strengthened, not relaxed. Counts against completion criterion #2. ## Verification - `Column.test.tsx` + `TaskContextMenu.test.tsx`: **100 passed** - `tsc -p tsconfig.app.json` (the root config does not cover `app/`) and the CLI typecheck: clean - `pnpm test:gate`: green 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a54d60ee70 |
fix(core): restore standalone central backend initialization (#2596)
## Summary - restore the owned PostgreSQL backend bootstrap for layer-less `CentralCore.init()` callers - fix node, mesh, and project CLI commands returning empty state and logging `backendHandle is only available in backend mode` during cleanup - add a hermetic regression test for standalone backend ownership and shutdown PR #2454 accidentally added an unconditional early return immediately before the existing standalone bootstrap. Runtime pool sharing remains unchanged: `attachBackendLayer()` releases the central-only connections before adopting the project store layer. ## Verification - RED: regression failed because `createCentralBackendLayer` had zero calls - GREEN: focused regression passes - `pnpm --filter @fusion/core typecheck` - `pnpm --filter @fusion/core build` - `pnpm test` with `FUSION_PG_TEST_SKIP=1`: 482 engine + 132 Core gate + 71 CLI shape + changed regression passed; isolation clean - changeset format check passed The local PostgreSQL merge-gate harness is unavailable without credentials (`empty password returned by client`), so its 10 tests were explicitly skipped rather than misreported. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Restored PostgreSQL central registry access for standalone Node/mesh/project CLI commands without marking the host offline on shutdown. * Improved CentralCore lifecycle handling: concurrent `init()` coalesces, and operations are blocked once `close()` is requested/in progress. * Refined embedded PostgreSQL runtime shutdown: owner stop is coordinated with lease release, registrations are rejected while stopping, and shutdown/teardown uses lease lifecycle consistently. Embedded start failures now treat stopping as retryable. * **Tests** * Expanded coverage for CentralCore close/init/attach races and embedded PostgreSQL lease/shutdown coordination scenarios. * **Documentation** * Updated Changeset notes to clarify CLI and shutdown semantics. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
76b513e028 |
comments-ops.ts: user comments stopped invalidating spec approval (guards 3 → 0) (#2606)
Taking **`packages/core/src/task-store/comments-ops.ts`** from the shared 48-guard backlog. | file | before | after | |---|---:|---:| | `packages/core/src/task-store/comments-ops.ts` | 3 | **0** | ## One of the three was a live defect The awaiting-approval branch read: ```ts task.column === "triage" && task.status === "awaiting-approval" ``` #2515 merged the two pre-implementation columns into one with id `todo`, so a card awaiting spec approval now sits in `todo` and **that condition can never match**. A user comment on such a card silently stopped invalidating the approval — the operator types a correction, the spec stays approved, and the task proceeds on the very spec they were correcting. No error, no log line, nothing to notice. This is exactly the failure mode the census exists to eliminate, and it is user-visible: the operator’s correction is accepted into the comment thread and then ignored by the pipeline. The other two guards survived by luck — their `column === "todo"` arm still matched the merged column, so only the dead `triage` arm was inert. ## Fix All three resolve the **intake/hold roles** from the task’s own workflow. Unresolvable workflows fall back to the legacy pair: this is a best-effort re-triage path whose failure mode is a *missed* re-spec, so degrading to the old vocabulary beats dropping the card out of the branch entirely. ## Verification Regression test drives the **real store** on the merged column and asserts the approval is invalidated. **Revert-proof, measured:** restoring the `triage` literal fails with `expected awaiting-approval not to be awaiting-approval`. `comments-ops.ts` restored byte-identical. `pnpm lint` clean · core `tsc` clean · `pnpm test:gate` green (482 + 132) · `store-comments` 15/15. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
51e108b4f0 |
core/workflow-ir-resolver: let callers tell a RESOLVED workflow from a GUESSED one (unblocks the triage census) (#2618)
Shared-backlog infrastructure, not a single-file conversion. This is the blocker I hit on three separate census files and flagged twice; landing it once beats working around it five more times. ## The problem `resolveWorkflowIrForTask` returns the default coding IR in two cases that are **not** the same as knowing which workflow governs a task: - the selection read threw; - the store reported no selection at all — the synchronous PostgreSQL path does exactly this, deliberately. Callers cannot distinguish either from a genuine selection. **For lifecycle-column work that difference decides correctness.** Post-merge the default coding lineage declares `todo` as its single Planning column and **no `triage`**. So a call site converting a `column === "triage"` guard to trait resolution silently stops firing for `builtin:legacy-coding` cards whenever the store cannot name the workflow — it is handed the default's vocabulary with no signal that it was a guess. ## Why this is the census blocker, with receipts Every conversion I have landed has hit it and worked around it the same way: | Site | Workaround forced | |---|---| | `usage-limit-detector.ts` (#2572) | narrowed to intake, three separate corrections | | `mission-feature-sync.ts` (#2609) | legacy ids unioned, then position-ordered to stop over-claiming | | `live-agent-count.ts` (#2604) | not converted at all — left as a documented finding | That is why the count stalls around a dozen rather than converging on zero: the honest conversion is unavailable, so each site keeps the literal "just in case". With provenance a caller can finally say what it means — **trust the resolved columns when the workflow was selected; fall back to legacy compat only when it was guessed.** ## What lands `resolveWorkflowIrForTaskWithProvenance` returning `{ ir, source: "selection" | "default", workflowId? }`. **Additive by construction:** `resolveWorkflowIrForTask` delegates to it and drops the provenance, so the two answers cannot drift and no existing caller changes behaviour. ## Red-green Mislabelling the no-selection guess as a selection fails its test (`1 failed | 5 passed`). One test deliberately pins the underlying *fact* rather than assuming it — the default guess really does lack `triage` and does have `todo`. If that lineage ever regains the column the hazard changes, and the callers relying on provenance should be revisited; this is what will tell them. Another asserts `resolveWorkflowIrForTask` returns exactly the provenance form's IR across all three paths, so the delegation cannot silently diverge. ## Not done here I have **not** converted any call site onto it. Each one is a behaviour decision for its owner — `comments-ops.ts`, `task-creation.ts`, `archive-planning.ts`, plus revisiting the three above — and bundling them would make this unrevertable. The enabler is the shared part. ## Verification - 6 new tests green; `pnpm test:gate` green (132 / 10 / 482 / 71); `pnpm lint` clean; `tsc --noEmit` clean - Additive API on a private package (`@fusion/core`), no behaviour change, so no changeset 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f14059e8d3 |
Retry still refuses cards parked mid-planning on 5 builtins (survives #2614; count 0 → 0, defect-only) (#2621)
**Rebased onto main after #2614 landed this file.** That PR's conversion already took the tracked count for `register-task-workflow-routes.ts` to **0**, so this PR does **not** move your number and I am not claiming it does. | file | before | after | |---|---:|---:| | `packages/dashboard/src/routes/register-task-workflow-routes.ts` | 0 (post-#2614) | 0 | What it fixes is a **live 400** that #2614 left in place. Measured on current main: **9 of this file's 14 retry tests fail** without the change below. ## The defect `POST /api/tasks/:id/retry` must answer *"does this card sit where its workflow **plans**?"*, because the yes-branch is **destructive** — it stamps `needs-replan` **and deletes PROMPT.md**. Two predicates stood in for that question and neither answered it: - **#2614** resolved the **intake** column. Correct for the merged lineage; wrong wherever intake and the planning column differ. - The older arm asked `!workflowHasColumn(ir, "triage")`. **Measured across all 12 builtins:** *not one* plans in `triage`, while **seven** still declare that column. So for the five that declare `triage` **and** run every plan node in `todo` — `quick-fix`, `review-heavy`, `compound-engineering`, `design`, `legacy-coding` — the predicate is `false` and a `planning`/`needs-replan` card sitting in **its own planning column** is refused outright: ``` 400 — "Task is not in a retryable state (current status: needs-replan)" ``` The operator has no button at all on a card parked mid-planning. The mirror-image fault is destructive rather than obstructive: a workflow that plans anywhere other than `todo` had a `todo` card's PROMPT.md deleted for a re-plan nobody asked for. ## Fix `workflowPlansInColumn` asks the graph. Planning nodes are recognised by the **semantic markers** the builtins carry — `config.seam === "planning"` and an **exact** `workflowAction` set (measured vocabulary: `plan-replan`, `code-review`, `pre-merge-remediation`) — with node ids as a backstop. Deliberately **not** a `startsWith("plan")` prefix. That was my first attempt and greptile was right to kill it: it matched in the **destructive** direction, classifying a custom `plan-execute` column as a planning column, which deletes a specification. An unlisted planning action costs a replan (recoverable, card stays retryable); a wrongly-listed one costs a spec (not). Hence opt-in. ### Second concern, split out Narrowing the destructive branch must not narrow **retryability** — those were one boolean and are two questions. A card parked outside its planning column would otherwise fail the gate and answer 400: that trades *a card which loses its spec* for *a card nothing can rescue*. It stays retryable via the non-destructive branch, scoped to pre-WIP columns so no `in-progress`/`in-review` status gains a path it lacked. A **v1 IR** declares neither columns nor nodes, so placement is **unanswerable** rather than answered "no". `workflowDeclaresColumnModel` distinguishes the two — reading that silence as "past planning" is exactly what 400'd a v1 planning card. ## Verification All three greptile P1s on the earlier revision were real and are fixed with revert-proof tests (bespoke planning-node ids; the v1 regression I introduced; my own loose action prefix). `pnpm lint` clean · dashboard `tsc` clean · `pnpm test:gate` green (132 + 10 + 482 + 71) · core 11/11 · dashboard 119/119 (`retry-planning-column` + `stale-merge-status` + `routes-tasks`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
31e49b684a |
TAKING default-workflow-hooks.ts + executor.ts + live-agent-count.ts + 6 dashboard files: reopen semantics by role, and the census's blind spot in both directions (13 sites) (#2628)
Batched conversion of every lifecycle-column guard I hold, plus the three the census could not see. **Six files to zero, repo-wide 60 → 49 by a comment-stripped unanchored sweep.** Each conversion has an isolated revert proof and a paired negative case, and the one code move is a separate commit from the behavior changes. ## Per-file before → after Counts from a comment-stripped, unanchored `(===|!==) ["']triage["']` sweep over `packages/*/src` + `plugins/*/src`, excluding tests. | file | before | after | note | |---|---:|---:|---| | `core/default-workflow-hooks.ts` | 4 | **0** | | | `core/task-store/moves.ts` | 5 | **4** | only the flag-ON mirror converted; the flag-OFF inline block is the parity reference and stays | | `engine/executor.ts` | 3 | **0** | **absent from the 45-guard list** — see below | | `core/live-agent-count.ts` | 2 | **0** | duplication removed; answer deliberately unchanged | | `engine/replan-target.ts` | 2 | **0** | both were comment prose, not guards | | `core/agent-prompts.ts` | 3 | **0** | ROLE comparisons, never column guards | | `engine/usage-limit-detector.ts` | 2 | **0** | ROLE comparisons | | `dashboard/app/components/DocumentsView.tsx` | 1 | **0** | real column guard | | `dashboard/app/components/TaskChatTab.tsx` | 2 | **0** | ROLE | | `dashboard/app/components/AgentLogViewer.tsx` | 1 | **0** | ROLE | | `dashboard/app/components/effective-model-resolution.ts` | 1 | **0** | ROLE | | `dashboard/app/hooks/useTasks.ts` | 1 | **0** | ROLE | | `dashboard/…/command-center/MissionControlPanel.tsx` | 1 | 1 | alias table, marked `DELIBERATE-LITERAL` with its reason | ## The census errs in BOTH directions This is the finding I would most like carried into the remaining work. - It **flagged 10 sites that were never column guards.** `role === "triage"` / `agentType === "triage"` compare an **AGENT ROLE**. The planner *lane* is named `triage` and keeps that name — U11 removed the *column*. Worse than noise: the obvious "finish the migration" edit is to rename the role, and that silently empties the planner's prompt template and mis-binds its model markers. `PLANNER_AGENT_ROLE` now names it, so the two vocabularies are distinguishable by grep and a rename fails loudly (revert proof: 4 tests, two of them pre-existing). - It **missed 3 real guards in `executor.ts`**, because the pattern matches `column`/`toColumn`/`fromColumn` and those locals are named `from` and `originColumn`. A census keyed on variable names will keep missing guards wherever a local was named for its role in the function. ## Two real defects, not tidying **1. A renamed board could merge with its re-review never run.** `default-workflow-hooks.ts` is named for the default workflow, but the store runs it on the flag-ON path for *every* workflow — the trait registry resolves hooks by trait id, not by workflow. Its reopen predicates listed the default lineage's column names, so on a renamed board **no reopen effect fired at all**. One of them clears `workflowStepResults`, which `getTaskMergeBlocker` reads: a card bounced out of review carried its old `passed` result back in, and that satisfies the merge gate. Same regression the graph-owned-crossing carve-out exists to prevent, arriving through the other door. (Two smaller ones rode along: failure state never cleared on a renamed reopen, and an operator dragging a card back to the queue never parked it, so the scheduler re-dispatched what they had just pulled back.) **I forgot the carve-out on my first pass, and that was worse than not converting.** A role-resolved clear plus a *name*-matched exemption means a renamed board takes the clear and never the exemption, destroying the remediation input the graph had just written. My own paired negative test caught it. **2. The last-resort recovery for completed-but-stranded work did not exist off the default lineage.** In `recoverCompletedTask`, `promotedFromPlannerColumn` was false on a renamed board, so finished work resting in the planning lane was never promoted — the code fell through to `handoffTaskToReview` straight from the planning column, and role adjacency has no planning → review edge, so the handoff was rejected and the card stayed stuck with its work complete. I converted the promotion **target** too: resolving the lane and then moving to a literal `in-progress` is the half-conversion I have already been burned by twice this program, where the guard starts admitting cards and the move then sends them to a column the board does not declare. ## E2E evidence `renamed-board-reopen.pg.test.ts` drives a **real PostgreSQL store** and a real `moveTask` on a workflow whose columns carry the standard traits under non-default names. The unit tests cannot show this: if `moves.ts` passed `undefined`, every unit case still passes via the no-basis fallback while the real board keeps the old behavior. **Proof it is load-bearing: forcing `moveLifecycleColumns` to `undefined` fails 2 of 3.** The executor suite covers both the split-role and the MERGED post-U11 shape. ## Revert proofs, isolated per site | change reverted | result | |---|---| | reopen predicate → literal names | 4 of 10 fail | | reopen field clears → literal names | 2 of 10 fail | | `userPaused` hold lane → literal `todo` | 1 of 10 fail | | graph carve-out → literal names | 1 of 10 fail | | store passes `undefined` lifecycle columns | 2 of 3 fail (real PG) | | `promotedFromPlannerColumn` → literals | 3 of 7 fail | | two-hop condition → `=== "triage"` | 1 of 7 fails | | promotion target → `"in-progress"` | 3 of 7 fail | | `isPlannerColumnFor` → literals | 1 of 7 fails | | live-agent-count: one arm dropped | 2 of 11 fail | | DocumentsView: trait branch removed | 3 of 7 fail | | planner role renamed to `"planner"` | 4 fail (2 pre-existing) | Every conversion is paired with a negative case (a forward move, a not-a-planner-lane card, a default-lineage card, a renamed column with no traits), so neither "always fire" nor "never fire" can pass for "resolve the role". ## Deliberately NOT converted, with reasons - **`moves.ts` flag-OFF inline block (4).** That branch *is* the legacy path, kept verbatim so the two can be parity-checked. Converting it erases the reference implementation. - **`live-agent-count.ts`'s no-flags fallback.** Reachable, and there is nothing to resolve from — `enrich…FromFlags` exists for callers with board flags rather than an IR, so a column missing from that map is the renamed case. "Not intake" is as much a guess as "todo is intake", and Running/Waiting are complements, so a card matching neither arm is reported as neither and the footer's queued total under-reports it. The real fix is at the caller; four new cases pin that flags override the legacy answer **in both directions**. What did change is the duplication: two hand-written copies of one rule now call one named function. - **`MissionControlPanel`'s `FUNNEL_STAGES`.** An alias table of column *names* where `triage` sits beside `signal` and `backlog`. Command Center aggregates across projects, so there is no single workflow to resolve traits from — the honest conversion is a data change, not a predicate change. - **`DocumentsView` with no traits.** Same no-basis rule; the documents list is full of historical columns absent from the current board. A case asserts a renamed column with no traits still reads as "working", documenting the gap rather than hiding it. ## Fixture findings Each cost a red run that looked like the code under test: - a `merge-blocker` column needs a reachable merge-class node, or `parseWorkflowIr` rejects the workflow; - a back-edge must be `kind: "rework"`, and a rework edge is legal only **into** a node with `config.reworkRegion: true`; - a workflow gets role-level transitions only when it declares wip + review + complete + **archived** plus a planning lane — without the archived column, adjacency falls back to order-derived neighbours and `checking -> queued` is not a legal move at all; - `recoverCompletedTask` only *reaches* the promotion seam when nothing is left to gate; without passed `plan-review`/`code-review` rows it re-enters the workflow graph and returns first, so a naive fixture silently tests the wrong branch and every assertion reads "no moves happened" for an unrelated reason. ## Verification - `pnpm test:gate` **71/71** - new suites: 10/10 reopen-semantics, 3/3 renamed-board-reopen (real PG), 7/7 executor-planner-lanes, 7/7 documents-status-dot, 4/4 planner-role-is-not-a-column - neighbours: 132 + 10 + 482 (gate shards), 350/351 engine planning/replan suites, 64/64 agent-prompts, 51/51 usage-limit-detector, 11/11 live-agent-count, 11/11 dashboard hook/log suites - the single engine failure (`executor-fast-mode-workflows.test.ts` › "raw fast mode still invokes non-executable review seam nodes") **reproduces with my changes stashed** — pre-existing on `origin/main` - typechecks clean for core, engine, and dashboard-app (`tsconfig.app.json`; `tsconfig.json` checks nothing under `app/`); `pnpm lint` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6ca7cc94ec |
triage census — core/types/archive-planning.ts 1→0, plus the self-healing.ts audit (10 guards, 2 traps a mechanical conversion would miss) (#2622)
Batched: one conversion plus the audit for the largest remaining file,
so the CAPACITY worker inherits the analysis instead of redoing it.
## Per-file counts
| File | Before | After |
|---|---:|---:|
| `packages/core/src/types/archive-planning.ts` | 1 | **0** |
Verified with the raw pattern (`column [!=]== "triage"`), which is what
the coordinator greps — including checking that my own explanatory
comment did not reintroduce the literal. It did, on the first attempt;
caught and removed before pushing.
## The conversion: a doc that manufactures dead guards
There is **no executable guard** in this file — raw 1, code 0. I fixed
it anyway, because the documentation was wrong in the way that
propagates: it told consumers to derive running agents via a hardcoded
intake-column comparison. Post-merge the default lineage declares one
Planning column and no `triage`, so anyone implementing from that
sentence writes a comparison that matches nothing — **a dead guard
authored on purpose, from an instruction we left lying around.** A doc
handing out a dead predicate is worse than a dead guard, because it
manufactures more of them.
Now describes roles. Also disambiguates the neighbouring line where
"triage agent" is a lane/role id, not a column — the same conflation
that accounts for 23 of the original broad 48.
## Audit: `self-healing.ts` (10 guards, unclaimed at time of writing)
45% of the remaining bar, and every one sits in a recovery sweep. **6 of
the 10 are sole-`triage` and already dead for default-workflow cards.**
| Line | Guard | Fires for default cards? | What silently stops |
|---|---|---|---|
| 2964 / 2984 / 3019 | advanced-triage recovery: filter, live re-check,
`moveTaskIf` CAS | **No** | stranded specification work never recovered
|
| 12173 / 12494 | orphaned-approved + orphaned-planning sweeps | **No**
| orphaned planning sessions never reaped |
| 12321 | `task_refine` candidates | **No** | refinement tasks never
recovered |
| 9218, 10703, 11282, 12218 | paired with `todo` | Yes, via the `todo`
arm | — (legacy-compat arms) |
**Two traps a mechanical conversion walks straight into:**
1. **`listTasks({ column: "triage" })` at 12172 and 12493 is a dead
QUERY, not just a dead filter.** Convert only the `t.column ===
"triage"` predicate and both sweeps scan an empty result set — the file
counts as converted while the sweeps stay exactly as dead. This is the
coordinator's rule #2 in its most literal form: a guard surviving in
another branch of the same function.
2. **Lines 2964 / 2984 / 3019 are one transaction** — filter, live
re-verify, and a `moveTaskIf` compare-and-set. Convert them
independently and you get a filter matching the resolved intake column
against a CAS still demanding the literal, so **every move refuses**.
Silently: `moveTaskIf` returning false is indistinguishable from a lost
race.
Both need the resolved-vs-guessed distinction from **#2618** — a
`builtin:legacy-coding` card in `triage` must still be recovered when
the store cannot name its workflow.
## Related live finding, not fixed here
`resolvePlannerLanesForTask` (merged in #2610; used by
`executor.ts:11978`, `scheduler.ts:1843`/`:2414`,
`mission-autopilot.ts:973`) cannot tell a resolved workflow from a
guessed one. Probe on main against a `{ getTask }`-only store:
```
PROBE lanes: ["todo"] dedicated: []
```
So a `builtin:legacy-coding` card in `triage` is not recognised as a
planner lane — mission-feature rollback stops firing and the
spec-staleness planner skip never fires. Neither errors. #2618 is the
fix; ~3 lines per resolver in `planner-lane-resolution.ts`.
## Verification
`tsc --noEmit` on `@fusion/core` clean; `pnpm lint` clean. Comment-only
change, no behaviour change, no changeset.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Documentation**
* Clarified terminology for archived task planning overrides.
* Updated project health documentation to better explain how active
agent counts are calculated across workflow stages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6a33d8f8cc |
Phase B — TAKING task-creation.ts: intake classification by trait (4 sites → 0) (#2613)
**Taking:** `packages/core/src/task-store/task-creation.ts` | file | guards before | after | |---|---:|---:| | `packages/core/src/task-store/task-creation.ts` | **4** | **0** | (4 remaining pattern matches in that file are inside the new explanatory comments, not code.) ## What the literals meant, and why they had stopped meaning it ``` resolvedEntryColumn !== "triage" ×2 "this workflow has a MANUAL intake" task.column === "triage" ×2 "created into the intake column" ``` The first named the **default workflow's intake id** to express *"not the default workflow"*. Post-U11 the default's intake **is** `todo`, so the comparison became vacuously true for the default workflow and the guard stopped separating the two shapes it exists to separate. The real fact is the intake trait's `autoTriage: false`, which `resolveWorkflowIntakeFacts` now reads from the IR alongside the intake column id. The second was the last-resort clause for a card whose workflow could not be resolved. `intakeFacts.intake` covers that properly — it falls back to `DEFAULT_WORKFLOW_ID` rather than to a bare id — so an explicit `column: "triage"` create on a workflow that still declares `triage` (R11) is matched through the *resolved* intake instead of a coincidence of naming. `isUnplannedStartCreate` is also restated in terms of what it actually detects — *"the card landed past its workflow's manual intake"*, which is what quick-add Start does by submitting the workflow id and the post-intake column together. That replaces `&& task.column === "todo"`, another id standing in for a relationship. ## Two expectations the conversion legitimately inverted Both read before changing, neither retargeted blindly. **1. *"keeps generateSpecifiedPrompt for a direct create into todo (not bootstrap)"*** `todo` **is** the default's intake now, so a card created there with no spec **must** get the bootstrap seed — triage admits a card for planning only when its `PROMPT.md` reads as a seed. Keeping the old expectation would have pinned the FN-8587 stall: a boilerplate spec that reads as "already planned" and is never planned. The old behaviour survived only by **accident of resolution failing** in the harness, which left the `=== "triage"` literal as the sole deciding clause. Removing that literal is what surfaced it — which is the point of the conversion. Split into two tests so the contract it was really protecting (an explicit **non**-intake column stays a specified create) keeps its own case. **2. `store-reservation-atomicity`'s file-scope rollback test** It stubs `generateSpecifiedPrompt` to inject a bad `## File Scope`, so it needs a create that actually **calls** that generator. A `todo` create now gets the bootstrap seed instead, and bootstrap intake prompts deliberately skip the file-scope hard-fail because their body is freeform operator prose where a stray `## File Scope` token is not a real declaration. Moved to a non-intake column so validation still runs. Left as-is the test would have been **vacuous** — no throw, no rollback exercised — while still reporting green. ## Verification Core package vs the 47-failure post-merge main baseline: **47 failed — zero new.** Two files reported failures in the wide run and pass in isolation (`create-task-reserved-id` 4/4, `schema-applier` 75/75) — the known contention pattern in this suite; re-run before attributing. Gate **482 + 10 + 71** green. Lint and core typecheck clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9a11e0b136 |
U2b reproduction: the live move path accepts the column U11 deleted (characterized, not patched) (#2601)
Found while proving U11's caveat 2. **Characterization plus guard-rails
— no production change, deliberately.**
## The defect
A default-workflow card in Planning can be moved **into `triage`** — a
column its workflow no longer declares — re-creating exactly the
stranded state `reconcileUndeclaredTaskColumns` exists to repair.
Measured on a fresh store:
```
experimentalFeatures.workflowColumns null ← no production writer
createTask(...) column = "todo"
moveTask("todo" → "triage") ACCEPTED
moveTask("todo" → "bogus-column") REJECTED: "Valid targets: in-progress, triage, archived"
```
The second rejection is the tell. Validation is real — but it is the
**legacy `VALID_TRANSITIONS`** table talking, and that table does not
know the card's workflow. Its `todo` row still lists `triage`.
## Why the workflow-aware check does not run
`moves.ts` gates its adjacency block — including
`workflowHasColumn(workflowIr, toColumn)` — on
`isWorkflowColumnsCompatibilityFlagEnabled`, which reads the raw
`experimentalFeatures.workflowColumns` key. Nothing writes it, so the
block is dead on the path every real project takes.
**Corollary, already reported:** U11's undeclared-source escape hatch in
`resolveAllowedColumns` also does not run in production. It was added
with #2515 so a stranded card would have a legal move instead of `Valid
targets: none`; on the live path that rescue comes from the legacy table
instead. Mutation-verified — stubbing the hatch back to `[]` leaves the
operator-move test green.
## Why I did not fix it
PR #2499 un-gated the capacity check and **explicitly scoped validation
out**:
> SCOPE, deliberately narrow: only the CAPACITY check is un-gated.
`workflowIr` stays flag-gated so transition VALIDATION keeps its current
behavior — the inline path's bare-Error/"Valid targets:" contract is
unchanged, and none of the Phase A2 divergences are flipped here.
That is a considered decision by the owner of this function, and several
suites pin the contract it protects. Overriding it from outside would
flip an error shape I do not own.
**What has changed since that decision is U11:** the legacy table now
offers a target the default workflow does not declare, which it never
did before. That is new input to the scoping call, not licence to ignore
it — so this lands as a reproduction for U2b rather than a patch.
U2b's branch (`feature/workflow-move-path-convergence`) is stale — HEAD
predates several merged PRs, clean tree — so nothing is being raced.
## What ships
The defect is **characterized, not asserted-as-correct**: the test pins
today's behaviour so it is visible and measurable, and an `it.todo`
states the intended behaviour. Writing it as a passing "refuses" test
would have required the fix; writing it as a failing test would redden
CI; asserting the current behaviour as *correct* would be a lie.
Characterization plus `it.todo` is the honest third option.
Four guard-rails pin what a fix must **not** break:
- every declared lifecycle move (`todo → in-progress → in-review →
done`)
- archiving
- a `recoveryRehome` deliberately reaching an undeclared column — the
path that rescues already-stranded cards, and the one a careless fix
would break
- a premise test asserting the compatibility flag really is unset, so
the suite fails loudly if that ever changes rather than silently testing
a different code path
## Exposure
Narrow but real. U10 already fixed the dashboard move menu to offer only
workflow-declared targets, so the board does not present this. The
**write path** does — REST API, CLI, plugins, any stale client — which
is why the guard belongs in `moves.ts` rather than only in the UI.
5 passed + 1 todo; lint clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Added coverage for task moves involving workflow-declared and
undeclared columns.
* Documented a known issue where tasks can currently be moved into the
deleted `triage` column.
* Preserved valid moves, archiving, and recovery re-homing behavior.
* **Documentation**
* Added reproduction steps, affected move paths, and guardrails for
addressing the issue.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f47fc167ee |
convert(core/task-store/comments-ops.ts): triage guards 3 → 1, and the dead approval-invalidation it hid (#2608)
**Taking `packages/core/src/task-store/comments-ops.ts`** (announced for collision avoidance). Two commits: a behaviour-identical extraction, then the conversion. | File | triage column comparisons before | after | |---|---|---| | `packages/core/src/task-store/comments-ops.ts` | **3** | **1** | `pnpm test:gate` green. ## The bug the literal was hiding `builtin:coding` → `BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR`, whose merged Planning column keeps the id **`todo`** and declares **no `triage` column**. So `task.column === "triage" && task.status === "awaiting-approval"` never matched a default card. The damage was graded: - **with a real spec** — the card fell through to the re-triage arm. Same `needs-replan` write, but audited as *"requested re-specification of planned task"* instead of *"invalidated spec approval"*. - **with a bootstrap-stub spec** — `hasRealPrompt` was false and **neither arm fired**, so a user comment on a card awaiting spec approval invalidated **nothing**. The approval silently stood. That second case is the real regression; the wording is cosmetic. I checked both rather than assuming the first one was the whole story. ## The conversion The column was never the discriminator. Callers reach this only after establishing the card sits in a pre-implementation column, so re-testing it inside was redundant before U11 and wrong after. **Status carries the distinction** — the same conclusion `spec-staleness.test.ts` already reached for its sibling guard. **Red-green:** the 3 new cases fail with the literal reinstated (**3 failed / 4 passed**) and pass without it. Two assert the merged-Planning card is now invalidated; the third uses a `planning`-named column to show no column id remains in the decision at all. **The 1 remaining literal is deliberate:** the caller's gate `column === "todo" || column === "triage"` names *both* vocabularies, so it still fires for default cards, and narrowing it to traits needs an IR the caller doesn't have. Commit 1 is move-only — the extracted body is the inlined expression verbatim, `triage` literals included, so the moved logic diffs empty apart from field renames. Behaviour change is entirely in commit 2. --- ## Census correction — the 48 is 41, and "reach ZERO" is wrong as stated I re-measured before picking a file, and the shared number needs three corrections. Same-scope method: `packages/*/src`, `.ts`, tests excluded, **comments stripped**. | Measurement | Count | |---|---| | raw `=== "triage"` / `!== "triage"` | 54 | | …comments stripped | **48** ← matches your figure | | …of those, genuine **column** comparisons | **41** | | …non-column identifiers that must NOT be converted | **7** | The 7 are `role === "triage"` ×3 (`agent-prompts.ts`), `agentType === "triage"` ×2 (`usage-limit-detector.ts`), `sessionPurpose === "triage"` (`skill-resolver.ts`), `surface === "triage"` (`tool-availability.ts`). **The triage service keeps its name; only the column id was merged away.** Converting these would break the triage lane, so the bar cannot be literal zero — it's zero *column* comparisons, with those 7 documented as permanent. Two I nearly misclassified and hand-checked: `col === "triage"` (`cli/commands/task.ts`, indexes `COLUMN_LABELS`) and `from === "triage"` (`executor.ts`, a `moveTask` from-column) **are** columns despite their names. ## Of the 41, which are actually dead Splitting by whether a `todo` companion arm sits in the same condition: - **27 have one** → still fire for default cards. Real but lower priority. - **14 have none** → candidates for silently-dead. But on inspection that set shrinks further: - `register-task-workflow-routes.ts` ×5 compare against a *resolved* `approveIntakeColumn`/`refineIntakeColumn` variable **plus** a legacy `"triage"` fallback, so they still fire via the variable; - `spec-staleness.ts:40` is a **deliberate R11 compat retention** — `spec-staleness.test.ts` already carries a "U11 proof" block concluding the guard is carried by status, not column, and that other workflows still declare `triage`. Converting it would be wrong; - `self-healing.ts` ×7 is U4's file; - `comments-ops.ts` ×1 was genuinely dead — this PR. **So the actionable dead set is far smaller than 14, and `self-healing.ts` holds most of it.** I'd suggest whoever takes `self-healing.ts` starts from that 7 rather than its 11 total. ## Files I evaluated and did NOT convert - **`replan-target.ts`** — my first pick, then both its "sites" turned out to be **comment text**. Zero real sites; already trait-resolved via `workflowHasColumn`. - **`mission-feature-sync.ts:88`** — `(column === "triage" || column === "todo")` still fires via the `todo` arm. The genuine gap is a custom-named planning column, but `reconcileMissionFeatureState`'s store is narrowed to `Pick<TaskStore,"getTask">`, so trait resolution means plumbing through `scheduler.ts` — **U5's file**. Left to avoid the collision, per KTD-2's warning that most sites have no IR in scope. - **`tool-availability.ts` / `skill-resolver.ts` / `usage-limit-detector.ts`** — non-column identifiers, see above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
71f64025d8 |
triage census — core/live-agent-count.ts: the literal fallback is NOT fixture-only (finding, 2 sites still open) (#2604)
Taking `packages/core/src/live-agent-count.ts` from the shared triage-guard backlog. **This PR does not convert it** — it corrects a comment that would have stopped the conversion, and records why the conversion is not a one-liner. ## Per-file guard count | File | Before | After | Note | |---|---:|---:|---| | `packages/core/src/live-agent-count.ts` | 2 | **2** | not converted — see below | Census across `packages/*/src` excluding tests, for the pattern `column === "triage"` / `column !== "triage"`: | File | Sites | |---|---:| | `engine/self-healing.ts` | 10 | | `dashboard/src/routes/register-task-workflow-routes.ts` | 7 | | `core/task-store/comments-ops.ts` | 3 | | `dashboard/src/routes/board-workflows.ts` | 2 | | `core/task-store/task-creation.ts` | 2 | | `core/live-agent-count.ts` | 2 | | `engine/spec-staleness.ts`, `engine/replan-target.ts`, `engine/mission-feature-sync.ts`, `core/types/archive-planning.ts` | 1 each | ## The finding The comment in this file asserted the literal fallback was unreachable: > *"The literal fallback is fixture-only; board/store callers always supply flags/IR."* **It is false.** `useExecutorStats` resolves `columnFlagsByTaskId?.get(task.id) ?? columnFlagsById?.get(task.column)` — `undefined` for any card whose column is absent from the board's flag map, which is exactly the renamed-or-undeclared column case. So the literals run in production, on the cards least likely to match them. **Consequence is under-reporting, not a stall.** A card in a renamed planner column matches neither `triage` nor `todo`, so `isWaitingAgentTask` returns false and the footer's queued count silently omits it. Default-workflow cards still match through the `todo` arm after the Planning merge, which is why nothing looks broken — the same "still fires via the todo arm" shape as the executor sites I audited in #2572, but here with a real observable effect. ## Why I did not convert it Removing the id guesses means deciding what an **absent flag set** should mean, and `"not intake"` is as much a guess as `"todo is intake"` — either choice moves the numbers the operator sees in the footer. Doing that safely needs the dashboard's flag-map population understood and a test that pins the queued count, neither of which is a small change. A comment asserting an untrue invariant is worse than no comment: it is precisely what would stop the next person converting these two sites, because they would read it and move on. Correcting it is the useful part I can land with confidence right now; the census entry stays open. ## Verification `tsc --noEmit` on `@fusion/core` clean; `pnpm lint` clean. Comment-only change to production source, so no behaviour change and no changeset. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ad3dc202f8 |
P0: a fresh project created every task into a column its workflow no longer declares (#2589)
Highest-severity finding of the post-merge audit, and it is the
**out-of-the-box** shape rather than an edge case.
## The defect
`createTask` resolves the intake column only as a by-product of
materializing the project's default workflow. A project that has never
**explicitly** set a default workflow has no persisted default row — so
that materialization returns nothing, `resolvedEntryColumn` stays
`undefined`, and the row falls through to the hard-coded `|| "triage"`.
Post-merge, that column does not exist in the default workflow.
Measured, three creates on one store:
| create | column |
|---|---|
| no default row persisted | **`triage`** ← broken |
| default explicitly `builtin:coding` | `todo` |
| explicit `workflowId` | `todo` |
`builtin:coding` is the **implicit** default via `DEFAULT_WORKFLOW_ID`,
and nothing writes a default-workflow row until an operator picks one.
So this was **every new task on a fresh project.**
## What it costs
Triage discovery resolves intake **by trait**, so `isAtIntakeColumn` is
false for a card sitting in `triage` while its workflow says `todo` —
**the card is never admitted for planning.** It isn't in the hold column
either, so hold-release ignores it. Only
`reconcileUndeclaredTaskColumns` eventually re-homes it.
A newly created task is invisible to planning until that sweep runs. Not
a permanent stall, but the first thing an operator does on a new project
is create a task.
## The fix — three parts, and missing any one leaves it half-fixed
1. `resolveDefaultWorkflowIntakeColumn` falls back to
`DEFAULT_WORKFLOW_ID` when no default row is persisted — the implicit
default every other resolver already assumes.
2. Both create paths consult it as a **last** resort before the literal,
so any path that already has an explicit column or a resolved entry
column is untouched.
3. **`isIntakeColumn` honours the same fallback.** Without this the card
lands in the right column but is classified *not*-intake and receives
`generateSpecifiedPrompt` instead of the bootstrap seed — and triage
admits a card only when its `PROMPT.md` reads as a seed, so it would sit
in Planning already looking "planned". FN-8587's failure mode by another
route.
`workflowId: null` ("No workflow") is excluded and asserted — there is
no workflow whose intake could be resolved, so that path keeps the
literal.
## Fixture drift, fixed with intent preserved
Seven tests asserted a created card lands in `triage`. None had their
assertion merely retargeted:
- **`move-task-if-planning`, `delete-task-if-planning`** — the mechanism
under test is the **live predicate**, not the column. Predicates and the
"advanced" column now name where the card actually rests.
- **`task-lifecycle-e2e`, `activity-log-parity`, `mission-store`** —
first-column and first-transition expectations.
- **`workflow-reconciliation-production-shape`** — the subtle one. Its
filler must occupy the **target** workflow's capped `triage` entry
column, but was created *before* the switch and so landed in the
**project default's** intake. It now names its column explicitly, which
makes the fixture independent of the project default — exactly the
coupling that let it drift.
- **`store-create-intake-column`** — the "lands in triage" guard now
names the invariant (the default workflow's *own* intake column) and
keeps a `not.toBe("triage")` so a regression back to the literal still
fails.
## Measured
Core package, against the 47-failure post-merge main baseline: **47
failed / 4413 passed — zero new failures.**
Three engine triage tests are red and are **not from this change**:
verified by stashing these edits and re-running against clean main,
where they fail identically. They arrived with #2515 and belong to the
triage-fixture owner.
Gate 414 + 10 + 71 green. Lint clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* New tasks now consistently start in the default workflow’s `todo`
intake column, including fresh projects without persisted workflow
settings.
* Bootstrap `PROMPT.md` content is now created consistently for all
supported task-creation paths.
* Task movement and deletion behavior now correctly respects current
columns and avoids acting on stale task data.
* Workflow reconciliation and activity tracking now reflect the updated
default task lifecycle.
* **Tests**
* Expanded coverage for intake-column resolution, task lifecycle
transitions, stale candidates, and workflow edge cases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d1cbb8ce90 |
U11: rank assigned work by lifecycle role (1 -> 0), plus two documented non-conversions (#2563)
Based on `main`. Continuing with unassigned work in my area (scheduling/ranking core). ## Measured (drift-review tracking) | file | comparisons before | after | |---|---:|---:| | `packages/core/src/assigned-task-ranking.ts` | **1** | **0** | ## What was wrong `tierForTask` identified the two **actionable** tiers by literal id — `in-progress` → `in_progress`, `todo` → `ready_todo` / `partial_blocked`. The file's own comment already recorded half of this: > Only treating default `todo`/`in-progress` as titled hid assigned work as a bare count But the fix that followed was a **floor, not a fix**: unrecognised columns fall to `other` so work stays *visible*, while a renamed hold column loses `ready_todo` and `partial_blocked` entirely. Work that is genuinely ready to start then ranks **below everything already in progress**, so an agent reading its Wake Delta sees ready work buried. Nothing errors and nothing disappears — the ordering is just wrong, which is how it survived a comment that noticed the adjacent problem. `partial_blocked` is the sharper loss: it's the **only** tier distinguishing "ready" from "waiting on a dependency" for hold-column cards, and it was unreachable for any renamed workflow. ## Two sibling files deliberately NOT converted Checked before assuming work existed: **`live-agent-count.ts` — already trait-driven.** Its literals are the else-branch of `flags ? traits : literals`, and the source says why: *"The literal fallback is fixture-only; board/store callers always supply flags/IR."* Converting a fixture-only fallback would be churn. **`task-priority.ts` → `sortTasksForDisplayColumn` — dead.** No production caller. The dashboard has its own independent implementation in `app/components/taskSorting.ts` with a richer signature (`doneSortMode`, `isArchivedColumn`), and that's the one `Lane.tsx` imports. Core's copy is reached only by its own tests and the barrel export. That's the **third dead export** this unit has found by checking reachability before converting (after the legacy dispatcher and `isRunnableQueuedOverlapCandidate`). Deletion is a separate concern from conversion and is not in this PR. ## Verification - **Mutation-verified:** not threading `roles` through to `tierForTask` fails **4 of 6** new tests - 13 tests green (6 new + the pre-existing ranking suite) - merge gate green (414 + 10 + 71), tsc clean, lint clean No changeset: `@fusion/core` is private. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
969c2cdf1d |
capacity part 4: drop the central global_concurrency table (migration 0037) (#2555)
Final piece of the cross-project cap removal. Enforcement (#2509), settings/API/UI (#2529) are merged; this removes the storage. Nothing read the table. `global_max_concurrent` held the deleted machine-wide cap; `currently_active`/`queued_count` were written only by `acquireGlobalSlot`/`releaseGlobalSlot`, measured earlier in this program to have **no production caller**, so those counters were fiction. Live “N running (all projects)” telemetry comes from `CentralCore.getLiveRunningAgentCounts` and is unaffected. Dropped rather than left unread: a lingering table with plausible-looking counters invites a future reader to trust it — the same trap as a readable-but-ignored settings key. ## The trap this hit, because the first attempt looked correct `schema-applier.ts` warns that *“migrations are registered here explicitly (not auto-discovered from the migrations dir), so a new .sql file that is not wired through a version constant + bookkeeping check silently never runs.”* My first pass added the `.sql`, updated the drizzle model and bumped the baseline — **and the table was still present in a fresh database**. It was caught only because the test asserts the table is *gone* (`to_regclass(...) IS NULL`) rather than merely unreferenced; an absence-of-reference assertion would have passed while the table survived. Now registered properly: `DROP_GLOBAL_CONCURRENCY_VERSION = "0037"`, explicit path constant, applied-check, bookkeeping insert. The historical `0000` baseline is deliberately **not** rewritten — a fresh database CREATEs the table then drops it, converging with upgraded databases without editing history, which is how every prior migration here behaves. Also removed: the drizzle model, the `centralTableNames` entry, and the `replacesCentralSeed` special case in the SQLite migrator (a legacy SQLite `globalConcurrency` table now has no destination and is simply not migrated — correct, since its cap is deleted and its counters were never written). ## Verification `pnpm lint` clean · core `tsc` clean · `pnpm test:gate` green (414 + 10 + 71) · `schema-applier` 75/75 · `sqlite-migrator` 43/43 · full core PG suite **1044 passed / 3 failed** — the same 3 pre-existing (`central-archive-secrets` log-prefix, `workflow-settings-project-identity` legacy fallback ×2). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8578a1d27d |
U8 PR5: thread the implementation exit to the step seam, and declare the stepwise pending-review park (inert) (#2546)
Follows **#2519** (U8 PR4). Both halves are inert — **no behavior
change** — and this removes the blocker PR4 documented.
## What was blocking
PR4 could only land its IR half because the pending-review ending could
not reach a graph edge on the **default** workflow. Three links in the
chain:
| Link | Problem |
|---|---|
| `runGraphTaskStep` | awaited the memoized implementation pass and
**discarded** its result |
| `RunTaskStepResult` / `RunSingleStep` | had nowhere to carry an exit |
| `stepExecute` seam | flattened every ending to `step-done` /
`step-failed` |
All three are fixed. The outcome stays `failure` (the step genuinely did
not complete) while the **value** now names the ending — which is what
`runForeach` propagates upward, since it returns a failing instance's
value as the foreach node's own. Every other ending keeps `step-failed`
byte-identically.
One design note: the exit is a property of the **pass**, not of a step.
A single memoized pass serves every foreach instance, so all instances
report the same ending — correct, because the ending is what stopped the
whole session.
With the value surviving, the stepwise IR declares the same
`review-handoff` park node and `steps --outcome:review-pending-->
review-pending-handoff --success--> end` edge the plain-`execute` shape
got in PR4, inherited by the final-review and Ideas variants that clone
it.
## A bug my own threading introduced, and what caught it
The first threading commit covered **one of the two** paths out of
`runProjectedGraphTaskStep`. The early-return branch carried the exit;
the main path goes through `runTaskStep` in `step-runner.ts`, which
builds its own result and dropped it — i.e. it worked on the path I
happened to read, and not on the path the default workflow actually
takes.
**FN-5436's regression test caught it, not code review.** That is the
second time this test has stood between this unit and a silent
regression, which is worth recording somewhere durable:
`executor-step-session.test.ts > FN-5436: pending-review skip on
no-fn_task_done exit` is the load-bearing test for this area.
## Why the seam flip is still not here
With the threading complete I applied the behavior half again — flip the
execute seam to return `review-pending`, delete the inline
`handoffTaskToReview`, add a named compat classifier for user-authored
graphs. **FN-5436 still failed**: the card did not reach `in-review`, so
something between the seam value and the park node is not routing under
that harness. I have not isolated whether that is the mock store's IR
resolution (it exposes no `getWorkflowDefinition`, so the run resolves
the built-in through a different path), a foreach aggregation detail, or
the park node's own seam.
I stopped rather than keep guessing, and reverted the behavior edits so
this lands green and inert. Shipping a half-routed move is exactly the
failure this unit exists to remove — a lifecycle transition that
silently does not happen. The alternative on offer was to relax
FN-5436's assertion, which would have been appeasing a test that is
telling the truth.
### What the instrumentation showed (done after opening this PR)
I ran the bounded next step rather than leaving it as a note. Two facts,
both measured:
1. **The IR is correct.** Resolving
`BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR` at runtime shows the
node and the edge survive the final-review variant's edge rewiring:
```
EDGES [{"from":"steps","to":"browser-verification","condition":"success"},
{"from":"steps","to":"review-pending-handoff","condition":"outcome:review-pending"},
{"from":"steps","to":"end","condition":"failure"}]
HAS NODE true
```
That matters because the variant does `template.edges = [ ... ]` (a
wholesale replacement) and filters outer edges touching `review` —
`review-pending-handoff` is not `review`, so it survives. Worth knowing
before anyone adds another node near it.
2. **The `stepExecute` seam is never invoked in that harness**, even
though the run terminates at `steps#0:step-execute` and the
implementation session demonstrably runs (`"Agent finished without
calling fn_task_done but Step 0 is blocked on pending review"` is in the
task log). A `console.log` at the seam's value computation produced no
output. So the exit is threaded correctly and the IR can route it, but
under this harness the value never originates.
3. **Nor is `createPromptLikeHandler`'s returned handler.**
Instrumenting its dispatch (`node.id` + resolved seam) produced nothing
either — so the node is not reaching the prompt-like path at all.
**Control experiment, because a negative result from instrumentation is
worthless until you prove the instrumentation is observable.** A
`process.stderr.write` at module load of the same file appears exactly
once in the same run, so writes from that module *are* captured under
this harness and the two negatives above are real, not artifacts of
swallowed output.
That narrows the remaining work to one question — what actually drives
`steps#0:step-execute` in this run, if neither the prompt-like handler
nor the `stepExecute` seam does — and rules out the IR, the foreach
propagation, the threading, and the instrumentation as suspects.
**Next step, now much narrower:** find the handler registration this run
resolves for a foreach instance node (the graph executor's handler map,
not the seam table), then flip the seam, delete the inline handoff, and
update the three ratchets that will correctly fire — PR3's routing pin,
the out-of-band adjacency check, and PR1's ownership ledger
(`runImplementation` 3 → 2; `handleGraphFailure` 0 → 1 for custom graphs
only).
## Verification
- `executor-step-session` + exit-events + ownership ledger +
graph-boundary — **56 tests green**
- `builtin-workflows` + `builtin-coding-workflow-ir` — green. The
layout-completeness contract required a layout entry for the new node in
all four stepwise-derived workflows; placed off the main line, because a
park is an exit and not a stage.
- `pnpm test:gate` green (10 / 309 / 71); `pnpm lint` clean; `tsc
--noEmit` clean
- Changeset included (`patch`, `internal`)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|