Skip to content

feat(quorum): opt-in thread persistence per slot#374

Open
jobordu wants to merge 6 commits into
mainfrom
nf/quick-431-quorum-thread-persistence
Open

feat(quorum): opt-in thread persistence per slot#374
jobordu wants to merge 6 commits into
mainfrom
nf/quick-431-quorum-thread-persistence

Conversation

@jobordu

@jobordu jobordu commented Jul 25, 2026

Copy link
Copy Markdown

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 as min_live_voters / full_convergence / max_rounds).

Per-family resume behavior:

family round-2 argv mechanism
codex ['exec','resume','<thread_id>','--json','--skip-git-repo-check','{prompt}'] codex exec resume <id>
claude ['-p','--resume','<session_id>','{prompt}'] --resume
agy ['-c','{prompt}'] CWD-scoped -c (no id needed)
kimi ['-c','{prompt}'] CWD-scoped -c (no id needed)
others round-2 falls back to stateless (no false-positive resume)

Round-1 capture: call-quorum-slot.cjs parses the round-1 stdout (codex JSONL thread.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

  • No change for existing callers — default false preserves the current stateless CE-5 semantics.
  • Fail-open everywhere — bad/missing config warns and falls back; session-store IO errors swallow silently.
  • Secrets stay on disk only — session files contain only the family session id (uuid-like), no model responses or prompts.
  • No new dependencies, no API changes — only one new persistentThreads field in DEFAULT_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:

['exec','--json','--skip-git-repo-check','exec','resume','<id>','--json','--skip-git-repo-check','{prompt}']

— 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.cjs required ./config-loader which 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

  • buildResumeArgv — per-family shape, all id-bearing families carry the id, all families include {prompt} for the existing substitution loop.
  • parseSessionId — codex JSONL thread.started.thread_id, claude --output-format json.session_id, agy/kimi return null (CWD-scoped).
  • quorum-sessions-store — round-trip, multi-slot isolation, round progression, del leaves siblings, cleanup removes ALL invocation files, gcStale removes >24h files, fail-open on missing dir.
  • QUORUM_RESUME registry — every family has supportsSession + freshArgs + resumeArgs + parseId; freshArgs/resumeArgs lengths stay near (within ±3 elements).

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

  • New Features
    • Added an opt-in setting (quorum.persistent_threads) to persist CLI conversation threads/session state across quorum rounds (per slot).
    • Enables best-effort resume for compatible providers using stored session/thread ids.
    • Persists session markers on disk and automatically garbage-collects entries older than 24 hours.
  • Bug Fixes
    • Improved config validation: non-boolean values warn and safely fall back to default.
    • Improved quorum slot invocation behavior to better support resume context.
  • Tests
    • Added coverage for resume argv generation, session parsing, session storage lifecycle, cleanup/GC behavior, and config safeguards.

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.
Copilot AI review requested due to automatic review settings July 25, 2026 11:15
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jobordu, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ceaefbf4-9ad9-4c5a-ac01-7efa50a62f65

📥 Commits

Reviewing files that changed from the base of the PR and between b07f550 and 573c889.

📒 Files selected for processing (5)
  • bin/autonomous-grind.test.cjs
  • bin/call-quorum-slot.cjs
  • bin/quorum-resume.cjs
  • bin/quorum-resume.test.cjs
  • package.json

Walkthrough

The quorum loop gains an opt-in persistent_threads setting that stores provider session identifiers per slot and invocation, resumes supported CLI sessions across rounds, and garbage-collects stale markers. Provider-specific helpers, storage operations, configuration validation, and tests are added.

Changes

Quorum thread persistence

Layer / File(s) Summary
Provider resume contracts
hooks/config-loader.js, bin/quorum-resume.cjs, bin/quorum-resume.test.cjs, hooks/config-loader-adversarial2.test.cjs
Adds the opt-in boolean configuration and provider-specific argument/session parsing contracts for Codex, Claude, Agy, and Kimi, with validation tests.
Session marker storage
bin/quorum-sessions-store.cjs, bin/quorum-resume.test.cjs
Adds per-slot, per-invocation JSON storage with lifecycle operations and stale-file garbage collection.
Quorum runtime persistence wiring
bin/call-quorum-slot.cjs, CHANGELOG.md
Threads resume options through subprocess and OAuth-rotation paths, captures successful session identifiers, performs stale-session cleanup, and documents the feature and path correction.

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
Loading

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: opt-in thread persistence per quorum slot.
Description check ✅ Passed The description is detailed and covers motivation, behavior, safety, and testing, though it doesn't use the template sections verbatim.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nf/quick-431-quorum-thread-persistence

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f2885df and 5e8e966.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • bin/call-quorum-slot.cjs
  • bin/quorum-resume.cjs
  • bin/quorum-resume.test.cjs
  • bin/quorum-sessions-store.cjs
  • hooks/config-loader.js

Comment thread bin/call-quorum-slot.cjs
Comment on lines +370 to +383
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 */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread bin/call-quorum-slot.cjs Outdated
Comment thread bin/call-quorum-slot.cjs
Comment thread bin/quorum-resume.cjs
…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
Copilot AI review requested due to automatic review settings July 25, 2026 12:14
…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 25, 2026 21:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 25, 2026 21:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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.
Copilot AI review requested due to automatic review settings July 25, 2026 21:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants