diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 934b9b4f3..01ffeb0d7 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -50,6 +50,16 @@ The package also includes an append-only governor decision ledger: `initGovernor persist structured allow/deny/throttle/kill-switch outcomes in local SQLite for contributor audit. Insert-only — no enforcement wiring yet. (#2328) +The package also includes a real, persisted governor pause/resume control surface: `gittensory-miner governor +pause [--reason ]` / `governor resume` / `governor status` toggle a `paused`/`reason`/`pausedAt` flag on +governor-state.js's existing scalar-state row, and `loop-cli.js`'s iteration loop checks it at the same two +boundaries as the kill switch (before the first cycle and at the top of every subsequent one) — pausing mid-run +stops the loop before its next cycle claims anything, and resuming (clear the flag, re-invoke `loop`) continues +from the already-persisted queue/run state exactly where it left off. Distinct from the read-only kill switch +(env/YAML inputs this package never writes) and the one-way run-halt breaker (no resume path at all) — this is +the first genuinely operator/governor-writable stop/go control; the autonomous logic that decides *when* to pause +is separate, later-wave scope. (#4851) + The package also includes a local soft-claim ledger: `openClaimLedger` / `claimIssue` / `releaseClaim` / `listActiveClaims` persist which issues this miner instance has claimed on this machine. The table is local bookkeeping only — duplicate winners are adjudicated elsewhere via `@jsonbored/gittensory-engine`. (#2291) diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 53a2ad1a5..df30691ba 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -43,6 +43,9 @@ export function printHelp(input) { " gittensory-miner plan list [--status pending|running|completed|failed] [--json]", " gittensory-miner plan show [--json]", " gittensory-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", + " gittensory-miner governor pause [--reason ] [--json] Stop the loop before its next cycle", + " gittensory-miner governor resume [--json] Let a paused loop continue", + " gittensory-miner governor status [--json] Show whether the governor is paused", " gittensory-miner calibration [--json] Report predicted-vs-realized gate accuracy", " gittensory-miner feasibility [--not-found] [--json]", " gittensory-miner hooks check --tool --input [--json]", diff --git a/packages/gittensory-miner/lib/governor-ledger-cli.d.ts b/packages/gittensory-miner/lib/governor-ledger-cli.d.ts index 5a6163ab0..a9263ee7d 100644 --- a/packages/gittensory-miner/lib/governor-ledger-cli.d.ts +++ b/packages/gittensory-miner/lib/governor-ledger-cli.d.ts @@ -1,4 +1,5 @@ import type { GovernorLedger, GovernorLedgerEntry } from "./governor-ledger.js"; +import type { GovernorPauseCliOptions } from "./governor-pause-cli.js"; export type GovernorLedgerEventType = "allowed" | "denied" | "throttled" | "kill_switch"; @@ -27,5 +28,5 @@ export function runGovernorList( export function runGovernorCli( subcommand: string | undefined, args: string[], - options?: { initGovernorLedger?: () => GovernorLedger }, + options?: { initGovernorLedger?: () => GovernorLedger } & GovernorPauseCliOptions, ): Promise; diff --git a/packages/gittensory-miner/lib/governor-ledger-cli.js b/packages/gittensory-miner/lib/governor-ledger-cli.js index f5257e9c0..f8f9a4f93 100644 --- a/packages/gittensory-miner/lib/governor-ledger-cli.js +++ b/packages/gittensory-miner/lib/governor-ledger-cli.js @@ -1,3 +1,5 @@ +import { runGovernorPause, runGovernorResume, runGovernorStatus } from "./governor-pause-cli.js"; + /** Must match `GOVERNOR_LEDGER_EVENT_TYPES` in `@jsonbored/gittensory-engine`. */ import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; @@ -11,6 +13,13 @@ const GOVERNOR_LEDGER_EVENT_TYPES = Object.freeze([ const GOVERNOR_LIST_USAGE = "Usage: gittensory-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]"; +const GOVERNOR_SUBCOMMAND_USAGE = [ + GOVERNOR_LIST_USAGE, + " gittensory-miner governor pause [--reason ] [--json]", + " gittensory-miner governor resume [--json]", + " gittensory-miner governor status [--json]", +].join("\n"); + function parseRepoArg(value, usage) { if (!value) return { error: usage }; const trimmed = value.trim(); @@ -136,5 +145,11 @@ export async function runGovernorList(args, options = {}) { export async function runGovernorCli(subcommand, args, options = {}) { if (subcommand === "list") return runGovernorList(args, options); - return reportCliFailure(argsWantJson(args), `Unknown governor subcommand: ${subcommand ?? ""}. ${GOVERNOR_LIST_USAGE}`); + if (subcommand === "pause") return runGovernorPause(args, options); + if (subcommand === "resume") return runGovernorResume(args, options); + if (subcommand === "status") return runGovernorStatus(args, options); + return reportCliFailure( + argsWantJson(args), + `Unknown governor subcommand: ${subcommand ?? ""}.\n${GOVERNOR_SUBCOMMAND_USAGE}`, + ); } diff --git a/packages/gittensory-miner/lib/governor-pause-cli.d.ts b/packages/gittensory-miner/lib/governor-pause-cli.d.ts new file mode 100644 index 000000000..e0396d01b --- /dev/null +++ b/packages/gittensory-miner/lib/governor-pause-cli.d.ts @@ -0,0 +1,17 @@ +import type { GovernorState } from "./governor-state.js"; + +export type ParsedGovernorPauseArgs = { json: boolean; reason: string | null } | { error: string }; + +export type ParsedGovernorNoArgsSubcommand = { json: boolean } | { error: string }; + +export type GovernorPauseCliOptions = { + openGovernorState?: () => GovernorState; +}; + +export function parseGovernorPauseArgs(args: string[]): ParsedGovernorPauseArgs; + +export function runGovernorPause(args: string[], options?: GovernorPauseCliOptions): Promise; + +export function runGovernorResume(args: string[], options?: GovernorPauseCliOptions): Promise; + +export function runGovernorStatus(args: string[], options?: GovernorPauseCliOptions): Promise; diff --git a/packages/gittensory-miner/lib/governor-pause-cli.js b/packages/gittensory-miner/lib/governor-pause-cli.js new file mode 100644 index 000000000..777d3e344 --- /dev/null +++ b/packages/gittensory-miner/lib/governor-pause-cli.js @@ -0,0 +1,126 @@ +// The governor pause/resume control surface (#4851): a real, persisted pause flag an operator (or, in a future +// wave, the governor itself) can toggle via this CLI, that loop-cli.js's iteration loop actually checks before +// each cycle. Distinct from governor-kill-switch.js (a read-only resolver over pre-existing env/YAML inputs this +// package never itself writes) and governor-run-halt.js (a one-way, run-scoped terminal breaker with no resume +// path) -- this is the first genuinely operator/governor-writable stop/go control. Persisted on governor-state.js's +// existing single-row scalar-state table, not a new store: a pause flag has no relational key of its own, the +// same reasoning that table's other scalar fields (rate-limit buckets, cap usage) already rely on. + +import { openGovernorState } from "./governor-state.js"; + +const GOVERNOR_PAUSE_USAGE = "Usage: gittensory-miner governor pause [--reason ] [--json]"; +const GOVERNOR_RESUME_USAGE = "Usage: gittensory-miner governor resume [--json]"; +const GOVERNOR_STATUS_USAGE = "Usage: gittensory-miner governor status [--json]"; + +export function parseGovernorPauseArgs(args) { + const options = { json: false, reason: null }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--reason") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: GOVERNOR_PAUSE_USAGE }; + options.reason = value; + index += 1; + continue; + } + return { error: `Unknown option: ${token}` }; + } + + return options; +} + +function parseNoArgsSubcommand(args, usage) { + if (args.length === 0) return { json: false }; + if (args.length === 1 && args[0] === "--json") return { json: true }; + return { error: usage }; +} + +async function withGovernorState(options, run) { + const ownsGovernorState = options.openGovernorState === undefined; + const governorState = (options.openGovernorState ?? openGovernorState)(); + try { + return run(governorState); + } finally { + if (ownsGovernorState) governorState.close(); + } +} + +function renderPauseState(pauseState) { + if (!pauseState.paused) return "governor is not paused"; + const reason = pauseState.reason ? ` (${pauseState.reason})` : ""; + return `governor is PAUSED since ${pauseState.pausedAt}${reason}`; +} + +export async function runGovernorPause(args, options = {}) { + const parsed = parseGovernorPauseArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.savePauseState({ paused: true, reason: parsed.reason }); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export async function runGovernorResume(args, options = {}) { + const parsed = parseNoArgsSubcommand(args, GOVERNOR_RESUME_USAGE); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.savePauseState({ paused: false }); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export async function runGovernorStatus(args, options = {}) { + const parsed = parseNoArgsSubcommand(args, GOVERNOR_STATUS_USAGE); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return await withGovernorState(options, (governorState) => { + const pauseState = governorState.loadPauseState(); + if (parsed.json) { + console.log(JSON.stringify(pauseState)); + } else { + console.log(renderPauseState(pauseState)); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} diff --git a/packages/gittensory-miner/lib/governor-state.d.ts b/packages/gittensory-miner/lib/governor-state.d.ts index 25dcbc801..becabf54a 100644 --- a/packages/gittensory-miner/lib/governor-state.d.ts +++ b/packages/gittensory-miner/lib/governor-state.d.ts @@ -10,12 +10,25 @@ export type ListRecentOwnSubmissionsFilter = { limit?: number; }; +export type GovernorPauseState = { + paused: boolean; + reason: string | null; + pausedAt: string | null; +}; + +export type GovernorPauseInput = { + paused: boolean; + reason?: string | null; +}; + export type GovernorState = { dbPath: string; loadRateLimitState(): GovernorRateLimitState; saveRateLimitState(rateLimitState: GovernorRateLimitState): void; loadCapUsage(): GovernorCapUsage; saveCapUsage(capUsage: GovernorCapUsage): void; + loadPauseState(): GovernorPauseState; + savePauseState(pauseState: GovernorPauseInput): GovernorPauseState; loadReputationHistory(repoFullName: string): RepoOutcomeHistory; saveReputationHistory(repoFullName: string, history: RepoOutcomeHistory): RepoOutcomeHistory; recordOwnSubmission(record: OwnSubmissionRecord): OwnSubmissionRecord; @@ -35,6 +48,10 @@ export function loadCapUsage(): GovernorCapUsage; export function saveCapUsage(capUsage: GovernorCapUsage): void; +export function loadPauseState(): GovernorPauseState; + +export function savePauseState(pauseState: GovernorPauseInput): GovernorPauseState; + export function loadReputationHistory(repoFullName: string): RepoOutcomeHistory; export function saveReputationHistory(repoFullName: string, history: RepoOutcomeHistory): RepoOutcomeHistory; diff --git a/packages/gittensory-miner/lib/governor-state.js b/packages/gittensory-miner/lib/governor-state.js index d9d945d71..5c3b0124c 100644 --- a/packages/gittensory-miner/lib/governor-state.js +++ b/packages/gittensory-miner/lib/governor-state.js @@ -49,6 +49,29 @@ function parseJsonColumn(value, fallback) { } } +// Add the pause/resume columns (#4851) to an on-disk file created before they existed. `CREATE TABLE IF NOT +// EXISTS` above is a no-op against an already-existing table, so a pre-#4851 file needs this explicit ALTER -- +// guarded by a per-column presence check (rather than a single `paused`-only check) so a file that somehow +// has `paused` but not `pause_reason`/`paused_at` still gets the columns it's missing, same technique as +// portfolio-queue.js's own post-creation column migration. +function ensurePauseColumns(db) { + const existingColumns = new Set( + db + .prepare("PRAGMA table_info(governor_scalar_state)") + .all() + .map((column) => column.name), + ); + if (!existingColumns.has("paused")) { + db.exec("ALTER TABLE governor_scalar_state ADD COLUMN paused INTEGER NOT NULL DEFAULT 0"); + } + if (!existingColumns.has("pause_reason")) { + db.exec("ALTER TABLE governor_scalar_state ADD COLUMN pause_reason TEXT"); + } + if (!existingColumns.has("paused_at")) { + db.exec("ALTER TABLE governor_scalar_state ADD COLUMN paused_at TEXT"); + } +} + /** Opens the local governor-state store, creating tables on first use. */ export function openGovernorState(dbPath = resolveGovernorStateDbPath()) { const resolvedPath = normalizeDbPath(dbPath); @@ -64,9 +87,13 @@ export function openGovernorState(dbPath = resolveGovernorStateDbPath()) { rate_limit_buckets_json TEXT NOT NULL, rate_limit_backoff_json TEXT NOT NULL, cap_usage_json TEXT NOT NULL, + paused INTEGER NOT NULL DEFAULT 0, + pause_reason TEXT, + paused_at TEXT, updated_at TEXT NOT NULL ) `); + ensurePauseColumns(db); db.exec(` CREATE TABLE IF NOT EXISTS governor_reputation_history ( repo_full_name TEXT PRIMARY KEY, @@ -89,12 +116,16 @@ export function openGovernorState(dbPath = resolveGovernorStateDbPath()) { const getScalarStatement = db.prepare("SELECT * FROM governor_scalar_state WHERE id = 1"); const upsertScalarStatement = db.prepare(` - INSERT INTO governor_scalar_state (id, rate_limit_buckets_json, rate_limit_backoff_json, cap_usage_json, updated_at) - VALUES (1, ?, ?, ?, ?) + INSERT INTO governor_scalar_state + (id, rate_limit_buckets_json, rate_limit_backoff_json, cap_usage_json, paused, pause_reason, paused_at, updated_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rate_limit_buckets_json = excluded.rate_limit_buckets_json, rate_limit_backoff_json = excluded.rate_limit_backoff_json, cap_usage_json = excluded.cap_usage_json, + paused = excluded.paused, + pause_reason = excluded.pause_reason, + paused_at = excluded.paused_at, updated_at = excluded.updated_at `); const getReputationStatement = db.prepare("SELECT * FROM governor_reputation_history WHERE repo_full_name = ?"); @@ -142,6 +173,9 @@ export function openGovernorState(dbPath = resolveGovernorStateDbPath()) { JSON.stringify(rateLimitState?.buckets ?? DEFAULT_RATE_LIMIT_BUCKETS), JSON.stringify(rateLimitState?.backoffAttempts ?? DEFAULT_RATE_LIMIT_BACKOFF), row ? row.cap_usage_json : JSON.stringify(DEFAULT_CAP_USAGE), + row ? row.paused : 0, + row ? row.pause_reason : null, + row ? row.paused_at : null, new Date().toISOString(), ); }, @@ -155,8 +189,41 @@ export function openGovernorState(dbPath = resolveGovernorStateDbPath()) { row ? row.rate_limit_buckets_json : JSON.stringify(DEFAULT_RATE_LIMIT_BUCKETS), row ? row.rate_limit_backoff_json : JSON.stringify(DEFAULT_RATE_LIMIT_BACKOFF), JSON.stringify(capUsage ?? DEFAULT_CAP_USAGE), + row ? row.paused : 0, + row ? row.pause_reason : null, + row ? row.paused_at : null, + new Date().toISOString(), + ); + }, + // The governor pause/resume control surface (#4851): a real, persisted, operator/governor-writable flag the + // loop checks before each cycle -- distinct from governor-kill-switch.js (a read-only resolver over env/YAML + // inputs the miner does not itself write) and governor-run-halt.js (a one-way, run-scoped terminal breaker). + // `pausedAt` is stamped fresh on every transition INTO paused, and cleared on resume, so a status query can + // report how long a pause has been in effect without needing a separate history table. + loadPauseState() { + const row = getScalarStatement.get(); + return { + paused: row ? Boolean(row.paused) : false, + reason: row?.pause_reason ?? null, + pausedAt: row?.paused_at ?? null, + }; + }, + savePauseState(pauseState) { + const row = getScalarStatement.get(); + const paused = Boolean(pauseState?.paused); + const reason = + typeof pauseState?.reason === "string" && pauseState.reason.trim() ? pauseState.reason.trim() : null; + const pausedAt = paused ? new Date().toISOString() : null; + upsertScalarStatement.run( + row ? row.rate_limit_buckets_json : JSON.stringify(DEFAULT_RATE_LIMIT_BUCKETS), + row ? row.rate_limit_backoff_json : JSON.stringify(DEFAULT_RATE_LIMIT_BACKOFF), + row ? row.cap_usage_json : JSON.stringify(DEFAULT_CAP_USAGE), + paused ? 1 : 0, + reason, + pausedAt, new Date().toISOString(), ); + return { paused, reason, pausedAt }; }, loadReputationHistory(repoFullName) { const normalized = normalizeRepoFullName(repoFullName); @@ -218,6 +285,14 @@ export function saveCapUsage(capUsage) { return getDefaultGovernorState().saveCapUsage(capUsage); } +export function loadPauseState() { + return getDefaultGovernorState().loadPauseState(); +} + +export function savePauseState(pauseState) { + return getDefaultGovernorState().savePauseState(pauseState); +} + export function loadReputationHistory(repoFullName) { return getDefaultGovernorState().loadReputationHistory(repoFullName); } diff --git a/packages/gittensory-miner/lib/loop-cli.js b/packages/gittensory-miner/lib/loop-cli.js index c2a63fa73..4b3a353fd 100644 --- a/packages/gittensory-miner/lib/loop-cli.js +++ b/packages/gittensory-miner/lib/loop-cli.js @@ -3,8 +3,9 @@ // evaluateRunLoopBoundaryGate, attemptLoopReentry, buildLoopClosureSummary, governor-state.js -- already // existed; this is the first caller that actually chains them into a real repeat-until-halted run. // -// STRUCTURE (one cycle): kill-switch check -> real-per-repo-policy-aware run-loop boundary gate (before -// claiming) -> real runAttempt -> real CI-status poll (ci-poller.js, #5394) + real PR-disposition poll +// STRUCTURE (one cycle): kill-switch check -> pause-flag check (#4851, governor-state.js's persisted +// paused/reason/pausedAt) -> real-per-repo-policy-aware run-loop boundary gate (before claiming) -> real +// runAttempt -> real CI-status poll (ci-poller.js, #5394) + real PR-disposition poll // (pr-disposition-poller.js, on a submitted outcome) -> real loop-closure summary -> real attemptLoopReentry // decision. `attemptLoopReentry`'s own dequeue is the // AUTHORITATIVE claim for every cycle after the first (its own doc: "if allowed -- dequeues the next @@ -296,12 +297,20 @@ export async function runLoop(args, options = {}) { try { // Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill - // switch halts the loop without ever touching GitHub or the queue. + // switch OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The + // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via + // `gittensory-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being + // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own + // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off. const initialKillSwitch = checkKillSwitchFn({ env }); + const initialPauseState = governorState.loadPauseState(); let claimed = null; if (initialKillSwitch.active) { haltReason = `kill_switch_${initialKillSwitch.scope}`; cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } else if (initialPauseState.paused) { + haltReason = "paused"; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); } else { await runDiscoveryOnce(); claimed = portfolioQueue.dequeueNext(); @@ -318,6 +327,13 @@ export async function runLoop(args, options = {}) { break; } + const pauseState = governorState.loadPauseState(); + if (pauseState.paused) { + haltReason = "paused"; + cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason }); + break; + } + if (!claimed) { cycles.push({ cycle: cycleIndex, outcome: "idle_queue_empty" }); await sleepFn(parsed.cycleDelayMs); diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 0bfdac0b1..51f843c25 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -37,7 +37,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/miner-governor-ledger-cli.test.ts b/test/unit/miner-governor-ledger-cli.test.ts index 65f4d41ec..edcf6f98a 100644 --- a/test/unit/miner-governor-ledger-cli.test.ts +++ b/test/unit/miner-governor-ledger-cli.test.ts @@ -141,6 +141,8 @@ describe("gittensory-miner governor ledger CLI (#2328)", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); expect(await runGovernorCli("tail", [])).toBe(2); expect(String(error.mock.calls[0]?.[0])).toContain("Unknown governor subcommand"); + expect(String(error.mock.calls[0]?.[0])).toContain("governor pause"); + error.mockClear(); log.mockClear(); expect(await runGovernorCli("tail", ["--json"])).toBe(2); @@ -148,6 +150,31 @@ describe("gittensory-miner governor ledger CLI (#2328)", () => { ok: false, error: expect.stringContaining("Unknown governor subcommand"), }); + + error.mockClear(); + expect(await runGovernorCli(undefined, [])).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("Unknown governor subcommand: ."); + }); + + it("runGovernorCli dispatches pause, resume, and status to governor-pause-cli.js (#4851)", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-ledger-cli-pause-")); + roots.push(root); + const { openGovernorState } = await import("../../packages/gittensory-miner/lib/governor-state.js"); + const governorState = openGovernorState(join(root, "governor-state.sqlite3")); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(await runGovernorCli("pause", ["--json"], { openGovernorState: () => governorState })).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ paused: true }); + + log.mockClear(); + expect(await runGovernorCli("status", ["--json"], { openGovernorState: () => governorState })).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ paused: true }); + + log.mockClear(); + expect(await runGovernorCli("resume", ["--json"], { openGovernorState: () => governorState })).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ paused: false }); + + governorState.close(); }); it("rejects unknown options from argv parsing", async () => { diff --git a/test/unit/miner-governor-pause-cli.test.ts b/test/unit/miner-governor-pause-cli.test.ts new file mode 100644 index 000000000..5e68e1df5 --- /dev/null +++ b/test/unit/miner-governor-pause-cli.test.ts @@ -0,0 +1,206 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { closeDefaultGovernorState, openGovernorState } from "../../packages/gittensory-miner/lib/governor-state.js"; +import { + parseGovernorPauseArgs, + runGovernorPause, + runGovernorResume, + runGovernorStatus, +} from "../../packages/gittensory-miner/lib/governor-pause-cli.js"; + +const roots: string[] = []; +const states: Array<{ close(): void }> = []; + +function tempGovernorState() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-pause-cli-")); + roots.push(root); + const state = openGovernorState(join(root, "governor-state.sqlite3")); + states.push(state); + return state; +} + +afterEach(() => { + for (const state of states.splice(0)) state.close(); + closeDefaultGovernorState(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("parseGovernorPauseArgs (#4851)", () => { + it("defaults to no reason and non-JSON output", () => { + expect(parseGovernorPauseArgs([])).toEqual({ json: false, reason: null }); + }); + + it("parses --reason and --json together", () => { + expect(parseGovernorPauseArgs(["--reason", "investigating a bad PR", "--json"])).toEqual({ + json: true, + reason: "investigating a bad PR", + }); + }); + + it("rejects a --reason flag missing its value", () => { + expect(parseGovernorPauseArgs(["--reason"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner governor pause"), + }); + expect(parseGovernorPauseArgs(["--reason", "--json"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner governor pause"), + }); + }); + + it("rejects an unknown option", () => { + expect(parseGovernorPauseArgs(["--verbose"])).toEqual({ error: "Unknown option: --verbose" }); + }); +}); + +describe("gittensory-miner governor pause/resume/status CLI (#4851)", () => { + it("pauses with a reason, then resumes, using an injected governor state", async () => { + const governorState = tempGovernorState(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + await runGovernorPause(["--reason", "operator requested", "--json"], { + openGovernorState: () => governorState, + }), + ).toBe(0); + const paused = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(paused).toMatchObject({ paused: true, reason: "operator requested" }); + expect(governorState.loadPauseState()).toMatchObject({ paused: true, reason: "operator requested" }); + + log.mockClear(); + expect(await runGovernorResume([], { openGovernorState: () => governorState })).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toBe("governor is not paused"); + expect(governorState.loadPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); + }); + + it("pauses with no reason and renders the plain-text form", async () => { + const governorState = tempGovernorState(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(await runGovernorPause([], { openGovernorState: () => governorState })).toBe(0); + const text = String(log.mock.calls[0]?.[0]); + expect(text).toContain("governor is PAUSED since"); + expect(text).not.toContain("("); + }); + + it("status reports the current pause state without mutating it", async () => { + const governorState = tempGovernorState(); + governorState.savePauseState({ paused: true, reason: "halting for review" }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(await runGovernorStatus(["--json"], { openGovernorState: () => governorState })).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + paused: true, + reason: "halting for review", + }); + expect(governorState.loadPauseState()).toMatchObject({ paused: true, reason: "halting for review" }); + }); + + it("resume and status reject stray positional arguments", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(await runGovernorResume(["extra"])).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("Usage: gittensory-miner governor resume"); + + error.mockClear(); + expect(await runGovernorStatus(["extra"])).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("Usage: gittensory-miner governor status"); + }); + + it("rejects an unknown pause option before opening any store", async () => { + const openGovernorStateFn = vi.fn(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(await runGovernorPause(["--verbose"], { openGovernorState: openGovernorStateFn })).toBe(2); + expect(openGovernorStateFn).not.toHaveBeenCalled(); + expect(String(error.mock.calls[0]?.[0])).toContain("Unknown option"); + }); + + it("closes an owned (non-injected) governor state and surfaces a real open failure", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const openGovernorStateFn = vi.fn(() => { + throw new Error("disk full"); + }); + expect(await runGovernorStatus([], { openGovernorState: openGovernorStateFn })).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("disk full"); + }); + + it("runGovernorStatus prints plain text by default and surfaces a non-Error thrown failure", async () => { + const governorState = tempGovernorState(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(await runGovernorStatus([], { openGovernorState: () => governorState })).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toBe("governor is not paused"); + + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + await runGovernorStatus([], { + openGovernorState: () => { + throw "boom"; + }, + }), + ).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toBe("boom"); + }); + + it("runGovernorPause surfaces both an Error and a non-Error thrown open failure", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + await runGovernorPause([], { + openGovernorState: () => { + throw new Error("disk full"); + }, + }), + ).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toBe("disk full"); + + error.mockClear(); + expect( + await runGovernorPause([], { + openGovernorState: () => { + throw "boom"; + }, + }), + ).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toBe("boom"); + }); + + it("runGovernorResume surfaces both an Error and a non-Error thrown open failure", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + await runGovernorResume([], { + openGovernorState: () => { + throw new Error("disk full"); + }, + }), + ).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toBe("disk full"); + + error.mockClear(); + expect( + await runGovernorResume([], { + openGovernorState: () => { + throw "boom"; + }, + }), + ).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toBe("boom"); + }); + + it("opens and closes the default on-disk governor state when no override is supplied", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-pause-cli-default-")); + roots.push(root); + const dbPath = join(root, "governor-state.sqlite3"); + const previousDbPath = process.env.GITTENSORY_MINER_GOVERNOR_STATE_DB; + process.env.GITTENSORY_MINER_GOVERNOR_STATE_DB = dbPath; + try { + vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(await runGovernorPause(["--reason", "default path"])).toBe(0); + + const reopened = openGovernorState(dbPath); + states.push(reopened); + expect(reopened.loadPauseState()).toMatchObject({ paused: true, reason: "default path" }); + } finally { + if (previousDbPath === undefined) delete process.env.GITTENSORY_MINER_GOVERNOR_STATE_DB; + else process.env.GITTENSORY_MINER_GOVERNOR_STATE_DB = previousDbPath; + } + }); +}); diff --git a/test/unit/miner-governor-state.test.ts b/test/unit/miner-governor-state.test.ts index cf13fc105..884b29c25 100644 --- a/test/unit/miner-governor-state.test.ts +++ b/test/unit/miner-governor-state.test.ts @@ -1,8 +1,14 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { closeDefaultGovernorState, openGovernorState } from "../../packages/gittensory-miner/lib/governor-state.js"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + closeDefaultGovernorState, + loadPauseState, + openGovernorState, + savePauseState, +} from "../../packages/gittensory-miner/lib/governor-state.js"; const roots: string[] = []; const states: Array<{ close(): void }> = []; @@ -18,6 +24,7 @@ function tempState() { afterEach(() => { for (const state of states.splice(0)) state.close(); closeDefaultGovernorState(); + vi.unstubAllEnvs(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -69,6 +76,140 @@ describe("governor-state cap usage (#5134)", () => { }); }); +describe("governor-state pause/resume control surface (#4851)", () => { + it("defaults to not-paused when nothing has been saved yet", () => { + const state = tempState(); + expect(state.loadPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); + }); + + it("pausing stamps pausedAt and stores a trimmed reason", () => { + const state = tempState(); + const before = Date.now(); + const written = state.savePauseState({ paused: true, reason: " operator requested " }); + expect(written.paused).toBe(true); + expect(written.reason).toBe("operator requested"); + expect(Date.parse(written.pausedAt ?? "")).toBeGreaterThanOrEqual(before); + expect(state.loadPauseState()).toEqual(written); + }); + + it("pausing with no reason stores a null reason", () => { + const state = tempState(); + const written = state.savePauseState({ paused: true }); + expect(written.reason).toBeNull(); + }); + + it("pausing with a blank reason stores a null reason", () => { + const state = tempState(); + const written = state.savePauseState({ paused: true, reason: " " }); + expect(written.reason).toBeNull(); + }); + + it("resuming clears paused, reason, and pausedAt", () => { + const state = tempState(); + state.savePauseState({ paused: true, reason: "investigating a bad PR" }); + const resumed = state.savePauseState({ paused: false }); + expect(resumed).toEqual({ paused: false, reason: null, pausedAt: null }); + expect(state.loadPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); + }); + + it("pausing preserves a previously saved cap usage and rate-limit state in the same scalar row", () => { + const state = tempState(); + state.saveCapUsage({ budgetSpent: 7, turnsTaken: 1, elapsedMs: 500 }); + state.saveRateLimitState({ buckets: { global: { open_pr: { count: 1, windowStartMs: 0 } }, perRepo: {} }, backoffAttempts: {} }); + state.savePauseState({ paused: true, reason: "halting for review" }); + expect(state.loadCapUsage()).toEqual({ budgetSpent: 7, turnsTaken: 1, elapsedMs: 500 }); + expect(state.loadRateLimitState().buckets.global.open_pr?.count).toBe(1); + }); + + it("saving cap usage or rate-limit state preserves a previously saved pause", () => { + const state = tempState(); + state.savePauseState({ paused: true, reason: "halting for review" }); + state.saveCapUsage({ budgetSpent: 1, turnsTaken: 1, elapsedMs: 1 }); + state.saveRateLimitState({ buckets: { global: {}, perRepo: {} }, backoffAttempts: {} }); + expect(state.loadPauseState()).toMatchObject({ paused: true, reason: "halting for review" }); + }); + + it("migrates an on-disk file created before the pause columns existed", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-state-premigration-")); + roots.push(root); + const dbPath = join(root, "governor-state.sqlite3"); + + // Hand-build the OLD schema (no paused/pause_reason/paused_at columns), with a pre-existing scalar row, to + // simulate a real file written before #4851. + const raw = new DatabaseSync(dbPath); + raw.exec(` + CREATE TABLE governor_scalar_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + rate_limit_buckets_json TEXT NOT NULL, + rate_limit_backoff_json TEXT NOT NULL, + cap_usage_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + raw.prepare( + "INSERT INTO governor_scalar_state (id, rate_limit_buckets_json, rate_limit_backoff_json, cap_usage_json, updated_at) VALUES (1, ?, ?, ?, ?)", + ).run("{}", "{}", JSON.stringify({ budgetSpent: 9, turnsTaken: 2, elapsedMs: 200 }), "2026-07-01T00:00:00Z"); + raw.close(); + + const state = openGovernorState(dbPath); + states.push(state); + expect(state.loadPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); + // The pre-existing row's other columns survived the ALTER TABLE. + expect(state.loadCapUsage()).toEqual({ budgetSpent: 9, turnsTaken: 2, elapsedMs: 200 }); + + state.savePauseState({ paused: true, reason: "post-migration pause" }); + expect(state.loadPauseState()).toMatchObject({ paused: true, reason: "post-migration pause" }); + }); + + it("reopening an already-migrated file is a safe no-op (column-presence guard)", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-state-remigrate-")); + roots.push(root); + const dbPath = join(root, "governor-state.sqlite3"); + const first = openGovernorState(dbPath); + first.savePauseState({ paused: true, reason: "first open" }); + first.close(); + + const second = openGovernorState(dbPath); + states.push(second); + expect(() => second.loadPauseState()).not.toThrow(); + expect(second.loadPauseState()).toMatchObject({ paused: true, reason: "first open" }); + }); + + it("REGRESSION: adds each missing pause column independently, not just when `paused` alone is absent", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-state-partial-migration-")); + roots.push(root); + const dbPath = join(root, "governor-state.sqlite3"); + + // Hand-build a file that already has `paused` but is missing `pause_reason`/`paused_at` -- a state a + // single "does `paused` exist?" guard would (incorrectly) treat as fully migrated and skip entirely, + // leaving upsertScalarStatement referencing columns that don't exist. + const raw = new DatabaseSync(dbPath); + raw.exec(` + CREATE TABLE governor_scalar_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + rate_limit_buckets_json TEXT NOT NULL, + rate_limit_backoff_json TEXT NOT NULL, + cap_usage_json TEXT NOT NULL, + paused INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ) + `); + raw.prepare( + "INSERT INTO governor_scalar_state (id, rate_limit_buckets_json, rate_limit_backoff_json, cap_usage_json, paused, updated_at) VALUES (1, ?, ?, ?, 0, ?)", + ).run("{}", "{}", JSON.stringify({ budgetSpent: 3, turnsTaken: 1, elapsedMs: 50 }), "2026-07-01T00:00:00Z"); + raw.close(); + + const state = openGovernorState(dbPath); + states.push(state); + expect(() => state.loadPauseState()).not.toThrow(); + expect(state.loadPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); + expect(state.loadCapUsage()).toEqual({ budgetSpent: 3, turnsTaken: 1, elapsedMs: 50 }); + + expect(() => state.savePauseState({ paused: true, reason: "partial-migration pause" })).not.toThrow(); + expect(state.loadPauseState()).toMatchObject({ paused: true, reason: "partial-migration pause" }); + }); +}); + describe("governor-state reputation history (#5134)", () => { it("defaults to zero decided/unfavorable for a repo with no history", () => { const state = tempState(); @@ -144,4 +285,14 @@ describe("governor-state module-level default singleton (#5134)", () => { it("closeDefaultGovernorState is a safe no-op when nothing was ever opened", () => { expect(() => closeDefaultGovernorState()).not.toThrow(); }); + + it("loadPauseState/savePauseState module-level wrappers round-trip through the default singleton (#4851)", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-state-singleton-")); + roots.push(root); + vi.stubEnv("GITTENSORY_MINER_GOVERNOR_STATE_DB", join(root, "governor-state.sqlite3")); + expect(loadPauseState()).toEqual({ paused: false, reason: null, pausedAt: null }); + const written = savePauseState({ paused: true, reason: "singleton pause" }); + expect(written).toMatchObject({ paused: true, reason: "singleton pause" }); + expect(loadPauseState()).toEqual(written); + }); }); diff --git a/test/unit/miner-loop-cli.test.ts b/test/unit/miner-loop-cli.test.ts index 949e0681f..ae2c9e4b4 100644 --- a/test/unit/miner-loop-cli.test.ts +++ b/test/unit/miner-loop-cli.test.ts @@ -267,6 +267,106 @@ describe("runLoop (#5135)", () => { expect(printed.cycles).toEqual([{ cycle: 1, outcome: "halted", reason: "kill_switch_global" }]); }); + it("halts immediately on an active pause, before running discovery or any attempt (#4851)", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + governorState.savePauseState({ paused: true, reason: "operator requested" }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const runDiscoverSpy = vi.fn(); + const runAttemptSpy = vi.fn(); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(0); + expect(runDiscoverSpy).not.toHaveBeenCalled(); + expect(runAttemptSpy).not.toHaveBeenCalled(); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.haltReason).toBe("paused"); + expect(printed.cycles).toEqual([{ cycle: 1, outcome: "halted", reason: "paused" }]); + }); + + it("checks the pause flag again at the top of the per-cycle loop, not just before the first cycle (#4851)", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + // Unpaused for the pre-loop check (so the initial discovery still runs), then paused for every check after -- + // proving the SECOND checkpoint (inside the while loop) independently halts a run that started clean. + const loadPauseState = vi + .fn() + .mockReturnValueOnce({ paused: false, reason: null, pausedAt: null }) + .mockReturnValue({ paused: true, reason: "stop mid-run", pausedAt: "2026-07-12T00:00:00.000Z" }); + const pausableGovernorState = { ...governorState, loadPauseState }; + const runDiscoverSpy = vi.fn(async () => 0); + const runAttemptSpy = vi.fn(); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--json"], { + openGovernorState: () => pausableGovernorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(0); + expect(runDiscoverSpy).toHaveBeenCalledTimes(1); + expect(runAttemptSpy).not.toHaveBeenCalled(); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.haltReason).toBe("paused"); + expect(printed.cycles).toEqual([{ cycle: 1, outcome: "halted", reason: "paused" }]); + }); + + it("resuming (clearing the flag) lets a later invocation continue from the persisted queue state (#4851)", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7" }); + governorState.savePauseState({ paused: true, reason: "stop for review" }); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runDiscoverSpy = vi.fn(async () => 0); + const runAttemptSpy = vi.fn(); + + const firstRun = await runLoop(["acme/widgets", "--miner-login", "alice", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions(), + }); + expect(firstRun).toBe(0); + expect(runDiscoverSpy).not.toHaveBeenCalled(); + + // Resume: clear the flag, then re-invoke against FRESH handles to the SAME on-disk files -- runLoop's own + // finally already closed the first-run handles above, so this mirrors the real cross-invocation resume path + // (a separate CLI call) rather than reusing a closed connection. + const resumedGovernorState = openGovernorState(paths.governorStatePath); + resumedGovernorState.savePauseState({ paused: false }); + const secondRun = await runLoop(["acme/widgets", "--miner-login", "alice", "--json", "--max-cycles", "0"], { + openGovernorState: () => resumedGovernorState, + initEventLedger: () => initEventLedger(paths.eventLedgerPath), + initGovernorLedger: () => initGovernorLedger(paths.governorLedgerPath), + initPortfolioQueue: () => initPortfolioQueueStore(paths.portfolioQueuePath), + initRunStateStore: () => initRunStateStore(paths.runStatePath), + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions(), + }); + + expect(secondRun).toBe(0); + // Now unblocked: the pre-loop discovery call ran, proving the run continued instead of halting again. + expect(runDiscoverSpy).toHaveBeenCalledTimes(1); + }); + it("REGRESSION: runs a full cycle end to end -- claims, attempts, polls real PR disposition, records the outcome, and re-enters", async () => { const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined);