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
+16
View File
@@ -303,6 +303,22 @@ describe("codex ingest pass", () => {
lanes.deleteLane(lane.id);
});
it("does not replay a journal whose mtime is in the FUTURE", () => {
// A clock stepped back (or a restore that preserved timestamps) leaves a
// finished thread's mtime ahead of now. Raw `now - mtime` is then negative,
// which reads as "written milliseconds ago" and would replay it as live work.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
const file = writeRollout("rollout-2026-08-27T10-00-00-fut.jsonl", cwd);
const future = Date.now() + 6 * 60 * 60 * 1000;
fs.utimesSync(file, future / 1000, future / 1000);
codex.__resetOffsets();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
lanes.deleteLane(lane.id);
});
it("ignores a torn/partial JSONL line at the end of a file", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
+21
View File
@@ -102,6 +102,27 @@ describe("activateFeature / archiveActiveFeature", () => {
assert.equal(features.getFeature(lane.id, "one").archived_at, null);
});
it("switching features clears the outgoing feature's detection", () => {
// Without this, a brand-new slug (stage='idle', stages={}) renders its map
// from the PREVIOUS feature's detection via inferredDisplayStage, and
// forward-only rejects every real detection for the new feature until the
// carried-over one ages past DETECTION_TTL_MS.
const lane = makeLane();
features.activateFeature(lane.id, "one");
lanesLib.recordDetection(lane.id, { nodeId: "ship", signal: "`git push`" });
assert.equal(lanesLib.getLane(lane.id).detected_stage, "ship");
const { lane: updated } = features.activateFeature(lane.id, "two");
assert.equal(updated.detected_stage, null);
assert.equal(updated.detected_signal, null);
assert.equal(updated.detected_at, null);
// ...and a detection for the new feature lands immediately.
assert.deepEqual(lanesLib.recordDetection(lane.id, { nodeId: "plan", signal: "`Write`" }), {
written: true,
});
});
it("archiveActiveFeature returns null and touches nothing when no feature is active", () => {
const lane = makeLane();
assert.equal(features.archiveActiveFeature(lane.id), null);
+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" });
+7 -1
View File
@@ -266,7 +266,13 @@ function ingestCodexOnce({ broadcast } = {}) {
if (!lane) continue;
const firstSight = !offsets.has(file.path);
const active = Date.now() - file.mtime < CODEX_ACTIVE_MS;
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
// preserved timestamps) makes the raw difference negative, which reads as
// "written milliseconds ago" and would let a finished thread's journal
// replay as live work. A file that claims to be from the future is not
// evidence of an agent working now, so treat it as idle.
const age = Date.now() - file.mtime;
const active = age >= 0 && age < CODEX_ACTIVE_MS;
let from = firstSight ? Math.max(0, file.size - TAIL_BYTES) : offsets.get(file.path);
// A stored offset past EOF means the file was truncated or a new thread took
// the same path. Restart from a bounded tail, never from 0: reading a
+11 -1
View File
@@ -133,6 +133,15 @@ function archiveActiveFeature(laneId) {
* the live row already IS that feature's state, so there's nothing to
* restore and nothing to archive.
*
* A real switch also clears `detected_stage`/`detected_signal`/`detected_at`,
* for the same reason `setStage` and `clearLane` do: a detection belongs to the
* feature that produced it. Carried across a switch it would both paint the new
* feature's map from the old one's activity (a fresh slug has `stage='idle'` and
* `stages={}`, so `inferredDisplayStage` renders that detection verbatim) AND —
* because `recordDetection` is forward-only — reject every real detection for
* the new feature until the old one ages past DETECTION_TTL_MS. Detection is
* live-only state, so it is deliberately NOT archived onto the outgoing row.
*
* @param {number} laneId
* @param {string} rawSlug - Canonicalized internally; the caller's raw input is never stored.
* @param {{title?: string}} [options]
@@ -171,7 +180,8 @@ function activateFeature(laneId, rawSlug, options = {}) {
db.prepare(
`UPDATE lanes SET
stage = ?, stage_since = ?, status = ?, gate_decision = ?, ci_status = ?,
stages = ?, links = ?, notes = ?, active_feature_id = ?, updated_at = ?
stages = ?, links = ?, notes = ?, active_feature_id = ?, updated_at = ?,
detected_stage = NULL, detected_signal = NULL, detected_at = NULL
WHERE id = ?`
).run(
target.stage,
+20
View File
@@ -286,6 +286,17 @@ function reservedPorts(excludeLaneId = null) {
* paint stale progress in the UI AND — because recordDetection is forward-only
* — silently reject every real detection for the new stage until the old one
* ages past DETECTION_TTL_MS.
*
* A transition BACKWARDS additionally drops the `stages` records of every node
* ahead of the new one, for the same reason and with the same consequence in
* the UI. Those records are the previous pass's — a lane that ran through
* `done` and is then declared back at `plan` for a new request would otherwise
* 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 it sits next to. A gate ahead
* of where the agent now says it is has not been passed for this pass; it has
* to be re-earned. Records under a key matching no node in the pipeline are
* left untouched — their position is unknown, so they cannot be proven ahead.
*/
function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id);
@@ -293,6 +304,15 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
const next = stage || lane.stage;
const changed = next !== lane.stage;
const stages = { ...lane.stages };
const pipeline = getPipeline(lane.pipeline);
const nextIdx = phaseIdx(pipeline, next);
const wentBack = changed && nextIdx > -1 && nextIdx < phaseIdx(pipeline, lane.stage);
if (wentBack) {
for (const key of Object.keys(stages)) {
const idx = phaseIdx(pipeline, key);
if (idx > nextIdx) delete stages[key];
}
}
const prev = stages[next] || {};
stages[next] = {
enteredAt: !changed && prev.enteredAt ? prev.enteredAt : nowIso(),