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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
145 changes: 145 additions & 0 deletions src/automation/autonomousRunner.dispatchFingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;
};

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<InternalRunner> {
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<void> {
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');
});
});
11 changes: 10 additions & 1 deletion src/automation/autonomousRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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).
Expand Down
128 changes: 128 additions & 0 deletions src/automation/dispatchFingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -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(); }
});
});
Loading
Loading