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

- **Only Todo is dispatched, and a draft's "a person has to do or decide this" verdict now stops the claim (AGT-4784).** On 2026-10-20 the daemon opened sessions on 52 cgf-portal issues in one night: 9 out-of-scope-labelled Backlog issues, 4 whose own title said a decision was pending, a production deploy, and a local-cleanup chore. Each rule that would have stopped them existed (R5's "Backlog is parked", AGT-4682's skip labels, AGT-4662's scope verdict); the default switched the Backlog rule off, the skip list held only `swarm:skip`, and the verdict was read after the claim. **`autonomous.includeBacklog` now defaults to `false`** (schema, `DecisionEngine`, runner — AGT-4257's `true` is reversed): moving an issue to Todo is the ready signal, and the heartbeat drops everything else before `filterAlreadyProcessed`, whose idle fill would otherwise lift a park on it. **A deployment that relied on free slots chewing the Backlog (vela) must set `includeBacklog: true`.** `src/automation/readinessGate.ts` (`evaluateReadiness`) asks the three questions in order — tracker state, a person's exclusion label, a grooming verdict — and an explicit dispatch passes all of them. `detectSafeCandidateIds` now reads the pre-admission draft's scope verdict **before** the claim (the draft is already computed there, and cached by title and description, so a held issue costs no model call on later heartbeats): a confident, evidenced decline leaves the issue unclaimed and comments once, with the same stable marker the worker-start gate uses, so the two never double-post. The drafter's closed verdict set gains `decision_pending` ("the issue itself says a person has to choose first"). **Not covered:** the verdict exists only for a project with a standing goal, and an answer given only as a comment does not change the draft (its fingerprint is title and description) — edit the description to release a held issue. In Progress and In Review keep resuming as before (the gate cannot tell the daemon's own run from a person's without the ledger). `backlogGrooming`, if enabled, still promotes Backlog to Todo on its own judgement. **Config twin for cgf-portal (not applied):** add `추가 유지보수` to `autonomous.skip.labels` and `결정`, `설계 결정`, `배포`, `운영` to `skip.titleTags`; the defaults hold only `swarm:skip` and three tags.

- **A run that did not finish opens no pull request (AGT-4786).** AGT-4664 published failed, rejected, `infra_error` and `rate_limited` runs as drafts so their work was visible. On 2026-10-20 that produced 25 PRs on cgf-portal in one night: 19 said "published because this run ended as `infra_error`", several were +3/-0 against multi-defect issues, every one was titled with the whole issue and closed it with `Closes`, and the owner closed all 25 unmerged. An infrastructure failure is not a deliverable. `publishFinishedRun` now takes only the reviewed path and the operator-park path; `publishUnfinishedWork` and `shouldPublishUnfinishedWork` are removed, and the call site no longer reads `opts.lifecycleFailed` / `opts.failureSummary`. **The work is not lost:** `preserveWorktree` WIP-commits dirty work onto the swarm branch and keeps the tree (INT-2729), `createWorktree` reuses that branch, and the failure itself is already commented on the issue by the worker audit path. **Not done yet:** a failed run's branch is not pushed to the remote, because the secret-leak guard that must precede any push (`assertNoSensitiveDataOnBranch`, AGT-4188) lives inside `commitAndCreatePRWithHead` (`worktreeManager.ts`, 1484 of 1500 lines, five callers that destructure `prUrl`) — pushing through a second code path would bypass it. `summarizeFailure` is kept, tested, for the comment that follow-up will write.

- **`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.
Expand Down
13 changes: 13 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ autonomous:
schedule: "*/15 * * * *" # Every 15 minutes
maxAttempts: 3
maxConcurrentTasks: 64 # Operator-controlled global capacity (maximum 256)
# Which issues the daemon may start (AGT-4784). Only Todo is dispatched: moving an
# issue to Todo is the "ready" signal, so nothing nobody triaged is picked up. Set
# includeBacklog: true on a deployment that wants free slots to chew parked work.
# Issues the daemon parks itself (cancelled runs, decomposition limits) land in
# Backlog and wait for a person to move them back.
# includeBacklog: false
# Issues a person owns are never started: a label (e.g. a paid-scope exclusion), an
# epic, or a title tag such as [결정] / [배포] / [운영]. The draft's scope verdict
# (project goal set) also stops an issue that needs a person or a decision.
# skip:
# labels: ["swarm:skip", "추가 유지보수"]
# titleTags: ["인수", "확인 원장", "Docs", "결정", "설계 결정", "배포", "운영"]
# epics: true
# In Progress is a live ownership signal, not a parking lot. If neither the
# scheduler nor a durable lease owns an issue for this long, return it to Backlog.
stalledInProgressHours: 6
Expand Down
17 changes: 16 additions & 1 deletion src/agents/draftScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ describe('isScopeDeclined (AGT-4662)', () => {
});
});

describe('decision_pending (AGT-4784)', () => {
it('is accepted as a decline kind and survives parsing', () => {
const verdict = parseScopeVerdict({
applicable: false, kind: 'decision_pending', confidence: 0.95,
reason: 'the title asks the operator to choose', evidence: ['title: decide whether to rotate', 'no code deliverable'],
});
expect(verdict?.kind).toBe('decision_pending');
expect(isScopeDeclined(verdict)).toBe(true);
});

it('tells the drafter that a decision already made in the text is not pending', () => {
expect(scopePromptParts('Ship usable work.').rules).toContain('A decision already made in the text is not pending');
});
});

describe('scopePromptParts (AGT-4662)', () => {
it('is empty without a goal, so a project that sets none gets the prompt it always had', () => {
expect(scopePromptParts(undefined)).toEqual({ field: '', rules: '' });
Expand All @@ -58,7 +73,7 @@ describe('scopePromptParts (AGT-4662)', () => {
it('asks for the verdict, names every kind, and says hard tasks stay in scope', () => {
const { field, rules } = scopePromptParts('Ship usable work.');
expect(field).toContain('"scope"');
for (const kind of ['wrong_repository', 'human_action_only', 'blocked_by_dependency', 'out_of_goal']) {
for (const kind of ['wrong_repository', 'human_action_only', 'blocked_by_dependency', 'out_of_goal', 'decision_pending']) {
expect(field).toContain(kind);
expect(rules).toContain(kind);
}
Expand Down
5 changes: 3 additions & 2 deletions src/agents/draftScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Dependencies: none

/** Why a task cannot be worked here. Closed set: the gate acts on it. */
export const SCOPE_KINDS = ['wrong_repository', 'human_action_only', 'blocked_by_dependency', 'out_of_goal'] as const;
export const SCOPE_KINDS = ['wrong_repository', 'human_action_only', 'blocked_by_dependency', 'out_of_goal', 'decision_pending'] as const;
export type DraftScopeKind = (typeof SCOPE_KINDS)[number];

export interface DraftScopeVerdict {
Expand Down Expand Up @@ -54,7 +54,7 @@ export function isScopeDeclined(scope: DraftScopeVerdict | undefined): scope is
export function scopePromptParts(goal: string | undefined): { field: string; rules: string } {
if (!goal?.trim()) return { field: '', rules: '' };
return {
field: ',\n "scope": {"applicable": false, "kind": "wrong_repository" | "human_action_only" | "blocked_by_dependency" | "out_of_goal", '
field: ',\n "scope": {"applicable": false, "kind": "wrong_repository" | "human_action_only" | "blocked_by_dependency" | "out_of_goal" | "decision_pending", '
+ '"confidence": 0.0, "reason": "one sentence", "evidence": ["concrete fact you verified"]} // optional: ONLY when this task cannot be done here',
rules: `
### scope (only because a Standing project goal is set)
Expand All @@ -63,6 +63,7 @@ Decide whether an autonomous worker can usefully act on this task in THIS reposi
- human_action_only: completing it needs something only a person can do (send a message to a customer or vendor, obtain an approval, wait for an external reply, change production data by hand) and there is no code or doc change you can describe that completes it.
- blocked_by_dependency: it needs the output of another open issue that is not done yet, and the Standing project goal makes that order binding.
- out_of_goal: completing it would not move the Standing project goal forward.
- decision_pending: the issue itself says a person has to decide or choose first (a title or body such as "decide whether…", "waiting for X to pick", a design question with no chosen answer) and no code or doc change completes it before that answer exists. A decision already made in the text is not pending.
Use confidence >= 0.90 only with at least two concrete pieces of evidence (a path you searched that has nothing, the line of the task that says the action is human-only, the open issue it waits on). If the task has a code or doc deliverable you can name, it is in scope.
`,
};
Expand Down
21 changes: 17 additions & 4 deletions src/automation/autonomousRunner.agentEligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type InternalRunner = {

const task = (id: string, over: Partial<TaskItem> = {}): TaskItem => ({
id, issueId: id, issueIdentifier: id.toUpperCase(), source: 'linear', title: `[B1] ${id}`,
priority: 2, createdAt: Date.now(), linearState: 'Backlog', labels: [], ...over,
priority: 2, createdAt: Date.now(), linearState: 'Todo', labels: [], ...over,
});

describe('heartbeat skips issues that are not the agent\'s work (AGT-4682)', () => {
Expand All @@ -50,15 +50,19 @@ describe('heartbeat skips issues that are not the agent\'s work (AGT-4682)', ()
vi.resetModules();
});

async function runHeartbeat(fetched: TaskItem[], skip?: { labels: string[]; titleTags: string[]; epics: boolean }) {
async function runHeartbeat(
fetched: TaskItem[],
skip?: { labels: string[]; titleTags: string[]; epics: boolean },
extra: { includeBacklog?: boolean } = {},
) {
const [{ AutonomousRunner }, execution] = await Promise.all([
import('./autonomousRunner.js'),
import('./runnerExecution.js'),
]);
const source = {
kind: 'linear',
fetchTasks: vi.fn(async () => fetched),
lookupIssueState: vi.fn(async () => ({ ok: true as const, issue: { state: 'Backlog', stateType: 'backlog' } })),
lookupIssueState: vi.fn(async () => ({ ok: true as const, issue: { state: 'Todo', stateType: 'unstarted' } })),
updateState: vi.fn(async () => true), addComment: vi.fn(async () => {}),
createTask: vi.fn(), createSubIssue: vi.fn(), logPairStart: vi.fn(),
logPairComplete: vi.fn(), logBlocked: vi.fn(), logStuck: vi.fn(), unstick: vi.fn(),
Expand All @@ -69,7 +73,7 @@ describe('heartbeat skips issues that are not the agent\'s work (AGT-4682)', ()
linearTeamId: 'team', allowedProjects: ['/repo'], heartbeatSchedule: '0 * * * *',
autoExecute: true, dryRun: true, pairMode: true, maxConcurrentTasks: 4,
automationLedgerMode: 'primary', automationDbPath: join(root, 'eligibility.db'),
...(skip ? { skip } : {}),
...(skip ? { skip } : {}), ...extra,
});
const internal = runner as unknown as InternalRunner;
internal.refreshKnowledgeGraphs = vi.fn();
Expand All @@ -95,6 +99,15 @@ describe('heartbeat skips issues that are not the agent\'s work (AGT-4682)', ()
expect(selected.filter((id: string) => id !== 'ax-1')).toEqual([]);
}, 60_000);

it('never hands a Backlog issue to idle fill or selection unless the project opts in (AGT-4784)', async () => {
const backlog = [task('bl-1', { linearState: 'Backlog' }), task('td-1')];
const off = await runHeartbeat(backlog);
expect(off.processed.mock.calls[0][0].map((t) => t.id)).toEqual(['td-1']);

const on = await runHeartbeat(backlog, undefined, { includeBacklog: true });
expect(on.processed.mock.calls[0][0].map((t) => t.id)).toEqual(['bl-1', 'td-1']);
}, 60_000);

it('honours configured labels, and stops everything when nothing is left for the swarm', async () => {
const { processed, parallel } = await runHeartbeat(
[task('ax-1', { labels: ['외부 검증·결정 대기'] }), task('ax-2', { title: '[UAT] 10/2 배포 readback' })],
Expand Down
66 changes: 66 additions & 0 deletions src/automation/autonomousRunner.coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,72 @@ describe('AutonomousRunner coverage — safely-reachable helpers', () => {
});
});

describe('readiness verdict before the claim (AGT-4784)', () => {
const declined = {
applicable: false as const, kind: 'decision_pending' as const, confidence: 0.95,
reason: 'the title waits on a person to choose', evidence: ['title says pending', 'no code deliverable'],
};
const withDraft = (id: string, scope?: typeof declined) => task({
id, issueId: `ISSUE-${id}`, issueIdentifier: id.toUpperCase(), fileScope: [`src/${id}.ts`],
preAdmissionDraft: { relevantFiles: [`src/${id}.ts`], ...(scope ? { scope } : {}) } as TaskItem['preAdmissionDraft'],
});
const goalCfg = (goal?: string) => cfg({
allowSameProjectConcurrent: true, worktreeMode: true, maxConcurrentTasks: 3,
projectAgents: [{ projectPath: '/repo', ...(goal ? { goal } : {}) }],
});

it('does not claim an issue whose draft declined it, comments once with the stable marker, and still claims the rest', async () => {
const addComment = vi.fn(async () => {});
runnerExecution.setTaskSource({ kind: 'linear', addComment } as unknown as ITaskSource);
const internal = new AutonomousRunner(goalCfg('ship it')) as unknown as Internal;
const held = withDraft('held', declined);
const fine = withDraft('fine');
detectFileConflictsMock.mockResolvedValue({ safe: [fine], conflictGroups: [] });

const safe = await internal.detectSafeCandidateIds([
{ task: held, projectPath: '/repo' }, { task: fine, projectPath: '/repo' },
]);

expect(safe).toEqual(new Set(['fine']));
expect(detectFileConflictsMock).toHaveBeenCalledWith([fine], '/repo', { unknownScopeAdmission: 'admit' });
expect(addComment).toHaveBeenCalledTimes(1);
const [issueId, body, marker] = addComment.mock.calls[0] as unknown as [string, string, string];
expect(issueId).toBe('ISSUE-held');
expect(body).toContain('decision_pending');
expect(marker).toMatch(/^draft-scope:[0-9a-f]{24}$/);
});

it('changes nothing for a project with no goal: the drafter never asked for a verdict there', async () => {
const addComment = vi.fn(async () => {});
runnerExecution.setTaskSource({ kind: 'linear', addComment } as unknown as ITaskSource);
const internal = new AutonomousRunner(goalCfg()) as unknown as Internal;
const candidate = withDraft('nogoal', declined);
detectFileConflictsMock.mockResolvedValue({ safe: [candidate], conflictGroups: [] });

const safe = await internal.detectSafeCandidateIds([{ task: candidate, projectPath: '/repo' }]);

expect(safe).toEqual(new Set(['nogoal']));
expect(addComment).not.toHaveBeenCalled();
});

it('does not hold on a weak verdict: confidence under the bar is a hunch, not a decline', async () => {
const internal = new AutonomousRunner(goalCfg('ship it')) as unknown as Internal;
const candidate = withDraft('weak', { ...declined, confidence: 0.6 });
detectFileConflictsMock.mockResolvedValue({ safe: [candidate], conflictGroups: [] });

expect(await internal.detectSafeCandidateIds([{ task: candidate, projectPath: '/repo' }])).toEqual(new Set(['weak']));
});

it('survives a comment that fails to post: the issue is still not claimed', async () => {
runnerExecution.setTaskSource({ kind: 'linear', addComment: vi.fn(async () => { throw new Error('linear down'); }) } as unknown as ITaskSource);
const internal = new AutonomousRunner(goalCfg('ship it')) as unknown as Internal;
const held = withDraft('held2', declined);

expect(await internal.detectSafeCandidateIds([{ task: held, projectPath: '/repo' }])).toEqual(new Set());
expect(detectFileConflictsMock).not.toHaveBeenCalled();
});
});

it('repays a known-first deferred unknown as the next exclusive idle wave', async () => {
const r = new AutonomousRunner(cfg({
allowSameProjectConcurrent: true, worktreeMode: true, maxConcurrentTasks: 3,
Expand Down
Loading
Loading