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
159 changes: 159 additions & 0 deletions packages/gittensory-engine/src/governor/run-halt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Governor run-loop halt enforcement (#2347): composes the pure non-convergence detector and the
// budget/turn/termination cap calculator at every iteration boundary before the portfolio queue claims
// the next item. Either signal tripping halts the current run; the caller releases in-flight work and
// blocks further claims until a human clears the halt.

import type { GovernorLedgerEvent, GovernorLedgerEventType } from "../governor-ledger.js";
import {
classifyPortfolioConvergence,
DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS,
type PortfolioConvergenceInput,
type PortfolioConvergenceThresholds,
} from "../portfolio/non-convergence.js";
import {
evaluateGovernorCaps,
type GovernorCapLimits,
type GovernorCapReport,
type GovernorCapUsage,
} from "./budget-cap.js";

/** Issue vocabulary alias for the pure budget/turn/termination cap calculator. */
export const evaluateBudgetCaps = evaluateGovernorCaps;

export type NonConvergenceSignal = {
tripped: boolean;
status: ReturnType<typeof classifyPortfolioConvergence>["status"];
reasons: string[];
};

/** Issue vocabulary wrapper over {@link classifyPortfolioConvergence}. */
export function detectNonConvergence(
input: PortfolioConvergenceInput,
thresholds: PortfolioConvergenceThresholds = DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS,
): NonConvergenceSignal {
const verdict = classifyPortfolioConvergence(input, thresholds);
return {
tripped: verdict.status === "non_convergent",
status: verdict.status,
reasons: verdict.reasons,
};
}

export type RunLoopHaltReason =
| "cleared"
| "prior_halt"
| "non_convergence"
| "budget_exceeded"
| "turn_cap_exceeded"
| "termination_cap_exceeded";

export type RunLoopHaltVerdict = {
shouldHalt: boolean;
canClaimNext: boolean;
reason: RunLoopHaltReason;
convergence: NonConvergenceSignal;
caps: GovernorCapReport;
ledgerEventType: GovernorLedgerEventType;
ledgerReason: string;
};

function haltVerdict(
reason: RunLoopHaltReason,
convergence: NonConvergenceSignal,
caps: GovernorCapReport,
ledgerEventType: GovernorLedgerEventType,
ledgerReason: string,
): RunLoopHaltVerdict {
return {
shouldHalt: true,
canClaimNext: false,
reason,
convergence,
caps,
ledgerEventType,
ledgerReason,
};
}

/**
* Consult non-convergence and budget caps at a run-loop iteration boundary. A prior halt sticks until
* the caller clears it; otherwise either signal tripping halts the run and blocks further queue claims.
*/
export function evaluateRunLoopHalt(input: {
runHalted: boolean;
usage: GovernorCapUsage;
limits: GovernorCapLimits;
convergence: PortfolioConvergenceInput;
convergenceThresholds?: PortfolioConvergenceThresholds;
}): RunLoopHaltVerdict {
const convergence = detectNonConvergence(input.convergence, input.convergenceThresholds);
const caps = evaluateGovernorCaps(input.usage, input.limits);

if (input.runHalted) {
return haltVerdict("prior_halt", convergence, caps, "denied", "run_already_halted");
}

if (caps.verdict === "kill_switch") {
return haltVerdict(
"termination_cap_exceeded",
convergence,
caps,
"kill_switch",
"termination_cap_exceeded",
);
}
if (caps.budget.exceeded) {
return haltVerdict("budget_exceeded", convergence, caps, "denied", "budget_cap_exceeded");
}
if (caps.turns.exceeded) {
return haltVerdict("turn_cap_exceeded", convergence, caps, "denied", "turn_cap_exceeded");
}
if (convergence.tripped) {
return haltVerdict(
"non_convergence",
convergence,
caps,
"denied",
"non_convergence_detected",
);
}

return {
shouldHalt: false,
canClaimNext: true,
reason: "cleared",
convergence,
caps,
ledgerEventType: "allowed",
ledgerReason: "under_limit",
};
}

/** Reset latch after an operator clears a halted run. */
export function clearRunLoopHalt(): { runHalted: false } {
return { runHalted: false };
}

/** Governor-ledger row for a run-loop halt decision (#2347 deliverable). */
export function buildRunLoopHaltGovernorLedgerEvent(
repoFullName: string | null | undefined,
inFlightIdentifier: string | null | undefined,
verdict: RunLoopHaltVerdict,
): GovernorLedgerEvent {
return {
eventType: verdict.ledgerEventType,
repoFullName,
actionClass: "run_loop",
decision: verdict.shouldHalt ? "halt" : "continue",
reason: verdict.ledgerReason,
payload: {
haltReason: verdict.reason,
convergenceStatus: verdict.convergence.status,
convergenceReasons: verdict.convergence.reasons,
budgetExceeded: verdict.caps.budget.exceeded,
turnsExceeded: verdict.caps.turns.exceeded,
terminationExceeded: verdict.caps.termination.exceeded,
inFlightIdentifier: inFlightIdentifier ?? null,
},
};
}
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export * from "./governor/budget-cap.js";
export * from "./governor/self-plagiarism.js";
export * from "./governor/reputation-throttle.js";
export * from "./governor/write-rate-limit.js";
export * from "./governor/run-halt.js";
export {
GOVERNOR_LEDGER_EVENT_TYPES,
normalizeGovernorLedgerEvent,
Expand Down
44 changes: 44 additions & 0 deletions packages/gittensory-engine/test/run-halt-enforcement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import {
buildRunLoopHaltGovernorLedgerEvent,
detectNonConvergence,
evaluateBudgetCaps,
evaluateRunLoopHalt,
} from "../dist/index.js";

test("barrel: the public entrypoint re-exports run-loop halt enforcement (#2347)", () => {
assert.equal(typeof evaluateRunLoopHalt, "function");
assert.equal(typeof detectNonConvergence, "function");
assert.equal(typeof evaluateBudgetCaps, "function");
assert.equal(typeof buildRunLoopHaltGovernorLedgerEvent, "function");
});

test("evaluateRunLoopHalt: either signal tripping halts the run", () => {
const healthy = evaluateRunLoopHalt({
runHalted: false,
usage: { budgetSpent: 1, turnsTaken: 1, elapsedMs: 1_000 },
limits: { budget: 100, turns: 5, elapsedMs: 60_000 },
convergence: { attempts: 1, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },
});
assert.equal(healthy.shouldHalt, false);

const budgetHalt = evaluateRunLoopHalt({
runHalted: false,
usage: { budgetSpent: 100, turnsTaken: 1, elapsedMs: 1_000 },
limits: { budget: 100, turns: 5, elapsedMs: 60_000 },
convergence: { attempts: 1, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },
});
assert.equal(budgetHalt.shouldHalt, true);

assert.equal(
detectNonConvergence({ attempts: 4, consecutiveFailures: 3, reenqueues: 0, reachedDone: false }).tripped,
true,
);
assert.equal(evaluateBudgetCaps({ budgetSpent: 1, turnsTaken: 1, elapsedMs: 1_000 }, {
budget: 100,
turns: 5,
elapsedMs: 60_000,
}).verdict, "allowed");
});
37 changes: 37 additions & 0 deletions packages/gittensory-miner/lib/governor-run-halt.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {
GovernorCapLimits,
GovernorCapUsage,
PortfolioConvergenceInput,
PortfolioConvergenceThresholds,
RunLoopHaltVerdict,
} from "@jsonbored/gittensory-engine";
import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js";
import type { QueueEntry } from "./portfolio-queue.js";

export type RunLoopInFlightItem = {
repoFullName: string;
identifier: string;
};

export type EvaluateRunLoopBoundaryGateInput = {
runHalted?: boolean;
usage: GovernorCapUsage;
limits: GovernorCapLimits;
convergence: PortfolioConvergenceInput;
convergenceThresholds?: PortfolioConvergenceThresholds;
inFlightItem?: RunLoopInFlightItem | null;
markFailed?: (repoFullName: string, identifier: string) => QueueEntry | null;
};

export type EvaluateRunLoopBoundaryGateResult = {
verdict: RunLoopHaltVerdict;
recorded: GovernorLedgerEntry | null;
runHalted: boolean;
canClaimNext: boolean;
releasedItem: QueueEntry | null;
};

export function evaluateRunLoopBoundaryGate(
input: EvaluateRunLoopBoundaryGateInput,
options?: { append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry },
): EvaluateRunLoopBoundaryGateResult;
58 changes: 58 additions & 0 deletions packages/gittensory-miner/lib/governor-run-halt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Governor run-loop halt gate (#2347). Consults non-convergence + budget caps at each iteration boundary,
// releases in-flight portfolio items on a fresh halt, and records the decision to the governor ledger.

import {
buildRunLoopHaltGovernorLedgerEvent,
evaluateRunLoopHalt,
} from "@jsonbored/gittensory-engine";
import { appendGovernorEvent } from "./governor-ledger.js";

/**
* Evaluate run-loop halt signals before claiming the next portfolio item.
*
* @param {object} input
* @param {boolean} [input.runHalted] whether the run is already halted
* @param {import("@jsonbored/gittensory-engine").GovernorCapUsage} input.usage cumulative run usage
* @param {import("@jsonbored/gittensory-engine").GovernorCapLimits} input.limits run ceilings
* @param {import("@jsonbored/gittensory-engine").PortfolioConvergenceInput} input.convergence in-flight item history
* @param {import("@jsonbored/gittensory-engine").PortfolioConvergenceThresholds} [input.convergenceThresholds]
* @param {{ repoFullName: string, identifier: string } | null | undefined} [input.inFlightItem]
* @param {(repoFullName: string, identifier: string) => import("./portfolio-queue.js").QueueEntry | null} [input.markFailed]
* @param {{ append?: typeof appendGovernorEvent }} [options]
*/
export function evaluateRunLoopBoundaryGate(input, options = {}) {
const append = options.append ?? appendGovernorEvent;
const wasHalted = Boolean(input.runHalted);
const verdict = evaluateRunLoopHalt({
runHalted: wasHalted,
usage: input.usage,
limits: input.limits,
convergence: input.convergence,
convergenceThresholds: input.convergenceThresholds,
});

const newlyHalted = !wasHalted && verdict.shouldHalt;
let releasedItem = null;
if (newlyHalted && input.inFlightItem && typeof input.markFailed === "function") {
releasedItem = input.markFailed(input.inFlightItem.repoFullName, input.inFlightItem.identifier);
}

const recorded =
newlyHalted || (!wasHalted && !verdict.shouldHalt)
? append(
buildRunLoopHaltGovernorLedgerEvent(
input.inFlightItem?.repoFullName ?? null,
input.inFlightItem?.identifier ?? null,
verdict,
),
)
: null;

return {
verdict,
recorded,
runHalted: verdict.shouldHalt,
canClaimNext: verdict.canClaimNext,
releasedItem,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type PortfolioQueueManager = {
enqueue(item: EnqueueItem): QueueEntry;
listQueue(repoFullName?: string | null): QueueEntry[];
markDone(repoFullName: string, identifier: string): QueueEntry | null;
markFailed(repoFullName: string, identifier: string): QueueEntry | null;
claimNextBatch(): QueueEntry[];
close(): void;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export function initPortfolioQueueManager(options = {}) {
markDone(repoFullName, identifier) {
return store.markDone(repoFullName, identifier);
},
markFailed(repoFullName, identifier) {
return store.markFailed(repoFullName, identifier);
},
claimNextBatch() {
return store.batchClaim((entries) => selectEligibleBatch(entries, caps));
},
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type PortfolioQueueStore = {
dequeueNext(): QueueEntry | null;
listQueue(repoFullName?: string | null): QueueEntry[];
markDone(repoFullName: string, identifier: string): QueueEntry | null;
markFailed(repoFullName: string, identifier: string): QueueEntry | null;
batchClaim(
selectFn: (entries: QueueEntry[]) => Array<{ repoFullName: string; identifier: string }>,
): QueueEntry[];
Expand All @@ -40,4 +41,6 @@ export function listQueue(repoFullName?: string | null): QueueEntry[];

export function markDone(repoFullName: string, identifier: string): QueueEntry | null;

export function markFailed(repoFullName: string, identifier: string): QueueEntry | null;

export function closeDefaultPortfolioQueueStore(): void;
16 changes: 16 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
const markDoneStatement = db.prepare(
"UPDATE miner_portfolio_queue SET status = 'done' WHERE repo_full_name = ? AND identifier = ? AND status <> 'done'",
);
const markFailedStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'queued'
WHERE repo_full_name = ? AND identifier = ? AND status = 'in_progress'
RETURNING *
`);
const listAllStatement = db.prepare(`SELECT * FROM miner_portfolio_queue ${ORDER}`);
const listRepoStatement = db.prepare(
`SELECT * FROM miner_portfolio_queue WHERE repo_full_name = ? ${ORDER}`,
Expand Down Expand Up @@ -146,6 +151,13 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
const row = getStatement.get(normalizedRepo, normalizedIdentifier);
return row ? rowToEntry(row) : null;
},
/** Release an in-flight item back to `queued` when a run halts (#2347). */
markFailed(repoFullName, identifier) {
const normalizedRepo = normalizeRepoFullName(repoFullName);
const normalizedIdentifier = normalizeIdentifier(identifier);
const row = markFailedStatement.get(normalizedRepo, normalizedIdentifier);
return row ? rowToEntry(row) : null;
},
/**
* Transactional caps-aware batch claim hook used by portfolio-queue-manager.js: re-read active rows under an
* exclusive lock, let the caller pick targets, then atomically flip each still-queued row to `in_progress`.
Expand Down Expand Up @@ -198,6 +210,10 @@ export function markDone(repoFullName, identifier) {
return getDefaultPortfolioQueueStore().markDone(repoFullName, identifier);
}

export function markFailed(repoFullName, identifier) {
return getDefaultPortfolioQueueStore().markFailed(repoFullName, identifier);
}

export function closeDefaultPortfolioQueueStore() {
if (!defaultPortfolioQueueStore) return;
defaultPortfolioQueueStore.close();
Expand Down
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"expected-engine.version"
],
"scripts": {
"build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js"
"build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/governor-run-halt.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js"
},
"dependencies": {
"@jsonbored/gittensory-engine": "*"
Expand Down
Loading