From ea8dba68718067cf7fd3b55d5bd01eedabd1ace9 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 11:56:19 -0500 Subject: [PATCH 01/11] fix(workflow): complete trusted empty-scope tasks --- .../issue-2763-empty-scope-completion.md | 5 + scripts/retention-registry.data.ts | 6 +- src/evidence/gate-bridge.ts | 14 +- src/gate-evidence.test.ts | 24 +- src/gate-evidence.ts | 219 +++++++++- src/tools/check-gate-status.ts | 22 +- src/tools/update-task-status.ts | 35 +- src/workflow/coder-settlement.ts | 23 +- src/workflow/task-terminal.ts | 7 + src/workflow/workflow-wal-schema.ts | 16 + .../check-gate-status-registration.test.ts | 12 +- tests/unit/evidence/gate-bridge-2763.test.ts | 111 +++++ .../unit/evidence/gate-evidence-2763.test.ts | 85 ++++ ...check-gate-status-receiptless-2525.test.ts | 10 +- ...gate-status-secretscan-regressions.test.ts | 83 +++- .../check-gate-status-secretscan.test.ts | 68 +-- .../tools/empty-scope-completion-2763.test.ts | 413 ++++++++++++++++++ .../update-task-status-lean-turbo.test.ts | 14 + .../tools/working-directory-override.test.ts | 8 +- .../task-terminal-read-only-2763.test.ts | 233 ++++++++++ 20 files changed, 1280 insertions(+), 128 deletions(-) create mode 100644 docs/releases/pending/issue-2763-empty-scope-completion.md create mode 100644 tests/unit/evidence/gate-bridge-2763.test.ts create mode 100644 tests/unit/evidence/gate-evidence-2763.test.ts create mode 100644 tests/unit/tools/empty-scope-completion-2763.test.ts create mode 100644 tests/unit/workflow/task-terminal-read-only-2763.test.ts diff --git a/docs/releases/pending/issue-2763-empty-scope-completion.md b/docs/releases/pending/issue-2763-empty-scope-completion.md new file mode 100644 index 000000000..1c924299f --- /dev/null +++ b/docs/releases/pending/issue-2763-empty-scope-completion.md @@ -0,0 +1,5 @@ +# Empty-scope task completion + +Verification-only tasks that explicitly declare `files_touched: []` can now complete when the trusted coder settlement proves that no mutation was accepted. The completion and read-only gate-status paths use the same durable evidence, preserve independent advisory gates, and keep ordinary or malformed scopes fail-closed. + +No configuration or migration is required. Existing tasks and terminal WAL records remain backward-compatible; only an authoritative empty-scope/no-mutation settlement may use the new path. diff --git a/scripts/retention-registry.data.ts b/scripts/retention-registry.data.ts index b8cc47592..ba8e0517f 100644 --- a/scripts/retention-registry.data.ts +++ b/scripts/retention-registry.data.ts @@ -1675,11 +1675,11 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ canonicalRoot: 'project-swarm', writerModules: ['src/gate-evidence.ts', 'src/council/council-evidence-writer.ts'], writerCitations: [ - 'src/gate-evidence.ts:984 transitionTaskWorkflowEvidence / :1094 recordGateEvidence / :1152 recordAgentDispatch — locked read-modify-write, atomic write', + 'src/gate-evidence.ts:1170 transitionTaskWorkflowEvidence / :1285 recordGateEvidence / :1343 recordAgentDispatch — locked read-modify-write, atomic write', 'src/council/council-evidence-writer.ts:96 writeCouncilEvidence — gates.council section under withTaskEvidenceLock', ], readerCitations: [ - 'src/gate-evidence.ts:1196 readTaskEvidence — FULL-FILE fail-open, async; :1272 readTaskEvidenceRaw — strict, sync', + 'src/gate-evidence.ts:1387 readTaskEvidence — FULL-FILE fail-open, async; :1463 readTaskEvidenceRaw — strict, sync', 'src/council/council-evidence-writer.ts:207 hasCouncilEvidenceAttempt', ], schemaVersion: 'workflow WAL states; unrecognized states degrade to null (documented :1183-1188)', @@ -1693,7 +1693,7 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ bound: 'retryHistory ≤3 (schema :347); per-task file; evidence/ archived+cleaned at close', scope: 'per-key', keyspaceBound: - 'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:832 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.', + 'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:967 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.', citation: 'src/gate-evidence.ts:347; src/commands/close/constants.ts:253-269 ACTIVE_STATE_DIRS_TO_CLEAN', }, readBound: { pattern: 'full-file', bound: 'single per-task JSON', sync: true, citation: 'src/gate-evidence.ts:1196-1224' }, diff --git a/src/evidence/gate-bridge.ts b/src/evidence/gate-bridge.ts index 4cce1a345..f1e6bf2ff 100644 --- a/src/evidence/gate-bridge.ts +++ b/src/evidence/gate-bridge.ts @@ -1,5 +1,6 @@ import type { Evidence } from '../config/evidence-schema'; import { + deriveApplicableGateSet, isValidTaskId, readTaskEvidence, readTaskEvidenceRaw, @@ -49,10 +50,7 @@ export function getDurableGateEvidenceStatus( }; } - if ( - !Array.isArray(evidence.required_gates) || - evidence.required_gates.length === 0 - ) { + if (!Array.isArray(evidence.required_gates)) { return { isComplete: false, missingGates: ['required_gates'], @@ -61,12 +59,10 @@ export function getDurableGateEvidenceStatus( }; } - const missingGates = evidence.required_gates.filter( - (gate) => evidence.gates[gate] == null, - ); + const derivedGates = deriveApplicableGateSet(evidence); return { - isComplete: missingGates.length === 0, - missingGates, + isComplete: derivedGates.missingGates.length === 0, + missingGates: derivedGates.missingGates, evidenceExists: true, invalid: false, }; diff --git a/src/gate-evidence.test.ts b/src/gate-evidence.test.ts index 1f1f2c88c..4e09f2d91 100644 --- a/src/gate-evidence.test.ts +++ b/src/gate-evidence.test.ts @@ -26,7 +26,7 @@ let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(path.join(os.tmpdir(), 'gate-evidence-test-')); - mkdirSync(path.join(tmpDir, '.swarm'), { recursive: true }); + mkdirSync(path.join(tmpDir, '.swarm', 'evidence'), { recursive: true }); }); afterEach(() => { @@ -235,8 +235,26 @@ describe('hasPassedAllGates', () => { expect(await hasPassedAllGates(tmpDir, '1.4')).toBe(false); }); - it('15. returns true for docs task with docs evidence', async () => { - await recordGateEvidence(tmpDir, '1.5', 'docs', 'sess-1'); + it('15. returns true when every derived nonempty gate has evidence', async () => { + writeFileSync( + path.join(tmpDir, '.swarm', 'evidence', '1.5.json'), + JSON.stringify({ + taskId: '1.5', + required_gates: ['pre_check', 'docs'], + gates: { + pre_check: { + sessionId: 'sess-1', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'pre_check', + }, + docs: { + sessionId: 'sess-1', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'docs', + }, + }, + }), + ); expect(await hasPassedAllGates(tmpDir, '1.5')).toBe(true); }); diff --git a/src/gate-evidence.ts b/src/gate-evidence.ts index d2f390f97..7904938b5 100644 --- a/src/gate-evidence.ts +++ b/src/gate-evidence.ts @@ -113,6 +113,16 @@ export interface TaskWorkflowMetadata { * `repair_idle` (which opens a new generation for genuinely new work). */ supervisedRecovery?: boolean; + /** + * Exact proof that the generation-0 coder settlement declared no files and + * observed no mutation. This is deliberately separate from retry history and + * lastOutcome so it cannot be overwritten by a later advisory transition. + */ + noMutationSettlement?: { + generation: 0; + transitionId: string; + declaredFiles: string[]; + }; } export interface TaskWorkflowSnapshot extends TaskWorkflowMetadata { @@ -137,6 +147,13 @@ export interface TaskEvidence { }; } +export interface ApplicableGateSet { + requiredGates: string[]; + satisfiedGates: string[]; + missingGates: string[]; + readOnlyNoMutation: boolean; +} + /** * Legacy internal marker written by receipt-less gate-evidence repair before * issue #2525. It is read only for recovery and must never be emitted as a @@ -306,6 +323,8 @@ export type TaskWorkflowTransitionEvent = type: 'task_completed'; /** Locked plan phase explicitly declares that reviewer QA is not required. */ qaExempt?: boolean; + /** Durable, non-forced completion of a trusted empty-scope settlement. */ + readOnlyNoMutation?: boolean; expectedGeneration: number; transitionId?: string; } @@ -351,6 +370,13 @@ const TaskWorkflowMetadataSchema = z.object({ updatedAt: z.string(), forcedCompletion: z.boolean().optional(), supervisedRecovery: z.boolean().optional(), + noMutationSettlement: z + .object({ + generation: z.literal(0), + transitionId: z.string().min(1).max(512), + declaredFiles: z.array(z.string()).length(0), + }) + .optional(), }); const TaskEvidenceSchema = z.object({ @@ -396,6 +422,10 @@ export interface GateDerivationContext { testEngineerExempt?: boolean; /** The shared-root coder changed files but returned a failed/cancelled result. */ settlementFailed?: boolean; + /** Trusted coder-settlement declared scope. Null/missing is not proof. */ + declaredFiles?: readonly string[] | null; + /** Internal binding set only by the coder-settlement WAL transition adapter. */ + settlementTransitionId?: string; } const DEFAULT_WORKFLOW_STATE: TaskWorkflowState = 'idle'; @@ -570,6 +600,16 @@ function isDuplicateTransition( snapshot: TaskWorkflowSnapshot, event: TaskWorkflowTransitionEvent, ): boolean { + if ( + event.type === 'dispatch_no_mutation' && + event.context?.declaredFiles?.length === 0 && + event.context.settlementTransitionId === event.transitionId && + !snapshot.noMutationSettlement + ) { + // A pre-fix duplicate no-mutation transition did not persist the trusted + // settlement proof. Replay it once so the new durable marker is materialized. + return false; + } return ( snapshot.authoritative && typeof event.transitionId === 'string' && @@ -579,6 +619,68 @@ function isDuplicateTransition( ); } +function isNoMutationSettlementMetadata( + workflow: TaskWorkflowSnapshot, +): boolean { + const settlement = workflow.noMutationSettlement; + if ( + !workflow.authoritative || + workflow.generation !== 0 || + settlement?.generation !== 0 || + typeof settlement.transitionId !== 'string' || + settlement.transitionId.length === 0 || + !Array.isArray(settlement.declaredFiles) || + settlement.declaredFiles.length !== 0 + ) + return false; + // The marker itself is the durable transition-bound proof. Do not consult + // lastOutcome/lastTransitionId here: advisory gate_recorded transitions are + // intentionally state-preserving but may overwrite both fields. + return workflow.state === 'idle' || workflow.state === 'complete'; +} + +/** + * Derive the single evidence-backed gate set used by both completion and the + * read-only status tool. Empty required gates are meaningful only when the + * exact generation-0 no-mutation settlement proof is present; otherwise + * Stage A remains applicable and is reported as missing. + */ +export function deriveApplicableGateSet( + evidence: TaskEvidence | null | undefined, +): ApplicableGateSet { + if (!evidence) { + return { + requiredGates: ['pre_check'], + satisfiedGates: [], + missingGates: ['pre_check'], + readOnlyNoMutation: false, + }; + } + const workflow = getTaskWorkflowSnapshot(evidence); + const readOnlyNoMutation = isNoMutationSettlementMetadata(workflow); + const requiredGates = [...new Set(evidence.required_gates ?? [])]; + if (!readOnlyNoMutation && !requiredGates.includes('pre_check')) { + requiredGates.unshift('pre_check'); + } + const satisfiedGates = requiredGates.filter( + (gate) => evidence.gates?.[gate] != null, + ); + return { + requiredGates, + satisfiedGates, + missingGates: requiredGates.filter( + (gate) => evidence.gates?.[gate] == null, + ), + readOnlyNoMutation, + }; +} + +export function isReadOnlyNoMutationEligible( + evidence: TaskEvidence | null | undefined, +): boolean { + return isNoMutationSettlementMetadata(getTaskWorkflowSnapshot(evidence)); +} + export function reduceTaskWorkflowSnapshot( current: TaskWorkflowSnapshot, event: TaskWorkflowTransitionEvent, @@ -624,18 +726,37 @@ export function reduceTaskWorkflowSnapshot( ...(current.supervisedRecovery === true ? { supervisedRecovery: true } : {}), + ...(current.noMutationSettlement + ? { noMutationSettlement: current.noMutationSettlement } + : {}), }; switch (event.type) { case 'dispatch_attempted': return base; - case 'dispatch_no_mutation': + case 'dispatch_no_mutation': { + const { noMutationSettlement: _clearedSettlement, ...withoutSettlement } = + base; return { - ...base, + ...withoutSettlement, retryCount: Math.min(current.retryCount + 1, 3), retryHistory: [...current.retryHistory, outcome].slice(-3), retryEpoch: current.retryEpoch || current.generation + 1, + ...(current.generation === 0 && + event.transitionId && + Array.isArray(event.context?.declaredFiles) && + event.context.declaredFiles.length === 0 && + event.context.settlementTransitionId === event.transitionId + ? { + noMutationSettlement: { + generation: 0 as const, + transitionId: event.transitionId, + declaredFiles: [] as [], + }, + } + : {}), }; + } case 'stage_b_failed': if ( current.state !== 'pre_check_passed' && @@ -654,10 +775,12 @@ export function reduceTaskWorkflowSnapshot( retryHistory: [...current.retryHistory, outcome].slice(-3), retryEpoch: current.retryEpoch || current.generation + 1, }; - case 'accepted_mutation': + case 'accepted_mutation': { + const { noMutationSettlement: _clearedSettlement, ...withoutSettlement } = + base; if (event.context?.settlementFailed === true) { return { - ...base, + ...withoutSettlement, generation: current.generation + 1, state: 'rework_required', retryCount: Math.min(current.retryCount + 1, 3), @@ -668,13 +791,14 @@ export function reduceTaskWorkflowSnapshot( }; } return { - ...base, + ...withoutSettlement, generation: current.generation + 1, state: 'coder_delegated', // A mutation is a repair attempt, not proof that prior rejections were // resolved. Preserve the task-level circuit history across generations. supervisedRecovery: undefined, }; + } case 'stage_a_passed': if ( current.state !== 'coder_delegated' && @@ -749,12 +873,21 @@ export function reduceTaskWorkflowSnapshot( // Advisory/non-Stage-B gates never advance the code QA lifecycle. state: current.state, }; - case 'task_completed': + case 'task_completed': { + const readOnlyNoMutation = + event.readOnlyNoMutation === true && + current.state === 'idle' && + current.generation === 0 && + isNoMutationSettlementMetadata(current) && + context.requiredGates.every((gate) => context.gates[gate] != null); if ( event.qaExempt !== true && current.state !== 'tests_run' && current.state !== 'complete' && - !(current.state === 'pre_check_passed' && context.gates.council != null) + !( + current.state === 'pre_check_passed' && context.gates.council != null + ) && + !readOnlyNoMutation ) { throw new Error( `TASK_WORKFLOW_QA_REQUIRED: cannot complete from ${current.state}`, @@ -768,6 +901,7 @@ export function reduceTaskWorkflowSnapshot( // on the exempt path; a genuine completion leaves the field absent. ...(event.qaExempt === true ? { forcedCompletion: true } : {}), }; + } case 'task_blocked': return { ...base, @@ -785,6 +919,7 @@ export function reduceTaskWorkflowSnapshot( const { forcedCompletion: _cleared, supervisedRecovery: _clearedMarker, + noMutationSettlement: _clearedSettlement, ...withoutForced } = base; return { @@ -834,6 +969,57 @@ function getEvidencePath(directory: string, taskId: string): string { return taskEvidencePath(directory, taskId); } +/** + * Bind the no-mutation proof to the coder-settlement WAL at the evidence write + * boundary. The context field is an internal adapter hint, not authorization: + * direct transition callers can supply it, but it is stripped unless the + * durable WAL is the matching committed/prepared accepted=false transition + * with an exact empty declared scope. + */ +function bindTrustedNoMutationSettlement( + directory: string, + taskId: string, + event: TaskWorkflowTransitionEvent, +): TaskWorkflowTransitionEvent { + if (event.type !== 'dispatch_no_mutation') return event; + const context = event.context; + if (!context || event.transitionId === undefined) { + return { ...event, context: undefined }; + } + try { + const wal = readWorkflowWalFileSync( + 'coder-settlement', + validateSwarmPath(directory, `coder-settlements/${taskId}.json`), + taskId, + ); + const declaredFiles = wal?.context?.declaredFiles; + const trusted = + wal !== null && + (wal.state === 'PREPARED' || wal.state === 'COMMITTED') && + wal.accepted === false && + wal.transitionId === event.transitionId && + Array.isArray(declaredFiles) && + declaredFiles.length === 0 && + Array.isArray(context.declaredFiles) && + context.declaredFiles.length === 0; + return { + ...event, + context: trusted + ? { + ...context, + declaredFiles, + settlementTransitionId: wal.transitionId, + } + : { ...context, settlementTransitionId: undefined }, + }; + } catch { + return { + ...event, + context: { ...context, settlementTransitionId: undefined }, + }; + } +} + function readExisting( evidencePath: string, taskId: string, @@ -1053,10 +1239,15 @@ export async function withTaskEvidenceTransaction( taskId, read: () => current, transition: async (event) => { - assertTaskEvidenceWriteAllowed(directory, taskId, event); - const nextEvidence = updateEvidenceForTransition(current, event); + const boundEvent = bindTrustedNoMutationSettlement( + directory, + taskId, + event, + ); + assertTaskEvidenceWriteAllowed(directory, taskId, boundEvent); + const nextEvidence = updateEvidenceForTransition(current, boundEvent); nextEvidence.taskId = taskId; - return persist(nextEvidence, event); + return persist(nextEvidence, boundEvent); }, }); }); @@ -1301,12 +1492,8 @@ export async function hasPassedAllGates( ): Promise { const evidence = await readTaskEvidence(directory, taskId); if (!evidence) return false; - if ( - !Array.isArray(evidence.required_gates) || - evidence.required_gates.length === 0 - ) - return false; - return evidence.required_gates.every((gate) => evidence.gates[gate] != null); + if (!Array.isArray(evidence.required_gates)) return false; + return deriveApplicableGateSet(evidence).missingGates.length === 0; } export function compareTaskWorkflowStateRank( diff --git a/src/tools/check-gate-status.ts b/src/tools/check-gate-status.ts index dfd3f3c5e..63c8b4028 100644 --- a/src/tools/check-gate-status.ts +++ b/src/tools/check-gate-status.ts @@ -9,6 +9,8 @@ import * as path from 'node:path'; import type { tool } from '@opencode-ai/plugin'; import { z } from 'zod'; import { isSecretscanEvidence, loadEvidence } from '../evidence/manager.js'; +import type { TaskEvidence } from '../gate-evidence.js'; +import { deriveApplicableGateSet } from '../gate-evidence.js'; import { isStrictTaskId } from '../validation/task-id'; import { createSwarmTool } from './create-tool'; import { resolveWorkingDirectory } from './resolve-working-directory'; @@ -232,22 +234,18 @@ export const check_gate_status: ReturnType = createSwarmTool({ } // Calculate passed and missing gates - const requiredGates = evidenceData.required_gates || []; + const derivedGates = deriveApplicableGateSet(evidenceData as TaskEvidence); + const requiredGates = derivedGates.requiredGates; const gatesMap = evidenceData.gates || {}; - const passedGates: string[] = []; - const missingGates: string[] = []; - - for (const requiredGate of requiredGates) { - if (gatesMap[requiredGate]) { - passedGates.push(requiredGate); - } else { - missingGates.push(requiredGate); - } - } + const passedGates = derivedGates.satisfiedGates; + const missingGates = derivedGates.missingGates; // Determine overall status let status: 'all_passed' | 'incomplete' = - requiredGates.length > 0 && missingGates.length === 0 + missingGates.length === 0 && + (derivedGates.readOnlyNoMutation || + evidenceData.workflow?.state === 'tests_run' || + evidenceData.workflow?.state === 'complete') ? 'all_passed' : 'incomplete'; diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts index 0e0f53e3d..9c2de2dce 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -18,7 +18,9 @@ import { } from '../evidence/task-gate-requirements.js'; import type { transitionTaskWorkflowEvidence } from '../gate-evidence.js'; import { + deriveApplicableGateSet, getTaskWorkflowSnapshot, + isReadOnlyNoMutationEligible, readTaskEvidenceRaw, TASK_GATE_REQUIREMENTS_RECONSTRUCTION_SENTINEL, } from '../gate-evidence.js'; @@ -502,7 +504,11 @@ export function checkReviewerGate( // Find the task and check its files_touched for (const planPhase of plan.phases ?? []) { for (const task of planPhase.tasks ?? []) { - if (task.id === taskId && task.files_touched) { + if ( + task.id === taskId && + Array.isArray(task.files_touched) && + task.files_touched.length > 0 + ) { // If no Tier 3 patterns matched, bypass Stage B if (!matchesTier3(task.files_touched)) { return reviewerGateDecision( @@ -591,14 +597,10 @@ export function checkReviewerGate( ); } const workflow = getTaskWorkflowSnapshot(evidence); - const requiredGates = [...evidence.required_gates]; - const satisfiedGates = requiredGates.filter( - (gate) => evidence.gates[gate] != null, - ); - const missingGates = requiredGates.filter( - (gate) => evidence.gates[gate] == null, - ); - if (evidence.gates.pre_check == null) missingGates.unshift('pre_check'); + const derivedGates = deriveApplicableGateSet(evidence); + const requiredGates = derivedGates.requiredGates; + const satisfiedGates = derivedGates.satisfiedGates; + const missingGates = derivedGates.missingGates; if ( requiredGates.includes(TASK_GATE_REQUIREMENTS_RECONSTRUCTION_SENTINEL) ) { @@ -645,12 +647,14 @@ export function checkReviewerGate( const workflowComplete = workflow.state === 'tests_run' || workflow.state === 'complete'; if ( - requiredGates.length > 0 && missingGates.length === 0 && contradictorySignals.length === 0 && - workflowComplete + (workflowComplete || derivedGates.readOnlyNoMutation) ) { - if (!routeGateAllowsTask(authoritativeDir, taskId, sessionID)) { + if ( + !derivedGates.readOnlyNoMutation && + !routeGateAllowsTask(authoritativeDir, taskId, sessionID) + ) { return reviewerGateDecision( taskId, sessionID, @@ -2094,6 +2098,13 @@ export async function executeUpdateTaskStatus( currentPlanStatus: lockedTask.status, targetStatus: 'completed', qaExempt: !lockedPhaseRequiresReviewer, + resolveTerminal: (evidence) => ({ + targetStatus: 'completed', + qaExempt: !lockedPhaseRequiresReviewer, + readOnlyNoMutation: !lockedPhaseRequiresReviewer + ? false + : isReadOnlyNoMutationEligible(evidence), + }), currentPlan: authoritativePlan, validateEvidence: async () => { const lockedGate = checkReviewerGate( diff --git a/src/workflow/coder-settlement.ts b/src/workflow/coder-settlement.ts index a05476755..c5330ebc4 100644 --- a/src/workflow/coder-settlement.ts +++ b/src/workflow/coder-settlement.ts @@ -218,6 +218,10 @@ function settlementTransitionEvent( : { type: 'dispatch_no_mutation', agentType: 'coder', + context: { + declaredFiles: wal.context.declaredFiles, + settlementTransitionId: wal.transitionId, + }, expectedGeneration: wal.expectedGeneration, transitionId: wal.transitionId, }; @@ -267,9 +271,13 @@ async function commitPrepared( ? 'accepted_mutation_failed' : 'accepted_mutation') && snapshot.lastTransitionId === lockedWal.transitionId - : snapshot.generation === lockedWal.expectedGeneration && - snapshot.lastOutcome === 'dispatch_no_mutation' && - snapshot.lastTransitionId === lockedWal.transitionId; + : snapshot.authoritative && + snapshot.generation === lockedWal.expectedGeneration && + (lockedWal.context.declaredFiles?.length === 0 + ? snapshot.noMutationSettlement?.transitionId === + lockedWal.transitionId + : snapshot.lastOutcome === 'dispatch_no_mutation' && + snapshot.lastTransitionId === lockedWal.transitionId); if (evidenceAlreadySettled) { const evidence = transaction.read() as TaskEvidence; if (lockedWal.accepted === true) { @@ -301,7 +309,14 @@ async function commitPrepared( alreadyApplied: true, }; } - if (lockedWal.state !== 'PREPARED') { + if ( + lockedWal.state !== 'PREPARED' && + !( + lockedWal.state === 'COMMITTED' && + lockedWal.accepted !== true && + snapshot.state === 'idle' + ) + ) { throw new Error('CODER_SETTLEMENT_NOT_PREPARED'); } const evidence = await transaction.transition(transitionEvent); diff --git a/src/workflow/task-terminal.ts b/src/workflow/task-terminal.ts index 80b48115b..1d12c2414 100644 --- a/src/workflow/task-terminal.ts +++ b/src/workflow/task-terminal.ts @@ -54,6 +54,7 @@ async function applyTerminalEvidence( ? { type: 'task_completed', qaExempt: wal.qaExempt, + readOnlyNoMutation: wal.readOnlyNoMutation, expectedGeneration: wal.generation, transitionId: wal.transitionId, } @@ -256,6 +257,7 @@ export async function commitTaskTerminalUnderPlanLock(options: { targetStatus: TerminalPlanStatus; qaExempt: boolean; preserveEvidence?: boolean; + readOnlyNoMutation?: boolean; }; planIdentityHash?: string; planEpoch?: string; @@ -309,6 +311,8 @@ export async function commitTaskTerminalUnderPlanLock(options: { existingWal?.state === 'COMMITTED' && existingWal.transitionId === options.transitionId && existingWal.newPlanStatus === terminal.targetStatus && + (existingWal.readOnlyNoMutation === true) === + (terminal.readOnlyNoMutation === true) && evidenceMatchesTerminal(evidence, existingWal) ) { return { @@ -380,6 +384,9 @@ export async function commitTaskTerminalUnderPlanLock(options: { newWorkflowState, generation: workflow.generation, qaExempt: terminal.qaExempt, + ...(terminal.readOnlyNoMutation === true + ? { readOnlyNoMutation: true } + : {}), recordedAt: new Date().toISOString(), }; const wal: TaskTerminalWal = diff --git a/src/workflow/workflow-wal-schema.ts b/src/workflow/workflow-wal-schema.ts index a94dd89e9..c93a60696 100644 --- a/src/workflow/workflow-wal-schema.ts +++ b/src/workflow/workflow-wal-schema.ts @@ -74,6 +74,8 @@ export interface TaskTerminalWalV1 { newWorkflowState: 'blocked' | 'complete'; generation: number; qaExempt: boolean; + /** Completion used the trusted generation-0 empty-scope settlement path. */ + readOnlyNoMutation?: boolean; recordedAt: string; } @@ -416,6 +418,7 @@ export function parseTaskTerminalWal( newWorkflowState: TerminalWorkflowState; generation: number; qaExempt: boolean; + readOnlyNoMutation?: boolean; recordedAt: string; planIdentityHash: string; planEpoch: string; @@ -447,6 +450,8 @@ export function parseTaskTerminalWal( !Number.isInteger(parsed.generation) || (parsed.generation ?? -1) < 0 || typeof parsed.qaExempt !== 'boolean' || + (parsed.readOnlyNoMutation !== undefined && + typeof parsed.readOnlyNoMutation !== 'boolean') || typeof parsed.recordedAt !== 'string' || !Number.isFinite(Date.parse(parsed.recordedAt)) ) { @@ -482,6 +487,17 @@ export function parseTaskTerminalWal( 'Preserve this file and reconcile the task terminal transition before moving it aside.', ); } + if ( + parsed.readOnlyNoMutation === true && + (parsed.newPlanStatus !== 'completed' || + parsed.newWorkflowState !== 'complete' || + parsed.generation !== 0 || + parsed.qaExempt !== false) + ) { + throw new Error( + `TASK_TERMINAL_WAL_STATE_MISMATCH: ${filePath} claims read-only no-mutation completion outside generation 0`, + ); + } if ( !( (parsed.newPlanStatus === 'completed' && diff --git a/tests/integration/check-gate-status-registration.test.ts b/tests/integration/check-gate-status-registration.test.ts index f09a27397..4f61b5aa6 100644 --- a/tests/integration/check-gate-status-registration.test.ts +++ b/tests/integration/check-gate-status-registration.test.ts @@ -208,7 +208,7 @@ describe('check_gate_status evidence file processing', () => { const parsed = JSON.parse(result); expect(parsed.status).toBe('incomplete'); - expect(parsed.required_gates).toEqual(['lint', 'test']); + expect(parsed.required_gates).toEqual(['pre_check', 'lint', 'test']); expect(parsed.passed_gates).toContain('lint'); expect(parsed.missing_gates).toContain('test'); }); @@ -219,8 +219,13 @@ describe('check_gate_status evidence file processing', () => { const evidenceData = { taskId: '2.1', - required_gates: ['lint', 'test', 'review'], + required_gates: ['pre_check', 'lint', 'test', 'review'], gates: { + pre_check: { + sessionId: 's0', + timestamp: '2024-01-01', + agent: 'pre_check_batch', + }, lint: { sessionId: 's1', timestamp: '2024-01-01', agent: 'reviewer' }, test: { sessionId: 's2', @@ -229,6 +234,7 @@ describe('check_gate_status evidence file processing', () => { }, review: { sessionId: 's3', timestamp: '2024-01-01', agent: 'reviewer' }, }, + workflow: { state: 'tests_run', generation: 1 }, }; writeFileSync( @@ -242,7 +248,7 @@ describe('check_gate_status evidence file processing', () => { const parsed = JSON.parse(result); expect(parsed.status).toBe('all_passed'); - expect(parsed.passed_gates).toHaveLength(3); + expect(parsed.passed_gates).toHaveLength(4); expect(parsed.missing_gates).toHaveLength(0); }); diff --git a/tests/unit/evidence/gate-bridge-2763.test.ts b/tests/unit/evidence/gate-bridge-2763.test.ts new file mode 100644 index 000000000..83447bd11 --- /dev/null +++ b/tests/unit/evidence/gate-bridge-2763.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'bun:test'; +import { + getDurableGateEvidenceStatus, + hasCompleteDurableGateEvidence, +} from '../../../src/evidence/gate-bridge'; +import type { TaskEvidence } from '../../../src/gate-evidence'; + +const trustedEmptySettlement = (): TaskEvidence => ({ + taskId: '1.1', + required_gates: [], + gates: {}, + workflow: { + schema: 'exact-task-v1', + generation: 0, + state: 'idle', + retryCount: 1, + retryHistory: ['dispatch_no_mutation'], + retryEpoch: 1, + lastOutcome: 'dispatch_no_mutation', + lastTransitionId: 'settlement-1.1', + updatedAt: '2026-09-14T00:00:00.000Z', + noMutationSettlement: { + generation: 0, + transitionId: 'settlement-1.1', + declaredFiles: [], + }, + }, +}); + +describe('durable gate bridge applicability (#2763)', () => { + test('ordinary empty required gates retain the pre_check obligation', () => { + const status = getDurableGateEvidenceStatus({ + taskId: '1.2', + required_gates: [], + gates: {}, + }); + + expect(status).toEqual({ + isComplete: false, + missingGates: ['pre_check'], + evidenceExists: true, + invalid: false, + }); + }); + + test('trusted empty-scope settlement has an empty complete gate set', () => { + const evidence = trustedEmptySettlement(); + + expect(getDurableGateEvidenceStatus(evidence)).toEqual({ + isComplete: true, + missingGates: [], + evidenceExists: true, + invalid: false, + }); + expect(hasCompleteDurableGateEvidence(evidence)).toBe(true); + }); + + test('nonempty gate evidence still requires pre_check and every declared gate', () => { + const evidence: TaskEvidence = { + taskId: '1.3', + required_gates: ['reviewer'], + gates: { + reviewer: { + sessionId: 'session-1', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'reviewer', + }, + }, + }; + + const status = getDurableGateEvidenceStatus(evidence); + expect(status.isComplete).toBe(false); + expect(status.missingGates).toEqual(['pre_check']); + }); + + test('invalid required_gates shape keeps the invalid-gate diagnostic', () => { + const evidence = { + taskId: '1.4', + required_gates: 'reviewer', + gates: {}, + } as unknown as TaskEvidence; + + expect(getDurableGateEvidenceStatus(evidence)).toEqual({ + isComplete: false, + missingGates: ['required_gates'], + evidenceExists: true, + invalid: false, + }); + }); + + test('missing evidence remains distinct from malformed gate evidence', () => { + expect(getDurableGateEvidenceStatus(null)).toEqual({ + isComplete: false, + missingGates: [], + evidenceExists: false, + invalid: false, + }); + expect( + getDurableGateEvidenceStatus({ + taskId: '1.5', + required_gates: [], + gates: null, + } as unknown as TaskEvidence), + ).toEqual({ + isComplete: false, + missingGates: [], + evidenceExists: true, + invalid: false, + }); + }); +}); diff --git a/tests/unit/evidence/gate-evidence-2763.test.ts b/tests/unit/evidence/gate-evidence-2763.test.ts new file mode 100644 index 000000000..9c85ea4d8 --- /dev/null +++ b/tests/unit/evidence/gate-evidence-2763.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { hasPassedAllGates } from '../../../src/gate-evidence'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; + +let directory: string; + +beforeEach(() => { + directory = canonicalMkdtemp('gate-evidence-2763-'); + mkdirSync(path.join(directory, '.swarm', 'evidence'), { recursive: true }); +}); + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }); +}); + +function writeEvidence( + taskId: string, + evidence: Record, +): void { + writeFileSync( + path.join(directory, '.swarm', 'evidence', `${taskId}.json`), + JSON.stringify({ taskId, ...evidence }), + ); +} + +describe('hasPassedAllGates applicability (#2763)', () => { + test('accepts trusted empty-scope no-mutation evidence', async () => { + writeEvidence('1.1', { + required_gates: [], + gates: {}, + workflow: { + schema: 'exact-task-v1', + generation: 0, + state: 'idle', + retryCount: 1, + retryHistory: ['dispatch_no_mutation'], + retryEpoch: 1, + lastOutcome: 'dispatch_no_mutation', + lastTransitionId: 'settlement-1.1', + updatedAt: '2026-09-14T00:00:00.000Z', + noMutationSettlement: { + generation: 0, + transitionId: 'settlement-1.1', + declaredFiles: [], + }, + }, + }); + + expect(await hasPassedAllGates(directory, '1.1')).toBe(true); + }); + + test('rejects ordinary and legacy empty required-gate evidence', async () => { + writeEvidence('1.2', { required_gates: [], gates: {} }); + writeEvidence('1.3', { + required_gates: [], + gates: {}, + workflow: { state: 'idle', generation: 0 }, + }); + + expect(await hasPassedAllGates(directory, '1.2')).toBe(false); + expect(await hasPassedAllGates(directory, '1.3')).toBe(false); + }); + + test('preserves nonempty all-gates behavior while requiring pre_check', async () => { + writeEvidence('1.4', { + required_gates: ['reviewer'], + gates: { + pre_check: { + sessionId: 'session-1', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'pre_check', + }, + reviewer: { + sessionId: 'session-1', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'reviewer', + }, + }, + }); + + expect(await hasPassedAllGates(directory, '1.4')).toBe(true); + }); +}); diff --git a/tests/unit/tools/check-gate-status-receiptless-2525.test.ts b/tests/unit/tools/check-gate-status-receiptless-2525.test.ts index c08bb65fd..b8f413887 100644 --- a/tests/unit/tools/check-gate-status-receiptless-2525.test.ts +++ b/tests/unit/tools/check-gate-status-receiptless-2525.test.ts @@ -19,8 +19,8 @@ describe('check_gate_status — receiptless recovery (FB-001)', () => { }); test('reports incomplete when receiptless repair leaves no required gates', async () => { - // Before FB-001, an empty required_gates array made the read-side diagnostic - // claim all gates passed even though no gate proof existed. + // An empty required_gates array without trusted no-mutation settlement proof + // still has the ordinary Stage-A pre_check obligation. const evidenceDir = path.join(directory, '.swarm', 'evidence'); fs.mkdirSync(evidenceDir, { recursive: true }); fs.writeFileSync( @@ -39,11 +39,11 @@ describe('check_gate_status — receiptless recovery (FB-001)', () => { }; expect(parsed.status).toBe('incomplete'); - expect(parsed.required_gates).toEqual([]); + expect(parsed.required_gates).toEqual(['pre_check']); expect(parsed.passed_gates).toEqual([]); - expect(parsed.missing_gates).toEqual([]); + expect(parsed.missing_gates).toEqual(['pre_check']); expect(parsed.message).toBe( - 'Task "1.1" is incomplete. No required gates are configured for this task generation.', + 'Task "1.1" is incomplete. Missing gates: pre_check.', ); }); }); diff --git a/tests/unit/tools/check-gate-status-secretscan-regressions.test.ts b/tests/unit/tools/check-gate-status-secretscan-regressions.test.ts index 6835c04a3..727fe6ed9 100644 --- a/tests/unit/tools/check-gate-status-secretscan-regressions.test.ts +++ b/tests/unit/tools/check-gate-status-secretscan-regressions.test.ts @@ -32,12 +32,14 @@ describe('check_gate_status secretscan feature', () => { string, { sessionId?: string; timestamp?: string; agent?: string } >, + workflow?: Record, ) { fs.mkdirSync(EVIDENCE_DIR, { recursive: true }); const evidence = { taskId, required_gates: requiredGates, gates, + ...(workflow ? { workflow } : {}), }; fs.writeFileSync( path.join(EVIDENCE_DIR, `${taskId}.json`), @@ -169,8 +171,25 @@ describe('check_gate_status secretscan feature', () => { describe('loadEvidence error handling', () => { it('should silently skip when loadEvidence throws an error', async () => { - // Setup: gate-evidence shows all gates passed - createGateEvidence('3.1', ['test', 'review'], { test: {}, review: {} }); + // Setup: a genuinely completed ordinary workflow. The supplementary + // EvidenceBundle below is the only surface under test; shared gate + // derivation must not mistake this fixture for an empty or legacy task. + createGateEvidence( + '3.1', + ['pre_check', 'test', 'review'], + { pre_check: {}, test: {}, review: {} }, + { + schema: 'exact-task-v1', + generation: 0, + state: 'complete', + retryCount: 0, + retryHistory: [], + retryEpoch: 0, + lastOutcome: 'task_completed', + lastTransitionId: 'test-terminal:3.1', + updatedAt: new Date().toISOString(), + }, + ); // Setup: EvidenceBundle that will cause loadEvidence to throw // (path traversal in taskId would cause issue, but here we use valid path) @@ -277,5 +296,65 @@ describe('check_gate_status secretscan feature', () => { expect(result.status).toBe('incomplete'); expect(result.message).toContain('BLOCKED'); }); + + it('uses the latest secretscan entry when multiple scans exist', async () => { + // The prior implementation selected an arbitrary/older entry instead of + // the latest scan, so a clean rerun could remain incorrectly blocked. + createGateEvidence( + '1.9', + ['pre_check', 'test', 'review'], + { pre_check: {}, test: {}, review: {} }, + { state: 'tests_run', generation: 1 }, + ); + + const earlier = new Date('2024-01-01T00:00:00Z').toISOString(); + const later = new Date('2024-01-02T00:00:00Z').toISOString(); + const latest = new Date('2024-01-03T00:00:00Z').toISOString(); + + createEvidenceBundle('1.9', [ + { + task_id: '1.9', + type: 'secretscan', + timestamp: earlier, + agent: 'pre_check_batch', + verdict: 'fail', + summary: 'Earlier scan with secrets', + findings_count: 5, + scan_directory: 'src', + files_scanned: 5, + skipped_files: 0, + }, + { + task_id: '1.9', + type: 'secretscan', + timestamp: later, + agent: 'pre_check_batch', + verdict: 'pass', + summary: 'Later scan clean', + findings_count: 0, + scan_directory: 'src', + files_scanned: 5, + skipped_files: 0, + }, + { + task_id: '1.9', + type: 'secretscan', + timestamp: latest, + agent: 'pre_check_batch', + verdict: 'pass', + summary: 'Latest scan clean', + findings_count: 0, + scan_directory: 'src', + files_scanned: 5, + skipped_files: 0, + }, + ]); + + const result = await runTool('1.9'); + + expect(result.secretscan_verdict).toBe('pass'); + expect(result.status).toBe('all_passed'); + expect(result.message).not.toContain('BLOCKED'); + }); }); }); diff --git a/tests/unit/tools/check-gate-status-secretscan.test.ts b/tests/unit/tools/check-gate-status-secretscan.test.ts index 0f2181531..00df44303 100644 --- a/tests/unit/tools/check-gate-status-secretscan.test.ts +++ b/tests/unit/tools/check-gate-status-secretscan.test.ts @@ -39,8 +39,16 @@ describe('check_gate_status secretscan feature', () => { fs.mkdirSync(EVIDENCE_DIR, { recursive: true }); const evidence = { taskId, - required_gates: requiredGates, - gates, + required_gates: ['pre_check', ...requiredGates], + gates: { + pre_check: { + sessionId: 'pre-check-session', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'pre_check_batch', + }, + ...gates, + }, + workflow: { state: 'tests_run', generation: 1 }, }; fs.writeFileSync( path.join(EVIDENCE_DIR, `${taskId}.json`), @@ -457,62 +465,6 @@ describe('check_gate_status secretscan feature', () => { }); } - it('9. Most recent secretscan entry is used (when multiple entries exist)', async () => { - // Setup: gate-evidence shows all gates passed - createGateEvidence('1.9', ['test', 'review'], { test: {}, review: {} }); - - // Setup: EvidenceBundle with multiple secretscan entries - const earlier = new Date('2024-01-01T00:00:00Z').toISOString(); - const later = new Date('2024-01-02T00:00:00Z').toISOString(); - const latest = new Date('2024-01-03T00:00:00Z').toISOString(); - - createEvidenceBundle('1.9', [ - { - task_id: '1.9', - type: 'secretscan', - timestamp: earlier, - agent: 'pre_check_batch', - verdict: 'fail', - summary: 'Earlier scan with secrets', - findings_count: 5, - scan_directory: 'src', - files_scanned: 5, - skipped_files: 0, - }, - { - task_id: '1.9', - type: 'secretscan', - timestamp: later, - agent: 'pre_check_batch', - verdict: 'pass', - summary: 'Later scan clean', - findings_count: 0, - scan_directory: 'src', - files_scanned: 5, - skipped_files: 0, - }, - { - task_id: '1.9', - type: 'secretscan', - timestamp: latest, - agent: 'pre_check_batch', - verdict: 'pass', - summary: 'Latest scan clean', - findings_count: 0, - scan_directory: 'src', - files_scanned: 5, - skipped_files: 0, - }, - ]); - - const result = await runTool('1.9'); - - // Should use the most recent entry (verdict=pass) - expect(result.secretscan_verdict).toBe('pass'); - expect(result.status).toBe('all_passed'); - expect(result.message).not.toContain('BLOCKED'); - }); - it('10. secretscan_verdict=not_run when EvidenceBundle exists but has no secretscan entries and no other evidence', async () => { // Setup: gate-evidence shows all gates passed createGateEvidence('1.10', ['test', 'review'], { test: {}, review: {} }); diff --git a/tests/unit/tools/empty-scope-completion-2763.test.ts b/tests/unit/tools/empty-scope-completion-2763.test.ts new file mode 100644 index 000000000..9c68d4c7d --- /dev/null +++ b/tests/unit/tools/empty-scope-completion-2763.test.ts @@ -0,0 +1,413 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { BackgroundTaskChangeContext } from '../../../src/background/pending-delegations'; +import { + getTaskWorkflowSnapshot, + readTaskEvidenceRaw, + transitionTaskWorkflowEvidence, +} from '../../../src/gate-evidence'; +import { resetSwarmState } from '../../../src/state'; +import { check_gate_status } from '../../../src/tools/check-gate-status'; +import { + checkReviewerGate, + executeUpdateTaskStatus, +} from '../../../src/tools/update-task-status'; +import { + beginCoderSettlement, + settleCoderDispatch, +} from '../../../src/workflow/coder-settlement'; +import { createSafeTestDir } from '../../helpers/safe-test-dir'; +import { withFrozenClockAsync } from '../../helpers/test-clock'; + +const TASK_ID = '1.1'; + +function runGit(directory: string, args: string[], capture = false): string { + const result = spawnSync('git', ['-C', directory, ...args], { + cwd: directory, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'ignore', + timeout: 5000, + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error(`fixture git command failed: ${args.join(' ')}`); + } + return capture ? String(result.stdout).trim() : ''; +} + +function writePlan(directory: string, filesTouched: unknown = []): void { + fs.mkdirSync(path.join(directory, '.swarm'), { recursive: true }); + fs.mkdirSync(path.join(directory, '.opencode'), { recursive: true }); + fs.writeFileSync( + path.join(directory, '.swarm', 'plan.json'), + JSON.stringify({ + schema_version: '1.0.0', + title: 'Issue 2763 fixture', + swarm: 'issue-2763', + current_phase: 1, + phases: [ + { + id: 1, + name: 'Phase 1', + status: 'in_progress', + tasks: [ + { + id: TASK_ID, + phase: 1, + status: 'in_progress', + size: 'small', + description: 'Read-only verification task', + depends: [], + files_touched: filesTouched, + }, + ], + }, + ], + }), + ); + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'issue-2763@example.invalid']); + runGit(directory, ['config', 'user.name', 'Issue 2763 test']); + runGit(directory, ['add', '.']); + runGit(directory, ['commit', '--quiet', '-m', 'issue 2763 fixture']); +} + +function makeContext( + directory: string, + declaredFiles?: string[] | null, +): BackgroundTaskChangeContext { + const context = { + baseline: { + directory, + gitHead: runGit(directory, ['rev-parse', 'HEAD'], true), + dirtyHash: null, + changedFiles: [], + prHeadSha: null, + scope: null, + }, + workflowGeneration: 0, + } as BackgroundTaskChangeContext; + if (declaredFiles !== undefined) context.declaredFiles = declaredFiles; + return context; +} + +async function settleNoMutation( + directory: string, + declaredFiles?: string[] | null, + transitionId = 'issue-2763-no-mutation', +): Promise { + await beginCoderSettlement({ + directory, + taskId: TASK_ID, + transitionId, + actor: 'issue-2763-test', + expectedGeneration: 0, + context: makeContext(directory, declaredFiles), + }); + await settleCoderDispatch({ + directory, + taskId: TASK_ID, + transitionId, + accepted: false, + testEngineerExempt: false, + }); +} + +async function gateStatus(directory: string): Promise> { + return JSON.parse( + await check_gate_status.execute( + { task_id: TASK_ID, working_directory: directory }, + { directory }, + ), + ) as Record; +} + +describe('issue #2763 — empty-scope read-only completion', () => { + let directory: string; + let cleanup: () => void; + + beforeEach(() => { + resetSwarmState(); + ({ dir: directory, cleanup } = createSafeTestDir('empty-scope-2763-')); + writePlan(directory, []); + }); + + afterEach(() => { + resetSwarmState(); + cleanup(); + }); + + test('completes a trusted no-mutation task and keeps status arrays in parity', async () => { + await settleNoMutation(directory, []); + + const before = checkReviewerGate( + TASK_ID, + directory, + false, + 'session', + directory, + ); + expect(before.blocked).toBe(false); + expect(before.requiredGates).toEqual([]); + expect(before.missingGates).toEqual([]); + + const result = await executeUpdateTaskStatus( + { task_id: TASK_ID, status: 'completed', working_directory: directory }, + directory, + ); + const status = await gateStatus(directory); + const evidence = readTaskEvidenceRaw(directory, TASK_ID); + const workflow = evidence?.workflow as Record; + + expect(result).toMatchObject({ success: true }); + expect(status.status).toBe('all_passed'); + expect(status.required_gates).toEqual([]); + expect(status.missing_gates).toEqual([]); + expect(evidence?.required_gates).toEqual([]); + expect(evidence?.gates).not.toHaveProperty('pre_check'); + expect(getTaskWorkflowSnapshot(evidence).state).toBe('complete'); + expect(workflow.qaExempt).not.toBe(true); + expect(workflow.forcedCompletion).not.toBe(true); + }); + + test('retains the dedicated settlement proof across an advisory gate and remains idempotent', async () => { + await settleNoMutation(directory, [], 'issue-2763-preserve'); + await transitionTaskWorkflowEvidence(directory, TASK_ID, { + type: 'gate_recorded', + gate: 'critic', + sessionId: 'critic-session', + expectedGeneration: 0, + transitionId: 'issue-2763-critic', + }); + + const afterAdvisory = readTaskEvidenceRaw(directory, TASK_ID); + const metadata = afterAdvisory?.workflow as Record; + expect(metadata.noMutationSettlement).toMatchObject({ + generation: 0, + transitionId: 'issue-2763-preserve', + }); + expect(afterAdvisory?.required_gates).toEqual(['critic']); + + const first = await executeUpdateTaskStatus( + { task_id: TASK_ID, status: 'completed', working_directory: directory }, + directory, + ); + const second = await executeUpdateTaskStatus( + { task_id: TASK_ID, status: 'completed', working_directory: directory }, + directory, + ); + expect(first).toMatchObject({ success: true }); + expect(second.success).toBe(true); + expect(readTaskEvidenceRaw(directory, TASK_ID)?.workflow?.state).toBe( + 'complete', + ); + }); + + test('fails closed for malformed authoritative no-mutation evidence', async () => { + // Before the hardening, check_gate_status trusted the authoritative schema + // marker without validating noMutationSettlement, so malformed raw evidence + // could bypass pre_check or throw while deriving the gate set. + const evidencePath = path.join( + directory, + '.swarm', + 'evidence', + `${TASK_ID}.json`, + ); + fs.mkdirSync(path.dirname(evidencePath), { recursive: true }); + fs.writeFileSync( + evidencePath, + JSON.stringify({ + taskId: TASK_ID, + required_gates: [], + gates: {}, + workflow: { + schema: 'exact-task-v1', + generation: 0, + state: 'idle', + retryCount: 0, + retryHistory: [], + retryEpoch: 0, + lastOutcome: 'dispatch_no_mutation', + lastTransitionId: 'issue-2763-malformed', + updatedAt: '2026-09-14T00:00:00.000Z', + noMutationSettlement: { + generation: 0, + transitionId: 'issue-2763-malformed', + declaredFiles: 'not-an-array', + }, + }, + }), + ); + + const status = await gateStatus(directory); + expect(status.status).toBe('incomplete'); + expect(status.required_gates).toEqual(['pre_check']); + expect(status.missing_gates).toContain('pre_check'); + }); + + test('keeps a committed empty-scope settlement unchanged when retried after an advisory gate', async () => { + const transitionId = 'issue-2763-settlement-retry'; + await settleNoMutation(directory, [], transitionId); + await transitionTaskWorkflowEvidence(directory, TASK_ID, { + type: 'gate_recorded', + gate: 'critic', + sessionId: 'critic-session', + expectedGeneration: 0, + transitionId: 'issue-2763-retry-critic', + }); + + const beforeRetry = readTaskEvidenceRaw(directory, TASK_ID); + const beforeWorkflow = getTaskWorkflowSnapshot(beforeRetry); + const retry = await settleCoderDispatch({ + directory, + taskId: TASK_ID, + transitionId, + accepted: false, + testEngineerExempt: false, + }); + const afterRetry = readTaskEvidenceRaw(directory, TASK_ID); + const afterWorkflow = getTaskWorkflowSnapshot(afterRetry); + + // Before the hardening, replaying a COMMITTED settlement after an advisory + // gate could reapply the transition and erase the advisory requirement. + expect(retry.alreadyApplied).toBe(true); + expect(afterRetry?.required_gates).toEqual(beforeRetry?.required_gates); + expect(afterRetry?.gates).toHaveProperty('critic'); + expect(afterWorkflow.retryCount).toBe(beforeWorkflow.retryCount); + expect(afterWorkflow.noMutationSettlement).toEqual( + beforeWorkflow.noMutationSettlement, + ); + }); + + test('keeps the secretscan overlay authoritative for a proven empty-scope task', async () => { + await withFrozenClockAsync( + async () => { + await settleNoMutation(directory, [], 'issue-2763-secretscan'); + const bundleDirectory = path.join( + directory, + '.swarm', + 'evidence', + TASK_ID, + ); + fs.mkdirSync(bundleDirectory, { recursive: true }); + fs.writeFileSync( + path.join(bundleDirectory, 'evidence.json'), + JSON.stringify({ + schema_version: '1.0.0', + task_id: TASK_ID, + entries: [ + { + task_id: TASK_ID, + type: 'secretscan', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'pre_check_batch', + verdict: 'fail', + summary: 'secret found', + findings_count: 1, + scan_directory: 'src', + files_scanned: 1, + skipped_files: 0, + incomplete_files: 0, + incomplete_paths: [], + }, + ], + created_at: '2026-09-14T00:00:00.000Z', + updated_at: '2026-09-14T00:00:00.000Z', + }), + ); + + const status = await gateStatus(directory); + expect(status.required_gates).toEqual([]); + expect(status.status).toBe('incomplete'); + expect(status.missing_gates).toContain( + 'secretscan (BLOCKED — secrets detected)', + ); + }, + { fixedNow: 1_767_225_600_000, isoNow: '2026-01-01T00:00:00.000Z' }, + ); + }); + + test('does not grant the exception to plan scope alone or malformed settlement scope', async () => { + const cases: Array<{ label: string; scope?: unknown }> = [ + { label: 'without-settlement' }, + { label: 'null-settlement', scope: null }, + { label: 'non-empty-settlement', scope: ['src/changed.ts'] }, + { label: 'malformed-settlement', scope: 'not-an-array' }, + ]; + + for (const [index, candidate] of cases.entries()) { + resetSwarmState(); + cleanup(); + ({ dir: directory, cleanup } = createSafeTestDir( + `empty-scope-2763-${index}-`, + )); + writePlan(directory, []); + if (candidate.label !== 'without-settlement') { + if (candidate.label === 'malformed-settlement') { + await expect( + settleNoMutation( + directory, + candidate.scope as string[] | null, + `issue-2763-${candidate.label}`, + ), + ).rejects.toThrow('CODER_SETTLEMENT_WAL_UNREADABLE'); + continue; + } + await settleNoMutation( + directory, + candidate.scope as string[] | null, + `issue-2763-${candidate.label}`, + ); + } + const result = await executeUpdateTaskStatus( + { task_id: TASK_ID, status: 'completed', working_directory: directory }, + directory, + ); + expect(result.success, candidate.label).toBe(false); + } + }); + + test('keeps accepted mutation and stale Stage A on the ordinary pre_check path', async () => { + await settleNoMutation(directory, [], 'issue-2763-mutation'); + await transitionTaskWorkflowEvidence(directory, TASK_ID, { + type: 'accepted_mutation', + agentType: 'coder', + context: { declaredFiles: [], testEngineerExempt: false }, + expectedGeneration: 0, + transitionId: 'issue-2763-accepted-mutation', + }); + const decision = checkReviewerGate( + TASK_ID, + directory, + false, + 'session', + directory, + ); + expect(decision.blocked).toBe(true); + expect(decision.missingGates).toContain('pre_check'); + + await transitionTaskWorkflowEvidence(directory, TASK_ID, { + type: 'stage_a_passed', + expectedGeneration: 1, + transitionId: 'issue-2763-stale-stage-a', + }); + await transitionTaskWorkflowEvidence(directory, TASK_ID, { + type: 'accepted_mutation', + agentType: 'coder', + context: { declaredFiles: [], testEngineerExempt: false }, + expectedGeneration: 1, + transitionId: 'issue-2763-second-mutation', + }); + const afterMutation = checkReviewerGate( + TASK_ID, + directory, + false, + 'session', + directory, + ); + expect(afterMutation.blocked).toBe(true); + expect(afterMutation.missingGates).toContain('pre_check'); + }); +}); diff --git a/tests/unit/tools/update-task-status-lean-turbo.test.ts b/tests/unit/tools/update-task-status-lean-turbo.test.ts index ed5715070..0061f370c 100644 --- a/tests/unit/tools/update-task-status-lean-turbo.test.ts +++ b/tests/unit/tools/update-task-status-lean-turbo.test.ts @@ -366,6 +366,20 @@ describe('Lean Turbo integration — checkReviewerGate', () => { expect(result.blocked).toBe(false); expect(result.reason).toBe('Turbo Mode bypass'); }); + + it('does not bypass an explicitly empty file scope', () => { + const taskId = '4.2'; + + writeFileSync( + path.join(tmpDir, '.swarm', 'plan.json'), + makePlanJson([{ id: taskId, files_touched: [] }]), + ); + createStandardTurboSession('session-std-empty'); + + const result = checkReviewerGate(taskId, tmpDir); + expect(result.blocked).toBe(true); + expect(result.reason).not.toBe('Turbo Mode bypass'); + }); }); // ------------------------------------------------------------------------- diff --git a/tests/unit/tools/working-directory-override.test.ts b/tests/unit/tools/working-directory-override.test.ts index 4461cbdee..c2f8fe673 100644 --- a/tests/unit/tools/working-directory-override.test.ts +++ b/tests/unit/tools/working-directory-override.test.ts @@ -43,8 +43,13 @@ describe('working_directory override — check_gate_status', () => { path.join(evidenceDir, '1.6.json'), JSON.stringify({ taskId: '1.6', - required_gates: ['reviewer', 'test_engineer'], + required_gates: ['pre_check', 'reviewer', 'test_engineer'], gates: { + pre_check: { + sessionId: 'pre-check-session', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'pre_check_batch', + }, reviewer: { sessionId: 'test-session', timestamp: new Date().toISOString(), @@ -56,6 +61,7 @@ describe('working_directory override — check_gate_status', () => { agent: 'test_engineer', }, }, + workflow: { state: 'tests_run', generation: 1 }, }), 'utf-8', ); diff --git a/tests/unit/workflow/task-terminal-read-only-2763.test.ts b/tests/unit/workflow/task-terminal-read-only-2763.test.ts new file mode 100644 index 000000000..9548dae23 --- /dev/null +++ b/tests/unit/workflow/task-terminal-read-only-2763.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { BackgroundTaskChangeContext } from '../../../src/background/pending-delegations'; +import type { Plan } from '../../../src/config/plan-schema'; +import { + getTaskWorkflowSnapshot, + readTaskEvidenceRaw, +} from '../../../src/gate-evidence'; +import { getOrAdoptPlanEpochUnderLock } from '../../../src/plan/ledger'; +import { loadPlanJsonOnly, savePlan } from '../../../src/plan/manager'; +import { resetSwarmState } from '../../../src/state'; +import { + beginCoderSettlement, + settleCoderDispatch, +} from '../../../src/workflow/coder-settlement'; +import { recoverPreparedTaskTerminal } from '../../../src/workflow/task-terminal'; +import { writeWorkflowWalFile } from '../../../src/workflow/workflow-wal-file'; +import { + parseTaskTerminalWal, + type TaskTerminalWal, +} from '../../../src/workflow/workflow-wal-schema'; +import { createSafeTestDir } from '../../helpers/safe-test-dir'; +import { canonicalTmpDir } from '../../helpers/tmpdir'; + +const TASK_ID = '1.1'; +const TERMINAL_PATH = path.join( + canonicalTmpDir(), + 'task-terminals', + '1.1.json', +); + +const LEGACY_TERMINAL_WAL = { + version: 1, + state: 'COMMITTED', + taskId: TASK_ID, + transitionId: 'legacy-terminal', + actor: 'legacy-test', + oldPlanStatus: 'in_progress', + newPlanStatus: 'completed', + oldWorkflowState: 'tests_run', + newWorkflowState: 'complete', + generation: 4, + qaExempt: false, + recordedAt: '2026-01-01T00:00:00.000Z', +}; + +function runGit(directory: string, args: string[], capture = false): string { + const result = spawnSync('git', ['-C', directory, ...args], { + cwd: directory, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'ignore', + timeout: 5000, + windowsHide: true, + }); + if (result.status !== 0) + throw new Error(`fixture git failed: ${args.join(' ')}`); + return capture ? String(result.stdout).trim() : ''; +} + +function fixturePlan(): Plan { + return { + schema_version: '1.0.0', + title: 'Issue 2763 terminal fixture', + swarm: 'issue-2763', + current_phase: 1, + phases: [ + { + id: 1, + name: 'Phase 1', + status: 'in_progress', + tasks: [ + { + id: TASK_ID, + phase: 1, + status: 'in_progress', + size: 'small', + description: 'No-mutation terminal replay', + depends: [], + files_touched: [], + }, + ], + }, + ], + }; +} + +async function seedPreparedReadOnlyTerminal( + directory: string, +): Promise { + fs.mkdirSync(path.join(directory, '.swarm'), { recursive: true }); + fs.mkdirSync(path.join(directory, '.opencode'), { recursive: true }); + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'issue-2763@example.invalid']); + runGit(directory, ['config', 'user.name', 'Issue 2763 test']); + fs.writeFileSync(path.join(directory, '.opencode', 'marker'), 'fixture'); + runGit(directory, ['add', '.']); + runGit(directory, ['commit', '--quiet', '-m', 'issue 2763 terminal fixture']); + await savePlan(directory, fixturePlan()); + + const context = { + declaredFiles: [], + baseline: { + directory, + gitHead: runGit(directory, ['rev-parse', 'HEAD'], true), + dirtyHash: null, + changedFiles: [], + prHeadSha: null, + scope: null, + }, + workflowGeneration: 0, + } as BackgroundTaskChangeContext; + await beginCoderSettlement({ + directory, + taskId: TASK_ID, + transitionId: 'issue-2763-terminal-settlement', + actor: 'issue-2763-terminal-test', + expectedGeneration: 0, + context, + }); + await settleCoderDispatch({ + directory, + taskId: TASK_ID, + transitionId: 'issue-2763-terminal-settlement', + accepted: false, + testEngineerExempt: false, + }); + + const plan = await loadPlanJsonOnly(directory); + if (!plan) throw new Error('terminal fixture plan missing'); + const identity = await getOrAdoptPlanEpochUnderLock(directory, plan); + const wal = { + version: 2, + state: 'PREPARED', + taskId: TASK_ID, + transitionId: 'issue-2763-terminal-replay', + actor: 'issue-2763-terminal-test', + oldPlanStatus: 'in_progress', + newPlanStatus: 'completed', + oldWorkflowState: 'idle', + newWorkflowState: 'complete', + generation: 0, + qaExempt: false, + readOnlyNoMutation: true, + recordedAt: '2026-09-14T00:00:00.000Z', + planIdentityHash: identity.planIdentityHash, + planEpoch: identity.planEpoch, + } as unknown as TaskTerminalWal; + const walPath = path.join( + directory, + '.swarm', + 'task-terminals', + `${TASK_ID}.json`, + ); + await writeWorkflowWalFile('task-terminal', walPath, wal); + return walPath; +} + +describe('issue #2763 — read-only terminal WAL', () => { + let directory: string; + let cleanup: () => void; + + beforeEach(() => { + resetSwarmState(); + ({ dir: directory, cleanup } = createSafeTestDir( + 'task-terminal-read-only-2763-', + )); + }); + + afterEach(() => { + resetSwarmState(); + cleanup(); + }); + + test('keeps old terminal WALs backward-compatible when the optional field is absent', () => { + const parsed = parseTaskTerminalWal( + JSON.stringify(LEGACY_TERMINAL_WAL), + TERMINAL_PATH, + TASK_ID, + ); + expect(parsed.version).toBe(1); + expect(parsed.newPlanStatus).toBe('completed'); + expect( + (parsed as TaskTerminalWal & { readOnlyNoMutation?: boolean }) + .readOnlyNoMutation, + ).toBeUndefined(); + }); + + test('rejects malformed or contradictory read-only terminal combinations', () => { + const valid = { + ...LEGACY_TERMINAL_WAL, + version: 2, + planIdentityHash: 'a'.repeat(64), + planEpoch: '11111111-1111-4111-8111-111111111111', + readOnlyNoMutation: true, + }; + for (const candidate of [ + { ...valid, readOnlyNoMutation: 'true' }, + { ...valid, newPlanStatus: 'blocked', newWorkflowState: 'blocked' }, + { ...valid, qaExempt: true }, + { ...valid, generation: 1 }, + ]) { + expect(() => + parseTaskTerminalWal(JSON.stringify(candidate), TERMINAL_PATH, TASK_ID), + ).toThrow('TASK_TERMINAL_WAL'); + } + }); + + test('replays a PREPARED read-only completion deterministically', async () => { + const walPath = await seedPreparedReadOnlyTerminal(directory); + const replay = await recoverPreparedTaskTerminal( + directory, + TASK_ID, + 'issue-2763-terminal-recovery', + ); + + expect(replay?.targetStatus).toBe('completed'); + expect( + (await loadPlanJsonOnly(directory))?.phases[0]?.tasks[0]?.status, + ).toBe('completed'); + const evidence = readTaskEvidenceRaw(directory, TASK_ID); + expect(getTaskWorkflowSnapshot(evidence)).toMatchObject({ + state: 'complete', + generation: 0, + }); + expect(evidence?.workflow?.qaExempt).not.toBe(true); + expect(evidence?.workflow?.forcedCompletion).not.toBe(true); + expect(JSON.parse(fs.readFileSync(walPath, 'utf8')).state).toBe( + 'COMMITTED', + ); + }); +}); From d1183eec4112de07a0e50a8ef8c9fae46ed81f55 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 16:13:40 -0500 Subject: [PATCH 02/11] fix(workflow): fence empty-scope settlement Keep explicit empty plan preflight fail-closed while recording a trusted no-mutation proof only after a clean baseline confirms no child ran. Preserve authoritative FILE scopes, clear stale proofs on new dispatches, and classify raw empty-scope mutations as failed rework. Review findings from PR #2776 are covered by production-path and mutation regression tests. --- .../issue-2763-empty-scope-completion.md | 4 +- src/gate-evidence.ts | 10 +- src/hooks/delegation-gate.ts | 203 ++++++++++++++++-- src/workflow/coder-settlement.ts | 63 ++++-- .../delegation-gate-empty-scope-2763.test.ts | 149 +++++++++++++ .../tools/empty-scope-completion-2763.test.ts | 60 ++++++ 6 files changed, 457 insertions(+), 32 deletions(-) create mode 100644 tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts diff --git a/docs/releases/pending/issue-2763-empty-scope-completion.md b/docs/releases/pending/issue-2763-empty-scope-completion.md index 1c924299f..17402344f 100644 --- a/docs/releases/pending/issue-2763-empty-scope-completion.md +++ b/docs/releases/pending/issue-2763-empty-scope-completion.md @@ -1,5 +1,7 @@ # Empty-scope task completion -Verification-only tasks that explicitly declare `files_touched: []` can now complete when the trusted coder settlement proves that no mutation was accepted. The completion and read-only gate-status paths use the same durable evidence, preserve independent advisory gates, and keep ordinary or malformed scopes fail-closed. +Verification-only tasks that explicitly declare `files_touched: []` can now complete when the trusted coder settlement proves that no mutation was accepted. The completion and read-only gate-status paths use the same durable evidence, preserve independent advisory gates, and keep ordinary or malformed scopes fail-closed. A rejected empty-scope coder preflight records this proof only after a clean baseline confirms that no child ran; a complete `FILE:` directive remains the authoritative non-empty scope. No configuration or migration is required. Existing tasks and terminal WAL records remain backward-compatible; only an authoritative empty-scope/no-mutation settlement may use the new path. + +Raw workspace mutations observed against an empty declared scope are treated as failed mutations requiring rework rather than as no-mutation proof. diff --git a/src/gate-evidence.ts b/src/gate-evidence.ts index 7904938b5..ed925cd0e 100644 --- a/src/gate-evidence.ts +++ b/src/gate-evidence.ts @@ -732,8 +732,14 @@ export function reduceTaskWorkflowSnapshot( }; switch (event.type) { - case 'dispatch_attempted': - return base; + case 'dispatch_attempted': { + // A new coder dispatch opens a fresh attribution window. A prior + // generation-0 no-mutation proof must not survive it, even when the + // later dispatch is aborted before it can publish its own settlement. + const { noMutationSettlement: _clearedSettlement, ...withoutSettlement } = + base; + return withoutSettlement; + } case 'dispatch_no_mutation': { const { noMutationSettlement: _clearedSettlement, ...withoutSettlement } = base; diff --git a/src/hooks/delegation-gate.ts b/src/hooks/delegation-gate.ts index e54e6dc18..8dfff1416 100644 --- a/src/hooks/delegation-gate.ts +++ b/src/hooks/delegation-gate.ts @@ -141,6 +141,7 @@ import { abortCoderSettlementIfDoomed, beginCoderSettlement, completeCoderSettlementCleanup, + hasUnattributedEmptyScopeMutation, recordCoderMergeProvenance, recoverCoderSettlement, releaseCoderDispatchOwnership, @@ -457,10 +458,9 @@ async function prepareCoderScope( } const explicitBinding = explicitResolution.status === 'found' ? explicitResolution.binding : null; - const declaredFiles = declaredFilesForTask; const resolved = resolveCoderScopeSources({ explicitFiles: explicitBinding?.files, - planFiles: declaredFiles, + planFiles: declaredFilesForTask, fileDirectiveFiles: directives.files, }); if (!resolved.ok) { @@ -482,7 +482,17 @@ async function prepareCoderScope( 'SCOPE_NOT_DECLARED: coder scope could not be bound to this Task invocation.', ); } - return { kind: 'plan', plan, taskId, declaredFiles, binding }; + return { + kind: 'plan', + plan, + taskId, + // Use the same authoritative scope that was bound for this call. When + // an empty plan scope is supplemented by a FILE directive, retaining the + // empty plan array here would incorrectly mint a read-only settlement + // proof for a non-empty coder scope. + declaredFiles: resolved.files, + binding, + }; } /** @@ -2238,6 +2248,124 @@ function getPlanTaskDeclaredFiles( return null; } +const MAX_RAW_PLAN_SCOPE_INSPECTION_BYTES = 4 * 1024 * 1024; + +/** + * PlanSchema defaults an omitted files_touched field to [], so the parsed plan + * cannot distinguish an explicit empty declaration from an older task that + * omitted the field. The distinction is needed only on the rejected empty + * scope preflight path; keep the raw read bounded and fail closed on any read + * or parse ambiguity. + */ +async function hasExplicitEmptyPlanScope( + directory: string, + taskId: string, +): Promise { + const planPath = path.join(directory, '.swarm', 'plan.json'); + let handle: fs.promises.FileHandle | undefined; + try { + handle = await fs.promises.open(planPath, 'r'); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > MAX_RAW_PLAN_SCOPE_INSPECTION_BYTES) { + return false; + } + const bytes = Buffer.alloc(stat.size); + let offset = 0; + while (offset < bytes.length) { + const result = await handle.read( + bytes, + offset, + bytes.length - offset, + offset, + ); + if (result.bytesRead === 0) return false; + offset += result.bytesRead; + } + const parsed = JSON.parse(bytes.toString('utf8')) as { + phases?: unknown; + }; + if (!Array.isArray(parsed.phases)) return false; + for (const phase of parsed.phases) { + if (!phase || typeof phase !== 'object') continue; + const tasks = (phase as { tasks?: unknown }).tasks; + if (!Array.isArray(tasks)) continue; + for (const task of tasks) { + if (!task || typeof task !== 'object') continue; + const record = task as { + id?: unknown; + files_touched?: unknown; + }; + if ( + record.id === taskId && + Object.hasOwn(record, 'files_touched') && + Array.isArray(record.files_touched) && + record.files_touched.length === 0 + ) { + return true; + } + } + } + } catch { + return false; + } finally { + await handle?.close().catch(() => undefined); + } + return false; +} + +async function settleRejectedEmptyPlanScope( + directory: string, + input: { callID: string; sessionID: string }, + taskId: string, + expectedGeneration: number, +): Promise> | null> { + const baseline = await captureWorkspaceSnapshotAsync(directory); + if ( + baseline.gitHead === null || + !Array.isArray(baseline.changedFiles) || + baseline.changedFiles.length !== 0 + ) { + return null; + } + const transitionId = `coder-preflight:${input.callID}`; + try { + await beginCoderSettlement({ + directory, + taskId, + transitionId, + actor: input.sessionID, + expectedGeneration, + context: { + baseline, + declaredFiles: [], + workflowGeneration: expectedGeneration, + }, + }); + return await settleCoderDispatch({ + directory, + taskId, + transitionId, + accepted: false, + testEngineerExempt: false, + observedFiles: [], + }); + } catch (error) { + try { + await abortCoderSettlement({ + directory, + taskId, + transitionId, + reason: `empty-scope preflight settlement failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } catch (abortError) { + logger.criticalWarn( + `[delegation-gate] failed to abort empty-scope preflight settlement for ${taskId}: ${abortError instanceof Error ? abortError.message : String(abortError)}`, + ); + } + throw error; + } +} + /** * Builds a cause-specific diagnostic for `prepareCoderScope`'s SCOPE_NOT_DECLARED * throw when `resolveDelegatedPlanTaskId` returns null. Re-runs extraction with @@ -4586,17 +4714,41 @@ export function createDelegationGateHook( preparedScope = await prepareCoderScope(directory, input, args); } catch (error) { if (preflightTaskId) { - const failed = await transitionTaskWorkflowEvidence( - directory, - preflightTaskId, - { - type: 'dispatch_no_mutation', - agentType: 'coder', - expectedGeneration: preflightWorkflow.generation, - transitionId: `coder-preflight:${input.callID}`, - }, + const transitionId = `coder-preflight:${input.callID}`; + const emptyScopePreflightFailure = + error instanceof Error && + error.message.includes( + 'coder delegation has no complete, valid, non-empty scope.', + ); + const explicitEmptyPlanScope = + emptyScopePreflightFailure && + !extractTaskFileDirectives(args).present && + (await hasExplicitEmptyPlanScope(directory, preflightTaskId)); + let settled: Awaited> | null = + null; + if (explicitEmptyPlanScope && preflightWorkflow.generation === 0) { + try { + settled = await settleRejectedEmptyPlanScope( + directory, + input, + preflightTaskId, + preflightWorkflow.generation, + ); + } catch (settlementError) { + logger.criticalWarn( + `[delegation-gate] empty-scope preflight settlement failed for ${preflightTaskId}: ${settlementError instanceof Error ? settlementError.message : String(settlementError)}`, + ); + } + } + const failedWorkflow = getTaskWorkflowSnapshot( + settled?.evidence ?? + (await transitionTaskWorkflowEvidence(directory, preflightTaskId, { + type: 'dispatch_no_mutation', + agentType: 'coder', + expectedGeneration: preflightWorkflow.generation, + transitionId, + })), ); - const failedWorkflow = getTaskWorkflowSnapshot(failed); if (failedWorkflow.retryCount >= 3) { await emitCoderRetryEscalation(directory, { taskId: preflightTaskId, @@ -5710,12 +5862,20 @@ export function createDelegationGateHook( observedFiles: observedForMerge, }), onMerged: async (merged) => { + const unattributedEmptyScopeMutation = + hasUnattributedEmptyScopeMutation( + coderTaskChangeContextByCallID.get(input.callID) + ?.declaredFiles, + observedForMerge, + ); const result = await settleCoderDispatch({ directory, taskId: standardDispatch.planTaskId ?? standardDispatch.taskId, transitionId: `coder:${input.callID}`, - accepted: observedForMerge.length > 0, + accepted: + observedForMerge.length > 0 || + unattributedEmptyScopeMutation, testEngineerExempt: isMarkdownOnlyTaskChange( coderTaskChangeContextByCallID.get(input.callID) ?.declaredFiles, @@ -5725,6 +5885,8 @@ export function createDelegationGateHook( // later completeCoderSettlementCleanup pass retains // the lane branch instead of deleting it as residue. landedUnstaged: merged.strategy === 'squash-unstaged', + observedFiles: observedForMerge, + settlementFailed: unattributedEmptyScopeMutation, }); coderSettlementEvidence = result.evidence; coderSettlementCommitted = true; @@ -6302,6 +6464,11 @@ export function createDelegationGateHook( ), ) : []; + const unattributedEmptyScopeMutation = + hasUnattributedEmptyScopeMutation( + taskChangeContext?.declaredFiles, + rawObservedFiles, + ); const context = { testEngineerExempt: targetAgentForEvidence === 'coder' && @@ -6315,7 +6482,8 @@ export function createDelegationGateHook( (!standardDispatch || !isTerminalFailure) && standardWorktreeSettled && Array.isArray(observedFiles) && - observedFiles.length > 0; + (observedFiles.length > 0 || + unattributedEmptyScopeMutation); let updated: Awaited< ReturnType >['evidence']; @@ -6333,7 +6501,10 @@ export function createDelegationGateHook( transitionId: `coder:${input.callID}`, accepted, testEngineerExempt: context.testEngineerExempt === true, - settlementFailed: !standardDispatch && isTerminalFailure, + settlementFailed: + (!standardDispatch && isTerminalFailure) || + unattributedEmptyScopeMutation, + observedFiles: rawObservedFiles, }); updated = settlement.evidence; coderSettlementCommitted = true; diff --git a/src/workflow/coder-settlement.ts b/src/workflow/coder-settlement.ts index c5330ebc4..5a8b7db8f 100644 --- a/src/workflow/coder-settlement.ts +++ b/src/workflow/coder-settlement.ts @@ -165,6 +165,23 @@ function baselineAttributionDoomed(baseline: { ); } +/** + * An empty declared scope cannot distinguish a coder mutation from an + * out-of-scope workspace change. Such a change must not be reduced to the + * empty scoped observation that powers the generation-0 read-only exception. + */ +export function hasUnattributedEmptyScopeMutation( + declaredFiles: readonly string[] | null | undefined, + rawObservedFiles: readonly string[] | null | undefined, +): boolean { + return ( + Array.isArray(declaredFiles) && + declaredFiles.length === 0 && + Array.isArray(rawObservedFiles) && + rawObservedFiles.length > 0 + ); +} + function doomedReason(baseline: { gitHead: string | null; changedFiles?: string[] | null; @@ -196,6 +213,9 @@ async function scopedObservedFiles( const baseline = { ...context.baseline, directory }; const observed = await changedFilesSinceSnapshotAsync(directory, baseline); if (!observed || !context.declaredFiles) return null; + if (hasUnattributedEmptyScopeMutation(context.declaredFiles, observed)) { + return null; + } return observed.filter((filePath) => isPathWithinDeclaredScope(filePath, context.declaredFiles ?? [], directory), ); @@ -481,6 +501,8 @@ export async function settleCoderDispatch(options: { accepted: boolean; testEngineerExempt: boolean; settlementFailed?: boolean; + /** Raw workspace changes used to prevent empty-scope no-mutation proofs. */ + observedFiles?: readonly string[] | null; /** #2508: landed via squash-unstaged — retain the lane branch at cleanup. */ landedUnstaged?: boolean; }): Promise { @@ -496,11 +518,18 @@ export async function settleCoderDispatch(options: { if (wal.transitionId !== options.transitionId) { throw new Error('CODER_SETTLEMENT_WAL_REPLACED'); } + const unattributedEmptyScopeMutation = hasUnattributedEmptyScopeMutation( + wal.context.declaredFiles, + options.observedFiles, + ); + const accepted = options.accepted || unattributedEmptyScopeMutation; + const settlementFailed = + options.settlementFailed === true || unattributedEmptyScopeMutation; if (wal.state === 'COMMITTED') { if ( - wal.accepted !== options.accepted || + wal.accepted !== accepted || wal.testEngineerExempt !== options.testEngineerExempt || - wal.settlementFailed !== (options.settlementFailed === true) + wal.settlementFailed !== settlementFailed ) { throw new Error('CODER_SETTLEMENT_IDEMPOTENCY_CONFLICT'); } @@ -513,18 +542,18 @@ export async function settleCoderDispatch(options: { } if ( wal.state === 'PREPARED' && - (wal.accepted !== options.accepted || + (wal.accepted !== accepted || wal.testEngineerExempt !== options.testEngineerExempt || - wal.settlementFailed !== (options.settlementFailed === true)) + wal.settlementFailed !== settlementFailed) ) { throw new Error('CODER_SETTLEMENT_IDEMPOTENCY_CONFLICT'); } const prepared: CoderSettlementWal = { ...wal, state: 'PREPARED', - accepted: options.accepted, + accepted, testEngineerExempt: options.testEngineerExempt, - settlementFailed: options.settlementFailed === true, + settlementFailed, }; if (wal.state !== 'PREPARED') await writeWal(filePath, prepared); liveDispatches.delete( @@ -1182,6 +1211,10 @@ export async function recoverCoderSettlement( directory, wal.context.baseline, ); + const unattributedEmptyScopeMutation = hasUnattributedEmptyScopeMutation( + wal.context.declaredFiles, + rawObserved, + ); let observed: string[] | null; if (rawObserved === null) { if (baselineAttributionDoomed(wal.context.baseline)) { @@ -1210,13 +1243,15 @@ export async function recoverCoderSettlement( } else if (wal.context.declaredFiles === null) { observed = []; } else { - observed = rawObserved.filter((filePath) => - isPathWithinDeclaredScope( - filePath, - wal.context.declaredFiles ?? [], - directory, - ), - ); + observed = unattributedEmptyScopeMutation + ? rawObserved + : rawObserved.filter((filePath) => + isPathWithinDeclaredScope( + filePath, + wal.context.declaredFiles ?? [], + directory, + ), + ); } if (observed === null) { throw new Error( @@ -1227,6 +1262,8 @@ export async function recoverCoderSettlement( ...wal, state: 'PREPARED', accepted: observed.length > 0, + settlementFailed: + wal.settlementFailed === true || unattributedEmptyScopeMutation, testEngineerExempt: isMarkdownOnlyTaskChange( wal.context.declaredFiles, observed, diff --git a/tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts b/tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts new file mode 100644 index 000000000..dc2b7645d --- /dev/null +++ b/tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { PluginConfig } from '../../../src/config'; +import { + getTaskWorkflowSnapshot, + readTaskEvidence, +} from '../../../src/gate-evidence'; +import { createDelegationGateHook } from '../../../src/hooks/delegation-gate'; +import { ensureAgentSession, resetSwarmState } from '../../../src/state'; +import { writeApprovedPlan } from '../../helpers/approved-plan'; +import { createSafeTestDir } from '../../helpers/safe-test-dir'; + +const TASK_ID = '1.1'; +const config = { + max_iterations: 5, + qa_retry_limit: 3, + hooks: { delegation_gate: true }, + worktree: { policy: 'disabled' }, +} as PluginConfig; + +function runGit(directory: string, args: string[], capture = false): string { + const result = spawnSync('git', ['-C', directory, ...args], { + cwd: directory, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'ignore', + stdin: 'ignore', + timeout: 5000, + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error(`fixture git command failed: ${args.join(' ')}`); + } + return capture ? String(result.stdout).trim() : ''; +} + +describe('issue #2763 — delegation gate empty-scope admission', () => { + let directory = ''; + let cleanup = (): void => {}; + + beforeEach( + async () => { + resetSwarmState(); + ({ dir: directory, cleanup } = createSafeTestDir( + 'delegation-empty-2763-', + )); + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'issue-2763@example.invalid']); + runGit(directory, ['config', 'user.name', 'Issue 2763 test']); + fs.mkdirSync(path.join(directory, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(directory, 'src', 'feature.ts'), + 'export const feature = 1;\n', + ); + runGit(directory, ['add', 'src/feature.ts']); + runGit(directory, ['commit', '--quiet', '-m', 'issue 2763 fixture']); + fs.appendFileSync( + path.join(directory, '.git', 'info', 'exclude'), + '\n.swarm/\n', + ); + await writeApprovedPlan(directory, [{ id: TASK_ID, files: [] }]); + const session = ensureAgentSession('parent', 'architect', directory); + session.currentTaskId = TASK_ID; + }, + { timeout: 30_000 }, + ); + + afterEach( + () => { + resetSwarmState(); + cleanup(); + }, + { timeout: 30_000 }, + ); + + test( + 'uses a complete FILE directive as the coder scope when the plan is empty', + async () => { + const hook = createDelegationGateHook(config, directory); + const args = { + subagent_type: 'coder', + task_id: TASK_ID, + prompt: + 'TASK: 1.1\nFILE: src/feature.ts\nACCEPTANCE: verify the implementation', + }; + await hook.toolBefore( + { tool: 'Task', sessionID: 'parent', callID: 'empty-plan-file' }, + { args }, + ); + fs.mkdirSync(path.join(directory, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(directory, 'src', 'feature.ts'), + 'export const feature = 9;\n', + ); + await hook.toolAfter( + { + tool: 'Task', + sessionID: 'parent', + callID: 'empty-plan-file', + args, + }, + { state: 'completed', output: 'verified' }, + ); + + const evidence = await readTaskEvidence(directory, TASK_ID); + expect(getTaskWorkflowSnapshot(evidence)).toMatchObject({ + state: 'coder_delegated', + generation: 1, + lastOutcome: 'accepted_mutation', + }); + expect(evidence?.workflow?.noMutationSettlement).toBeUndefined(); + expect(evidence?.required_gates).toEqual(['reviewer', 'test_engineer']); + }, + { timeout: 30_000 }, + ); + + test( + 'records a trusted no-mutation proof while rejecting an explicit empty plan dispatch', + async () => { + const hook = createDelegationGateHook(config, directory); + const args = { + subagent_type: 'coder', + task_id: TASK_ID, + prompt: 'TASK: 1.1\nACCEPTANCE: verify no code change is required', + }; + + await expect( + hook.toolBefore( + { tool: 'Task', sessionID: 'parent', callID: 'empty-plan-rejected' }, + { args }, + ), + ).rejects.toThrow('SCOPE_NOT_DECLARED'); + + const evidence = await readTaskEvidence(directory, TASK_ID); + expect(getTaskWorkflowSnapshot(evidence)).toMatchObject({ + state: 'idle', + generation: 0, + lastOutcome: 'dispatch_no_mutation', + }); + expect(evidence?.workflow?.noMutationSettlement).toMatchObject({ + generation: 0, + transitionId: 'coder-preflight:empty-plan-rejected', + declaredFiles: [], + }); + }, + { timeout: 30_000 }, + ); +}); diff --git a/tests/unit/tools/empty-scope-completion-2763.test.ts b/tests/unit/tools/empty-scope-completion-2763.test.ts index 9c68d4c7d..375c7aaaa 100644 --- a/tests/unit/tools/empty-scope-completion-2763.test.ts +++ b/tests/unit/tools/empty-scope-completion-2763.test.ts @@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import type { BackgroundTaskChangeContext } from '../../../src/background/pending-delegations'; +import { changedFilesSinceSnapshotAsync } from '../../../src/background/workspace-snapshot'; import { getTaskWorkflowSnapshot, readTaskEvidenceRaw, @@ -281,6 +282,65 @@ describe('issue #2763 — empty-scope read-only completion', () => { ); }); + test('does not treat an actual empty-scope mutation as a no-mutation settlement', async () => { + const transitionId = 'issue-2763-out-of-scope-mutation'; + const context = makeContext(directory, []); + await beginCoderSettlement({ + directory, + taskId: TASK_ID, + transitionId, + actor: 'issue-2763-test', + expectedGeneration: 0, + context, + }); + // Before the fix, the empty declared scope filtered this real workspace + // change away before settlement, allowing read-only completion. + fs.mkdirSync(path.join(directory, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(directory, 'src', 'undeclared.ts'), + 'export const changed = true;\n', + ); + const observedFiles = await changedFilesSinceSnapshotAsync( + directory, + context.baseline, + ); + expect(observedFiles).toContain('src/undeclared.ts'); + + const settlement = await settleCoderDispatch({ + directory, + taskId: TASK_ID, + transitionId, + accepted: false, + testEngineerExempt: false, + observedFiles, + }); + expect(settlement.accepted).toBe(true); + expect(getTaskWorkflowSnapshot(settlement.evidence)).toMatchObject({ + state: 'rework_required', + generation: 1, + lastOutcome: 'accepted_mutation_failed', + }); + expect(settlement.evidence.workflow?.noMutationSettlement).toBeUndefined(); + }); + + test('clears a prior read-only proof when a new dispatch starts', async () => { + await settleNoMutation(directory, [], 'issue-2763-proof-reset'); + await transitionTaskWorkflowEvidence(directory, TASK_ID, { + type: 'dispatch_attempted', + agentType: 'coder', + expectedGeneration: 0, + transitionId: 'issue-2763-new-dispatch', + }); + + const evidence = readTaskEvidenceRaw(directory, TASK_ID); + expect(evidence?.workflow?.noMutationSettlement).toBeUndefined(); + expect(getTaskWorkflowSnapshot(evidence)).toMatchObject({ + state: 'idle', + generation: 0, + lastOutcome: 'dispatch_attempted', + }); + }); + test('keeps the secretscan overlay authoritative for a proven empty-scope task', async () => { await withFrozenClockAsync( async () => { From 0d9f4d834d7a9298f67f13d54765d42d15ddebcc Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 16:39:06 -0500 Subject: [PATCH 03/11] fix(workflow): fence empty-scope settlement Refresh retention citations after the empty-scope settlement hardening. Issue: #2763 --- scripts/retention-registry.data.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/retention-registry.data.ts b/scripts/retention-registry.data.ts index ba8e0517f..3dbc82a49 100644 --- a/scripts/retention-registry.data.ts +++ b/scripts/retention-registry.data.ts @@ -1675,11 +1675,11 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ canonicalRoot: 'project-swarm', writerModules: ['src/gate-evidence.ts', 'src/council/council-evidence-writer.ts'], writerCitations: [ - 'src/gate-evidence.ts:1170 transitionTaskWorkflowEvidence / :1285 recordGateEvidence / :1343 recordAgentDispatch — locked read-modify-write, atomic write', + 'src/gate-evidence.ts:1176 transitionTaskWorkflowEvidence / :1291 recordGateEvidence / :1349 recordAgentDispatch — locked read-modify-write, atomic write', 'src/council/council-evidence-writer.ts:96 writeCouncilEvidence — gates.council section under withTaskEvidenceLock', ], readerCitations: [ - 'src/gate-evidence.ts:1387 readTaskEvidence — FULL-FILE fail-open, async; :1463 readTaskEvidenceRaw — strict, sync', + 'src/gate-evidence.ts:1393 readTaskEvidence — FULL-FILE fail-open, async; :1469 readTaskEvidenceRaw — strict, sync', 'src/council/council-evidence-writer.ts:207 hasCouncilEvidenceAttempt', ], schemaVersion: 'workflow WAL states; unrecognized states degrade to null (documented :1183-1188)', @@ -1693,7 +1693,7 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ bound: 'retryHistory ≤3 (schema :347); per-task file; evidence/ archived+cleaned at close', scope: 'per-key', keyspaceBound: - 'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:967 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.', + 'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:973 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.', citation: 'src/gate-evidence.ts:347; src/commands/close/constants.ts:253-269 ACTIVE_STATE_DIRS_TO_CLEAN', }, readBound: { pattern: 'full-file', bound: 'single per-task JSON', sync: true, citation: 'src/gate-evidence.ts:1196-1224' }, From bda2e623d9dafc0c2ff840e001bbe6c06e936b2b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 17:55:03 -0500 Subject: [PATCH 04/11] fix(workflow): fence empty-scope settlement Harden recovery and current-scope validation after review. Issue: #2763 --- scripts/retention-registry.data.ts | 6 +- src/evidence/gate-bridge.ts | 55 +++++++++++++++- src/gate-evidence.ts | 66 ++++++++++++++++++- src/hooks/delegation-gate.ts | 14 +++- src/tools/check-gate-status.ts | 53 +++++++++++---- src/tools/update-task-status.ts | 18 +++-- src/workflow/coder-settlement.ts | 19 +++++- src/workflow/workflow-wal-schema.ts | 12 ++-- .../delegation-gate-empty-scope-2763.test.ts | 40 +++++++++++ ...-settlement-worktree-recovery-2098.test.ts | 49 +++++++++++++- 10 files changed, 296 insertions(+), 36 deletions(-) diff --git a/scripts/retention-registry.data.ts b/scripts/retention-registry.data.ts index 3dbc82a49..8546e79cc 100644 --- a/scripts/retention-registry.data.ts +++ b/scripts/retention-registry.data.ts @@ -1675,11 +1675,11 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ canonicalRoot: 'project-swarm', writerModules: ['src/gate-evidence.ts', 'src/council/council-evidence-writer.ts'], writerCitations: [ - 'src/gate-evidence.ts:1176 transitionTaskWorkflowEvidence / :1291 recordGateEvidence / :1349 recordAgentDispatch — locked read-modify-write, atomic write', + 'src/gate-evidence.ts:1236 transitionTaskWorkflowEvidence / :1351 recordGateEvidence / :1409 recordAgentDispatch — locked read-modify-write, atomic write', 'src/council/council-evidence-writer.ts:96 writeCouncilEvidence — gates.council section under withTaskEvidenceLock', ], readerCitations: [ - 'src/gate-evidence.ts:1393 readTaskEvidence — FULL-FILE fail-open, async; :1469 readTaskEvidenceRaw — strict, sync', + 'src/gate-evidence.ts:1453 readTaskEvidence — FULL-FILE fail-open, async; :1529 readTaskEvidenceRaw — strict, sync', 'src/council/council-evidence-writer.ts:207 hasCouncilEvidenceAttempt', ], schemaVersion: 'workflow WAL states; unrecognized states degrade to null (documented :1183-1188)', @@ -1693,7 +1693,7 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [ bound: 'retryHistory ≤3 (schema :347); per-task file; evidence/ archived+cleaned at close', scope: 'per-key', keyspaceBound: - 'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:973 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.', + 'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:1033 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.', citation: 'src/gate-evidence.ts:347; src/commands/close/constants.ts:253-269 ACTIVE_STATE_DIRS_TO_CLEAN', }, readBound: { pattern: 'full-file', bound: 'single per-task JSON', sync: true, citation: 'src/gate-evidence.ts:1196-1224' }, diff --git a/src/evidence/gate-bridge.ts b/src/evidence/gate-bridge.ts index f1e6bf2ff..8fddf989e 100644 --- a/src/evidence/gate-bridge.ts +++ b/src/evidence/gate-bridge.ts @@ -2,8 +2,10 @@ import type { Evidence } from '../config/evidence-schema'; import { deriveApplicableGateSet, isValidTaskId, + readCurrentTaskDeclaredFiles, readTaskEvidence, readTaskEvidenceRaw, + TASK_WORKFLOW_SCHEMA_MARKER, type TaskEvidence, } from '../gate-evidence.js'; @@ -82,7 +84,11 @@ export async function getDurableGateEvidenceStatusForTask( } try { - return getDurableGateEvidenceStatus(readTaskEvidenceRaw(directory, taskId)); + return getDurableGateEvidenceStatusWithCurrentScope( + readTaskEvidenceRaw(directory, taskId), + directory, + taskId, + ); } catch { return { isComplete: false, @@ -93,6 +99,53 @@ export async function getDurableGateEvidenceStatusForTask( } } +function getDurableGateEvidenceStatusWithCurrentScope( + evidence: TaskEvidence | null, + directory: string, + taskId: string, +): DurableGateEvidenceStatus { + if (!evidence?.gates || typeof evidence.gates !== 'object') { + return getDurableGateEvidenceStatus(evidence); + } + if (!Array.isArray(evidence.required_gates)) { + return getDurableGateEvidenceStatus(evidence); + } + if ( + !evidence.workflow || + evidence.workflow.schema !== TASK_WORKFLOW_SCHEMA_MARKER + ) { + // Task-specific legacy callers historically evaluated the persisted list + // directly. Keep that behavior separate from the public pure helper above, + // whose derived set intentionally reports the modern pre_check obligation. + if (evidence.required_gates.length === 0) { + return { + isComplete: false, + missingGates: ['required_gates'], + evidenceExists: true, + invalid: false, + }; + } + const missingGates = evidence.required_gates.filter( + (gate) => evidence.gates[gate] == null, + ); + return { + isComplete: missingGates.length === 0, + missingGates, + evidenceExists: true, + invalid: false, + }; + } + const derivedGates = deriveApplicableGateSet(evidence, { + currentDeclaredFiles: readCurrentTaskDeclaredFiles(directory, taskId), + }); + return { + isComplete: derivedGates.missingGates.length === 0, + missingGates: derivedGates.missingGates, + evidenceExists: true, + invalid: false, + }; +} + export async function hasCompleteDurableGateEvidenceForTask( directory: string, taskId: string, diff --git a/src/gate-evidence.ts b/src/gate-evidence.ts index ed925cd0e..f49e3ce56 100644 --- a/src/gate-evidence.ts +++ b/src/gate-evidence.ts @@ -17,7 +17,7 @@ * that requires a protected trust root outside the project workspace. */ -import { mkdirSync, readFileSync, realpathSync } from 'node:fs'; +import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import * as path from 'node:path'; import { z } from 'zod'; import { @@ -154,6 +154,59 @@ export interface ApplicableGateSet { readOnlyNoMutation: boolean; } +const MAX_PLAN_SCOPE_INSPECTION_BYTES = 4 * 1024 * 1024; + +/** + * Read the current task scope for a completion-time guard. A generation-0 + * no-mutation settlement is meaningful only while the current task still has + * an empty scope. If the plan cannot be read, parsed, or identifies the task + * ambiguously, return null so callers fail closed rather than trusting stale + * proof. + */ +export function readCurrentTaskDeclaredFiles( + directory: string, + taskId: string, +): string[] | null { + try { + const planPath = validateSwarmPath(directory, 'plan.json'); + const stat = statSync(planPath); + if (!stat.isFile() || stat.size > MAX_PLAN_SCOPE_INSPECTION_BYTES) { + return null; + } + const parsed = JSON.parse(readFileSync(planPath, 'utf8')) as { + phases?: unknown; + }; + if (!Array.isArray(parsed.phases)) return null; + let declaredFiles: string[] | null = null; + for (const phase of parsed.phases) { + if (!phase || typeof phase !== 'object') return null; + const tasks = (phase as { tasks?: unknown }).tasks; + if (!Array.isArray(tasks)) return null; + for (const task of tasks) { + if (!task || typeof task !== 'object') return null; + const record = task as { + id?: unknown; + files_touched?: unknown; + }; + if (record.id !== taskId) continue; + if ( + !Array.isArray(record.files_touched) || + !record.files_touched.every( + (file): file is string => typeof file === 'string', + ) + ) { + return null; + } + if (declaredFiles !== null) return null; + declaredFiles = [...record.files_touched]; + } + } + return declaredFiles; + } catch { + return null; + } +} + /** * Legacy internal marker written by receipt-less gate-evidence repair before * issue #2525. It is read only for recovery and must never be emitted as a @@ -647,6 +700,7 @@ function isNoMutationSettlementMetadata( */ export function deriveApplicableGateSet( evidence: TaskEvidence | null | undefined, + options?: { currentDeclaredFiles?: readonly string[] | null }, ): ApplicableGateSet { if (!evidence) { return { @@ -657,7 +711,12 @@ export function deriveApplicableGateSet( }; } const workflow = getTaskWorkflowSnapshot(evidence); - const readOnlyNoMutation = isNoMutationSettlementMetadata(workflow); + const readOnlyNoMutation = + isNoMutationSettlementMetadata(workflow) && + (options?.currentDeclaredFiles === undefined + ? true + : options.currentDeclaredFiles !== null && + options.currentDeclaredFiles.length === 0); const requiredGates = [...new Set(evidence.required_gates ?? [])]; if (!readOnlyNoMutation && !requiredGates.includes('pre_check')) { requiredGates.unshift('pre_check'); @@ -677,8 +736,9 @@ export function deriveApplicableGateSet( export function isReadOnlyNoMutationEligible( evidence: TaskEvidence | null | undefined, + options?: { currentDeclaredFiles?: readonly string[] | null }, ): boolean { - return isNoMutationSettlementMetadata(getTaskWorkflowSnapshot(evidence)); + return deriveApplicableGateSet(evidence, options).readOnlyNoMutation; } export function reduceTaskWorkflowSnapshot( diff --git a/src/hooks/delegation-gate.ts b/src/hooks/delegation-gate.ts index 8dfff1416..13a238295 100644 --- a/src/hooks/delegation-gate.ts +++ b/src/hooks/delegation-gate.ts @@ -2341,13 +2341,25 @@ async function settleRejectedEmptyPlanScope( workflowGeneration: expectedGeneration, }, }); + const observedFiles = await changedFilesSinceSnapshotAsync( + directory, + baseline, + ); + if (observedFiles === null) { + throw new Error( + 'CODER_SETTLEMENT_BASELINE_UNAVAILABLE: empty-scope preflight could not prove a clean post-baseline workspace.', + ); + } return await settleCoderDispatch({ directory, taskId, transitionId, accepted: false, testEngineerExempt: false, - observedFiles: [], + // Preserve the raw post-baseline observation. Any concurrent mutation + // is converted to accepted_mutation_failed by settlement rather than + // being filtered into a read-only proof. + observedFiles, }); } catch (error) { try { diff --git a/src/tools/check-gate-status.ts b/src/tools/check-gate-status.ts index 63c8b4028..17946e021 100644 --- a/src/tools/check-gate-status.ts +++ b/src/tools/check-gate-status.ts @@ -10,7 +10,11 @@ import type { tool } from '@opencode-ai/plugin'; import { z } from 'zod'; import { isSecretscanEvidence, loadEvidence } from '../evidence/manager.js'; import type { TaskEvidence } from '../gate-evidence.js'; -import { deriveApplicableGateSet } from '../gate-evidence.js'; +import { + deriveApplicableGateSet, + readCurrentTaskDeclaredFiles, + TASK_WORKFLOW_SCHEMA_MARKER, +} from '../gate-evidence.js'; import { isStrictTaskId } from '../validation/task-id'; import { createSwarmTool } from './create-tool'; import { resolveWorkingDirectory } from './resolve-working-directory'; @@ -233,19 +237,46 @@ export const check_gate_status: ReturnType = createSwarmTool({ return JSON.stringify(errorResult, null, 2); } - // Calculate passed and missing gates - const derivedGates = deriveApplicableGateSet(evidenceData as TaskEvidence); - const requiredGates = derivedGates.requiredGates; + // Calculate passed and missing gates. Legacy evidence predates the + // authoritative workflow schema and must retain its direct required_gates + // semantics; only authoritative evidence participates in derived gates and + // the generation-0 empty-scope exception. const gatesMap = evidenceData.gates || {}; - const passedGates = derivedGates.satisfiedGates; - const missingGates = derivedGates.missingGates; + const authoritativeWorkflow = + evidenceData.workflow?.schema === TASK_WORKFLOW_SCHEMA_MARKER; + let requiredGates: string[]; + let passedGates: string[]; + let missingGates: string[]; + let readOnlyNoMutation = false; + if (authoritativeWorkflow) { + const derivedGates = deriveApplicableGateSet( + evidenceData as TaskEvidence, + { + currentDeclaredFiles: readCurrentTaskDeclaredFiles( + directory, + taskIdInput, + ), + }, + ); + requiredGates = derivedGates.requiredGates; + passedGates = derivedGates.satisfiedGates; + missingGates = derivedGates.missingGates; + readOnlyNoMutation = derivedGates.readOnlyNoMutation; + } else { + requiredGates = evidenceData.required_gates; + passedGates = requiredGates.filter((gate) => gatesMap[gate] != null); + missingGates = requiredGates.filter((gate) => gatesMap[gate] == null); + } // Determine overall status - let status: 'all_passed' | 'incomplete' = - missingGates.length === 0 && - (derivedGates.readOnlyNoMutation || - evidenceData.workflow?.state === 'tests_run' || - evidenceData.workflow?.state === 'complete') + let status: 'all_passed' | 'incomplete' = authoritativeWorkflow + ? missingGates.length === 0 && + (readOnlyNoMutation || + evidenceData.workflow?.state === 'tests_run' || + evidenceData.workflow?.state === 'complete') + ? 'all_passed' + : 'incomplete' + : requiredGates.length > 0 && missingGates.length === 0 ? 'all_passed' : 'incomplete'; diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts index 9c2de2dce..8ff2cc8cc 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -21,6 +21,7 @@ import { deriveApplicableGateSet, getTaskWorkflowSnapshot, isReadOnlyNoMutationEligible, + readCurrentTaskDeclaredFiles, readTaskEvidenceRaw, TASK_GATE_REQUIREMENTS_RECONSTRUCTION_SENTINEL, } from '../gate-evidence.js'; @@ -504,11 +505,7 @@ export function checkReviewerGate( // Find the task and check its files_touched for (const planPhase of plan.phases ?? []) { for (const task of planPhase.tasks ?? []) { - if ( - task.id === taskId && - Array.isArray(task.files_touched) && - task.files_touched.length > 0 - ) { + if (task.id === taskId && task.files_touched) { // If no Tier 3 patterns matched, bypass Stage B if (!matchesTier3(task.files_touched)) { return reviewerGateDecision( @@ -597,7 +594,12 @@ export function checkReviewerGate( ); } const workflow = getTaskWorkflowSnapshot(evidence); - const derivedGates = deriveApplicableGateSet(evidence); + const derivedGates = deriveApplicableGateSet(evidence, { + currentDeclaredFiles: readCurrentTaskDeclaredFiles( + authoritativeDir, + taskId, + ), + }); const requiredGates = derivedGates.requiredGates; const satisfiedGates = derivedGates.satisfiedGates; const missingGates = derivedGates.missingGates; @@ -2103,7 +2105,9 @@ export async function executeUpdateTaskStatus( qaExempt: !lockedPhaseRequiresReviewer, readOnlyNoMutation: !lockedPhaseRequiresReviewer ? false - : isReadOnlyNoMutationEligible(evidence), + : isReadOnlyNoMutationEligible(evidence, { + currentDeclaredFiles: lockedTask.files_touched, + }), }), currentPlan: authoritativePlan, validateEvidence: async () => { diff --git a/src/workflow/coder-settlement.ts b/src/workflow/coder-settlement.ts index 5a8b7db8f..d31043448 100644 --- a/src/workflow/coder-settlement.ts +++ b/src/workflow/coder-settlement.ts @@ -214,7 +214,10 @@ async function scopedObservedFiles( const observed = await changedFilesSinceSnapshotAsync(directory, baseline); if (!observed || !context.declaredFiles) return null; if (hasUnattributedEmptyScopeMutation(context.declaredFiles, observed)) { - return null; + // Preserve the raw observation so recovery can record a failed rework + // settlement rather than silently reducing an empty-scope mutation to a + // no-op proof (issue #2763). + return observed; } return observed.filter((filePath) => isPathWithinDeclaredScope(filePath, context.declaredFiles ?? [], directory), @@ -1033,6 +1036,14 @@ export async function recoverCoderSettlement( `CODER_SETTLEMENT_RECOVERY_UNCERTAIN: transition ${wal.transitionId} for isolated task ${taskId} could not attribute worktree changes to the declared scope (${filePath}, state ${wal.state}). Run /swarm recover ${taskId} (or /swarm reset-session), then retry; do not remove the WAL by hand.`, ); } + const unattributedEmptyScopeMutation = + hasUnattributedEmptyScopeMutation( + wal.context.declaredFiles, + observed, + ); + const accepted = observed.length > 0 || unattributedEmptyScopeMutation; + const settlementFailed = + wal.settlementFailed === true || unattributedEmptyScopeMutation; if (wal.mergeProvenance) { const landed = await reconcileLandedMerge( directory, @@ -1048,7 +1059,8 @@ export async function recoverCoderSettlement( ...wal, state: 'PREPARED', observedFiles: observed, - accepted: observed.length > 0, + accepted, + settlementFailed, testEngineerExempt: isMarkdownOnlyTaskChange( wal.context.declaredFiles, observed, @@ -1142,7 +1154,8 @@ export async function recoverCoderSettlement( wal = { ...wal, state: 'PREPARED', - accepted: observed.length > 0, + accepted, + settlementFailed, testEngineerExempt: isMarkdownOnlyTaskChange( wal.context.declaredFiles, observed, diff --git a/src/workflow/workflow-wal-schema.ts b/src/workflow/workflow-wal-schema.ts index c93a60696..d4c03ce65 100644 --- a/src/workflow/workflow-wal-schema.ts +++ b/src/workflow/workflow-wal-schema.ts @@ -274,11 +274,13 @@ export function parseCoderSettlementWal( (candidatePath) => typeof candidatePath !== 'string' || candidatePath.length > 4096 || - !isPathWithinDeclaredScope( - candidatePath, - context.declaredFiles ?? [], - baseline.directory, - ), + (Array.isArray(context.declaredFiles) && + context.declaredFiles.length > 0 && + !isPathWithinDeclaredScope( + candidatePath, + context.declaredFiles, + baseline.directory, + )), ))) || (worktree !== undefined && (typeof worktree.callID !== 'string' || diff --git a/tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts b/tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts index dc2b7645d..6658859dd 100644 --- a/tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts +++ b/tests/unit/hooks/delegation-gate-empty-scope-2763.test.ts @@ -9,6 +9,7 @@ import { } from '../../../src/gate-evidence'; import { createDelegationGateHook } from '../../../src/hooks/delegation-gate'; import { ensureAgentSession, resetSwarmState } from '../../../src/state'; +import { checkReviewerGate } from '../../../src/tools/update-task-status'; import { writeApprovedPlan } from '../../helpers/approved-plan'; import { createSafeTestDir } from '../../helpers/safe-test-dir'; @@ -146,4 +147,43 @@ describe('issue #2763 — delegation gate empty-scope admission', () => { }, { timeout: 30_000 }, ); + + test( + 'revokes the no-mutation exception when the current plan later gains scope', + async () => { + const hook = createDelegationGateHook(config, directory); + const args = { + subagent_type: 'coder', + task_id: TASK_ID, + prompt: 'TASK: 1.1\nACCEPTANCE: verify no code change is required', + }; + + await expect( + hook.toolBefore( + { tool: 'Task', sessionID: 'parent', callID: 'empty-plan-expanded' }, + { args }, + ), + ).rejects.toThrow('SCOPE_NOT_DECLARED'); + + const planPath = path.join(directory, '.swarm', 'plan.json'); + const plan = JSON.parse(fs.readFileSync(planPath, 'utf8')) as { + phases: Array<{ + tasks: Array<{ id: string; files_touched: string[] }>; + }>; + }; + plan.phases[0].tasks[0].files_touched = ['src/expanded.ts']; + fs.writeFileSync(planPath, JSON.stringify(plan)); + + const decision = checkReviewerGate( + TASK_ID, + directory, + false, + 'parent', + directory, + ); + expect(decision.blocked).toBe(true); + expect(decision.missingGates).toContain('pre_check'); + }, + { timeout: 30_000 }, + ); }); diff --git a/tests/unit/workflow/coder-settlement-worktree-recovery-2098.test.ts b/tests/unit/workflow/coder-settlement-worktree-recovery-2098.test.ts index 8a0291f33..c4b75b3df 100644 --- a/tests/unit/workflow/coder-settlement-worktree-recovery-2098.test.ts +++ b/tests/unit/workflow/coder-settlement-worktree-recovery-2098.test.ts @@ -63,6 +63,7 @@ interface Fixture { function createFixture( label: string, canonicalDirectoryScope = false, + declaredFiles?: string[] | null, ): Fixture { const root = fs.realpathSync( fs.mkdtempSync(path.join(canonicalTmpDir(), `coder-wt-recovery-${label}-`)), @@ -86,7 +87,7 @@ function createFixture( const branch = `swarm-lane/session-${label}/lane-1`; git(repo, ['worktree', 'add', '-b', branch, worktree]); const context: BackgroundTaskChangeContext = { - declaredFiles: [ + declaredFiles: declaredFiles ?? [ canonicalDirectoryScope ? path.join(worktree, 'src') : 'src', ], baseline: captureWorkspaceSnapshot(worktree), @@ -117,7 +118,7 @@ function createFixture( }; } -function commitAndLand(fixture: Fixture): MergeOperationProvenance { +function commitWorktree(fixture: Fixture): MergeOperationProvenance { fs.writeFileSync( path.join(fixture.worktree, 'src', 'nested', 'feature.ts'), 'export const feature = 2;\n', @@ -131,6 +132,11 @@ function commitAndLand(fixture: Fixture): MergeOperationProvenance { branchName: fixture.branch, strategy: 'merge', }; + return provenance; +} + +function commitAndLand(fixture: Fixture): MergeOperationProvenance { + const provenance = commitWorktree(fixture); git(fixture.repo, ['merge', '--no-edit', fixture.branch]); return provenance; } @@ -335,7 +341,46 @@ describe('issue #2098 coder settlement isolated-worktree recovery', () => { ]); } }); + for (const [label, landed] of [ + ['landed', true], + ['unlanded', false], + ] as const) { + test(`${label} empty-scope mutation recovers as failed rework`, async () => { + const fixture = createFixture(`empty-scope-${label}`, false, []); + await begin(fixture); + const provenance = landed + ? commitAndLand(fixture) + : commitWorktree(fixture); + await recordCoderMergeProvenance({ + directory: fixture.repo, + taskId: TASK_ID, + transitionId: fixture.transitionId, + provenance, + observedFiles: ['src/nested/feature.ts'], + }); + _internals.liveDispatches.clear(); + const recovered = await recoverCoderSettlement(fixture.repo, TASK_ID); + expect(recovered?.accepted).toBe(true); + expect(readWal(fixture)).toMatchObject({ + state: 'COMMITTED', + settlementFailed: true, + }); + expect( + getTaskWorkflowSnapshot(await readTaskEvidence(fixture.repo, TASK_ID)), + ).toMatchObject({ state: 'rework_required', generation: 1 }); + if (landed) { + expectCleanup(fixture); + } else { + expect(fs.existsSync(fixture.worktree)).toBe(false); + expect(branchExists(fixture)).toBe(true); + const ownerScan = scanWorktreeProvisioningOwnersForRecovery( + fixture.repo, + ); + expect(ownerScan).toMatchObject({ status: 'ok', owners: [] }); + } + }); + } test('canonical directory scope accepts an observed descendant during landed recovery', async () => { const fixture = createFixture('canonical-scope', true); await begin(fixture); From 9f2439284e39d03879300413cbed1cc8cb07152c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 18:49:35 -0500 Subject: [PATCH 05/11] fix(workflow): preserve legacy gate readers and fence empty Turbo bypass --- src/services/evidence-summary-service.ts | 9 +++++++-- src/tools/update-task-status.ts | 6 +++++- src/tools/update-task-status.turbo-bypass.test.ts | 8 ++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/services/evidence-summary-service.ts b/src/services/evidence-summary-service.ts index 791dc8f44..fb0e3f969 100644 --- a/src/services/evidence-summary-service.ts +++ b/src/services/evidence-summary-service.ts @@ -17,7 +17,7 @@ import type { TaskStatus, } from '../config/plan-schema'; import { - getDurableGateEvidenceStatus, + getDurableGateEvidenceStatusForTask, mergeDurableGateEntriesFromEvidence, readDurableGateEvidence, } from '../evidence/gate-bridge.js'; @@ -245,7 +245,12 @@ async function buildTaskSummary( ); let evidenceCheck = _internals.evidenceCompleteFromEntries(entries); if (gateEvidence) { - const gateStatus = getDurableGateEvidenceStatus(gateEvidence); + // Task-specific status preserves legacy evidence semantics while applying + // current-scope derivation to authoritative exact-task workflow records. + const gateStatus = await getDurableGateEvidenceStatusForTask( + directory, + taskId, + ); evidenceCheck = gateStatus.isComplete ? { isComplete: true, missingEvidence: [] } : { diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts index 8ff2cc8cc..d29b26fe2 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -505,7 +505,11 @@ export function checkReviewerGate( // Find the task and check its files_touched for (const planPhase of plan.phases ?? []) { for (const task of planPhase.tasks ?? []) { - if (task.id === taskId && task.files_touched) { + if ( + task.id === taskId && + Array.isArray(task.files_touched) && + task.files_touched.length > 0 + ) { // If no Tier 3 patterns matched, bypass Stage B if (!matchesTier3(task.files_touched)) { return reviewerGateDecision( diff --git a/src/tools/update-task-status.turbo-bypass.test.ts b/src/tools/update-task-status.turbo-bypass.test.ts index c63ffc7f4..6e76ab5b9 100644 --- a/src/tools/update-task-status.turbo-bypass.test.ts +++ b/src/tools/update-task-status.turbo-bypass.test.ts @@ -363,7 +363,7 @@ describe('checkReviewerGate Turbo Mode edge cases', () => { expect(result.blocked).toBe(true); }); - it('handles empty files_touched array (allows bypass)', () => { + it('does not bypass an explicitly empty files_touched array', () => { const planJson = JSON.stringify({ schema_version: '1.0.0', title: 'Test', @@ -394,10 +394,10 @@ describe('checkReviewerGate Turbo Mode edge cases', () => { const session = swarmState.agentSessions.get('session-1'); session!.turboMode = true; - // Empty files_touched → no Tier 3 match → bypass + // An explicit empty scope is not evidence that Stage B can be skipped. const result = checkReviewerGate('3.2', tmpDir); - expect(result.blocked).toBe(false); - expect(result.reason).toBe('Turbo Mode bypass'); + expect(result.blocked).toBe(true); + expect(result.reason).not.toBe('Turbo Mode bypass'); }); it('falls back to normal gate check when plan.json is missing', () => { From 7bbc4829bf7be871bbd6e6371c75d89a821ce528 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 19:25:49 -0500 Subject: [PATCH 06/11] fix(gates): derive legacy pre-check status consistently --- src/tools/check-gate-status.gates.test.ts | 7 +-- src/tools/check-gate-status.ts | 59 +++++++++-------------- 2 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/tools/check-gate-status.gates.test.ts b/src/tools/check-gate-status.gates.test.ts index d7e9de99c..63e1c487d 100644 --- a/src/tools/check-gate-status.gates.test.ts +++ b/src/tools/check-gate-status.gates.test.ts @@ -420,11 +420,12 @@ describe('check_gate_status', () => { const result = await executeTool({ task_id: '7.1' }, tmpDir); const parsed = JSON.parse(result); - // An empty requirement set is not proof that the task passed. + // An empty requirement set is not proof that the task passed; legacy + // receiptless evidence retains the ordinary pre_check obligation. expect(parsed.status).toBe('incomplete'); - expect(parsed.required_gates).toEqual([]); + expect(parsed.required_gates).toEqual(['pre_check']); expect(parsed.passed_gates).toEqual([]); - expect(parsed.missing_gates).toEqual([]); + expect(parsed.missing_gates).toEqual(['pre_check']); }); it('handles extra gates in evidence that are not required', async () => { diff --git a/src/tools/check-gate-status.ts b/src/tools/check-gate-status.ts index 17946e021..bba08f6b3 100644 --- a/src/tools/check-gate-status.ts +++ b/src/tools/check-gate-status.ts @@ -237,46 +237,35 @@ export const check_gate_status: ReturnType = createSwarmTool({ return JSON.stringify(errorResult, null, 2); } - // Calculate passed and missing gates. Legacy evidence predates the - // authoritative workflow schema and must retain its direct required_gates - // semantics; only authoritative evidence participates in derived gates and - // the generation-0 empty-scope exception. + // Calculate passed and missing gates from the shared applicability + // derivation. Legacy records without an authoritative workflow marker + // still retain the ordinary pre_check obligation; only a trusted + // generation-zero empty-scope settlement may omit it. const gatesMap = evidenceData.gates || {}; const authoritativeWorkflow = evidenceData.workflow?.schema === TASK_WORKFLOW_SCHEMA_MARKER; - let requiredGates: string[]; - let passedGates: string[]; - let missingGates: string[]; - let readOnlyNoMutation = false; - if (authoritativeWorkflow) { - const derivedGates = deriveApplicableGateSet( - evidenceData as TaskEvidence, - { - currentDeclaredFiles: readCurrentTaskDeclaredFiles( - directory, - taskIdInput, - ), - }, - ); - requiredGates = derivedGates.requiredGates; - passedGates = derivedGates.satisfiedGates; - missingGates = derivedGates.missingGates; - readOnlyNoMutation = derivedGates.readOnlyNoMutation; - } else { - requiredGates = evidenceData.required_gates; - passedGates = requiredGates.filter((gate) => gatesMap[gate] != null); - missingGates = requiredGates.filter((gate) => gatesMap[gate] == null); - } + const derivedGates = deriveApplicableGateSet( + evidenceData as TaskEvidence, + authoritativeWorkflow + ? { + currentDeclaredFiles: readCurrentTaskDeclaredFiles( + directory, + taskIdInput, + ), + } + : undefined, + ); + const requiredGates = derivedGates.requiredGates; + const passedGates = derivedGates.satisfiedGates; + const missingGates = derivedGates.missingGates; + const readOnlyNoMutation = derivedGates.readOnlyNoMutation; // Determine overall status - let status: 'all_passed' | 'incomplete' = authoritativeWorkflow - ? missingGates.length === 0 && - (readOnlyNoMutation || - evidenceData.workflow?.state === 'tests_run' || - evidenceData.workflow?.state === 'complete') - ? 'all_passed' - : 'incomplete' - : requiredGates.length > 0 && missingGates.length === 0 + let status: 'all_passed' | 'incomplete' = + missingGates.length === 0 && + (readOnlyNoMutation || + evidenceData.workflow?.state === 'tests_run' || + evidenceData.workflow?.state === 'complete') ? 'all_passed' : 'incomplete'; From e599740b153431ab862ac1af97981852ec12059a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 19:34:33 -0500 Subject: [PATCH 07/11] test(gates): keep legacy receiptless fixture within cap --- src/tools/check-gate-status.gates.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tools/check-gate-status.gates.test.ts b/src/tools/check-gate-status.gates.test.ts index 63e1c487d..2b1149125 100644 --- a/src/tools/check-gate-status.gates.test.ts +++ b/src/tools/check-gate-status.gates.test.ts @@ -420,8 +420,7 @@ describe('check_gate_status', () => { const result = await executeTool({ task_id: '7.1' }, tmpDir); const parsed = JSON.parse(result); - // An empty requirement set is not proof that the task passed; legacy - // receiptless evidence retains the ordinary pre_check obligation. + // Empty requirements are not proof; receiptless evidence retains pre_check. expect(parsed.status).toBe('incomplete'); expect(parsed.required_gates).toEqual(['pre_check']); expect(parsed.passed_gates).toEqual([]); From 01c13fd57d0de2d7babeb877059cb4a701edc2ae Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 19:57:05 -0500 Subject: [PATCH 08/11] test(gates): expect pre-check for legacy evidence --- src/tools/check-gate-status.gates.test.ts | 35 +++++++++++------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/tools/check-gate-status.gates.test.ts b/src/tools/check-gate-status.gates.test.ts index 2b1149125..83013183b 100644 --- a/src/tools/check-gate-status.gates.test.ts +++ b/src/tools/check-gate-status.gates.test.ts @@ -171,7 +171,7 @@ describe('check_gate_status', () => { // This demonstrates the security issue: the tool reads from arbitrary directories // Note: This is currently the behavior - the path validation doesn't prevent this - expect(parsed.status).toBe('all_passed'); + expect(parsed.status).toBe('incomplete'); expect(parsed.gates.reviewer.sessionId).toBe('stolen-session'); // Cleanup @@ -228,7 +228,7 @@ describe('check_gate_status', () => { // ── Gate Status Calculation Tests ───────────────────────────────────────── - it('returns all_passed when all required gates have evidence', async () => { + it('requires pre_check when legacy gates all have evidence', async () => { const evidence = { taskId: '1.1', required_gates: ['reviewer', 'test_engineer'], @@ -252,12 +252,13 @@ describe('check_gate_status', () => { const result = await executeTool({ task_id: '1.1' }, tmpDir); const parsed = JSON.parse(result); + const expectedRequiredGates = ['pre_check', ...evidence.required_gates]; - expect(parsed.status).toBe('all_passed'); - expect(parsed.required_gates).toEqual(['reviewer', 'test_engineer']); + expect(parsed.status).toBe('incomplete'); + expect(parsed.required_gates).toEqual(expectedRequiredGates); expect(parsed.passed_gates).toEqual(['reviewer', 'test_engineer']); - expect(parsed.missing_gates).toEqual([]); - expect(parsed.message).toContain('All required gates have passed'); + expect(parsed.missing_gates).toEqual(['pre_check']); + expect(parsed.message).toContain('Missing gates: pre_check'); }); it('returns incomplete when some gates are missing', async () => { @@ -280,15 +281,13 @@ describe('check_gate_status', () => { const result = await executeTool({ task_id: '2.1' }, tmpDir); const parsed = JSON.parse(result); + const expectedRequiredGates = ['pre_check', ...evidence.required_gates]; + const expectedMissingGates = ['pre_check', 'test_engineer', 'docs']; expect(parsed.status).toBe('incomplete'); - expect(parsed.required_gates).toEqual([ - 'reviewer', - 'test_engineer', - 'docs', - ]); + expect(parsed.required_gates).toEqual(expectedRequiredGates); expect(parsed.passed_gates).toEqual(['reviewer']); - expect(parsed.missing_gates).toEqual(['test_engineer', 'docs']); + expect(parsed.missing_gates).toEqual(expectedMissingGates); expect(parsed.message).toContain('incomplete'); expect(parsed.message).toContain('test_engineer'); expect(parsed.message).toContain('docs'); @@ -310,7 +309,7 @@ describe('check_gate_status', () => { expect(parsed.status).toBe('incomplete'); expect(parsed.passed_gates).toEqual([]); - expect(parsed.missing_gates).toEqual(['reviewer']); + expect(parsed.missing_gates).toEqual(['pre_check', 'reviewer']); }); it('handles task with single required gate', async () => { @@ -333,9 +332,9 @@ describe('check_gate_status', () => { const result = await executeTool({ task_id: '4.1' }, tmpDir); const parsed = JSON.parse(result); - expect(parsed.status).toBe('all_passed'); + expect(parsed.status).toBe('incomplete'); expect(parsed.passed_gates).toEqual(['docs']); - expect(parsed.missing_gates).toEqual([]); + expect(parsed.missing_gates).toEqual(['pre_check']); }); // ── Output Format Tests ─────────────────────────────────────────────────── @@ -454,9 +453,9 @@ describe('check_gate_status', () => { const parsed = JSON.parse(result); // Only required gates should be in passed_gates - expect(parsed.status).toBe('all_passed'); + expect(parsed.status).toBe('incomplete'); expect(parsed.passed_gates).toEqual(['reviewer']); - expect(parsed.missing_gates).toEqual([]); + expect(parsed.missing_gates).toEqual(['pre_check']); // But gates object should contain all gates expect(parsed.gates).toHaveProperty('reviewer'); expect(parsed.gates).toHaveProperty('extra_gate'); @@ -486,7 +485,7 @@ describe('check_gate_status', () => { const result = await executeTool({ task_id: '9.1' }, customDir); const parsed = JSON.parse(result); - expect(parsed.status).toBe('all_passed'); + expect(parsed.status).toBe('incomplete'); // Cleanup custom dir rmSync(customDir, { recursive: true, force: true }); From 2d40c56f640b573aa868f5e25fd379d3c57be940 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 21:54:35 -0500 Subject: [PATCH 09/11] fix(workflow): harden empty-scope completion scope checks --- src/gate-evidence.ts | 8 ++- src/tools/phase-complete.ts | 26 +++++---- src/tools/update-task-status.ts | 7 ++- .../unit/evidence/gate-evidence-2763.test.ts | 10 +++- .../tools/legacy-gate-parity-2763.test.ts | 56 +++++++++++++++++++ .../phase-complete-doc-only-evidence.test.ts | 52 +++++++++++++++++ 6 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 tests/unit/tools/legacy-gate-parity-2763.test.ts diff --git a/src/gate-evidence.ts b/src/gate-evidence.ts index f49e3ce56..acb046e99 100644 --- a/src/gate-evidence.ts +++ b/src/gate-evidence.ts @@ -1555,11 +1555,17 @@ export function readTaskEvidenceRaw( export async function hasPassedAllGates( directory: string, taskId: string, + currentDeclaredFiles?: readonly string[] | null, ): Promise { const evidence = await readTaskEvidence(directory, taskId); if (!evidence) return false; if (!Array.isArray(evidence.required_gates)) return false; - return deriveApplicableGateSet(evidence).missingGates.length === 0; + return ( + deriveApplicableGateSet(evidence, { + // Missing caller scope is unknown, not proof of a still-empty plan. + currentDeclaredFiles: currentDeclaredFiles ?? null, + }).missingGates.length === 0 + ); } export function compareTaskWorkflowStateRank( diff --git a/src/tools/phase-complete.ts b/src/tools/phase-complete.ts index 6323cb24c..b3f3176b5 100644 --- a/src/tools/phase-complete.ts +++ b/src/tools/phase-complete.ts @@ -280,11 +280,18 @@ function canInferMissingAgentsFromTaskGates(agentsMissing: string[]): boolean { async function allCompletedTasksHavePassedGateEvidence( directory: string, - tasks: Array<{ id: string; status: string }>, + tasks: Array<{ + id: string; + status: string; + files_touched?: string[]; + }>, ): Promise { for (const task of tasks) { if (task.status !== 'completed') return false; - if (!(await hasPassedAllGates(directory, task.id))) return false; + if ( + !(await hasPassedAllGates(directory, task.id, task.files_touched ?? null)) + ) + return false; } return tasks.length > 0; } @@ -994,17 +1001,12 @@ export async function executePhaseComplete( let inferredFromTaskGates: string[] = []; if (missing.length > 0) { try { - const planRaw = fs.readFileSync( - validateSwarmPath(dir, 'plan.json'), - 'utf8', + // Use loadPlan's ledger-replayed authority. Reading plan.json here + // directly could validate the empty-scope proof against a stale + // projection after the authoritative task scope expanded. + const target = participationPlan?.phases.find( + (item) => item.id === phase, ); - const plan = JSON.parse(planRaw) as { - phases: Array<{ - id: number; - tasks: Array<{ id: string; status: string }>; - }>; - }; - const target = plan.phases.find((item) => item.id === phase); if ( target && target.tasks.length > 0 && diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts index d29b26fe2..9dfc6795d 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -579,15 +579,16 @@ export function checkReviewerGate( ); } if (!evidence.workflow) { + const derivedGates = deriveApplicableGateSet(evidence); return reviewerGateDecision( taskId, sessionID, { blocked: true, reason: `Task ${taskId} has legacy QA evidence without an authoritative workflow generation. Run a fresh exact-task workflow transition before completion.`, - requiredGates: [...evidence.required_gates], - satisfiedGates: Object.keys(evidence.gates), - missingGates: [...evidence.required_gates], + requiredGates: derivedGates.requiredGates, + satisfiedGates: derivedGates.satisfiedGates, + missingGates: derivedGates.missingGates, source: 'durable_exact_task', generation: 0, nextAction: diff --git a/tests/unit/evidence/gate-evidence-2763.test.ts b/tests/unit/evidence/gate-evidence-2763.test.ts index 9c85ea4d8..4c77055a2 100644 --- a/tests/unit/evidence/gate-evidence-2763.test.ts +++ b/tests/unit/evidence/gate-evidence-2763.test.ts @@ -48,7 +48,15 @@ describe('hasPassedAllGates applicability (#2763)', () => { }, }); - expect(await hasPassedAllGates(directory, '1.1')).toBe(true); + let filesTouched: string[] = []; + expect(await hasPassedAllGates(directory, '1.1', filesTouched)).toBe(true); + + // The plan task's current scope is supplied from loadPlan's ledger-replayed + // task object by phase_complete. Expanding it after the old settlement must + // revoke the read-only exception even though generation-0 evidence remains. + filesTouched = ['src/expanded.ts']; + expect(await hasPassedAllGates(directory, '1.1', filesTouched)).toBe(false); + expect(await hasPassedAllGates(directory, '1.1')).toBe(false); }); test('rejects ordinary and legacy empty required-gate evidence', async () => { diff --git a/tests/unit/tools/legacy-gate-parity-2763.test.ts b/tests/unit/tools/legacy-gate-parity-2763.test.ts new file mode 100644 index 000000000..c59303d06 --- /dev/null +++ b/tests/unit/tools/legacy-gate-parity-2763.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { check_gate_status } from '../../../src/tools/check-gate-status'; +import { checkReviewerGate } from '../../../src/tools/update-task-status'; +import { createSafeTestDir } from '../../helpers/safe-test-dir'; + +describe('legacy gate diagnostics parity (#2763)', () => { + let directory = ''; + let cleanup = (): void => {}; + + afterEach(() => cleanup()); + + test('legacy reviewer gate includes the same derived pre_check obligation', async () => { + ({ dir: directory, cleanup } = createSafeTestDir('legacy-gate-2763-')); + const evidenceDir = path.join(directory, '.swarm', 'evidence'); + fs.mkdirSync(evidenceDir, { recursive: true }); + fs.writeFileSync( + path.join(evidenceDir, '1.1.json'), + JSON.stringify({ + taskId: '1.1', + required_gates: ['reviewer'], + gates: { + reviewer: { + sessionId: 'reviewer-session', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'reviewer', + }, + }, + }), + ); + + const reviewer = checkReviewerGate( + '1.1', + directory, + false, + 'parent-session', + directory, + ); + const status = JSON.parse( + await check_gate_status.execute({ task_id: '1.1' }, { directory }), + ) as { + required_gates: string[]; + passed_gates: string[]; + missing_gates: string[]; + }; + + expect(reviewer.blocked).toBe(true); + expect(reviewer.requiredGates).toEqual(status.required_gates); + expect(reviewer.satisfiedGates).toEqual(status.passed_gates); + expect(reviewer.missingGates).toEqual(status.missing_gates); + expect(reviewer.requiredGates).toEqual(['pre_check', 'reviewer']); + expect(reviewer.satisfiedGates).toEqual(['reviewer']); + expect(reviewer.missingGates).toEqual(['pre_check']); + }); +}); diff --git a/tests/unit/tools/phase-complete-doc-only-evidence.test.ts b/tests/unit/tools/phase-complete-doc-only-evidence.test.ts index b81fed37e..850c26481 100644 --- a/tests/unit/tools/phase-complete-doc-only-evidence.test.ts +++ b/tests/unit/tools/phase-complete-doc-only-evidence.test.ts @@ -82,4 +82,56 @@ describe('phase_complete doc-only durable fallback', () => { ]), ).toBe(false); }); + + test('revokes empty-scope settlement when the completed task scope expands', async () => { + const evidenceDirectory = path.join(directory, '.swarm', 'evidence'); + fs.mkdirSync(evidenceDirectory, { recursive: true }); + fs.writeFileSync( + path.join(evidenceDirectory, '1.3.json'), + JSON.stringify({ + taskId: '1.3', + required_gates: [], + gates: {}, + workflow: { + schema: 'exact-task-v1', + generation: 0, + state: 'idle', + retryCount: 1, + retryHistory: ['dispatch_no_mutation'], + retryEpoch: 1, + lastOutcome: 'dispatch_no_mutation', + lastTransitionId: 'settlement-1.3', + updatedAt: '2026-09-14T00:00:00.000Z', + noMutationSettlement: { + generation: 0, + transitionId: 'settlement-1.3', + declaredFiles: [], + }, + }, + }), + ); + + const completedTask = { + id: '1.3', + status: 'completed', + files_touched: [] as string[], + }; + expect( + await _test_exports.allCompletedTasksHavePassedGateEvidence(directory, [ + completedTask, + ]), + ).toBe(true); + expect( + await _test_exports.allCompletedTasksHavePassedGateEvidence(directory, [ + { id: '1.3', status: 'completed' }, + ]), + ).toBe(false); + + completedTask.files_touched = ['src/expanded.ts']; + expect( + await _test_exports.allCompletedTasksHavePassedGateEvidence(directory, [ + completedTask, + ]), + ).toBe(false); + }); }); From 5b81b0027e2d5611e78c9ec6b3bfee913ddce3de Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 15 Sep 2026 01:15:56 -0500 Subject: [PATCH 10/11] fix(workflow): revalidate read-only terminal scope --- src/tools/update-task-status.ts | 10 +- src/workflow/task-terminal.ts | 93 ++++++++- ...date-task-status-locked-scope-2763.test.ts | 191 ++++++++++++++++++ .../task-terminal-read-only-2763.test.ts | 46 ++++- 4 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 tests/unit/tools/update-task-status-locked-scope-2763.test.ts diff --git a/src/tools/update-task-status.ts b/src/tools/update-task-status.ts index 9dfc6795d..e53ef2a10 100644 --- a/src/tools/update-task-status.ts +++ b/src/tools/update-task-status.ts @@ -441,6 +441,7 @@ export function checkReviewerGate( stageBParallelEnabled = false, sessionID?: string, fallbackDir?: string, + currentDeclaredFiles?: readonly string[] | null, ): ReviewerGateResult { try { // === Lean Turbo bypass check === @@ -600,10 +601,10 @@ export function checkReviewerGate( } const workflow = getTaskWorkflowSnapshot(evidence); const derivedGates = deriveApplicableGateSet(evidence, { - currentDeclaredFiles: readCurrentTaskDeclaredFiles( - authoritativeDir, - taskId, - ), + currentDeclaredFiles: + currentDeclaredFiles === undefined + ? readCurrentTaskDeclaredFiles(authoritativeDir, taskId) + : currentDeclaredFiles, }); const requiredGates = derivedGates.requiredGates; const satisfiedGates = derivedGates.satisfiedGates; @@ -2122,6 +2123,7 @@ export async function executeUpdateTaskStatus( false, ctx?.sessionID, fallbackDir ?? directory, + lockedTask.files_touched ?? null, ); const lockedCouncil = checkCouncilGate(directory, args.task_id); if ( diff --git a/src/workflow/task-terminal.ts b/src/workflow/task-terminal.ts index 1d12c2414..e5392224e 100644 --- a/src/workflow/task-terminal.ts +++ b/src/workflow/task-terminal.ts @@ -1,4 +1,4 @@ -import type { Plan } from '../config/plan-schema.js'; +import { type Plan, TaskStatusSchema } from '../config/plan-schema.js'; import { getTaskWorkflowSnapshot, type TaskEvidence, @@ -7,6 +7,7 @@ import { import { validateSwarmPath } from '../hooks/utils.js'; import { tryAcquireLock } from '../parallel/file-locks.js'; import { + peekPlanFromLedger, readPlanEpochIdentity, replayFromLedgerWithStatus, } from '../plan/ledger.js'; @@ -136,7 +137,7 @@ async function recoverPreparedTaskTerminalWithPlanLock( ); } } - const task = plan.phases + let task = plan.phases .flatMap((phase) => phase.tasks) .find((candidate) => candidate.id === taskId); if (!task) throw new Error(`TASK_TERMINAL_TASK_MISSING: ${taskId}`); @@ -152,6 +153,94 @@ async function recoverPreparedTaskTerminalWithPlanLock( await writeWal(walPath, { ...wal, state: 'ABORTED' }); return null; } + if (wal.readOnlyNoMutation === true && !evidenceAlreadyTerminal) { + let replayed: Awaited>; + try { + replayed = await peekPlanFromLedger(directory); + } catch (error) { + throw new Error( + `TASK_TERMINAL_READ_ONLY_SCOPE_UNKNOWN: could not inspect the authoritative plan ledger: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (replayed.truncated || replayed.badSuffix !== null) { + throw new Error('TASK_TERMINAL_LEDGER_TRUNCATED'); + } + if (!replayed.plan) { + throw new Error( + 'TASK_TERMINAL_READ_ONLY_SCOPE_UNKNOWN: authoritative plan ledger has no replayable plan', + ); + } + const authoritativeTask = replayed.plan.phases + .flatMap((phase) => phase.tasks) + .find((candidate) => candidate.id === taskId); + if (!authoritativeTask) { + throw new Error(`TASK_TERMINAL_TASK_MISSING: ${taskId}`); + } + if (wal.version === 2) { + const identity = await readPlanEpochIdentity( + directory, + replayed.plan, + ); + if ( + !identity || + identity.planIdentityHash !== wal.planIdentityHash || + identity.planEpoch !== wal.planEpoch + ) { + throw new Error( + `TASK_TERMINAL_PLAN_IDENTITY_MISMATCH: ${walPath} belongs to a different plan epoch`, + ); + } + } + plan = replayed.plan; + task = authoritativeTask; + const currentFiles = authoritativeTask.files_touched; + const currentScopeIsKnownEmpty = + Array.isArray(currentFiles) && currentFiles.length === 0; + if (!currentScopeIsKnownEmpty) { + if ( + authoritativeTask.status !== wal.oldPlanStatus && + authoritativeTask.status !== wal.newPlanStatus + ) { + throw new Error( + `TASK_TERMINAL_PLAN_CAS_MISMATCH: expected ${wal.oldPlanStatus} or ${wal.newPlanStatus}, found ${authoritativeTask.status}`, + ); + } + if ( + authoritativeTask.status === wal.newPlanStatus && + authoritativeTask.status !== wal.oldPlanStatus + ) { + const oldStatus = TaskStatusSchema.safeParse(wal.oldPlanStatus); + if (!oldStatus.success) { + throw new Error( + `TASK_TERMINAL_OLD_STATUS_INVALID: ${wal.oldPlanStatus}`, + ); + } + const rolledBackPlan = await updateTaskStatus( + directory, + taskId, + oldStatus.data, + { + planLockAlreadyHeld: true, + terminalReconciliation: true, + }, + ); + const rolledBackTask = rolledBackPlan.phases + .flatMap((phase) => phase.tasks) + .find((candidate) => candidate.id === taskId); + if (rolledBackTask?.status !== oldStatus.data) { + throw new Error( + `TASK_TERMINAL_READ_ONLY_SCOPE_ROLLBACK_FAILED: expected ${oldStatus.data}, found ${rolledBackTask?.status ?? 'missing task'}`, + ); + } + plan = rolledBackPlan; + } + await writeWal(walPath, { ...wal, state: 'ABORTED' }); + throw new Error( + `TASK_TERMINAL_READ_ONLY_SCOPE_CHANGED: task ${taskId} no longer has a known empty declared scope; terminal evidence was not applied`, + ); + } + } if (task.status === wal.oldPlanStatus && evidenceAlreadyTerminal) { plan = await updateTaskStatus(directory, taskId, wal.newPlanStatus, { planLockAlreadyHeld: true, diff --git a/tests/unit/tools/update-task-status-locked-scope-2763.test.ts b/tests/unit/tools/update-task-status-locked-scope-2763.test.ts new file mode 100644 index 000000000..eba32e0a0 --- /dev/null +++ b/tests/unit/tools/update-task-status-locked-scope-2763.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { BackgroundTaskChangeContext } from '../../../src/background/pending-delegations'; +import type { Plan, RuntimePlan } from '../../../src/config/plan-schema'; +import { replayFromLedgerWithStatus } from '../../../src/plan/ledger'; +import { savePlan } from '../../../src/plan/manager'; +import { resetSwarmState } from '../../../src/state'; +import { + checkReviewerGate, + executeUpdateTaskStatus, + _internals as updateTaskStatusInternals, +} from '../../../src/tools/update-task-status'; +import { + beginCoderSettlement, + settleCoderDispatch, +} from '../../../src/workflow/coder-settlement'; +import { createSafeTestDir } from '../../helpers/safe-test-dir'; + +const TASK_ID = '1.1'; + +function runGit(directory: string, args: string[], capture = false): string { + const result = spawnSync('git', ['-C', directory, ...args], { + cwd: directory, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'ignore', + timeout: 5000, + windowsHide: true, + }); + if (result.status !== 0) + throw new Error(`fixture git failed: ${args.join(' ')}`); + return capture ? String(result.stdout).trim() : ''; +} + +function fixturePlan(filesTouched: string[] = []): Plan { + return { + schema_version: '1.0.0', + title: 'Issue 2763 locked scope fixture', + swarm: 'issue-2763-locked-scope', + current_phase: 1, + phases: [ + { + id: 1, + name: 'Phase 1', + status: 'in_progress', + tasks: [ + { + id: TASK_ID, + phase: 1, + status: 'in_progress', + size: 'small', + description: 'Locked completion scope validation', + depends: [], + files_touched: filesTouched, + }, + ], + }, + ], + }; +} + +async function seedStaleEmptyProjection( + directory: string, +): Promise<{ planPath: string; authoritativePlan: Plan }> { + fs.mkdirSync(path.join(directory, '.swarm'), { recursive: true }); + fs.mkdirSync(path.join(directory, '.opencode'), { recursive: true }); + fs.writeFileSync(path.join(directory, '.opencode', 'marker'), 'fixture'); + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'issue-2763@example.invalid']); + runGit(directory, ['config', 'user.name', 'Issue 2763 test']); + runGit(directory, ['add', '.']); + runGit(directory, [ + 'commit', + '--quiet', + '-m', + 'issue 2763 locked-scope fixture', + ]); + await savePlan(directory, fixturePlan()); + const planPath = path.join(directory, '.swarm', 'plan.json'); + const emptyProjection = fs.readFileSync(planPath, 'utf8'); + const context = { + declaredFiles: [], + baseline: { + directory, + gitHead: runGit(directory, ['rev-parse', 'HEAD'], true), + dirtyHash: null, + changedFiles: [], + prHeadSha: null, + scope: null, + }, + workflowGeneration: 0, + } as BackgroundTaskChangeContext; + await beginCoderSettlement({ + directory, + taskId: TASK_ID, + transitionId: 'issue-2763-locked-scope-settlement', + actor: 'issue-2763-locked-scope-test', + expectedGeneration: 0, + context, + }); + await settleCoderDispatch({ + directory, + taskId: TASK_ID, + transitionId: 'issue-2763-locked-scope-settlement', + accepted: false, + testEngineerExempt: false, + }); + await savePlan(directory, fixturePlan(['src/new-file.ts'])); + const replay = await replayFromLedgerWithStatus(directory); + if (replay.truncated || !replay.plan) + throw new Error('locked-scope fixture ledger could not be replayed'); + fs.writeFileSync(planPath, emptyProjection); + return { planPath, authoritativePlan: replay.plan }; +} + +describe('issue #2763 — locked reviewer gate scope', () => { + let directory: string; + let cleanup: () => void; + + beforeEach(() => { + resetSwarmState(); + ({ dir: directory, cleanup } = createSafeTestDir( + 'update-task-status-locked-scope-2763-', + )); + }); + + afterEach(() => { + resetSwarmState(); + cleanup(); + }); + + test('issue #2763 review regression: locked scope overrides an empty stale projection', async () => { + const { planPath, authoritativePlan } = + await seedStaleEmptyProjection(directory); + expect(authoritativePlan.phases[0]?.tasks[0]?.files_touched).toEqual([ + 'src/new-file.ts', + ]); + // Simulate the locked caller's authoritative task scope alongside a stale + // projection. The under-lock gate must use the explicit scope argument, + // never infer emptiness from plan.json. + expect( + JSON.parse(fs.readFileSync(planPath, 'utf8')).phases[0].tasks[0] + .files_touched, + ).toEqual([]); + + const gate = checkReviewerGate( + TASK_ID, + directory, + false, + 'locked-session', + directory, + ['src/new-file.ts'], + ); + + expect(gate.blocked).toBe(true); + expect(gate.missingGates).toContain('pre_check'); + }); + + test('blocks locked completion before creating a WAL or forwarding status', async () => { + const { planPath, authoritativePlan } = + await seedStaleEmptyProjection(directory); + const originalLoadPlan = updateTaskStatusInternals.loadPlan; + updateTaskStatusInternals.loadPlan = async () => + authoritativePlan as RuntimePlan; + try { + const result = await executeUpdateTaskStatus( + { + task_id: TASK_ID, + status: 'completed', + working_directory: directory, + }, + directory, + ); + expect(result.success).toBe(false); + expect(result.errors?.join(' ')).toContain( + 'TASK_COMPLETION_CAS_MISMATCH', + ); + expect( + fs.existsSync( + path.join(directory, '.swarm', 'task-terminals', `${TASK_ID}.json`), + ), + ).toBe(false); + expect( + JSON.parse(fs.readFileSync(planPath, 'utf8')).phases[0].tasks[0].status, + ).toBe('in_progress'); + } finally { + updateTaskStatusInternals.loadPlan = originalLoadPlan; + } + }); +}); diff --git a/tests/unit/workflow/task-terminal-read-only-2763.test.ts b/tests/unit/workflow/task-terminal-read-only-2763.test.ts index 9548dae23..996cea940 100644 --- a/tests/unit/workflow/task-terminal-read-only-2763.test.ts +++ b/tests/unit/workflow/task-terminal-read-only-2763.test.ts @@ -9,7 +9,11 @@ import { readTaskEvidenceRaw, } from '../../../src/gate-evidence'; import { getOrAdoptPlanEpochUnderLock } from '../../../src/plan/ledger'; -import { loadPlanJsonOnly, savePlan } from '../../../src/plan/manager'; +import { + loadPlanJsonOnly, + savePlan, + updateTaskStatus, +} from '../../../src/plan/manager'; import { resetSwarmState } from '../../../src/state'; import { beginCoderSettlement, @@ -230,4 +234,44 @@ describe('issue #2763 — read-only terminal WAL', () => { 'COMMITTED', ); }); + + test('issue #2763 review regression: does not replay read-only proof after scope expands', async () => { + const walPath = await seedPreparedReadOnlyTerminal(directory); + await updateTaskStatus(directory, TASK_ID, 'completed'); + const forwardedPlan = await loadPlanJsonOnly(directory); + if (!forwardedPlan) throw new Error('terminal fixture plan missing'); + const expandedPlan: Plan = { + ...forwardedPlan, + phases: forwardedPlan.phases.map((phase) => ({ + ...phase, + tasks: phase.tasks.map((task) => + task.id === TASK_ID + ? { ...task, files_touched: ['src/new-file.ts'] } + : task, + ), + })), + }; + await savePlan(directory, expandedPlan); + + // The persisted true marker proves only that the original declaration was + // empty. Recovery must not apply it after the ledger-authoritative scope + // has expanded while the WAL is still PREPARED. + await expect( + recoverPreparedTaskTerminal( + directory, + TASK_ID, + 'issue-2763-expanded-scope-recovery', + ), + ).rejects.toThrow('TASK_TERMINAL_READ_ONLY_SCOPE_CHANGED'); + + const recoveredPlan = await loadPlanJsonOnly(directory); + expect(recoveredPlan?.phases[0]?.tasks[0]?.status).toBe('in_progress'); + expect(JSON.parse(fs.readFileSync(walPath, 'utf8')).state).toBe('ABORTED'); + expect( + getTaskWorkflowSnapshot(readTaskEvidenceRaw(directory, TASK_ID)), + ).toMatchObject({ + state: 'idle', + generation: 0, + }); + }); }); From 51953481e4f3e853cf7efd456b968c6322d73613 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 16 Sep 2026 02:30:57 -0500 Subject: [PATCH 11/11] fix(gates): preserve legacy advisory gate checks --- src/gate-evidence.ts | 12 +++++++---- .../unit/evidence/gate-evidence-2763.test.ts | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/gate-evidence.ts b/src/gate-evidence.ts index acb046e99..ce3797df8 100644 --- a/src/gate-evidence.ts +++ b/src/gate-evidence.ts @@ -1560,11 +1560,15 @@ export async function hasPassedAllGates( const evidence = await readTaskEvidence(directory, taskId); if (!evidence) return false; if (!Array.isArray(evidence.required_gates)) return false; + if (currentDeclaredFiles === undefined) { + // Scope-free callers retain the legacy advisory-gate behavior, but a + // no-mutation marker is never proof without an explicit empty scope. + if (deriveApplicableGateSet(evidence).readOnlyNoMutation) return false; + return hasAllRequiredGatesPassed(evidence.required_gates, evidence.gates); + } return ( - deriveApplicableGateSet(evidence, { - // Missing caller scope is unknown, not proof of a still-empty plan. - currentDeclaredFiles: currentDeclaredFiles ?? null, - }).missingGates.length === 0 + deriveApplicableGateSet(evidence, { currentDeclaredFiles }).missingGates + .length === 0 ); } diff --git a/tests/unit/evidence/gate-evidence-2763.test.ts b/tests/unit/evidence/gate-evidence-2763.test.ts index 4c77055a2..7eaeee901 100644 --- a/tests/unit/evidence/gate-evidence-2763.test.ts +++ b/tests/unit/evidence/gate-evidence-2763.test.ts @@ -90,4 +90,24 @@ describe('hasPassedAllGates applicability (#2763)', () => { expect(await hasPassedAllGates(directory, '1.4')).toBe(true); }); + + test('preserves legacy advisory gates but keeps no-mutation scope fail-closed', async () => { + writeEvidence('1.5', { + required_gates: ['critic'], + gates: { + critic: { + sessionId: 'session-1', + timestamp: '2026-09-14T00:00:00.000Z', + agent: 'critic', + }, + }, + }); + + // Legacy callers do not have scope authority and retain the historical + // advisory-gate result. Explicit scope input uses the strict derivation + // path, which requires pre_check for this non-marker evidence. + expect(await hasPassedAllGates(directory, '1.5')).toBe(true); + expect(await hasPassedAllGates(directory, '1.5', [])).toBe(false); + expect(await hasPassedAllGates(directory, '1.5', null)).toBe(false); + }); });