Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>]` / `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)
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export function printHelp(input) {
" gittensory-miner plan list [--status pending|running|completed|failed] [--json]",
" gittensory-miner plan show <planId> [--json]",
" gittensory-miner governor list [--repo <owner/repo>] [--type allowed|denied|throttled|kill_switch] [--json]",
" gittensory-miner governor pause [--reason <text>] [--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 <claimStatus> <duplicateClusterRisk> <issueStatus> [--not-found] [--json]",
" gittensory-miner hooks check --tool <name> --input <json> [--json]",
Expand Down
3 changes: 2 additions & 1 deletion packages/gittensory-miner/lib/governor-ledger-cli.d.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -27,5 +28,5 @@ export function runGovernorList(
export function runGovernorCli(
subcommand: string | undefined,
args: string[],
options?: { initGovernorLedger?: () => GovernorLedger },
options?: { initGovernorLedger?: () => GovernorLedger } & GovernorPauseCliOptions,
): Promise<number>;
17 changes: 16 additions & 1 deletion packages/gittensory-miner/lib/governor-ledger-cli.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -11,6 +13,13 @@ const GOVERNOR_LEDGER_EVENT_TYPES = Object.freeze([
const GOVERNOR_LIST_USAGE =
"Usage: gittensory-miner governor list [--repo <owner/repo>] [--type allowed|denied|throttled|kill_switch] [--json]";

const GOVERNOR_SUBCOMMAND_USAGE = [
GOVERNOR_LIST_USAGE,
" gittensory-miner governor pause [--reason <text>] [--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();
Expand Down Expand Up @@ -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}`,
);
}
17 changes: 17 additions & 0 deletions packages/gittensory-miner/lib/governor-pause-cli.d.ts
Original file line number Diff line number Diff line change
@@ -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<number>;

export function runGovernorResume(args: string[], options?: GovernorPauseCliOptions): Promise<number>;

export function runGovernorStatus(args: string[], options?: GovernorPauseCliOptions): Promise<number>;
126 changes: 126 additions & 0 deletions packages/gittensory-miner/lib/governor-pause-cli.js
Original file line number Diff line number Diff line change
@@ -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 <text>] [--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;
}
}
17 changes: 17 additions & 0 deletions packages/gittensory-miner/lib/governor-state.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
79 changes: 77 additions & 2 deletions packages/gittensory-miner/lib/governor-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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 = ?");
Expand Down Expand Up @@ -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(),
);
},
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
Loading