import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { rm, appendFile } from "fs/promises"; import path from "path"; import { TodoStore, CycleError, openStore } from "./todoStore"; const isWindows = process.platform === "win32"; const TEST_ROOT = isWindows ? path.join(process.env.TEMP || "C:\\Temp", "todostore-test-" + process.pid) : "/tmp/todostore-test-" + process.pid; describe("TodoStore", () => { beforeEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); }); afterEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); }); it("create() assigns sequential ids and the kind's initial state", async () => { const s = await openStore(TEST_ROOT); const a = await s.create({ summary: "write the spec", kind: "doc" }); const b = await s.create({ summary: "ship the feature", kind: "code" }); expect(a._id).toBe("T1"); expect(a.state).toBe("drafting"); expect(b._id).toBe("T2"); expect(b.state).toBe("doing"); }); it("transition() across an ungated edge succeeds with no approval needed", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); const moved = await s.transition(t._id, "merged"); expect(moved.state).toBe("merged"); }); it("transition() across a nonexistent edge is refused", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); await expect(s.transition(t._id, "done")).rejects.toThrow(/no transition/); }); it("transition() across a gated edge is refused until the gate is satisfied", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc", owner: "worker" }); await s.transition(t._id, "review"); await expect(s.transition(t._id, "done")).rejects.toThrow(/requires gate "human-approved"/); }); it("INDEPENDENT VERIFICATION: approve() refuses when the validator is the task's own owner (self-certification blocked)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc", owner: "worker" }); await s.transition(t._id, "review"); await expect(s.approve(t._id, "human-approved", "worker")).rejects.toThrow( /independent verification required/, ); // case-insensitive match await expect(s.approve(t._id, "human-approved", "WORKER")).rejects.toThrow( /independent verification required/, ); // whitespace must not bypass the check either direction (codex-review Important) await expect(s.approve(t._id, "human-approved", "worker ")).rejects.toThrow( /independent verification required/, ); const t2 = await s.create({ summary: "y", kind: "doc", owner: " worker " }); await s.transition(t2._id, "review"); await expect(s.approve(t2._id, "human-approved", "worker")).rejects.toThrow( /independent verification required/, ); }); it("CONCURRENT array-field mutations on the SAME task both land — neither addDep() silently overwrites the other's blockedBy write (codex-review round-5 Important)", async () => { const s = await openStore(TEST_ROOT); const target = await s.create({ summary: "target", kind: "code" }); const blockerA = await s.create({ summary: "a", kind: "code" }); const blockerB = await s.create({ summary: "b", kind: "code" }); // Fired concurrently (both start before either awaits its own lock) — // without the write lock serializing the reload-recompute-write cycle, // whichever addDep() call's jsonl.updateById landed SECOND would have // built its blockedBy array from a snapshot taken before the FIRST // call's write, silently dropping it. await Promise.all([s.addDep(target._id, blockerA._id), s.addDep(target._id, blockerB._id)]); expect(s.get(target._id)?.blockedBy.sort()).toEqual([blockerA._id, blockerB._id].sort()); }); it("approve() by a DIFFERENT identity succeeds, records evidence, and unblocks the transition", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc", owner: "worker" }); await s.transition(t._id, "review"); const approved = await s.approve(t._id, "human-approved", "reviewer", { note: "looks good", link: "https://example/pr/1", }); expect(approved.satisfiedGates).toEqual(["human-approved"]); expect(approved.verifyEvidence).toHaveLength(1); expect(approved.verifyEvidence[0]).toMatchObject({ gate: "human-approved", validator: "reviewer", note: "looks good", link: "https://example/pr/1", }); const done = await s.transition(t._id, "done"); expect(done.state).toBe("done"); // the satisfied-gate flag is consumed by the transition — cannot be replayed expect(done.satisfiedGates).toEqual([]); // evidence is NOT duplicated: transition() does not append a second entry expect(done.verifyEvidence).toHaveLength(1); }); it("create() stores acceptanceCriteria, and approve() snapshots it into GateEvidence at approval time — later edits don't retroactively change the recorded snapshot (Milestone 1.5)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc", owner: "worker", acceptanceCriteria: "the spec covers all 5 lifecycle kinds", }); expect(t.acceptanceCriteria).toBe("the spec covers all 5 lifecycle kinds"); await s.transition(t._id, "review"); const approved = await s.approve(t._id, "human-approved", "reviewer"); expect(approved.verifyEvidence[0]?.acceptanceCriteriaAtApproval).toBe( "the spec covers all 5 lifecycle kinds", ); // editing the criteria AFTER approval must not change the already- // recorded snapshot — the audit trail is append-only history, not a // live reference to the current field. await s.setAcceptanceCriteria(t._id, "the spec ALSO covers the human kind"); const fresh = s.get(t._id)!; expect(fresh.acceptanceCriteria).toBe("the spec ALSO covers the human kind"); expect(fresh.verifyEvidence[0]?.acceptanceCriteriaAtApproval).toBe( "the spec covers all 5 lifecycle kinds", ); }); it("approve() omits acceptanceCriteriaAtApproval entirely when the task never had criteria set", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc", owner: "worker" }); await s.transition(t._id, "review"); const approved = await s.approve(t._id, "human-approved", "reviewer"); expect(approved.verifyEvidence[0]?.acceptanceCriteriaAtApproval).toBeUndefined(); }); it("setAcceptanceCriteria() refuses empty text and refuses an unknown task id", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc" }); await expect(s.setAcceptanceCriteria(t._id, "")).rejects.toThrow(/must not be empty/); await expect(s.setAcceptanceCriteria("T99", "text")).rejects.toThrow(/no such task/); }); it("setOwner() reassigns a task, trimming the value and refusing an empty one", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); expect((await s.setOwner(t._id, " lane-b ")).owner).toBe("lane-b"); await expect(s.setOwner(t._id, " ")).rejects.toThrow(/must not be empty/); await expect(s.setOwner("T99", "lane-b")).rejects.toThrow(/no such task/); }); it("setOwner() with expectedOwner refuses when the owner changed underneath — the lost-update guard `ay todo claim` relies on", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "contested", kind: "code" }); // Two claimers both read "unowned"; the first one writes. await s.setOwner(t._id, "lane-a", null); // The second one is still acting on its stale read, and must lose loudly // rather than silently overwrite a claim it never saw. await expect(s.setOwner(t._id, "lane-b", null)).rejects.toThrow(/owner changed to lane-a/); expect(s.get(t._id)?.owner).toBe("lane-a"); // A claimer that DID see the current owner is allowed through. expect((await s.setOwner(t._id, "lane-b", "lane-a")).owner).toBe("lane-b"); // And omitting expectedOwner skips the check entirely. expect((await s.setOwner(t._id, "lane-c")).owner).toBe("lane-c"); }); it("setAcceptanceCriteria() refuses WHITESPACE-only text too, and trims what it stores (codex-review round-8 Important: a blank-looking value must not silently pass as real criteria)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc" }); await expect(s.setAcceptanceCriteria(t._id, " ")).rejects.toThrow(/must not be empty/); await expect(s.setAcceptanceCriteria(t._id, "\t\n")).rejects.toThrow(/must not be empty/); const updated = await s.setAcceptanceCriteria(t._id, " real criteria "); expect(updated.acceptanceCriteria).toBe("real criteria"); }); it("create() treats a whitespace-only acceptanceCriteria as not provided (never stored), and trims a real one", async () => { const s = await openStore(TEST_ROOT); const blank = await s.create({ summary: "x", kind: "doc", acceptanceCriteria: " " }); expect(blank.acceptanceCriteria).toBeUndefined(); const real = await s.create({ summary: "y", kind: "doc", acceptanceCriteria: " real one " }); expect(real.acceptanceCriteria).toBe("real one"); }); it("approve() cannot have its audit trail falsified via the evidence argument — gate/validator/passedAt are trusted, never caller-overridable (codex-review round-6 Important)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc", owner: "worker" }); await s.transition(t._id, "review"); // evidence is typed to only note/link, but an untyped JS caller (or a // bug) could still pass extra fields at runtime; the store must not let // them win regardless of what TypeScript alone would forbid, so this // simulates that via an untyped value rather than fighting the compiler. const forgedEvidence = { note: "legit note", link: "https://example/legit", gate: "forged-gate", validator: "forged-validator", passedAt: "1999-01-01T00:00:00.000Z", } as unknown as { note?: string; link?: string }; const approved = await s.approve(t._id, "human-approved", "reviewer", forgedEvidence); const entry = approved.verifyEvidence[0]!; expect(entry.gate).toBe("human-approved"); expect(entry.validator).toBe("reviewer"); expect(entry.passedAt).not.toBe("1999-01-01T00:00:00.000Z"); expect(entry.note).toBe("legit note"); expect(entry.link).toBe("https://example/legit"); }); it("approve() with an empty owner allows any validator (nothing to compare against) but still requires one", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc" }); // no owner await s.transition(t._id, "review"); const approved = await s.approve(t._id, "human-approved", "anyone"); expect(approved.satisfiedGates).toEqual(["human-approved"]); await expect(s.approve(t._id, "human-approved", "")).rejects.toThrow( /requires a validatorIdentity/, ); }); it("approve() refuses a gate name that is not on any edge from the task's current state", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "doc", owner: "worker" }); await expect(s.approve(t._id, "human-approved", "reviewer")).rejects.toThrow( /not a gate on any transition/, ); // still in "drafting" }); it("REGISTERED gates cannot be satisfied by transition() OR approve() — only verify()", async () => { const s = await openStore(TEST_ROOT); s.registerGate({ name: "verify-green", check: async () => ({ passed: true }) }); const t = await s.create({ summary: "x", kind: "code", owner: "worker" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); await expect(s.transition(t._id, "done")).rejects.toThrow(/registered gate.*verify\(/); await expect(s.approve(t._id, "verify-green", "someone-else")).rejects.toThrow( /cannot be approved manually/, ); }); it("verify() refuses to apply a transition if the task's state changed WHILE the (possibly slow) gate check was running (codex-review round-4 Critical)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); // "verify-red" resolves instantly and true, completing a REAL, valid // gated transition (verifying -> verify-failed) via the public API — // simulating a second, concurrent caller (a different process/agent) // finishing its own verify() call for the SAME task while our // "verify-green" check is still in flight. s.registerGate({ name: "verify-red", check: async () => ({ passed: true }) }); s.registerGate({ name: "verify-green", check: async () => { await s.verify(t._id, "verify-red"); // the "concurrent" verify() return { passed: true }; }, }); await expect(s.verify(t._id, "verify-green")).rejects.toThrow( /state changed from "verifying" to "verify-failed"/, ); expect(s.get(t._id)?.state).toBe("verify-failed"); // the concurrent verify()'s result stands; the stale one did NOT stomp it }); it("verify() with a passing registered gate moves to the gated state and records the gate name as validator", async () => { const s = await openStore(TEST_ROOT); s.registerGate({ name: "verify-green", check: async () => ({ passed: true, note: "canary green", link: "https://ci/run/1" }), }); const t = await s.create({ summary: "x", kind: "code", owner: "worker" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); const verified = await s.verify(t._id); expect(verified.state).toBe("done"); expect(verified.verifyEvidence.at(-1)).toMatchObject({ gate: "verify-green", validator: "gate:verify-green", note: "canary green", link: "https://ci/run/1", }); }); it("verify() passes the task record being checked to the registered gate's check(), so one shared gate name can distinguish between concurrently-verified tasks (codex-review round-6 Important)", async () => { const s = await openStore(TEST_ROOT); const seen: string[] = []; s.registerGate({ name: "verify-green", check: async (record) => { seen.push(record._id); return { passed: true, note: `checked ${record.summary}` }; }, }); const a = await s.create({ summary: "task a", kind: "code", owner: "worker" }); const b = await s.create({ summary: "task b", kind: "code", owner: "worker" }); for (const t of [a, b]) { await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); } const verifiedA = await s.verify(a._id); const verifiedB = await s.verify(b._id); expect(verifiedA.verifyEvidence.at(-1)?.note).toBe("checked task a"); expect(verifiedB.verifyEvidence.at(-1)?.note).toBe("checked task b"); expect(seen).toEqual([a._id, b._id]); }); it("verify() with a failing check takes the SIBLING edge (verify-failed), not done — the failure is a real distinct state, not silently dropped", async () => { const s = await openStore(TEST_ROOT); s.registerGate({ name: "verify-green", check: async () => ({ passed: false, note: "canary red: 2 tests failed" }), }); const t = await s.create({ summary: "x", kind: "code", owner: "worker" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); const result = await s.verify(t._id); expect(result.state).toBe("verify-failed"); expect(result.verifyEvidence.at(-1)?.note).toBe("canary red: 2 tests failed"); // the evidence entry names the SIBLING edge's own gate ("verify-red"), // never the checked gate that actually reported not-passed // ("verify-green") — an evidence entry means "this gate passed" // (codex-review Important) expect(result.verifyEvidence.at(-1)?.gate).toBe("verify-red"); }); it("verify-failed reopens ONLY back to doing, via a normal transition() call (real doing->verifying cycle preserved)", async () => { const s = await openStore(TEST_ROOT); s.registerGate({ name: "verify-green", check: async () => ({ passed: false }) }); const t = await s.create({ summary: "x", kind: "code", owner: "worker" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); const failed = await s.verify(t._id); expect(failed.state).toBe("verify-failed"); const reopened = await s.transition(t._id, "doing"); expect(reopened.state).toBe("doing"); await expect(s.transition(t._id, "verifying")).rejects.toThrow(/no transition/); // must go through merged/shipped again const remerged = await s.transition(t._id, "merged"); expect(remerged.state).toBe("merged"); }); it("verify() with no registered gate for the current state throws", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); await expect(s.verify(t._id)).rejects.toThrow(/no gated transition/); // still "doing", which has no gated outgoing edge }); it("verify() with an explicit gateName that matches no edge from the current state throws", async () => { const s = await openStore(TEST_ROOT); s.registerGate({ name: "verify-green", check: async () => ({ passed: true }) }); const t = await s.create({ summary: "x", kind: "code" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); await expect(s.verify(t._id, "no-such-gate")).rejects.toThrow(/no registered gate found/); }); it("verify() failing with no sibling edge to fall back to throws instead of silently dropping the failure", async () => { const s = await openStore(TEST_ROOT); s.registerGate({ name: "human-approved", check: async () => ({ passed: false, note: "not ready" }), }); const t = await s.create({ summary: "x", kind: "doc" }); await s.transition(t._id, "review"); // "review" has ONE outgoing gated edge (to done) — no sibling await expect(s.verify(t._id)).rejects.toThrow(/no alternate transition/); }); it("isRegisteredGate reports registration status by name", async () => { const s = await openStore(TEST_ROOT); expect(s.isRegisteredGate("verify-green")).toBe(false); s.registerGate({ name: "verify-green", check: async () => ({ passed: true }) }); expect(s.isRegisteredGate("verify-green")).toBe(true); }); it("dep add/rm: sorted, deduped, rejects self-dep, missing target, and transitive cycles", async () => { const s = await openStore(TEST_ROOT); const a = await s.create({ summary: "a", kind: "code" }); const b = await s.create({ summary: "b", kind: "code" }); const c = await s.create({ summary: "c", kind: "code" }); await expect(s.addDep(a._id, a._id)).rejects.toThrow(/cannot depend on itself/); await expect(s.addDep(a._id, "T99")).rejects.toThrow(/no such task/); const added = await s.addDep(c._id, a._id); expect(added.blockedBy).toEqual([a._id]); await s.addDep(b._id, a._id); await s.addDep(c._id, b._id); // a <- b <- c (c depends on both a and b); a depending on c would cycle await expect(s.addDep(a._id, c._id)).rejects.toThrow(CycleError); const removed = await s.rmDep(c._id, a._id); expect(removed.blockedBy).toEqual([b._id]); }); it("list() filters by kind/state/owner/tag/blocked", async () => { const s = await openStore(TEST_ROOT); await s.create({ summary: "a", kind: "code", owner: "Alice", tags: ["proj-x"] }); const b = await s.create({ summary: "b", kind: "doc" }); await s.setBlock(b._id, { type: "blocked-by-human", who: "bob" }); expect(s.list({ owner: "alice" }).map((t) => t.summary)).toEqual(["a"]); // case-insensitive expect(s.list({ tag: "proj-x" }).map((t) => t.summary)).toEqual(["a"]); expect(s.list({ kind: "doc" }).map((t) => t.summary)).toEqual(["b"]); expect(s.list({ blocked: true }).map((t) => t.summary)).toEqual(["b"]); }); it("a done task with a leftover block is not counted by --blocked (mirrors a private sibling CLI's equivalent fix)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "a", kind: "human" }); await s.setBlock(t._id, { type: "blocked-by-human", who: "x" }); await s.approve(t._id, "human-replied", "x"); await s.transition(t._id, "decided"); await s.transition(t._id, "done"); expect(s.list({ blocked: true })).toEqual([]); }); it("re-opening the store (new TodoStore.open call) sees writes made before it, including from a different instance", async () => { const s1 = await openStore(TEST_ROOT); await s1.create({ summary: "persisted", kind: "code" }); const s2 = await openStore(TEST_ROOT); expect(s2.list().map((t) => t.summary)).toEqual(["persisted"]); expect(s2.get("T1")?._id).toBe("T1"); }); it("a cleared block ACTUALLY clears after a fresh reload — not just in the instance that cleared it (codex-review Critical)", async () => { const s1 = await openStore(TEST_ROOT); const t = await s1.create({ summary: "x", kind: "code" }); await s1.setBlock(t._id, { type: "blocked-by-human", who: "someone" }); await s1.setBlock(t._id, null); // the bug: JSON.stringify({block: undefined}) drops the key, so the // clearing update line carried no `block` field at all, and the merge // `{...existing, ...doc}` left the OLD block value in place once a // DIFFERENT (or freshly reloaded) instance read it back from disk. const s2 = await openStore(TEST_ROOT); expect(s2.get(t._id)?.block).toBeFalsy(); }); it("N concurrent OS processes calling create() against the same store never collide on an id (codex-review Critical)", async () => { const N = 8; const script = path.join(TEST_ROOT, "create-once.ts"); const { writeFileSync, mkdirSync } = await import("fs"); mkdirSync(TEST_ROOT, { recursive: true }); writeFileSync( script, `import { openStore } from ${JSON.stringify(path.join(import.meta.dirname, "todoStore.ts"))};\n` + `const s = await openStore(${JSON.stringify(TEST_ROOT)});\n` + `const t = await s.create({ summary: "concurrent", kind: "code" });\n` + `console.log(t._id);\n`, ); // node:child_process, not Bun.spawn — vitest here runs under the node matrix too const { spawn } = await import("node:child_process"); const runOne = () => new Promise((resolve, reject) => { const p = spawn("bun", [script], { stdio: ["ignore", "pipe", "inherit"] }); let out = ""; p.stdout.on("data", (chunk) => (out += chunk.toString())); p.on("error", reject); p.on("close", () => resolve(out.trim())); }); const outputs = await Promise.all(Array.from({ length: N }, runOne)); expect(new Set(outputs).size).toBe(N); // every id distinct — none silently overwritten const s = await openStore(TEST_ROOT); expect(s.list()).toHaveLength(N); // every task actually persisted, not clobbered }, 30_000); it("a live (fresh, non-stale) write lock held by someone else makes create() wait and then throw a clear timeout — proper-lockfile's own retry budget, never a silent unlocked proceed (codex-review round-4 Critical: the store's write lock now delegates entirely to proper-lockfile instead of a hand-rolled mkdir/token scheme)", async () => { const s = await openStore(TEST_ROOT); const { lock: lockfileLock } = await import("proper-lockfile"); const lockPath = path.join(TEST_ROOT, ".agent-yes", "todos.jsonl"); const release = await lockfileLock(lockPath, { lockfilePath: `${lockPath}.writelock`, realpath: false, stale: 10_000, }); try { await expect(s.create({ summary: "x", kind: "code" })).rejects.toThrow( /store write: timed out waiting for the write lock/, ); } finally { await release(); } // once released, create() succeeds normally const rec = await s.create({ summary: "after release", kind: "code" }); expect(rec.summary).toBe("after release"); }, 20_000); it("a STALE write lock (older than proper-lockfile's stale window) is recovered automatically — a crashed holder never wedges future create() calls", async () => { const s = await openStore(TEST_ROOT); const lockPath = path.join(TEST_ROOT, ".agent-yes", "todos.jsonl.writelock"); const { mkdirSync: mkSync, utimesSync } = await import("fs"); // proper-lockfile's own lock representation IS a directory (mkdir-based // locking) — simulate a lock left behind by a crashed process the same // way proper-lockfile itself would have created one. mkSync(lockPath, { recursive: true }); utimesSync(lockPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000)); // well past the 10s stale window const rec = await s.create({ summary: "after stale recovery", kind: "code" }); expect(rec.summary).toBe("after stale recovery"); }); it("verify() on a NON-primary registered gate must not fall back to the sibling on failure (codex-review Critical exploit: register only the failure-oriented gate)", async () => { const s = await openStore(TEST_ROOT); // Only "verify-red" (the SECOND/non-primary gated edge from "verifying") // is registered — "verify-green" (the primary edge, listed first in // todoLifecycle.ts) is NOT. A naive "always fall back to the sibling on // not-passed" implementation would read this false as license to reach // the sibling edge, which happens to be "done". s.registerGate({ name: "verify-red", check: async () => ({ passed: false, note: "not confirmed red" }), }); const t = await s.create({ summary: "x", kind: "code" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); await expect(s.verify(t._id)).rejects.toThrow(/non-primary gate.*not passed/); expect(s.get(t._id)?.state).toBe("verifying"); // unchanged — definitely not "done" }); it("verify() on a non-primary gate DOES apply its own edge when it reports passed", async () => { const s = await openStore(TEST_ROOT); s.registerGate({ name: "verify-red", check: async () => ({ passed: true, note: "confirmed red" }), }); const t = await s.create({ summary: "x", kind: "code" }); await s.transition(t._id, "merged"); await s.transition(t._id, "shipped"); await s.transition(t._id, "verifying"); const result = await s.verify(t._id); expect(result.state).toBe("verify-failed"); }); it("registeredGateNames() lists exactly the gates registered so far", async () => { const s = await openStore(TEST_ROOT); expect(s.registeredGateNames()).toEqual([]); s.registerGate({ name: "verify-green", check: async () => ({ passed: true }) }); s.registerGate({ name: "human-approved", check: async () => ({ passed: true }) }); expect(s.registeredGateNames().sort()).toEqual(["human-approved", "verify-green"]); }); it("markOrphaned() records where the task was and the reassignment candidates, refuses on an already-done or already-orphaned task", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code", owner: "dead-agent" }); await s.transition(t._id, "merged"); const orphaned = await s.markOrphaned(t._id, "dead-agent", ["idle-1", "idle-2"]); expect(orphaned.state).toBe("orphaned"); expect(orphaned.orphanedFrom).toBe("merged"); expect(orphaned.reassignCandidates).toEqual(["idle-1", "idle-2"]); await expect(s.markOrphaned(t._id, "dead-agent", [])).rejects.toThrow(/already "orphaned"/); const done = await s.create({ summary: "y", kind: "human", owner: "dead-agent" }); await s.approve(done._id, "human-replied", "alice"); await s.transition(done._id, "decided"); await s.transition(done._id, "done"); await expect(s.markOrphaned(done._id, "dead-agent", [])).rejects.toThrow(/already "done"/); }); it("markOrphaned() refuses when the FRESH owner no longer matches expectedOwner — a stale decision must not orphan a task reassigned in the meantime (codex-review round-7 Important)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code", owner: "dead-agent" }); // Simulate a concurrent write from another process/mechanism (there is // no `reassign` API yet — this appends a merge line in the exact shape // `jsonl.updateById` itself would write, which is exactly what any real // future writer, in-process or not, would produce on this append-only // file) that reassigns the task AFTER reconcileTodos() would have // snapshotted the old owner. await appendFile( path.join(TEST_ROOT, ".agent-yes", "todos.jsonl"), JSON.stringify({ _id: t._id, owner: "someone-else" }) + "\n", ); await expect(s.markOrphaned(t._id, "dead-agent", [])).rejects.toThrow( /owner changed since this was decided/, ); expect(s.get(t._id)?.state).toBe("doing"); // unchanged — refused, not silently orphaned anyway }); it("clearWaitingOnAgentBlock() clears only when the FRESH block still matches type+agentId, and refuses (without erasing) a block that changed since decided (codex-review round-7 Important)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); await s.setBlock(t._id, { type: "waiting-on-agent", agentId: "a1" }); const cleared = await s.clearWaitingOnAgentBlock(t._id, "a1"); expect(cleared.block).toBeNull(); await s.setBlock(t._id, { type: "blocked-by-human", who: "alice" }); await expect(s.clearWaitingOnAgentBlock(t._id, "a1")).rejects.toThrow( /block changed since this was decided/, ); // refused, not erased: the newer block is still exactly what it was expect(s.get(t._id)?.block).toEqual({ type: "blocked-by-human", who: "alice" }); }); it("clearBlockIfMatches() — the generalized guard — clears only when the FRESH record's blockRev still equals the snapshot, and refuses (without erasing) a block that changed since decided (codex-review round-15 Important)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); const original = { type: "blocked-by-human", who: "alice", question: "canary or beta?", } as const; const afterSet = await s.setBlock(t._id, original); const cleared = await s.clearBlockIfMatches(t._id, afterSet.blockRev ?? 0); expect(cleared.block).toBeNull(); const afterReplace = await s.setBlock(t._id, { type: "blocked-by-human", who: "alice", question: "a NEW question", }); await expect(s.clearBlockIfMatches(t._id, (afterReplace.blockRev ?? 0) - 1)).rejects.toThrow( /block changed since this was decided/, ); // refused, not erased: the newer block survives untouched expect(s.get(t._id)?.block).toEqual({ type: "blocked-by-human", who: "alice", question: "a NEW question", }); }); it("clearBlockIfMatches() catches an ABA race — a block replaced with BYTE-FOR-BYTE IDENTICAL content still gets a fresh blockRev, so a stale caller is refused rather than erasing that distinct, newer block instance (codex-review round-17 Important)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "code" }); const identicalBlock = { type: "blocked-by-human", who: "alice", question: "canary or beta?", } as const; const firstSet = await s.setBlock(t._id, identicalBlock); const capturedRev = firstSet.blockRev ?? 0; // Someone re-sets the EXACT same block content — a content-only // comparison would see no difference at all. await s.setBlock(t._id, identicalBlock); await expect(s.clearBlockIfMatches(t._id, capturedRev)).rejects.toThrow( /block changed since this was decided/, ); // refused, not erased: the (identical-looking but distinct, newer) // block instance survives expect(s.get(t._id)?.block).toEqual(identicalBlock); }); it("answerHumanBlock() applies a gate's evidence, its transition, AND the block clear in ONE atomic write — never a partial application (codex-review round-18 Important, replacing the old approve()+transition()+clearBlockIfMatches() composition)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "pick a channel", kind: "decision" }); const afterSet = await s.setBlock(t._id, { type: "blocked-by-human", who: "alice", options: ["canary", "beta"], }); const answered = await s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, { name: "human-decided", toState: "decided", validator: "alice", note: "canary", }); expect(answered.block).toBeNull(); expect(answered.state).toBe("decided"); expect(answered.verifyEvidence).toEqual([ expect.objectContaining({ gate: "human-decided", validator: "alice", note: "canary" }), ]); }); it("answerHumanBlock() refuses (without applying anything) a stale expectedBlockRev, and separately refuses independent-verification violations (validator === owner) — matching approve()'s own unconditional rule (codex-review round-18 Important)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "decision" }); const afterSet = await s.setBlock(t._id, { type: "blocked-by-human", who: "alice" }); await s.setBlock(t._id, { type: "blocked-by-human", who: "alice" }); // bumps blockRev again await expect( s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, { name: "human-decided", toState: "decided", validator: "alice", note: "acknowledged", }), ).rejects.toThrow(/this ask has changed since it was loaded/); expect(s.get(t._id)?.state).toBe("deciding"); // unchanged — refused before any write const owned = await s.create({ summary: "y", kind: "decision", owner: "alice" }); const ownedBlock = await s.setBlock(owned._id, { type: "blocked-by-human", who: "alice" }); await expect( s.answerHumanBlock(owned._id, ownedBlock.blockRev ?? 0, { name: "human-decided", toState: "decided", validator: "alice", // same identity as owner note: "acknowledged", }), ).rejects.toThrow(/independent verification required/); expect(s.get(owned._id)?.state).toBe("deciding"); // unchanged expect(s.get(owned._id)?.block).not.toBeNull(); // NOT cleared either — refused, not partially applied }); it("answerHumanBlock() re-validates canTransition against the FRESH state, not a value captured before the call — a state change UNRELATED to block (so invisible to the blockRev check alone) must still be caught, matching transition()'s own concurrent-state-drift defense (codex-review round-18 Important)", async () => { const s = await openStore(TEST_ROOT); const t = await s.create({ summary: "x", kind: "decision" }); const afterSet = await s.setBlock(t._id, { type: "blocked-by-human", who: "alice" }); // Simulate a concurrent writer moving the task's STATE (not its block) // to "done" — the same append-only-merge-line technique the // markOrphaned() test above uses. blockRev is untouched, so the // blockRev check alone would see nothing wrong; only re-validating // canTransition against the fresh state catches this. await appendFile( path.join(TEST_ROOT, ".agent-yes", "todos.jsonl"), JSON.stringify({ _id: t._id, state: "done" }) + "\n", ); await expect( s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, { name: "human-decided", toState: "decided", validator: "alice", note: "acknowledged", }), ).rejects.toThrow(/no transition done -> decided.*state changed concurrently/); // refused, not partially applied: block survives untouched, state // stays exactly what the concurrent writer set it to expect(s.get(t._id)?.block).not.toBeNull(); expect(s.get(t._id)?.state).toBe("done"); }); });