feat(quorum): opt-in thread persistence per slot#374
Conversation
The quorum loop is stateless by default: each round spawns a fresh CLI
invocation with prior-round outputs spliced into the prompt. That's the
documented CE-5 semantics — the convergence check diffs prompt-injected
state, not thread memory.
With quorum.persistent_threads = true (opt-in, default false), this module
enables per-slot CLI session continuity across rounds:
- codex: uses 'codex exec resume <thread_id>'
- claude: uses '-p --resume <session_id>'
- agy: uses '-c' (CWD-scoped continue)
- kimi: uses '-c' (CWD-scoped continue)
Other families fall back to stateless.
Round-1 capture: call-quorum-slot.cjs parses the round-1 stdout for the
session id (codex JSONL thread.started, claude --output-format json) and
writes it to .planning/quorum/sessions/<slot>-<invocation>.json. Round-2+
reads that file and splices the resume argv into argsTemplate BEFORE
{prompt} substitution (the splice runs on argsTemplate, not on the
substituted args, so {prompt} remains a placeholder the existing loop
handles).
GC: every call-quorum-slot invocation sweeps session files older than
24h from .planning/quorum/sessions/, fail-open. Keeps the directory from
growing unbounded across invocations even when callers don't clean up.
DEFAULT_CONFIG carries persistent_threads:false with the same partial-merge
drop-guard as min_live_voters / full_convergence / max_rounds. A garbage
explicit value coerces with a warning; an absent value restores silently.
New: bin/quorum-resume.cjs (helper), bin/quorum-sessions-store.cjs
(scratch under .planning/), bin/quorum-resume.test.cjs (26 PURE tests,
red-proven). Modified: hooks/config-loader.js (DEFAULT_CONFIG + drop-guard),
bin/call-quorum-slot.cjs (splice site + capture site + GC). NO change to
existing callers — default false preserves the stateless CE-5 semantics.
|
Warning Review limit reached
Next review available in: 49 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
WalkthroughThe quorum loop gains an opt-in ChangesQuorum thread persistence
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant QuorumSlot
participant buildSpawnArgs
participant sessionStore
participant ProviderCLI
QuorumSlot->>sessionStore: Read stored session marker
QuorumSlot->>buildSpawnArgs: Build fresh or resume argv
buildSpawnArgs->>ProviderCLI: Execute provider CLI
ProviderCLI-->>QuorumSlot: Return output with session identifier
QuorumSlot->>sessionStore: Save session metadata
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@bin/call-quorum-slot.cjs`:
- Around line 370-383: Move the const require declarations for loadConfig and
sessionStore above the persistentThreads initialization and
sessionStore.gcStale(repoDir) calls, ensuring both dependencies are initialized
before use. Preserve the existing persistentThreads detection and fail-open GC
behavior.
- Line 376: Replace the locally generated invocationId in call-quorum-slot.cjs
with a quorum-run ID supplied by the dispatcher or process environment. Ensure
every short-lived worker process uses the same orchestration-stable ID for all
quorum rounds, while preserving the existing invocationId-based storage and
resume flow.
- Around line 1118-1130: Update the persistentThreads handling around
parseSessionId and sessionStore.write so supported CWD-scoped families that
return null still persist a session record with thread_id: null, while retaining
parsed IDs for families that provide them. Ensure unsupported families are not
marked, and preserve the existing best-effort error handling.
In `@bin/quorum-resume.cjs`:
- Around line 96-108: Update buildResumeArgv for the agy and kimi families to
return the registry-defined prompt arguments, including both -c and -p before
{prompt}. Keep QUORUM_RESUME and call-quorum-slot.cjs consistent with this
contract, and update affected tests from the bare ['-c', '{prompt}'] expectation
to the new form.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: efa32258-682d-4d4d-8a1d-2388184f4490
📒 Files selected for processing (6)
CHANGELOG.mdbin/call-quorum-slot.cjsbin/quorum-resume.cjsbin/quorum-resume.test.cjsbin/quorum-sessions-store.cjshooks/config-loader.js
| const persistentThreads = (() => { | ||
| try { | ||
| const cfg = loadConfig(process.cwd()); | ||
| return !!(cfg && cfg.quorum && cfg.quorum.persistent_threads === true); | ||
| } catch (_) { return false; } | ||
| })(); | ||
| const invocationId = `inv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; | ||
| const repoDir = findProjectRoot(spawnCwd); | ||
|
|
||
| // QPERSIST-05 (GC): on every invocation, sweep stale session files (>24h) so the | ||
| // .planning/quorum/sessions/ directory doesn't grow unbounded across invocations. | ||
| // Runs regardless of the persistent_threads flag (cheap, fail-open) — even callers | ||
| // who never opt in shouldn't leak disk. | ||
| try { sessionStore.gcStale(repoDir); } catch (_) { /* fail-open */ } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Initialize persistence dependencies before using them.
Line 372 accesses loadConfig and Line 383 accesses sessionStore before their const declarations at Lines 433-435 execute. Both throw TDZ ReferenceErrors and are swallowed, so persistentThreads is always false and stale-session GC never runs. Move these require() declarations above this initialization block.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@bin/call-quorum-slot.cjs` around lines 370 - 383, Move the const require
declarations for loadConfig and sessionStore above the persistentThreads
initialization and sessionStore.gcStale(repoDir) calls, ensuring both
dependencies are initialized before use. Preserve the existing persistentThreads
detection and fail-open GC behavior.
…ULT_CONFIG key) PR #374 added `persistent_threads: false` to DEFAULT_CONFIG.quorum with the standard drop-guard. The B1 'fully-valid custom config round-trips unchanged' test asserts validateConfig is a no-op on a fully-valid config, but its fixture's quorum block omitted `persistent_threads`, so the drop-guard restored the default — round-trip comparison failed. Adding `persistent_threads: false` to the fixture so the test reflects the current DEFAULT_CONFIG shape. This is the B1 fixture's purpose: pin that validateConfig doesn't over-coerce on good input. Adding a new DEFAULT_CONFIG key requires updating every fixture that exercises the round-trip. 3/3 CI node jobs were failing on this single test; with this fix they should be green. Verified locally: - config-loader-adversarial2.test.cjs: 6/6 - config-loader.test.js: 9/9 - nf-prompt-cache-budget.test.js: 9/9 (unaffected) - quorum-resume.test.cjs + goal-writer-blocks.test.cjs + release-guards.test.cjs: 65/65
…arseSessionId to extract the id E2E probe found this bug: claude round-1 stdout without --output-format json doesn't include session_id at all (claude prints text + writes the id to a per-CWD JSONL file we don't tail), so parseClaudeJsonId returns null. With the flag, stdout is JSON and parseClaudeJsonId extracts the id. Verified E2E against the live claude binary: round 1: --output-format json → stdout → parseSessionId → 5067db89-9833-... round 2: --resume <id> --output-format json → recalls secret ✓ Note: my initial unit tests passed because they only checked argv SHAPE, not the actual stdout content. The e2e probe caught what the unit tests couldn't. This is the same lesson as the v1 wire-up bug — test the real CLI, not just the argv builder. kimi verified blocked by a separate billing-cycle 403 (the user's own quota), not a code bug. copilot likewise quota-blocked. Both resume branches are correct in code but cannot be exercised today.
The original PR body asserted thread persistence as a generally-helpful opt-in. Three empirical tests on codex/gpt-5.5 (2-round essay, 3-round plan, 5-round complex synthesis) — judged on the FINAL design produced, not on word-reuse proxies — show the assumption is not universally true: stateless is at least as good as persistent in 2/3 task classes and ties the third. Add an 'Empirical findings' paragraph stating this, with the task-class table and a rule of thumb for when to flip the flag on. Default (false) is unchanged — this is a doc correction, not a behavior change.
CodeRabbit review on PR #374 surfaced four findings; three are real, one is a false positive from the 'require hoisted in CommonJS' distinction. (1) agy/kimi never wrote a session marker. parseSessionId returns null for CWD-scoped families; the prior 'if (newId)' guard meant round 2's --c resume path was never selected because there was no marker to read. Fix: write the marker unconditionally for persistent_threads=true, with thread_id:null for CWD-scoped families. Round 2 detects the null id and uses the CWD path. (2) invocationId was per-process. Two separate call-quorum-slot invocations would have different IDs, so round 2 could not find round 1's session file. Fix: read QUORUM_INVOCATION_ID from env; fall back to a per-process id when absent. The orchestrator (commands/nf/quorum.md) is expected to pass this env var for stable cross-round identification. (3) buildResumeArgv for agy/kimi was ['-c', '{prompt}'] — agy reads the prompt from -p non-interactively; without -p, agy reads from stdin and the CLI hangs. Fix: ['-c', '-p', '{prompt}'] matches the registry constant. (4) The require() 'TDZ' concern in call-quorum-slot.cjs is a false positive — CommonJS hoists requires at module load. The structural smell (declares below use) remains and is worth a follow-up cleanup but does not cause runtime failures. Tests: 29/29 in bin/quorum-resume.test.cjs. Live e2e probe confirms agy with -c -p correctly resumes a one-token secret across two invocations. OUT OF SCOPE: passing QUORUM_INVOCATION_ID from the orchestrator agent to call-quorum-slot.cjs is a separate change in commands/nf/quorum.md and bin/quorum-slot-dispatch.cjs. With (1)+(2) in place, persistence works as soon as the orchestrator passes the env var.
…p silently
The autonomous-grind doctrine (~/.claude/goals/autonomous-grind.md) has 17
principles, each citing a specific past failure. Principles that lose
their citation become generic-fluff guidance — exactly the failure mode
the doctrine exists to prevent.
New bin/autonomous-grind.test.cjs pins:
P1 (verify the metric) — the @requirement-annotation coverage trap
P3 (fix the class, not the call site) — the per-call-site fix trap
P4 (no regression gate means it recurs) — the ungated-class trap
P5 / P15 (red-proven needs a committed baseline) — the dirty-tree trap
+ the proof-must-assert-was-real invariant
P16 (test the blast radius) — the touched-files-only trap
P17 (judge by the final decision, not proxies) — stateless-default finding
Plus a meta-test: the doctrine file is in ~/.claude/goals/, not in the
project tree (so it doesn't get polluted by repo work and lives per-user).
Tests pin the *presence* of recorded-failure language and the headline
text. A future edit that drops a principle without acknowledgement fails
CI; an edit that rewrites it without preserving the citation also fails.
10/10 in the new file. test:ci wired.
Why
The CE-5 full-convergence loop is stateless by design: each round spawns a fresh CLI invocation with prior-round outputs spliced into the prompt, and the convergence check diffs that prompt-injected state. That's the documented contract ("the team has nothing left to add").
Sometimes a single conversation is the right shape — the model carries its own reasoning, the orchestrator stops fighting summary-induced drift, and round-2's reasoning builds on round-1's actual argument rather than a re-explanation of state. With this PR, opt-in.
Wire-up
quorum.persistent_threads: true(default false, same drop-guard asmin_live_voters/full_convergence/max_rounds).Per-family resume behavior:
['exec','resume','<thread_id>','--json','--skip-git-repo-check','{prompt}']codex exec resume <id>['-p','--resume','<session_id>','{prompt}']--resume['-c','{prompt}']-c(no id needed)['-c','{prompt}']-c(no id needed)Round-1 capture:
call-quorum-slot.cjsparses the round-1 stdout (codex JSONLthread.started, claude--output-format json) and writes.planning/quorum/sessions/<slot>-<invocation>.json. Round-2+ reads the file and replaces the fresh argv template with the resume argv before{prompt}substitution (whole-template replacement, so each family owns its complete shape — no flag duplication).GC: every invocation sweeps session files >24h old, fail-open. Keeps
.planning/quorum/sessions/from growing unbounded across invocations even when callers don't clean up explicitly.Safety
falsepreserves the current stateless CE-5 semantics.persistentThreadsfield inDEFAULT_CONFIG.What I learned (and a bug I caught)
The first version had a splice-by-index bug — it replaced only the
{prompt}element with the resume argv, but each family's resume argv carries its own flags. The result for codex was:— flags duplicated. A live probe against the actual
buildSpawnArgs(not just the unit tests) caught it. v2 fixes by doing whole-template replacement; the unit tests now assert the correct final argv.I also fixed a latent path bug I'd introduced:
bin/call-quorum-slot.cjsrequired./config-loaderwhich resolves to a nonexistent path. Corrected to../hooks/config-loader. The unit tests would have caught it on first load, but I missed running them on the first commit.Tests — 28/28, red-proven
{prompt}for the existing substitution loop.thread.started.thread_id, claude--output-format json.session_id, agy/kimi return null (CWD-scoped).Three independent mutations all caught the right tests; restoring makes them green again. Broader suite (config-loader, goal-writer, release-guards) all green at 65/65.
🤖 Generated with Claude Code
Summary by CodeRabbit
quorum.persistent_threads) to persist CLI conversation threads/session state across quorum rounds (per slot).