fix(lanes): keep stage detection honest across rollbacks and feature switches

Three defects let a lane's pipeline map claim progress that was not this
pass's work, all of them on the "going back" path a new request takes:

- `setStage` cleared the detection columns on a real transition but never
  pruned the `stages` records AHEAD of the new stage. A lane that ran through
  `done` and was then declared back at `plan` rendered `plan` as `current`
  while `tests`/`review`/`done` still read `done`, carrying the previous
  pass's evidence, and contradicting `progress` (computed from the declared
  index alone). A backwards declaration now drops every record it can prove
  is ahead; a key matching no node is unplaceable and left alone.
- `activateFeature` rewrote the live lane row for the incoming feature but
  carried the outgoing one's `detected_*` across. A never-seen slug starts at
  `stage='idle'`/`stages={}`, exactly the shape `inferredDisplayStage` renders
  from detection, so the new feature opened painted with the old one's
  progress — and forward-only rejected every real detection for it until the
  carried-over one aged past DETECTION_TTL_MS. Now cleared, like every other
  real transition; deliberately not archived, since detection is live-only.
- `codex-ingest` classified a rollout journal by raw `now - mtime`, which goes
  negative for a future mtime (clock stepped back, NTP correction, a restore
  that preserved timestamps) and read as "written milliseconds ago". A
  finished thread's journal could replay as live work; a file claiming to be
  from the future is now idle.

Also fixes `check-headers.sh`, which audited via `find` and so reported local
gitignored runtime state (a lane's own `.ccam/profile/hooks/*.sh`) that is not
this repo's source and can never be made compliant here. It now lists files
through git (tracked + untracked-but-not-ignored), so a file an agent just
wrote is still caught before it is staged while ignored state is not.

Tests: 1359 pass (4 new). Header audit exits 0.
This commit is contained in:
2026-09-03 15:29:08 +07:00
parent 13b2bc4ab6
commit 8a812a174b
11 changed files with 188 additions and 16 deletions
+48 -2
View File
@@ -529,7 +529,13 @@ by pipeline template:
Archived features matter here: a lane's own `stages` only holds its current
feature, so without them the history would reset every time a lane starts new
work. The Workflows page renders this as one bar per node (see
work. This is also why new work reported as a bare backwards `ccam stage` (see
"Declaring an EARLIER stage") contributes less here than the same work reported
under `ccam feature activate`: the backwards declaration prunes the records
ahead of it, so the previous pass leaves nothing behind to count, while a
feature switch archives them first. Neither ever counted *passes* — one lane row
contributes at most one record per node, however many times it ran — so this
narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see
`client/src/components/workflows/LanePipelinePanel.tsx`).
`?lane=<id>` narrows the same aggregation to one lane and its own archived
@@ -624,6 +630,39 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status
- `--result pass|fail` (optional): explicitly mark the stage as passed or failed. Ignored if `result` was already set; set once, never overwritten by a re-report.
### Declaring an EARLIER stage (going back)
A declaration may move a lane backwards — that is how a fix-loop re-entry, or a
new request landing on a lane that already reached `done`, is reported:
```bash
ccam stage plan --note "new request: export to CSV"
```
Declaring a stage that sits **before** the lane's current one does two extra
things beyond the ordinary transition:
- **Clears the detected columns**, like every real transition (see "Inferred vs.
declared" below).
- **Drops the `stages` records of every node ahead of the new one.** Those
records belong to the previous pass. Left in place, a lane declared back at
`plan` would render `plan` as `current` while `tests`/`review`/`done` still
read `done` — carrying the *old* work's evidence — and `progress` (computed
from the declared index alone) would contradict the map beside it. A gate
ahead of where the agent now says it is has not been passed for this pass; it
has to be re-earned and re-declared.
The node the declaration lands on keeps its own record (its evidence and
`result` survive), and `stage_since` restarts, so time-on-phase measures this
pass. Records stored under a key matching **no** node in the pipeline are left
untouched — their position is unknown, so they cannot be proven to be ahead;
for the same reason, declaring a stage that matches no node prunes nothing.
Going back this way is a *declaration*, not a reset: the lane, its worktree, and
its archived features are untouched. To start a genuinely separate piece of work
with its own archived pipeline, use `ccam feature activate <slug>` instead (see
"Per-feature state and archive").
### Which lane a CLI command addresses
Every cwd-resolving `ccam` command (`stage`, `lanes pipeline`, `feature
@@ -760,9 +799,12 @@ This is a **deliberately different rule** from `worktree.js:slugify`'s branch-na
1. Archives the current active feature (if any and if different from the target slug) — copies its live stage/status/notes to the archive
2. Restores the target slug's saved pipeline onto the live lane row — so switching back to a past feature resumes its pipeline exactly where it left off
3. Creates a fresh feature row for a never-seen slug (empty stage/status/notes)
4. Clears the live lane's detected columns (`detected_stage`/`detected_signal`/`detected_at`), the same way `ccam stage` and `clear` do
This means activating an archived feature twice resumes the same pipeline both times.
Step 4 matters because a detection belongs to the feature that produced it. A never-seen slug starts at `stage='idle'` with `stages={}`, which is exactly the shape `inferredDisplayStage` renders from detection — so a carried-over `detected_stage` would paint the brand-new feature's map with the previous one's progress, and (detection being forward-only) reject every real detection for the new feature until the old one aged past `DETECTION_TTL_MS`. Detection is live-only state and is deliberately **not** snapshotted onto the outgoing archive row: switching back to a feature restores what was declared, never what was inferred.
### The Workspace feature picker
The Workspace page's feature picker (read-only) shows every archived and active feature for the lane. Selecting one displays its saved pipeline. **It never changes the live lane** — a read-only view matching the standing rule that "the console never writes a lane's stage". Changing the active feature requires the CLI or the API route, not a UI button.
@@ -816,7 +858,11 @@ codex-only details:
- Only bytes appended since the last pass are read. On the first sight of a
journal only its recent tail is read, and nothing at all when that file is
already idle — replaying a thread that finished days ago would paint stages
for work nobody is doing.
for work nobody is doing. "Idle" means the journal's mtime is more than two
minutes old **or in the future**: a file that claims to be from the future
(clock stepped back, NTP correction, a restore that preserved timestamps)
makes a raw `now - mtime` negative, which would otherwise read as "written
milliseconds ago" and replay a finished thread as live work.
- The same pass mirrors the lane's `status` between `idle` and `running` from
the journal's mtime, under the identical guard hook ingestion uses (only a
lane with no `run_id`, and only from `idle`/`running` — never from