diff --git a/CHANGELOG.md b/CHANGELOG.md index c75cf351..40a27698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Changed +- **`idle_fill` no longer lifts a park or backoff on an issue nobody has touched (AGT-4787).** On 2026-10-20 one issue was dispatched 18 times in 14 hours and 52 were touched, mostly after an `infra_error` or a "parked for operator decision" that retrying cannot change. `src/automation/dispatchFingerprint.ts` stores, in its own table (`automation_parked_inputs`), a digest of the title and description a run started on (`recordRunInput`, called where the pipeline starts, so every later park or backoff โ€” wherever in the ledger it happens โ€” is measured against it). `filterAlreadyProcessed` skips the NEEDS_HUMAN resume and the RETRY_AT lift while the issue still matches that digest; an edit to either field lifts them as before. **The key is not `trackerUpdatedAt`, and not a comment count:** both bump on the daemon's own progress comments and state changes (AGT-4300 measured that key driving `attempt_no` to 40 on an unchanged issue), so a fingerprint on them would change after every comment the daemon posts and the block would never hold. Reads fail open (an unreadable store degrades to today's behaviour), the run-start write never stops a run. **Known gap:** tracker comments are not on a listed task, so an answer given *only* as a comment does not lift a held park; an explicit dispatch or a Todo transition does. The module already accepts comments and recognises the daemon's own signatures (`๐Ÿงญ **[OpenSwarm] โ€ฆ`, `_via OpenSwarm_`), so wiring them in is the follow-up. + - **The daemon refuses to start when unattended work is routed to a delegated CLI adapter (AGT-4444).** `claude`, `codex` and `cursor` hand the whole tool loop to another program, so OpenSwarm's publication fence, protected files, role tool limits and (since AGT-4783/4798) the credential-free, loopback-only shell never reach them; `base.ts` could only log that they were dropped, and `claude` runs with `--permission-mode bypassPermissions`. `src/adapters/unattendedPolicy.ts` now checks every place the daemon can pick an adapter โ€” the top-level `adapter`, `autonomous.defaultRoles`, per-project `roles`, `adapterRouting` primary **and fallbacks** (whose schema default is `['cc-router', 'cursor']`, so a bare `adapterRouting:` block already routed to cursor), `orchestrator` and `loopAdvisor` โ€” and startup throws naming each offending setting. A role switched off is not checked. Opt out with `autonomous.allowDelegatedAdapters: true`. Interactive CLI use is unaffected: the check runs only when an `autonomous` section exists. Periodic read-only reviews (`periodicReviews[].adapter`) are not covered. - **Worker shell commands get loopback-only network by default (AGT-4798).** AGT-4783 removed the credentials a worker's shell picked up on its own; a command could still read a token file by absolute path and use it while the sandbox allowed the network. The macOS sandbox profile now allows only `localhost` (bind and inbound on the local end, outbound to the remote end โ€” a single `network* (local ip "localhost:*")` rule was measured to also admit remote connections), and bwrap keeps `--unshare-net`. Tests that start a local server still work; anything that reaches another host fails with `EPERM`. Measured before the change: cgf-portal's `uv run --frozen pytest` and OpenSwarm's vitest run offline, because worktrees share installed dependencies. New `autonomous.workerNetwork: loopback | open` (default `loopback`) โ€” set `open` on a host whose sandbox cannot isolate the network. The worker prompt now says what is true (no credentials, no remote network) and tells it not to probe release/deploy scripts with `--help`. diff --git a/src/automation/autonomousRunner.dispatchFingerprint.test.ts b/src/automation/autonomousRunner.dispatchFingerprint.test.ts new file mode 100644 index 00000000..d01d866b --- /dev/null +++ b/src/automation/autonomousRunner.dispatchFingerprint.test.ts @@ -0,0 +1,145 @@ +// ============================================ +// OpenSwarm - idle_fill does not lift a park the issue's input never changed (AGT-4787) +// ============================================ +// +// The module is tested on its own in dispatchFingerprint.test.ts. This pins the +// consumer: the real `filterAlreadyProcessed`, with a real ledger, must leave an +// unchanged parked issue alone and still lift it once a person has edited it. +// Each blocking case has a control that differs only in the input, so a gate +// that blocks everything โ€” or nothing โ€” cannot pass. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { TaskItem } from '../orchestration/decisionEngine.js'; +import type { DurableRunCoordinator } from './durableRunCoordinator.js'; +import type { TaskScheduler } from '../orchestration/taskScheduler.js'; + +vi.mock('../core/providerOverride.js', () => ({ writeProviderOverride: vi.fn() })); +vi.mock('../agents/stageModelResolver.js', () => ({ resolveAdapterDefaultModel: vi.fn(async () => 'model') })); + +type InternalRunner = { + scheduler: TaskScheduler; + durableRuns: DurableRunCoordinator; + filterAlreadyProcessed(tasks: TaskItem[]): TaskItem[]; + resolveProjectPath(task: TaskItem): Promise; +}; + +const REPO = '/repo'; +const TASK: TaskItem = { + id: 'AGT-1', issueId: 'AGT-1', issueIdentifier: 'AGT-1', + source: 'linear', title: 'import is dropping rows', description: 'See the failing fixture.', priority: 2, createdAt: 0, + linearState: 'Todo', linearProject: { id: 'project', name: 'Repo' }, +}; +const EDITED: TaskItem = { ...TASK, description: 'See the failing fixture. Use the staging DB.' }; + +describe('idle_fill and the issue input (AGT-4787)', () => { + let root: string; + let dbPath: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'openswarm-dispatch-fp-')); + dbPath = join(root, 'runs.db'); + process.env.OPENSWARM_COORDINATION_FILE = join(root, 'coordination.json'); + process.env.OPENSWARM_TASK_STATE_FILE = join(root, 'task-state.json'); + process.env.OPENSWARM_RUNNER_TASK_STATE_FILE = join(root, 'runner-state.json'); + process.env.OPENSWARM_RUNNER_REJECTION_STATE_FILE = join(root, 'rejections.json'); + process.env.OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE = join(root, 'history.json'); + process.env.OPENSWARM_RUNNER_DECOMPOSITION_STATE_FILE = join(root, 'decomposition.json'); + process.env.OPENSWARM_AUTOMATION_DB = dbPath; + }); + + afterEach(async () => { + for (const key of [ + 'OPENSWARM_COORDINATION_FILE', 'OPENSWARM_TASK_STATE_FILE', 'OPENSWARM_RUNNER_TASK_STATE_FILE', + 'OPENSWARM_RUNNER_REJECTION_STATE_FILE', 'OPENSWARM_RUNNER_PIPELINE_HISTORY_FILE', + 'OPENSWARM_RUNNER_DECOMPOSITION_STATE_FILE', 'OPENSWARM_AUTOMATION_DB', + ]) delete process.env[key]; + (await import('./dispatchFingerprint.js')).resetDispatchFingerprintForTests(); + (await import('./execHold.js')).resetExecHoldForTests(); + (await import('../coordination/coordinationStore.js')).resetCoordinationStoreForTests(); + (await import('../coordination/coordinationTrace.js')).resetTraceDbForTests(); + rmSync(root, { recursive: true, force: true }); + vi.resetModules(); + }); + + async function makeRunner(): Promise { + const { AutonomousRunner } = await import('./autonomousRunner.js'); + const runner = new AutonomousRunner({ + linearTeamId: 'team', allowedProjects: [REPO], heartbeatSchedule: '0 * * * *', + autoExecute: true, dryRun: true, + automationLedgerMode: 'primary', automationDbPath: dbPath, + }); + const internal = runner as unknown as InternalRunner; + internal.resolveProjectPath = vi.fn(async () => REPO); + internal.durableRuns.observeTask(TASK, REPO); + vi.spyOn(internal.scheduler, 'getAvailableSlots').mockReturnValue(1); + return internal; + } + + /** The run started on `task`'s input and then parked, as a real run does. */ + async function startedOn(task: TaskItem): Promise { + const fp = await import('./dispatchFingerprint.js'); + fp.resetDispatchFingerprintForTests(); + fp.recordRunInput(task); + } + + it('does not lift a NEEDS_HUMAN park while the issue is unchanged', async () => { + const internal = await makeRunner(); + internal.durableRuns.markNeedsHuman('AGT-1', 'Reviewer rejected 4 attempts: still failing lint'); + await startedOn(TASK); + const resume = vi.spyOn(internal.durableRuns, 'resumeNeedsHuman'); + + expect(internal.filterAlreadyProcessed([TASK])).toEqual([]); + expect(internal.durableRuns.getRun('AGT-1')?.state).toBe('NEEDS_HUMAN'); + expect(resume).not.toHaveBeenCalled(); + }); + + it('lifts the same park once a person edited the issue (control)', async () => { + const internal = await makeRunner(); + internal.durableRuns.markNeedsHuman('AGT-1', 'Reviewer rejected 4 attempts: still failing lint'); + await startedOn(TASK); + + expect(internal.filterAlreadyProcessed([EDITED])).toEqual([EDITED]); + expect(internal.durableRuns.getRun('AGT-1')?.state).not.toBe('NEEDS_HUMAN'); + }); + + it('lifts a park for an issue it never recorded a run for (control: no record never blocks)', async () => { + const internal = await makeRunner(); + internal.durableRuns.markNeedsHuman('AGT-1', 'Reviewer rejected 4 attempts: still failing lint'); + + expect(internal.filterAlreadyProcessed([TASK])).toEqual([TASK]); + expect(internal.durableRuns.getRun('AGT-1')?.state).not.toBe('NEEDS_HUMAN'); + }); + + it('does not pull a RETRY_AT backoff forward while the issue is unchanged, and does once it is edited', async () => { + const internal = await makeRunner(); + const { RunLedger } = await import('./runLedger.js'); + const ledger = new RunLedger(dbPath); + const claim = ledger.claimRun('AGT-1', { ownerInstanceId: 'seed', leaseMs: 60_000, maxActiveForProject: 1 }); + expect(claim).not.toBeNull(); + expect(ledger.transition(claim!, 'RETRY_AT', { retryAt: Date.now() + 3_600_000, errorCode: 'infra_error' })).toBe(true); + ledger.close(); + await startedOn(TASK); + + const markReady = vi.spyOn(internal.durableRuns, 'markReady'); + expect(internal.filterAlreadyProcessed([TASK])).toEqual([]); + expect(markReady).not.toHaveBeenCalled(); + expect(internal.durableRuns.getRun('AGT-1')?.state).toBe('RETRY_AT'); + + expect(internal.filterAlreadyProcessed([EDITED])).toEqual([EDITED]); + expect(markReady).toHaveBeenCalledWith('AGT-1'); + }); + + it('ignores the daemon\'s own writes: a changed update time alone does not unblock', async () => { + const internal = await makeRunner(); + internal.durableRuns.markNeedsHuman('AGT-1', 'Reviewer rejected 4 attempts: still failing lint'); + await startedOn(TASK); + // The daemon's progress comment and state change bump the tracker's update time. + const touchedByDaemon: TaskItem = { ...TASK, trackerUpdatedAt: Date.now() + 60_000 }; + + expect(internal.filterAlreadyProcessed([touchedByDaemon])).toEqual([]); + expect(internal.durableRuns.getRun('AGT-1')?.state).toBe('NEEDS_HUMAN'); + }); +}); diff --git a/src/automation/autonomousRunner.ts b/src/automation/autonomousRunner.ts index 7ff2eff4..39f6cacd 100644 --- a/src/automation/autonomousRunner.ts +++ b/src/automation/autonomousRunner.ts @@ -69,6 +69,7 @@ import { t } from '../locale/index.js'; import { SANDBOX_OUTCOME_UNKNOWN_PARK_REASON } from '../sandboxExecutor/protocol.js'; import { INFRA_CIRCUIT_PARK_REASON } from './infraFailureCircuit.js'; import { listHeldIssueIds } from './execHold.js'; +import { inputFingerprint, mayRedispatch } from './dispatchFingerprint.js'; import { VERIFIED_WORKER_BLOCKER_PARK_REASON } from '../agents/pairPipelineTypes.js'; import { decideExplicitReadmission, OPERATOR_QUESTION_PARK_MARKER } from './explicitDispatchReadmission.js'; import { broadcastEvent, type SwarmStats } from '../core/eventHub.js'; @@ -1255,6 +1256,13 @@ export class AutonomousRunner { // skipped, so a park reaches the `exec await` that is watching for it. const execHeld = heldExecRuns.has(id); let durableRun = this.durableRuns.getRun(id); + // A park or backoff that nobody has touched is not lifted by a free slot: + // retrying an unchanged issue after an infra_error or an operator park + // changes nothing (AGT-4787 โ€” 18 dispatches of one issue in 14 hours). The + // input is title + description; tracker comments are not on a listed task, + // so an answer given only as a comment waits for an explicit dispatch. + const issueInput = inputFingerprint({ title: task.title, description: task.description }); + const inputUnchanged = !!durableRun && !mayRedispatch(id, issueInput); // Set once this task has spent idle budget, so a later branch in this // same pass (the stuck-label recovery) neither charges it twice nor // skips a row whose park idle_fill has already erased. @@ -1318,7 +1326,7 @@ export class AutonomousRunner { || durableRun?.lastErrorCode === SANDBOX_OUTCOME_UNKNOWN_PARK_REASON; // Nor does a free slot change a verdict that has already come back // twice; see idleFillWouldRepeatVerdict. - if (durableRun?.state === 'NEEDS_HUMAN' && idleFillBudget > 0 && !isInfraCircuitPark && !execHeld + if (durableRun?.state === 'NEEDS_HUMAN' && idleFillBudget > 0 && !isInfraCircuitPark && !execHeld && !inputUnchanged && !this.durableRuns.idleFillWouldRepeatVerdict(id)) { const idleResumed = this.durableRuns.resumeNeedsHuman(id, Date.now(), 'idle_fill'); if (idleResumed) { @@ -1377,6 +1385,7 @@ export class AutonomousRunner { // answer`, not by a free slot: lifting its backoff here would start a // second attempt while the orchestrator still owns the decision. && !execHeld + && !inputUnchanged // A run that has failed three times is backed off on purpose (AGT-4673). // Lifting it here whenever a slot frees put the highest-priority failing // run ahead of 36 READY tasks, so the backoff never held (AGT-4675). diff --git a/src/automation/dispatchFingerprint.test.ts b/src/automation/dispatchFingerprint.test.ts new file mode 100644 index 00000000..e40679f4 --- /dev/null +++ b/src/automation/dispatchFingerprint.test.ts @@ -0,0 +1,128 @@ +// ============================================ +// OpenSwarm - Re-dispatch only when the issue's input changed (AGT-4787) +// ============================================ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + inputFingerprint, isDaemonComment, mayRedispatch, recordParkedInput, recordRunInput, + resetDispatchFingerprintForTests, +} from './dispatchFingerprint.js'; + +let dir: string; +const saved = process.env.OPENSWARM_AUTOMATION_DB; +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'osw-dispatch-fp-')); + process.env.OPENSWARM_AUTOMATION_DB = path.join(dir, 'automation.db'); + resetDispatchFingerprintForTests(); +}); +afterEach(() => { + resetDispatchFingerprintForTests(); + if (saved === undefined) delete process.env.OPENSWARM_AUTOMATION_DB; else process.env.OPENSWARM_AUTOMATION_DB = saved; + rmSync(dir, { recursive: true, force: true }); +}); + +const issue = { title: 'Fix the importer', description: 'It drops rows.' }; + +describe('isDaemonComment', () => { + it.each([ + '๐Ÿงญ **[OpenSwarm] Task execution started**\n\nattempt 3', + '**[OpenSwarm] Worker halted**\n\nreason', + '**Worker actions โ€” Halted (attempt #1/5)**\n\nsummary\n\n_via OpenSwarm_', + '**Heading**\n\nbody\n\n_via OpenSwarm ยท run abc_', + ])('recognises the daemon\'s own shapes: %s', (body) => { + expect(isDaemonComment(body)).toBe(true); + }); + + it.each([ + 'Please use option B.', + 'I looked at OpenSwarm and it is fine', + 'via OpenSwarm is the footer they use', + 'The [OpenSwarm] label is wrong', + ])('does not mistake a person for the daemon: %s', (body) => { + expect(isDaemonComment(body)).toBe(false); + }); +}); + +describe('inputFingerprint', () => { + it('is stable for the same input and ignores surrounding whitespace', () => { + expect(inputFingerprint(issue)).toBe(inputFingerprint({ title: ' Fix the importer ', description: 'It drops rows.\n' })); + }); + + it('changes with the title, the description, or a human comment', () => { + const base = inputFingerprint({ ...issue, comments: [{ body: 'one' }] }); + expect(inputFingerprint({ ...issue, title: 'Fix the importer v2', comments: [{ body: 'one' }] })).not.toBe(base); + expect(inputFingerprint({ ...issue, description: 'changed', comments: [{ body: 'one' }] })).not.toBe(base); + expect(inputFingerprint({ ...issue, comments: [{ body: 'one' }, { body: 'two' }] })).not.toBe(base); + }); + + it('does not change when only the daemon commented', () => { + const base = inputFingerprint({ ...issue, comments: [{ body: 'one' }] }); + const withDaemon = inputFingerprint({ + ...issue, + comments: [{ body: 'one' }, { body: '๐Ÿงญ **[OpenSwarm] Task execution started**' }, { body: '**x**\n\n_via OpenSwarm_' }], + }); + expect(withDaemon).toBe(base); + }); + + it('treats a missing description like an empty one', () => { + expect(inputFingerprint({ title: 't' })).toBe(inputFingerprint({ title: 't', description: '', comments: [] })); + }); +}); + +describe('the parked-input store', () => { + it('blocks the input a run parked on, however often it is asked, and passes any other', () => { + recordParkedInput('AX-1', inputFingerprint(issue)); + for (let i = 0; i < 18; i += 1) expect(mayRedispatch('AX-1', inputFingerprint(issue))).toBe(false); + expect(mayRedispatch('AX-1', inputFingerprint({ ...issue, description: 'edited' }))).toBe(true); + }); + + it('keeps issues apart and passes an issue it has never seen', () => { + recordParkedInput('AX-1', inputFingerprint(issue)); + expect(mayRedispatch('AX-2', inputFingerprint(issue))).toBe(true); + }); + + it('measures against the latest run: an edited issue that ran again is blocked on its new input', () => { + const edited = { ...issue, description: 'edited' }; + recordRunInput({ id: 'AX-1', ...issue }); + expect(mayRedispatch('AX-1', inputFingerprint(issue))).toBe(false); + recordRunInput({ id: 'AX-1', ...edited }); // a person edited it, the run started again + expect(mayRedispatch('AX-1', inputFingerprint(edited))).toBe(false); + expect(mayRedispatch('AX-1', inputFingerprint(issue))).toBe(true); + }); + + it('keys by issueId when there is one, else by id', () => { + recordRunInput({ id: 'task-9', issueId: 'AX-9', ...issue }); + expect(mayRedispatch('AX-9', inputFingerprint(issue))).toBe(false); + expect(mayRedispatch('task-9', inputFingerprint(issue))).toBe(true); + }); + + it('survives a reopened handle, because it lives in the database', () => { + recordParkedInput('AX-1', inputFingerprint(issue)); + resetDispatchFingerprintForTests(); + expect(mayRedispatch('AX-1', inputFingerprint(issue))).toBe(false); + }); +}); + +describe('failure modes', () => { + it('reads fail open: an unopenable store never blocks a dispatch', () => { + process.env.OPENSWARM_AUTOMATION_DB = path.join(dir, 'missing-dir', 'nested', 'automation.db'); + resetDispatchFingerprintForTests(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + expect(mayRedispatch('AX-1', inputFingerprint(issue))).toBe(true); + } finally { warn.mockRestore(); } + }); + + it('explicit writes fail closed, but recording a run input never stops the run', () => { + process.env.OPENSWARM_AUTOMATION_DB = path.join(dir, 'missing-dir', 'nested', 'automation.db'); + resetDispatchFingerprintForTests(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + expect(() => recordParkedInput('AX-1', 'x')).toThrow(/Could not open the automation database/); + expect(() => recordRunInput({ id: 'AX-1', ...issue })).not.toThrow(); + } finally { warn.mockRestore(); } + }); +}); diff --git a/src/automation/dispatchFingerprint.ts b/src/automation/dispatchFingerprint.ts new file mode 100644 index 00000000..c5efe1be --- /dev/null +++ b/src/automation/dispatchFingerprint.ts @@ -0,0 +1,151 @@ +// ============================================ +// OpenSwarm - Re-dispatch only when the issue's input changed (AGT-4787) +// ============================================ +// +// On 2026-10-20 one issue was dispatched 18 times in 14 hours and 52 issues were +// touched, mostly after an infra_error or a "parked for operator decision" โ€” two +// outcomes that retrying cannot change. Nothing compared the issue as the last +// run saw it with the issue as it is now, so idle_fill lifted every park a free +// slot allowed. +// +// What counts as new input is what a person can change: the title, the +// description, and the comments the daemon did not write. Two keys that look +// natural are wrong: +// +// - `trackerUpdatedAt` bumps on the daemon's own tracker writes (progress +// comments, state transitions). AGT-4300 measured that key driving +// attempt_no to 40 on an unchanged issue, and a fingerprint on it would +// change after every comment the daemon posts. +// - a comment count rises with every daemon comment for the same reason. +// +// Tracker comments carry no author (`user` is unresolved in linear.ts) and the +// daemon writes as the operator, so "human comments only" cannot come from +// authorship. The daemon's own comments are recognised by their signature +// instead. That signature can be copied by a person, which is the safe +// direction here: a copied signature leaves an issue parked, it never lifts a +// park the input did not earn. +// +// Its own table rather than `automation_runs.metadata_json`, which `registerRun` +// replaces whole (see execHold.ts and draftCache.ts for the same reason). Reads +// fail OPEN โ€” an unreadable store must not stop the heartbeat, so it degrades to +// today's behaviour โ€” and writes fail CLOSED, since a park that is not recorded +// cannot be held. + +import { createHash } from 'node:crypto'; +import Database from 'better-sqlite3'; +import { defaultAutomationDbPath } from './automationDbPath.js'; + +export interface FingerprintInput { + title: string; + description?: string; + comments?: ReadonlyArray<{ body: string }>; +} + +/** The two shapes the daemon signs its comments with: task-state sync headings and `formatAutomationComment`'s tail. */ +const DAEMON_SIGNATURES: readonly RegExp[] = [ + /^๐Ÿงญ \*\*\[OpenSwarm\] /, + /^\*\*\[OpenSwarm\]/, + /_via OpenSwarm(?: ยท[^_]*)?_\s*$/, +]; + +/** True for a comment the daemon wrote, by its signature. */ +export function isDaemonComment(body: string): boolean { + const text = body.trim(); + return DAEMON_SIGNATURES.some((pattern) => pattern.test(text)); +} + +/** + * A stable digest of everything a person can change about an issue. Comments are + * hashed in the order the tracker returns them, so reordering is a change but + * the daemon's own comments, wherever they sit, are not. + */ +export function inputFingerprint(issue: FingerprintInput): string { + const human = (issue.comments ?? []).map((c) => c.body.trim()).filter((body) => body && !isDaemonComment(body)); + return createHash('sha256') + .update(JSON.stringify([issue.title.trim(), (issue.description ?? '').trim(), human])) + .digest('hex'); +} + +let db: Database.Database | undefined; +let openedPath: string | undefined; + +function connect(): Database.Database | undefined { + const path = defaultAutomationDbPath(); + if (db && openedPath === path) return db; + if (db) { try { db.close(); } catch { /* already closed */ } db = undefined; } + try { + const next = new Database(path); + next.pragma('journal_mode = WAL'); + next.exec(` + CREATE TABLE IF NOT EXISTS automation_parked_inputs ( + issue_id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + db = next; + openedPath = path; + return db; + } catch (err) { + console.warn('[DispatchFingerprint] Unavailable:', err instanceof Error ? err.message : err); + return undefined; + } +} + +/** Drop the handle so the next call re-resolves the path. Tests redirect the DB. */ +export function resetDispatchFingerprintForTests(): void { + if (db) { try { db.close(); } catch { /* already closed */ } } + db = undefined; + openedPath = undefined; +} + +/** + * Remember the input a run parked on. Re-recording replaces it: the latest park + * is the one a future change is measured against. + */ +export function recordParkedInput(issueId: string, fingerprint: string, now = Date.now()): void { + const handle = connect(); + if (!handle) throw new Error('Could not open the automation database to record the parked input'); + try { + handle.prepare(` + INSERT INTO automation_parked_inputs(issue_id, fingerprint, updated_at) VALUES(?, ?, ?) + ON CONFLICT(issue_id) DO UPDATE SET fingerprint = excluded.fingerprint, updated_at = excluded.updated_at + `).run(issueId, fingerprint, now); + } catch (err) { + throw new Error(`Could not record the parked input for ${issueId}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** + * Whether a parked issue may be dispatched again. False only when this issue was + * recorded as parked on exactly this input; an issue with no record, a different + * input, or an unreadable store passes. + */ +export function mayRedispatch(issueId: string, fingerprint: string): boolean { + const handle = connect(); + if (!handle) return true; + try { + const row = handle + .prepare('SELECT fingerprint FROM automation_parked_inputs WHERE issue_id = ?') + .get(issueId) as { fingerprint: string } | undefined; + return !row || row.fingerprint !== fingerprint; + } catch (err) { + console.warn('[DispatchFingerprint] Read failed:', err instanceof Error ? err.message : err); + return true; + } +} + +/** + * Record the input a run starts on. Called once, where the pipeline starts, so + * every later park or backoff โ€” wherever in the ledger it happens โ€” is measured + * against the issue as the run saw it. A failure here must not stop the run: it + * only means a later park cannot be held, which is the behaviour before this + * existed. + */ +export function recordRunInput(task: { issueId?: string; id: string; title: string; description?: string }): void { + try { + recordParkedInput(task.issueId || task.id, inputFingerprint({ title: task.title, description: task.description })); + } catch (err) { + console.warn('[DispatchFingerprint] Could not record the run input:', err instanceof Error ? err.message : err); + } +} diff --git a/src/automation/runnerExecution.ts b/src/automation/runnerExecution.ts index eaf558b5..76e952a2 100644 --- a/src/automation/runnerExecution.ts +++ b/src/automation/runnerExecution.ts @@ -970,8 +970,8 @@ export async function executePipeline( } try { - const setupFailure = await (await import('../agents/workerSetup.js')).setupFailureBeforeWorker(ctx.getProjectSetup?.(projectPath), actualPath, ctx.workerSandbox); // AGT-4798 - if (setupFailure) return setupFailure; + const setupFailure = await (await import('../agents/workerSetup.js')).setupFailureBeforeWorker(ctx.getProjectSetup?.(projectPath), actualPath, ctx.workerSandbox); if (setupFailure) return setupFailure; // AGT-4798 + (await import('./dispatchFingerprint.js')).recordRunInput(task); // the input this run starts on; a later park is measured against it (AGT-4787) const roles = ctx.getRolesForProject(projectPath); // look up config using original path const { prepareRunCoordination, normalizeAdapterRouting } = await import('../coordination/runCoordination.js'); const runMetadata = pipelineMetadata(task, projectPath, worktreeInfo);