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
+33
View File
@@ -189,6 +189,39 @@ describe("lanes lib", () => {
lanes.deleteLane(l.id);
});
it("declaring an EARLIER stage drops the records of every node ahead of it", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/rollback" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
lanes.setStage(l.id, { stage: "done", evidence: "shipped" });
// A new request arrives and the session declares back at `plan`. The old
// pass's tests/done records must not keep rendering `done` ahead of it.
const after = lanes.setStage(l.id, { stage: "plan" });
assert.equal(after.stages.tests, undefined);
assert.equal(after.stages.done, undefined);
assert.equal(after.stages.plan.evidence, "docs/plan.md"); // its own record survives
const nodes = lanes.lanePayload(after).pipeline_nodes;
assert.equal(nodes.find((n) => n.id === "plan").state, "current");
assert.equal(nodes.find((n) => n.id === "tests").state, "pending");
assert.equal(nodes.find((n) => n.id === "done").state, "pending");
lanes.deleteLane(l.id);
});
it("moving FORWARD keeps every earlier record, and an unplaceable stage prunes nothing", () => {
const l = lanes.createLane({ cwd: "/tmp/wt/rollback-fwd" });
lanes.setStage(l.id, { stage: "plan", evidence: "docs/plan.md" });
const fwd = lanes.setStage(l.id, { stage: "tests", evidence: "npm test" });
assert.equal(fwd.stages.plan.evidence, "docs/plan.md");
// A stage matching no node has no position, so direction is unknown.
const typo = lanes.setStage(l.id, { stage: "implemnt" });
assert.equal(typo.stages.plan.evidence, "docs/plan.md");
assert.equal(typo.stages.tests.evidence, "npm test");
lanes.deleteLane(l.id);
});
it("resolves a lane from a session cwd by longest path-boundary prefix", () => {
const outer = lanes.createLane({ cwd: "/tmp/wt" });
const inner = lanes.createLane({ cwd: "/tmp/wt/inner" });