From 4a872db4de7e53e9dbb9c740b34009cbedfe8d56 Mon Sep 17 00:00:00 2001 From: unohee Date: Thu, 22 Oct 2026 02:47:08 +0900 Subject: [PATCH] feat(daemon): dispatch only Todo and stop declined issues before the claim (AGT-4784) includeBacklog now defaults to false: moving an issue to Todo is the ready signal. A readiness gate (state, exclusion label, grooming verdict) runs ahead of idle fill, and the draft's scope verdict is read before the claim instead of after a worker has started. The drafter gains a decision_pending verdict. --- CHANGELOG.md | 2 + config.example.yaml | 13 ++ src/agents/draftScope.test.ts | 17 ++- src/agents/draftScope.ts | 5 +- .../autonomousRunner.agentEligibility.test.ts | 21 ++- .../autonomousRunner.coverage.test.ts | 66 ++++++++++ src/automation/autonomousRunner.ts | 56 +++++++- src/automation/draftGrooming.ts | 20 +-- src/automation/readinessGate.test.ts | 106 ++++++++++++++++ src/automation/readinessGate.ts | 120 ++++++++++++++++++ src/core/config.test.ts | 8 +- src/core/config.ts | 8 +- src/orchestration/decisionEngine.ts | 6 +- 13 files changed, 419 insertions(+), 29 deletions(-) create mode 100644 src/automation/readinessGate.test.ts create mode 100644 src/automation/readinessGate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd0a861..10cdb13c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/config.example.yaml b/config.example.yaml index fb2788dc..ae0898a7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/src/agents/draftScope.test.ts b/src/agents/draftScope.test.ts index d15c3622..25cfde83 100644 --- a/src/agents/draftScope.test.ts +++ b/src/agents/draftScope.test.ts @@ -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: '' }); @@ -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); } diff --git a/src/agents/draftScope.ts b/src/agents/draftScope.ts index e54307d9..6ae21a7c 100644 --- a/src/agents/draftScope.ts +++ b/src/agents/draftScope.ts @@ -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 { @@ -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) @@ -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. `, }; diff --git a/src/automation/autonomousRunner.agentEligibility.test.ts b/src/automation/autonomousRunner.agentEligibility.test.ts index 9dfbd96a..e6f56013 100644 --- a/src/automation/autonomousRunner.agentEligibility.test.ts +++ b/src/automation/autonomousRunner.agentEligibility.test.ts @@ -26,7 +26,7 @@ type InternalRunner = { const task = (id: string, over: Partial = {}): 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)', () => { @@ -50,7 +50,11 @@ 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'), @@ -58,7 +62,7 @@ describe('heartbeat skips issues that are not the agent\'s work (AGT-4682)', () 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(), @@ -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(); @@ -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' })], diff --git a/src/automation/autonomousRunner.coverage.test.ts b/src/automation/autonomousRunner.coverage.test.ts index 3f36b121..b97d3b30 100644 --- a/src/automation/autonomousRunner.coverage.test.ts +++ b/src/automation/autonomousRunner.coverage.test.ts @@ -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, diff --git a/src/automation/autonomousRunner.ts b/src/automation/autonomousRunner.ts index e67978f6..17284fc8 100644 --- a/src/automation/autonomousRunner.ts +++ b/src/automation/autonomousRunner.ts @@ -138,6 +138,8 @@ import { import { reconcileTrackerTerminalRuns } from './trackerTerminalReconciler.js'; import { planStalledInProgress } from './stalledInProgress.js'; import { DEFAULT_AGENT_SKIP, formatSkipSummary, partitionHumanOwned } from './agentEligibility.js'; +import { scopeDeclineComment, scopeDeclineMarker } from './draftGrooming.js'; +import { evaluateReadiness, formatReadinessSummary, partitionByReadiness, verdictFromDraft } from './readinessGate.js'; import { planMergedPublicationChecks, decideMergedPublication } from './mergedPublications.js'; import { completeParentIfChildrenDone as completeParentLocally, buildTaskStateSyncComment } from '../taskState/store.js'; import { ACTIVE_LEASE_STATES } from './runLedgerTypes.js'; @@ -591,7 +593,7 @@ export class AutonomousRunner { linearTeamId: config.linearTeamId, autoExecute: config.autoExecute, dryRun: config.dryRun, - includeBacklog: config.includeBacklog ?? true, + includeBacklog: config.includeBacklog ?? false, // AGT-4784: Todo is the ready signal // Same-project parallel selection only makes sense when the scheduler can // actually run those tasks concurrently (worktree isolation). (INT-2318) sameProjectParallel: (config.allowSameProjectConcurrent ?? true) && (config.worktreeMode ?? false), @@ -2939,10 +2941,19 @@ export class AutonomousRunner { // Issues a person owns (a skip label, an epic, a title tag) never reach selection or // idle fill: the idle-fill loop inside filterAlreadyProcessed would otherwise lift a // parked or backed-off ledger row for them. (AGT-4682) - const { eligible: agentTasks, skipped: humanOwned } = partitionHumanOwned(tasks, this.config.skip ?? DEFAULT_AGENT_SKIP); + // Readiness comes first and is the only thing in front of idle fill that reads the tracker + // state: a Backlog issue must not reach `filterAlreadyProcessed`, whose idle fill would lift + // a park on it. (AGT-4784) + const skipConfig = this.config.skip ?? DEFAULT_AGENT_SKIP; + const { ready: readyTasks, held: notReady } = partitionByReadiness(tasks, { + exclusionLabels: skipConfig.labels, + includeBacklog: this.config.includeBacklog ?? false, + }); + for (const line of formatReadinessSummary(notReady)) this.syslog(line); + const { eligible: agentTasks, skipped: humanOwned } = partitionHumanOwned(readyTasks, skipConfig); for (const line of formatSkipSummary(humanOwned)) this.syslog(line); if (agentTasks.length === 0) { - this.syslog('— Every task is a person\'s work (skip labels, epics, title tags)'); + this.syslog('— Nothing ready for the swarm (Backlog, skip labels, epics, title tags)'); return; } @@ -3019,7 +3030,7 @@ export class AutonomousRunner { // This prevents DecisionEngine from wasting its max-slot budget on non-enabled projects. // AGT-4257: Backlog is a queue when slots are free. Terminal Linear // states are still dropped later by the decision engine. - const executableTasks = (this.config.includeBacklog ?? true) + const executableTasks = (this.config.includeBacklog ?? false) ? tasks : tasks.filter(t => t.linearState !== 'Backlog'); @@ -3195,6 +3206,36 @@ export class AutonomousRunner { return candidates; } + /** + * Drop the candidates whose pre-admission draft carries a confident decline (the issue needs a + * person: `human_action_only`, `decision_pending`, ...) and say so on the issue once. Only a + * project with a standing goal asks the drafter for the verdict, and only a verdict with the + * confidence and evidence `isScopeDeclined` requires counts, so a project without a goal sees + * no change. The draft is cached by title and description, so a held issue costs no model call + * on later heartbeats; editing either field makes a new draft and a new verdict. + */ + private async holdDeclinedBeforeClaim(group: RunnableCandidate[], projPath: string): Promise { + const hasGoal = !!resolveProjectGoal(this.config.projectAgents, projPath); + if (!hasGoal) return group; + const kept: RunnableCandidate[] = []; + for (const candidate of group) { + const draft = candidate.task.preAdmissionDraft; + const verdict = verdictFromDraft(draft, hasGoal); + const decision = evaluateReadiness(candidate.task, { verdict, includeBacklog: true }); + if (decision.claim || !draft?.scope) { + kept.push(candidate); + continue; + } + this.syslog(`⏭ Not claimed: ${taskLabel(candidate.task)} — ${decision.reason}: ${draft.scope.reason}`); + const source = getTaskSource(); + if (candidate.task.issueId && source?.kind === 'linear') { + await source.addComment(candidate.task.issueId, scopeDeclineComment(draft.scope), scopeDeclineMarker(draft.scope)) + .catch((err) => console.warn('[AutonomousRunner] Readiness comment failed:', err instanceof Error ? err.message : err)); + } + } + return kept; + } + private async detectSafeCandidateIds(candidates: RunnableCandidate[]): Promise> { // Group candidates by canonical repository identity for conflict detection. // A symlink/relative-path alias must not split one repository into two groups @@ -3284,6 +3325,11 @@ export class AutonomousRunner { } })); + // The drafts above are the first look at what an issue asks for, and they are + // available before anything is claimed. A confident "a person has to do or decide + // this" verdict stops the claim here instead of after a worker has started. (AGT-4784) + const claimable = await this.holdDeclinedBeforeClaim(group, projPath); + // A later heartbeat must compare new candidates with workers that are // already editing another worktree. Candidate-vs-candidate detection // alone leaves a race window across heartbeat cycles. @@ -3295,7 +3341,7 @@ export class AutonomousRunner { // still deferred every other candidate — 11 of 12 slots idle with 126 // executable tasks waiting (AGT-4233). const admission = this.config.unknownScopeAdmission ?? 'admit'; - const runnable = group.filter(candidate => { + const runnable = claimable.filter(candidate => { let blockedBy: { label: string; reason: ScopeConflictReason } | undefined; for (const running of active) { const reason = describeScopeConflict( diff --git a/src/automation/draftGrooming.ts b/src/automation/draftGrooming.ts index 7ac6aa78..322422a7 100644 --- a/src/automation/draftGrooming.ts +++ b/src/automation/draftGrooming.ts @@ -25,6 +25,16 @@ export function projectDraftPeers(task: TaskItem, peers?: TaskItem[]): DraftPeer })); } +/** Stable per kind and reason, so the claim-time gate and this one leave a single comment between them. */ +export function scopeDeclineMarker(scope: { kind: string; reason: string }): string { + return `draft-scope:${createHash('sha256').update(`${scope.kind}:${scope.reason}`).digest('hex').slice(0, 24)}`; +} + +export function scopeDeclineComment(scope: { kind: string; reason: string; evidence: string[] }): string { + return `Draft grooming did not start a worker for this issue: \`${scope.kind}\`.\n\nReason: ${scope.reason}\n\nEvidence:\n${scope.evidence.map((item) => `- ${item}`).join('\n')}\n\n` + + 'Change the issue so it names work this project goal can act on, then move it to Todo to run it.'; +} + export async function applyDraftGates(options: { task: TaskItem; projectPath: string; @@ -56,16 +66,10 @@ export async function applyDraftGates(options: { // should not cost a checkout, a worker run and two retries to find that out. // Opt-in per project, and only on a verdict that clears the duplicate gate's bar. if (options.goalScopeGate && isScopeDeclined(draft.scope)) { - const { kind, reason, evidence } = draft.scope; + const { kind, reason } = draft.scope; console.log(`[AutonomousRunner] Draft scope gate: ${task.issueIdentifier ?? task.issueId} is ${kind} — ${reason}`); if (task.issueId && source?.kind === 'linear') { - const marker = `draft-scope:${createHash('sha256').update(`${kind}:${reason}`).digest('hex').slice(0, 24)}`; - await source.addComment( - task.issueId, - `Draft grooming did not start a worker for this issue: \`${kind}\`.\n\nReason: ${reason}\n\nEvidence:\n${evidence.map((item) => `- ${item}`).join('\n')}\n\n` - + 'Change the issue so it names work this project goal can act on, then move it to Todo to run it.', - marker, - ); + await source.addComment(task.issueId, scopeDeclineComment(draft.scope), scopeDeclineMarker(draft.scope)); } return supersededResult('out_of_scope', draft.durationMs, `${kind}: ${reason}`); } diff --git a/src/automation/readinessGate.test.ts b/src/automation/readinessGate.test.ts new file mode 100644 index 00000000..6009fb86 --- /dev/null +++ b/src/automation/readinessGate.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateReadiness, formatReadinessSummary, partitionByReadiness, verdictFromDraft } from './readinessGate.js'; + +const task = (over: Partial[0]> = {}) => ({ + issueIdentifier: 'T-1', linearState: 'Todo', labels: [] as string[], title: 'Fix the thing', ...over, +}); + +describe('evaluateReadiness (AGT-4784)', () => { + it('claims a Todo issue with nothing against it', () => { + expect(evaluateReadiness(task())).toEqual({ claim: true, reason: 'ready' }); + }); + + it('never claims Backlog unless the project opts in, and never claims a terminal state', () => { + expect(evaluateReadiness(task({ linearState: 'Backlog' }))).toEqual({ claim: false, reason: 'state:Backlog' }); + expect(evaluateReadiness(task({ linearState: 'Backlog' }), { includeBacklog: true }).claim).toBe(true); + for (const state of ['Done', 'Canceled', 'Cancelled']) { + expect(evaluateReadiness(task({ linearState: state }), { includeBacklog: true }).claim).toBe(false); + } + }); + + it('keeps resuming In Progress and In Review, and does not gate on an unknown state', () => { + for (const state of ['In Progress', 'In Review', undefined]) { + expect(evaluateReadiness(task({ linearState: state })).claim).toBe(true); + } + }); + + it('blocks an exclusion label, ignoring case and padding, and names it', () => { + const decision = evaluateReadiness(task({ labels: [' Out-Of-Scope '] }), { exclusionLabels: ['out-of-scope'] }); + expect(decision).toEqual({ claim: false, reason: 'label:out-of-scope' }); + expect(evaluateReadiness(task({ labels: ['out-of-scope'] }), { exclusionLabels: [] }).claim).toBe(true); + }); + + it('does not treat a title tag or an epic as an exclusion label', () => { + // Those are AGT-4682's partitionHumanOwned rules; this gate asks only for labels, state and verdict. + expect(evaluateReadiness(task({ title: '[Docs] write it', hasChildren: true }), { exclusionLabels: ['Docs'] }).claim).toBe(true); + }); + + it('blocks any verdict other than actionable, and names it', () => { + expect(evaluateReadiness(task(), { verdict: 'actionable' }).claim).toBe(true); + expect(evaluateReadiness(task(), { verdict: 'decision_pending' })).toEqual({ claim: false, reason: 'verdict:decision_pending' }); + expect(evaluateReadiness(task(), { verdict: 'human_action_only' }).claim).toBe(false); + }); + + it('answers the state first: a Backlog issue is not_ready even with an actionable verdict', () => { + expect(evaluateReadiness(task({ linearState: 'Backlog' }), { verdict: 'actionable' }).reason).toBe('state:Backlog'); + }); + + it('lets an explicit dispatch through every rule: choosing the issue is the decision', () => { + const chosen = task({ linearState: 'Backlog', labels: ['out-of-scope'], explicitDispatch: true }); + expect(evaluateReadiness(chosen, { exclusionLabels: ['out-of-scope'], verdict: 'decision_pending' }).claim).toBe(true); + }); + + it('replays a queue of the 2026-10-20 shape: only the ready Todo issues are claimed', () => { + const queue = [ + task({ issueIdentifier: 'Q-1', linearState: 'Backlog', labels: ['out-of-scope'] }), + task({ issueIdentifier: 'Q-2', linearState: 'Backlog' }), + task({ issueIdentifier: 'Q-3', linearState: 'Todo', title: '[decision] pick a channel' }), + task({ issueIdentifier: 'Q-4', linearState: 'Todo' }), + task({ issueIdentifier: 'Q-5', linearState: 'Todo', labels: ['out-of-scope'] }), + task({ issueIdentifier: 'Q-6', linearState: 'Todo' }), + ]; + const verdicts: Record = { 'Q-3': 'decision_pending', 'Q-4': 'human_action_only' }; + const claimed = queue + .filter((t) => evaluateReadiness(t, { exclusionLabels: ['out-of-scope'], verdict: verdicts[t.issueIdentifier] }).claim) + .map((t) => t.issueIdentifier); + expect(claimed).toEqual(['Q-6']); + }); +}); + +describe('partitionByReadiness', () => { + it('groups the held issues by reason, keyed by identifier, and keeps order for the ready ones', () => { + const tasks = [ + task({ issueIdentifier: 'A', linearState: 'Backlog' }), + task({ issueIdentifier: 'B' }), + task({ issueIdentifier: 'C', linearState: 'Backlog' }), + task({ issueIdentifier: 'D', labels: ['x'] }), + ]; + const { ready, held } = partitionByReadiness(tasks, { exclusionLabels: ['x'] }); + expect(ready.map((t) => t.issueIdentifier)).toEqual(['B']); + expect(held.get('state:Backlog')).toEqual(['A', 'C']); + expect(held.get('label:x')).toEqual(['D']); + }); + + it('falls back to the id when an issue has no identifier, and summarises one line per reason', () => { + const { held } = partitionByReadiness([{ id: 'raw-1', title: 't', linearState: 'Backlog' }]); + expect(formatReadinessSummary(held)).toEqual([' ⏭ not ready (state:Backlog) 1: raw-1']); + const many = new Map([['state:Backlog', Array.from({ length: 10 }, (_, i) => `K-${i}`)]]); + expect(formatReadinessSummary(many, 3)[0]).toContain('K-0, K-1, K-2 +7 more'); + }); +}); + +describe('verdictFromDraft', () => { + const scope = (over: Record = {}) => ({ + applicable: false as const, kind: 'decision_pending' as const, confidence: 0.95, + reason: 'the title asks for a decision', evidence: ['title says decide', 'no code deliverable'], ...over, + }); + + it('returns the declined kind only for a project with a goal and a confident, evidenced verdict', () => { + expect(verdictFromDraft({ scope: scope() }, true)).toBe('decision_pending'); + expect(verdictFromDraft({ scope: scope() }, false)).toBeUndefined(); + expect(verdictFromDraft({ scope: scope({ confidence: 0.5 }) }, true)).toBeUndefined(); + expect(verdictFromDraft({ scope: scope({ evidence: ['one'] }) }, true)).toBeUndefined(); + expect(verdictFromDraft({}, true)).toBeUndefined(); + expect(verdictFromDraft(undefined, true)).toBeUndefined(); + }); +}); diff --git a/src/automation/readinessGate.ts b/src/automation/readinessGate.ts new file mode 100644 index 00000000..d44ebaa1 --- /dev/null +++ b/src/automation/readinessGate.ts @@ -0,0 +1,120 @@ +// Created: 2026-10-22 +// Purpose: decide whether the daemon may start work on an issue at all, before anything is claimed (AGT-4784) +// Dependencies: agentEligibility (label rule), decisionEngine (state rule) +// Test Status: readinessGate.test.ts + +import { isActionableLinearState } from '../orchestration/decisionEngine.js'; +import { isScopeDeclined, type DraftScopeVerdict } from '../agents/draftScope.js'; +import { humanOwnedReason } from './agentEligibility.js'; + +// On 2026-10-20 the daemon opened sessions on 52 cgf-portal issues in one night. Nine carried the +// label that marks them out of the paid scope, four said in their own title that a decision was +// pending, one was a production deploy, and the Backlog was treated as a queue. Each rule that would +// have stopped them already existed somewhere (R5's "Backlog is parked", the skip labels of AGT-4682, +// the draft's scope verdict of AGT-4662); none of them ran in front of the claim, and the default +// switched the Backlog rule off. This is the one place that asks the three questions in order. + +export type ReadinessTask = { + issueIdentifier?: string; + id?: string; + linearState?: string; + labels?: readonly string[]; + title: string; + hasChildren?: boolean; + /** The operator chose this issue by hand: choosing it is the decision. */ + explicitDispatch?: boolean; +}; + +export interface ReadinessOptions { + /** Labels that mark work outside what the daemon may do for this project. */ + exclusionLabels?: readonly string[]; + /** + * A grooming verdict for the issue, when one exists. `actionable` clears it; anything else + * (`human_action_only`, `decision_pending`, ...) blocks. Injected so the gate is tested for + * *consulting* it before the claim, not for the model that produced it. + */ + verdict?: string; + /** Opt in to treating the Backlog as a queue. Default off: moving an issue to Todo is the "ready" signal. */ + includeBacklog?: boolean; +} + +export interface ReadinessDecision { + claim: boolean; + /** Why not (`state:Backlog`, `label:x`, `verdict:decision_pending`), or `ready`. */ + reason: string; +} + +/** + * Whether the daemon may claim this issue. Order is cheapest first and each answer is final: + * the tracker state, then a person's exclusion label, then the grooming verdict. + * + * Todo is the dispatch state. In Progress and In Review pass: the daemon resumes its own runs + * there and cannot tell them from a person's work without the ledger, so they keep today's + * behaviour. An unknown state also passes (`isActionableLinearState` — "don't gate on a guess"). + */ +export function evaluateReadiness(task: ReadinessTask, options: ReadinessOptions = {}): ReadinessDecision { + if (task.explicitDispatch) return { claim: true, reason: 'explicit_dispatch' }; + + if (!isActionableLinearState(task.linearState, options.includeBacklog === true)) { + return { claim: false, reason: `state:${task.linearState}` }; + } + + const labelReason = humanOwnedReason( + { title: task.title, labels: [...(task.labels ?? [])], hasChildren: false }, + { labels: [...(options.exclusionLabels ?? [])], titleTags: [], epics: false }, + ); + if (labelReason) return { claim: false, reason: labelReason }; + + if (options.verdict !== undefined && options.verdict !== 'actionable') { + return { claim: false, reason: `verdict:${options.verdict}` }; + } + return { claim: true, reason: 'ready' }; +} + +export interface ReadinessPartition { + ready: T[]; + /** Held issue keys grouped by reason, for one aggregated log line per reason. */ + held: Map; +} + +export function partitionByReadiness( + tasks: readonly T[], + options: ReadinessOptions = {}, +): ReadinessPartition { + const ready: T[] = []; + const held = new Map(); + for (const task of tasks) { + const decision = evaluateReadiness(task, options); + if (decision.claim) { + ready.push(task); + continue; + } + const keys = held.get(decision.reason) ?? []; + keys.push(task.issueIdentifier ?? task.id ?? '?'); + held.set(decision.reason, keys); + } + return { ready, held }; +} + +/** One line per reason: `not ready (state:Backlog) 3: AX-1, AX-2, AX-3`. */ +export function formatReadinessSummary(held: ReadonlyMap, maxListed = 8): string[] { + return [...held.entries()].map(([reason, keys]) => { + const shown = keys.slice(0, maxListed).join(', '); + const more = keys.length > maxListed ? ` +${keys.length - maxListed} more` : ''; + return ` ⏭ not ready (${reason}) ${keys.length}: ${shown}${more}`; + }); +} + +/** + * The verdict a draft analysis carries, in the gate's vocabulary: the declined scope kind, or + * undefined when the draft says nothing against the issue. Only a project with a standing goal + * asks the drafter for a scope verdict (`scopePromptParts`), and only a confident, evidenced one + * counts (`isScopeDeclined`) — without both, nothing here can block. + */ +export function verdictFromDraft( + draft: { scope?: DraftScopeVerdict } | undefined, + projectHasGoal: boolean, +): string | undefined { + if (!projectHasGoal || !draft) return undefined; + return isScopeDeclined(draft.scope) ? draft.scope.kind : undefined; +} diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 4d2cd425..835d6b9b 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -332,7 +332,7 @@ agents: }); }); - it('defaults includeBacklog to true so free slots chew parked work (AGT-4257)', () => { + it('defaults includeBacklog to false so only Todo is dispatched, and honours an explicit opt-in (AGT-4784)', () => { const base = { language: 'en', linear: { apiKey: 'k', teamId: 't' }, @@ -340,12 +340,12 @@ agents: }; vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ ...base, autonomous: { enabled: true } })); - expect(loadConfig('/tmp/config.json').autonomous?.includeBacklog).toBe(true); + expect(loadConfig('/tmp/config.json').autonomous?.includeBacklog).toBe(false); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ - ...base, autonomous: { enabled: true, includeBacklog: false }, + ...base, autonomous: { enabled: true, includeBacklog: true }, })); - expect(loadConfig('/tmp/config.json').autonomous?.includeBacklog).toBe(false); + expect(loadConfig('/tmp/config.json').autonomous?.includeBacklog).toBe(true); }); it('carries a project goal from autonomous.projectAgents to the runtime config (AGT-4662)', () => { diff --git a/src/core/config.ts b/src/core/config.ts index e4522e4f..77bce248 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -385,8 +385,12 @@ const AutonomousConfigSchema = z.object({ maxAttempts: z.number().min(1).max(10).default(3), /** Allowed project paths */ allowedProjects: z.array(z.string()).default(['~/dev']), - /** Treat Linear Backlog as a work queue. Default true so free slots chew parked work (AGT-4257). */ - includeBacklog: z.boolean().optional().default(true), + /** + * Treat Linear Backlog as a work queue. Default false: moving an issue to Todo is the signal that + * it is ready, so an unattended daemon never picks up what nobody triaged (AGT-4784, which reverses + * the AGT-4257 default). Set true on a deployment that wants free slots to chew parked work. + */ + includeBacklog: z.boolean().optional().default(false), /** Model configuration (legacy) */ models: ModelConfigSchema, /** Worker timeout (ms). 0/unset = use the pipeline's per-stage ceiling diff --git a/src/orchestration/decisionEngine.ts b/src/orchestration/decisionEngine.ts index 1629a3a7..4aa0551b 100644 --- a/src/orchestration/decisionEngine.ts +++ b/src/orchestration/decisionEngine.ts @@ -288,7 +288,7 @@ const DEFAULT_CONFIG: DecisionEngineConfig = { allowedProjects: [], autoExecute: false, // Default is manual approval dryRun: false, - includeBacklog: true, // AGT-4257: Backlog is still work when slots are free + includeBacklog: false, // AGT-4784: Todo is the ready signal; AGT-4257's Backlog queue is opt-in }; function expandHomePath(input: string): string { @@ -362,7 +362,7 @@ export function isAllowedProjectPath(projectPath: string, allowedProjects: strin /** * Linear states the daemon will NOT act on unless opted in. Done/Canceled are - * terminal. Backlog is admitted when includeBacklog is true (AGT-4257 default). + * terminal. Backlog is admitted only when includeBacklog is true (opt-in since AGT-4784). */ const NON_ACTIONABLE_LINEAR_STATES = new Set(['Backlog', 'Done', 'Canceled', 'Cancelled']); @@ -370,7 +370,7 @@ const NON_ACTIONABLE_LINEAR_STATES = new Set(['Backlog', 'Done', 'Canceled', 'Ca * Whether the daemon should act on an issue in the given Linear state. * Todo/In Progress/In Review and unknown states are always actionable. * Done/Canceled are terminal. Backlog is a queue when includeBacklog is true - * (DecisionEngine default and config default since AGT-4257). + * (opt-in; the DecisionEngine and config default is off since AGT-4784). */ export function isActionableLinearState(linearState: string | undefined, includeBacklog = false): boolean { if (!linearState) return true; // unknown state → don't gate (conservative)