Skip to content
Closed
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
5 changes: 4 additions & 1 deletion packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@ export async function runAttempt(args, options = {}) {
amsPolicySpec: amsPolicy.spec,
branchRef: worktreeResult.branchName,
});
const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused);
// Real per-issue attempt history (#5654): the queue-driven caller (loop-cli) reads it from the portfolio-queue
// store and passes it through; a one-off direct attempt has none, so buildAttemptGovernorContext fails open to a
// fresh, never-attempted item rather than fabricating history here.
const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, options.convergenceInput);

// Real soft-claim (#5393): recorded once we've committed to a real attempt (past feasibility), so a
// sibling miner process on this machine sees it via claimLedger.listClaims/listActiveClaims while this
Expand Down
9 changes: 8 additions & 1 deletion packages/gittensory-miner/lib/attempt-input-builder.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { AmsPolicySpec, CodingAgentExecutionMode, IterateLoopInput, SelfReviewContext } from "@loopover/engine";
import type {
AmsPolicySpec,
CodingAgentExecutionMode,
IterateLoopInput,
PortfolioConvergenceInput,
SelfReviewContext,
} from "@loopover/engine";
import type { AttemptGovernorContext } from "./attempt-runner.js";
import type { CodingTaskSpecResult } from "./coding-task-spec.js";

export function buildAttemptGovernorContext(
env: Record<string, string | undefined>,
amsPolicySpec: AmsPolicySpec,
repoPaused?: boolean,
convergenceInput?: PortfolioConvergenceInput,
): AttemptGovernorContext;

export type BuildAttemptLoopInputInput = {
Expand Down
18 changes: 9 additions & 9 deletions packages/gittensory-miner/lib/attempt-input-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,6 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@loopover/e
// same discipline as coding-task-spec.js's own composers.
//
// KNOWN, DOCUMENTED GAPS (not fabricated -- explicitly left as real, narrow follow-ups):
// - governor.convergenceInput is a first-attempt-shaped literal ({ attempts: 0, consecutiveFailures: 0,
// reenqueues: 0, reachedDone: false }), not a real per-issue query. attempt-log.js's schema has no
// repo+issue index (attemptId embeds a timestamp, so it's not a stable group key), and reenqueue counts
// aren't tracked ANYWHERE yet (non-convergence.ts's own header says that belongs on the portfolio-queue
// table once it grows attempt-history columns -- a real, separate schema change, not something to fake
// here). This literal is only ever an UNDER-estimate (reads "fresh, no prior failures" even on a real
// Nth attempt), which fails toward LETTING an attempt through, not blocking one -- documented, not silent.
// - governor.reputationHistory/selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted, which
// chokepoint.ts's own design treats as "skip that stage entirely" -- an honest absence, not a fabricated
// "clean" verdict.
Expand All @@ -26,18 +19,25 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@loopover/e
* (miner-goal-spec.js's resolveMinerGoalSpec) -- this composer stays pure and just threads whatever the
* caller already resolved through; passing nothing keeps the prior fails-open-on-that-axis-only behavior.
*
* `convergenceInput` (#5654) is the caller's real per-issue attempt history read from the portfolio-queue store
* (portfolio-queue.js `getAttemptHistory`). This composer stays pure and just forwards whatever the caller
* resolved; when the caller has none (e.g. a one-off direct attempt not driven by the queue) it defaults to a
* fresh, never-attempted item -- an UNDER-estimate that fails toward LETTING an attempt through, the same
* fail-open posture this axis had before real history existed.
*
* @param {Record<string, string | undefined>} env
* @param {import("@loopover/engine").AmsPolicySpec} amsPolicySpec
* @param {boolean} [repoPaused]
* @param {import("@loopover/engine").PortfolioConvergenceInput} [convergenceInput]
* @returns {import("./attempt-runner.js").AttemptGovernorContext}
*/
export function buildAttemptGovernorContext(env, amsPolicySpec, repoPaused) {
export function buildAttemptGovernorContext(env, amsPolicySpec, repoPaused, convergenceInput) {
return {
killSwitchGlobal: isGlobalMinerKillSwitch(env),
killSwitchRepoPaused: repoPaused,
liveModeGlobalOptIn: isGlobalMinerLiveModeOptIn(env),
capLimits: amsPolicySpec.capLimits,
convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },
convergenceInput: convergenceInput ?? { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },
};
}

Expand Down
4 changes: 4 additions & 0 deletions packages/gittensory-miner/lib/loop-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,10 @@ export async function runLoop(args, options = {}) {
await runAttemptFn(attemptArgv, {
...(options.attemptOptions ?? {}),
env,
// Real per-issue attempt history (#5654): the durable portfolio-queue counters (bumped by the claim above
// and every prior requeue/reclaim) feed this attempt's Governor chokepoint context, so its non-convergence
// detector sees genuine "Nth attempt, M prior re-enqueues" state instead of the old hardcoded "fresh" literal.
convergenceInput: portfolioQueue.getAttemptHistory(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl),
onResult: (result) => {
lastResult = result;
},
Expand Down
8 changes: 8 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PortfolioConvergenceInput } from "@loopover/engine";

export type QueueStatus = "queued" | "in_progress" | "done";

export type QueueEntry = {
Expand Down Expand Up @@ -35,6 +37,12 @@ export type PortfolioQueueStore = {
markFailed(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null;
reclaimStuckItem(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null;
requeueItem(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null;
/** Real per-item attempt history (#5654) for the Governor's non-convergence detector. */
getAttemptHistory(
repoFullName: string,
identifier: string,
apiBaseUrl?: string,
): PortfolioConvergenceInput;
batchClaim(
selectFn: (
entries: QueueEntry[],
Expand Down
61 changes: 56 additions & 5 deletions packages/gittensory-miner/lib/portfolio-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,23 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
migrationDb.exec("DROP TABLE miner_portfolio_queue");
migrationDb.exec("ALTER TABLE miner_portfolio_queue_v3 RENAME TO miner_portfolio_queue");
},
// v3 -> v4 (#5654): grow the attempt-history columns the Governor's non-convergence detector needs
// (packages/gittensory-engine/src/portfolio/non-convergence.ts, whose header names the portfolio-queue table
// as where these belong). `attempts` counts claims, `reenqueues` counts in_progress -> queued transitions that
// never reached done, `consecutive_failures` is those same transitions since the last markDone. All three are
// NOT NULL DEFAULT 0, so every pre-existing row reads as "fresh, no history yet" until next claimed/requeued --
// an additive migration. Defensive per-column table_info check (same idiom as the `leased_at` migration above)
// so a store already carrying them is never re-altered into a duplicate-column error.
(migrationDb) => {
const columns = migrationDb.prepare("PRAGMA table_info(miner_portfolio_queue)").all();
const hasColumn = (name) => columns.some((column) => column.name === name);
if (!hasColumn("attempts"))
migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0");
if (!hasColumn("consecutive_failures"))
migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0");
if (!hasColumn("reenqueues"))
migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN reenqueues INTEGER NOT NULL DEFAULT 0");
},
]);

// `rowid` is a stable, unique key assigned once at first insert (re-enqueue updates in place, never re-inserts),
Expand Down Expand Up @@ -174,22 +191,30 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
// cross-host priority ordering, not a per-host one.
// Claiming stamps `leased_at` with the caller-supplied claim time; leaving 'in_progress' (done/failed/reclaim)
// clears it back to NULL so only genuinely in-flight rows carry a lease.
// Claiming counts an attempt (#5654): every queued -> in_progress flip is one more try on the item, so the
// Governor's non-convergence detector can tell a genuine Nth attempt from a first one.
const dequeueStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'in_progress', leased_at = ?
UPDATE miner_portfolio_queue SET status = 'in_progress', leased_at = ?, attempts = attempts + 1
WHERE rowid = (
SELECT rowid FROM miner_portfolio_queue WHERE status = 'queued' ${ORDER} LIMIT 1
)
RETURNING *
`);
// RETURNING (rather than a separate post-UPDATE SELECT) makes the "nothing to mark done" case observable
// directly from one atomic statement.
// Reaching done resets the consecutive-failure streak to 0 (#5654): progress was made, so the item is no longer
// "stuck". `attempts`/`reenqueues` are cumulative lifetime totals and deliberately survive a done.
const markDoneStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'done', leased_at = NULL
UPDATE miner_portfolio_queue SET status = 'done', leased_at = NULL, consecutive_failures = 0
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status <> 'done'
RETURNING *
`);
// A failed/halted attempt cycles in_progress -> queued WITHOUT reaching done (#5654) -- exactly non-convergence.ts's
// reenqueue trigger -- so bump both the lifetime reenqueue count and the consecutive-failure streak. (requeueItem,
// which cycles a COMPLETED done row, is deliberately excluded: it DID reach done, so it is not a stuck loop.)
const markFailedStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL
UPDATE miner_portfolio_queue
SET status = 'queued', leased_at = NULL, reenqueues = reenqueues + 1, consecutive_failures = consecutive_failures + 1
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'in_progress'
RETURNING *
`);
Expand All @@ -203,8 +228,12 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
const listInProgressStatement = db.prepare(
`SELECT * FROM miner_portfolio_queue WHERE status = 'in_progress' ${ORDER}`,
);
// The stuck-lease reclaim sweep is the other in_progress -> queued-without-done cycle (#5654): a crashed/killed
// run's lease is swept back to queued, which is the same non-convergence signal as an explicit failure, so it
// bumps the same two counters.
const reclaimStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL
UPDATE miner_portfolio_queue
SET status = 'queued', leased_at = NULL, reenqueues = reenqueues + 1, consecutive_failures = consecutive_failures + 1
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'in_progress'
RETURNING *
`);
Expand All @@ -216,11 +245,15 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'done'
RETURNING *
`);
// Batch claim is the same queued -> in_progress attempt as dequeueNext, so it counts an attempt too (#5654).
const claimTargetStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'in_progress', leased_at = ?
UPDATE miner_portfolio_queue SET status = 'in_progress', leased_at = ?, attempts = attempts + 1
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'queued'
RETURNING *
`);
const attemptHistoryStatement = db.prepare(
"SELECT attempts, consecutive_failures, reenqueues, status FROM miner_portfolio_queue WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ?",
);

return {
dbPath: resolvedPath,
Expand Down Expand Up @@ -286,6 +319,24 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
);
return row ? rowToEntry(row) : null;
},
/** Real per-item attempt history for the Governor's non-convergence detector (#5654): a genuine
* `PortfolioConvergenceInput` (packages/gittensory-engine/src/portfolio/non-convergence.ts) instead of the
* first-attempt-shaped literal `buildAttemptGovernorContext` used to hardcode. `reachedDone` is strictly
* `status === 'done'`, never fabricated. An item never enqueued reads all-zero and not-done -- a fresh,
* never-attempted item the detector treats as `converging`, matching the prior literal's fail-open posture. */
getAttemptHistory(repoFullName, identifier, apiBaseUrl) {
const row = attemptHistoryStatement.get(
normalizeApiBaseUrl(apiBaseUrl),
normalizeRepoFullName(repoFullName),
normalizeIdentifier(identifier),
);
return {
attempts: row?.attempts ?? 0,
consecutiveFailures: row?.consecutive_failures ?? 0,
reenqueues: row?.reenqueues ?? 0,
reachedDone: row?.status === "done",
};
},
/**
* 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
Loading