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
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/attempt-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { resolveAmsPolicy } from "./ams-policy.js";
import type { checkMinerKillSwitch } from "./governor-kill-switch.js";
import type { resolveMinerGoalSpec } from "./miner-goal-spec.js";
import type { ClaimConflictResult, resolveClaimConflict } from "./claim-conflict-resolver.js";
import type { getAttemptHistory } from "./portfolio-queue.js";

type CommonAttemptResultFields = {
repoFullName: string;
Expand Down Expand Up @@ -91,6 +92,7 @@ export type RunAttemptOptions = {
resolveMinerGoalSpec?: typeof resolveMinerGoalSpec;
runMinerAttempt?: typeof runMinerAttempt;
resolveClaimConflict?: typeof resolveClaimConflict;
getAttemptHistory?: typeof getAttemptHistory;
/** Invoked with the real structured result at every return point, in addition to (never instead of) the
* plain exit-code return -- the loop orchestrator's real hook into what actually happened. */
onResult?: (result: AttemptCliResult) => void;
Expand Down
15 changes: 12 additions & 3 deletions packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
// checkSubmissionFreshness cannot see (two miners submitting almost simultaneously).
//
// KNOWN, DOCUMENTED GAPS (not fabricated -- see attempt-input-builder.js's own header for the full list):
// governor.convergenceInput is an honest first-attempt-shaped literal, not a real per-issue attempt-history
// query (attempt-log.js's schema has no repo+issue index, and reenqueue counts aren't tracked anywhere yet).
// governor.reputationHistory/selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted (chokepoint.ts's
// own design treats that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue
// portfolio-queue.js read (#5654), not a placeholder.

import { resolveCodingAgentModeFromConfig, resolveFirstConfiguredCodingAgentDriverName } from "@loopover/engine";
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
Expand All @@ -33,6 +34,7 @@ import { buildCodingTaskSpec } from "./coding-task-spec.js";
import { resolveAmsPolicy } from "./ams-policy.js";
import { checkMinerKillSwitch } from "./governor-kill-switch.js";
import { buildAttemptGovernorContext, buildAttemptLoopInput } from "./attempt-input-builder.js";
import { getAttemptHistory } from "./portfolio-queue.js";
import { runMinerAttempt } from "./attempt-runner.js";

const ATTEMPT_USAGE =
Expand Down Expand Up @@ -399,7 +401,14 @@ 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): portfolio-queue.js's own claim/reclaim/requeue/done counters,
// keyed the same way opportunity-fanout.js enqueues issue-shaped candidates (`issue:<number>`). No
// apiBaseUrl: this file has no multi-forge host context of its own today, so this reads (and every
// pre-#5563 single-forge caller already reads) the github.com default.
const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory;
const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`);
const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, 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
27 changes: 16 additions & 11 deletions packages/gittensory-miner/lib/attempt-input-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ 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.
//
// governor.convergenceInput is now a REAL per-issue attempt-history query (#5654): the caller (attempt-cli.js)
// reads it from portfolio-queue.js's own getAttemptHistory and passes it in here, this composer staying pure
// over it same as every other already-computed dependency below. The zero-state fallback only fires when a
// caller genuinely omits the argument -- an honest first-attempt shape, not the old hardcoded literal.

/**
* Assemble the real Governor chokepoint context for one attempt. rateLimitBuckets/rateLimitBackoffAttempts/
Expand All @@ -26,18 +24,24 @@ 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 own real portfolio-queue.js `getAttemptHistory` read -- this
* composer stays pure and just threads it through, same as `repoPaused`. Omitted (never fabricated) falls
* back to the honest first-attempt-shaped zero-state, so a caller that hasn't wired a real read yet (or an
* item genuinely absent from the queue) still produces a well-formed `PortfolioConvergenceInput`.
*
* @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 Expand Up @@ -71,8 +75,9 @@ export function buildAttemptLoopInput(input) {
// Real mid-attempt budget (#5395): the SAME Governor cap ceilings that already bound cross-cycle spend
// (loop-cli.js's after-the-fact governorState.saveCapUsage) now also bound this ONE attempt in progress,
// via the engine's real accumulateAttemptUsage/evaluateAttemptBudget -- a runaway attempt can no longer
// burn through the entire cross-cycle budget before anything reacts. No maxTokens: no driver reports a
// real per-iteration token count today, so that axis has no real ceiling to set (never fabricated).
// burn through the entire cross-cycle budget before anything reacts. No maxTokens: every driver now
// reports a real per-iteration token count (#5653), but no policy field sets a token ceiling yet -- that
// axis genuinely has no real ceiling to set today, never fabricated.
budget: {
maxTurns: input.amsPolicySpec.capLimits.turns,
maxWallClockMs: input.amsPolicySpec.capLimits.elapsedMs,
Expand Down
12 changes: 12 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ export type QueueLeaseEntry = {
leasedAt: string | null;
};

/** A real per-item PortfolioConvergenceInput (non-convergence.ts, #5654), read from this store's own
* attempt-history counters -- see getAttemptHistory. */
export type QueueAttemptHistory = {
attempts: number;
consecutiveFailures: number;
reenqueues: number;
reachedDone: boolean;
};

export type PortfolioQueueStore = {
dbPath: string;
enqueue(item: EnqueueItem): QueueEntry;
Expand All @@ -40,6 +49,7 @@ export type PortfolioQueueStore = {
entries: QueueEntry[],
) => Array<{ repoFullName: string; identifier: string; apiBaseUrl?: string }>,
): QueueEntry[];
getAttemptHistory(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueAttemptHistory;
close(): void;
};

Expand All @@ -59,4 +69,6 @@ export function markDone(repoFullName: string, identifier: string, apiBaseUrl?:

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

export function getAttemptHistory(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueAttemptHistory;

export function closeDefaultPortfolioQueueStore(): void;
81 changes: 73 additions & 8 deletions packages/gittensory-miner/lib/portfolio-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,24 @@ 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): three attempt-history counters feeding non-convergence.ts's real
// PortfolioConvergenceInput (see getAttemptHistory below) -- additive columns, same
// defensive column-presence guard as the leased_at migration above.
(migrationDb) => {
const existingColumns = migrationDb
.prepare("PRAGMA table_info(miner_portfolio_queue)")
.all()
.map((column) => column.name);
if (!existingColumns.includes("attempts_count")) {
migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN attempts_count INTEGER NOT NULL DEFAULT 0");
}
if (!existingColumns.includes("consecutive_failures")) {
migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0");
}
if (!existingColumns.includes("reenqueue_count")) {
migrationDb.exec("ALTER TABLE miner_portfolio_queue ADD COLUMN reenqueue_count 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 All @@ -172,24 +190,33 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
// flips it to 'in_progress', RETURNING it — so two processes sharing the file can't both claim the same row (a
// separate SELECT-then-UPDATE would race). Deliberately global (no api_base_url filter): the queue is a single
// 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 stamps `leased_at` with the caller-supplied claim time and increments the attempt-history
// `attempts_count` (#5654, non-convergence.ts's real PortfolioConvergenceInput.attempts) -- leaving
// 'in_progress' (done/failed/reclaim) clears leased_at back to NULL so only genuinely in-flight rows carry
// a lease.
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_count = attempts_count + 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.
// directly from one atomic statement. consecutive_failures resets to 0 on reaching done (#5654) -- the
// active failure streak breaks the moment an attempt actually succeeds; reenqueue_count is a lifetime
// total and deliberately untouched here (see getAttemptHistory's own doc comment).
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 *
`);
// Releasing an in-flight item back to queued WITHOUT reaching done is exactly non-convergence.ts's own
// "cycling queued -> in_progress -> queued without ever reaching done" reenqueue trigger (#5654) -- same
// counters, same increment, as reclaimStuckItem below (both are this same transition, just different
// callers: a run-halt release here vs. a stale-lease sweep there).
const markFailedStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL
UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL,
consecutive_failures = consecutive_failures + 1, reenqueue_count = reenqueue_count + 1
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'in_progress'
RETURNING *
`);
Expand All @@ -203,24 +230,36 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
const listInProgressStatement = db.prepare(
`SELECT * FROM miner_portfolio_queue WHERE status = 'in_progress' ${ORDER}`,
);
// A stale-lease sweep release is the SAME "in_progress -> queued without reaching done" event as
// markFailedStatement above (#5654) -- same counters, same increment.
const reclaimStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL
UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL,
consecutive_failures = consecutive_failures + 1, reenqueue_count = reenqueue_count + 1
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'in_progress'
RETURNING *
`);
// Requeue only ever targets a COMPLETED ('done') row — an in-flight item is released via reclaimStatement, and
// an already-'queued' item is a no-op — so a caller's manual requeue can never disturb an active claim. The
// row keeps its rowid/enqueued_at, so it re-enters the queue at its original FIFO position, not the back.
// Deliberately leaves attempts_count/consecutive_failures/reenqueue_count untouched (#5654): this is a
// manual reopen of ALREADY-COMPLETED work, not the stuck queued->in_progress->queued cycle those counters
// track -- reachedDone (derived live from status) simply reads false again once requeued, same as any
// other non-done row, until the item is claimed and completed again.
const requeueStatement = db.prepare(`
UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'done'
RETURNING *
`);
// Same attempts_count increment as dequeueStatement (#5654) -- batchClaim's per-item claim is just as much
// a real attempt as the single-item dequeueNext path.
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_count = attempts_count + 1
WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ? AND status = 'queued'
RETURNING *
`);
const attemptHistoryStatement = db.prepare(
"SELECT attempts_count, consecutive_failures, reenqueue_count, status FROM miner_portfolio_queue WHERE api_base_url = ? AND repo_full_name = ? AND identifier = ?",
);

return {
dbPath: resolvedPath,
Expand Down Expand Up @@ -313,6 +352,28 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath())
throw error;
}
},
/**
* A real `PortfolioConvergenceInput` (non-convergence.ts) for one queue item (#5654), replacing the
* first-attempt-shaped literal attempt-input-builder.js previously hardcoded. An item never enqueued here
* (not yet tracked at all) reads the same honest zero-state as a genuine first attempt -- absence of
* history is not evidence of a problem, same rule non-convergence.ts's own header documents. `reachedDone`
* is derived live from the row's current `status`, not a separate persisted flag (see requeueStatement's
* comment above for why that's the deliberate choice).
*/
getAttemptHistory(repoFullName, identifier, apiBaseUrl) {
const row = attemptHistoryStatement.get(
normalizeApiBaseUrl(apiBaseUrl),
normalizeRepoFullName(repoFullName),
normalizeIdentifier(identifier),
);
if (!row) return { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false };
return {
attempts: row.attempts_count,
consecutiveFailures: row.consecutive_failures,
reenqueues: row.reenqueue_count,
reachedDone: row.status === "done",
};
},
close() {
db.close();
},
Expand Down Expand Up @@ -344,6 +405,10 @@ export function markFailed(repoFullName, identifier, apiBaseUrl) {
return getDefaultPortfolioQueueStore().markFailed(repoFullName, identifier, apiBaseUrl);
}

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

export function closeDefaultPortfolioQueueStore() {
if (!defaultPortfolioQueueStore) return;
defaultPortfolioQueueStore.close();
Expand Down
Loading