Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/releases/pending/rework-recovery-escape-hatch-2755.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# rework_required gains an architect-only audited exit (issue #2755)

- **New `recover_rework_task` tool (architect-only).** A task stranded at
`rework_required` by a Stage B verdict that did not require a code change
(e.g. a SKIPPED verdict scored from a tool-argument mistake) can now be
returned to Stage B dispatch without re-running the coder. The tool writes
a supervised `stage_a_passed` for the task's current generation and appends
a `stage_a_repair` audit event (action `rework_recovered`, with the
sanitized reason) to `.swarm/events.jsonl`; the durable transition id is
prefixed `rework-recovery:` so a supervised recovery stays distinguishable
from a mechanical Stage A pass.
- **Fail-closed preconditions.** Requires the active session to be the
architect, the exact plan task id, durable workflow state exactly
`rework_required`, and green pre-check proof for the wedged generation
(both secretscan and sast_scan bundles green and newer than the failure
transition — the same #2665 bar the Stage A wedge repair uses; bundle
recency is global, not correlated to the task's changed files). Every
refusal is a distinct typed error (`RECOVER_REWORK_*`, `PLAN_*`). Known
limitation: a project with `gates.sast_scan.enabled` disabled never
persists a sast_scan bundle, so `recover_rework_task` cannot succeed
there by design — use the coder repair loop instead (or re-enable SAST).
- **Durable distinguishability.** The supervised pass also persists a
`supervisedRecovery` marker in the task's workflow evidence (mirroring the
`forcedCompletion` precedent: preserved across same-generation
transitions, cleared when `repair_idle` opens a new generation), so a
supervised recovery stays distinguishable from a mechanical Stage A pass
even after later transitions overwrite the transition id; the append
outcome of the `.swarm/events.jsonl` audit event is surfaced in the tool
result (`audit_event_recorded`) instead of being asserted.
- **Mechanical guardrail unchanged.** The reducer admits `stage_a_passed`
from `rework_required` only when the new `supervisedRecovery` event flag is
set, which only this tool sets. The guardrails recorder and the
`/swarm recover` wedge scan are untouched: a genuine code defect still
requires an accepted coder mutation before Stage A passes again, and the
automatic repair scan still skips `rework_required` tasks.
- **Truthful dispatch remediation.** The `TASK_WORKFLOW_STAGE_A_REQUIRED`
refusal for reviewer/test_engineer dispatch now branches on state: from
`rework_required` it names `recover_rework_task` (and the coder repair loop
for real defects) instead of the human-only `/swarm recover`; the
attribution-wedge guidance (`coder_delegated` after `/swarm reset-session`)
keeps the `/swarm recover` advice where it actually applies.
4 changes: 4 additions & 0 deletions scripts/check-core-events-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export const CORE_EVENTS_MENTION_ALLOWLIST: Readonly<
reason: 'tool description/help strings describing where the audit event lands (documentation text, no I/O) — mirrors the approve-plan-critic entry for the #2703 sibling gate',
cls: 'prompt-doc',
},
'src/tools/recover-rework-task.ts': {
reason: 'tool description/result strings describing where the audit event lands (documentation text, no I/O — the append itself goes through appendStageARepairEvent/appendCoreEventSync) — the #2755 sibling of the approve-* entries',
cls: 'prompt-doc',
},
'src/services/diagnose-service.ts': {
reason: 'user-facing diagnostic output strings naming the checked store (the reads themselves go through the seam API)',
cls: 'prompt-doc',
Expand Down
35 changes: 0 additions & 35 deletions scripts/registry-citation-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -589,41 +589,6 @@
"kind": "out-of-range",
"note": "Pre-existing debt, not approved drift: \"writeCouncilEvidence\" exists in src/council/council-evidence-writer.ts but not inside src/council/council-evidence-writer.ts:96 (task-workflow-evidence.writerCitations[1])."
},
{
"rowId": "task-workflow-evidence",
"file": "src/gate-evidence.ts",
"identifier": "readTaskEvidence",
"kind": "out-of-range",
"note": "Pre-existing debt, not approved drift: \"readTaskEvidence\" exists in src/gate-evidence.ts but not inside src/gate-evidence.ts:1054 (task-workflow-evidence.readerCitations[0])."
},
{
"rowId": "task-workflow-evidence",
"file": "src/gate-evidence.ts",
"identifier": "readTaskEvidenceRaw",
"kind": "out-of-range",
"note": "Pre-existing debt, not approved drift: \"readTaskEvidenceRaw\" exists in src/gate-evidence.ts but not inside :1071 (task-workflow-evidence.readerCitations[0])."
},
{
"rowId": "task-workflow-evidence",
"file": "src/gate-evidence.ts",
"identifier": "recordAgentDispatch",
"kind": "out-of-range",
"note": "Pre-existing debt, not approved drift: \"recordAgentDispatch\" exists in src/gate-evidence.ts but not inside :1010 (task-workflow-evidence.writerCitations[0])."
},
{
"rowId": "task-workflow-evidence",
"file": "src/gate-evidence.ts",
"identifier": "recordGateEvidence",
"kind": "out-of-range",
"note": "Pre-existing debt, not approved drift: \"recordGateEvidence\" exists in src/gate-evidence.ts but not inside :956 (task-workflow-evidence.writerCitations[0])."
},
{
"rowId": "task-workflow-evidence",
"file": "src/gate-evidence.ts",
"identifier": "transitionTaskWorkflowEvidence",
"kind": "out-of-range",
"note": "Pre-existing debt, not approved drift: \"transitionTaskWorkflowEvidence\" exists in src/gate-evidence.ts but not inside src/gate-evidence.ts:879 (task-workflow-evidence.writerCitations[0])."
},
{
"rowId": "telemetry-jsonl",
"file": "src/services/cost-accounting.ts",
Expand Down
16 changes: 8 additions & 8 deletions scripts/retention-registry.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1675,30 +1675,30 @@ 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:879 transitionTaskWorkflowEvidence / :956 recordGateEvidence / :1010 recordAgentDispatch — locked read-modify-write, atomic write',
'src/gate-evidence.ts:984 transitionTaskWorkflowEvidence / :1094 recordGateEvidence / :1152 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:1054 readTaskEvidence — FULL-FILE fail-open, async; :1071 readTaskEvidenceRaw — strict, sync',
'src/gate-evidence.ts:1196 readTaskEvidence — FULL-FILE fail-open, async; :1272 readTaskEvidenceRaw — strict, sync',
'src/council/council-evidence-writer.ts:207 hasCouncilEvidenceAttempt',
],
schemaVersion: 'workflow WAL states; unrecognized states degrade to null (documented :1048-1052)',
schemaVersion: 'workflow WAL states; unrecognized states degrade to null (documented :1183-1188)',
stateClass: 'authoritative',
privacyClass: 'mixed',
directFileExemption: {
reason: 'The per-task workflow WAL gates phase transitions and must be readable during recovery before any DB handle is warmed; retryHistory ≤3 and the close-cleaned evidence/ tree bound the direct files.',
reviewedIssue: 2036,
},
writeLimits: {
bound: 'retryHistory ≤3 (schema :303); per-task file; evidence/ archived+cleaned at close',
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:786 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:303; src/commands/close/constants.ts:253-269 ACTIVE_STATE_DIRS_TO_CLEAN',
'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.',
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:1054-1089' },
readBound: { pattern: 'full-file', bound: 'single per-task JSON', sync: true, citation: 'src/gate-evidence.ts:1196-1224' },
lockModel: 'withTaskEvidenceLock (evidence/{taskId}.json key) — proper-lockfile, 60 s timeout, backoff+jitter',
crashBehavior: 'atomic write; WAL PREPARED fencing (assertTaskEvidenceWriteAllowed :117)',
crashBehavior: 'atomic write; WAL PREPARED fencing (assertTaskEvidenceWriteAllowed :152)',
closePolicy: 'cleaned — evidence/ dir lifecycle',
resetPolicy: 'not reset',
legacyCompatibility: 'unknown workflow states read as null (graceful degrade)',
Expand Down
6 changes: 6 additions & 0 deletions src/commands/recover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ function renderStageARepairOutcome(outcome: StageARepairOutcome): string {
case 'repaired':
return `✅ Task ${outcome.taskId}: Stage A repaired — stage_a_passed written at generation ${outcome.generation} without re-running the coder`;
case 'skipped_not_wedged':
// Issue #2755: rework_required wedges have their own architect-legal
// exit that this scan deliberately does not touch — point at it instead
// of reporting a bare dead end.
if (outcome.state === 'rework_required') {
return `⏭️ Task ${outcome.taskId}: workflow state is rework_required — not a coder_delegated Stage A wedge, nothing to repair here. If the Stage B verdict did not require a code change, have the architect run the recover_rework_task tool for this task; otherwise delegate the coder to repair first.`;
}
return `⏭️ Task ${outcome.taskId}: workflow state is ${outcome.state} with pre_check proof present or not settled — nothing to repair`;
case 'skipped_not_green':
return `⏭️ Task ${outcome.taskId}: no green post-settlement pre-check evidence (${outcome.reason === 'no_pre_check_bundles' ? 'missing or non-green secretscan/SAST bundle — run pre_check_batch first' : 'latest pre-check run failed or predates the settlement'}) — refusing to mark Stage A passed without proof`;
Expand Down
5 changes: 4 additions & 1 deletion src/commands/reset-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,9 @@ export async function handleResetSessionCommand(
'intentionally survive this reset. If a task is blocked with ' +
'TASK_RETRY_CRITIC_REQUIRED waiting for a critic_sounding_board ' +
'APPROVED verdict that was already obtained, have the architect ' +
'record it with the approve_retry_sounding_board tool.',
'record it with the approve_retry_sounding_board tool. A task left ' +
'at rework_required also survives this reset; when the Stage B ' +
'verdict did not require a code change, the architect can exit it ' +
'with the recover_rework_task tool (issue #2755).',
].join('\n');
}
54 changes: 50 additions & 4 deletions src/gate-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ export interface TaskWorkflowMetadata {
* the same answer those files would have given anyway.
*/
forcedCompletion?: boolean;
/**
* True when this task's current generation entered `pre_check_passed` via the
* architect-supervised `recover_rework_task` escape hatch (issue #2755) rather
* than the mechanical pre_check_batch recorder.
*
* Same rationale as `forcedCompletion`: the reducer consumes the event-scoped
* `supervisedRecovery` flag transiently, so without a persisted marker the
* evidence file becomes byte-identical to a mechanically-earned Stage A pass
* as soon as the next transition overwrites `lastTransitionId`. Preserved
* across subsequent transitions in the same generation, cleared by
* `repair_idle` (which opens a new generation for genuinely new work).
*/
supervisedRecovery?: boolean;
}

export interface TaskWorkflowSnapshot extends TaskWorkflowMetadata {
Expand Down Expand Up @@ -241,6 +254,14 @@ export type TaskWorkflowTransitionEvent =
}
| {
type: 'stage_a_passed';
/**
* Architect-supervised recovery only (issue #2755): the recover_rework_task
* tool sets this to admit stage_a_passed from rework_required when the
* Stage B verdict did not require a code change. Mechanical emitters
* (the guardrails recorder, stage-a-repair) never set it, so they still
* fail closed with TASK_WORKFLOW_CODER_MUTATION_REQUIRED from that state.
*/
supervisedRecovery?: boolean;
expectedGeneration: number;
transitionId?: string;
}
Expand Down Expand Up @@ -329,6 +350,7 @@ const TaskWorkflowMetadataSchema = z.object({
lastTransitionId: z.string().min(1).nullable().optional().default(null),
updatedAt: z.string(),
forcedCompletion: z.boolean().optional(),
supervisedRecovery: z.boolean().optional(),
});

const TaskEvidenceSchema = z.object({
Expand Down Expand Up @@ -596,6 +618,12 @@ export function reduceTaskWorkflowSnapshot(
// completion stays visible downstream. Cleared by repair_idle, which opens a new
// generation for genuinely new work.
...(current.forcedCompletion === true ? { forcedCompletion: true } : {}),
// Same durability contract for the supervised Stage A entry mode (issue
// #2755): preserved across same-generation transitions, set by the
// supervised stage_a_passed below, stripped when a new generation opens.
...(current.supervisedRecovery === true
? { supervisedRecovery: true }
: {}),
};

switch (event.type) {
Expand Down Expand Up @@ -635,6 +663,8 @@ export function reduceTaskWorkflowSnapshot(
retryCount: Math.min(current.retryCount + 1, 3),
retryHistory: [...current.retryHistory, outcome].slice(-3),
retryEpoch: current.retryEpoch || current.generation + 1,
// New generation: any prior Stage A entry mode no longer applies.
supervisedRecovery: undefined,
};
}
return {
Expand All @@ -643,18 +673,29 @@ export function reduceTaskWorkflowSnapshot(
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' &&
current.state !== 'pre_check_passed'
current.state !== 'pre_check_passed' &&
!(
current.state === 'rework_required' &&
event.supervisedRecovery === true
)
) {
throw new Error(
`TASK_WORKFLOW_CODER_MUTATION_REQUIRED: cannot pass Stage A from ${current.state}`,
);
}
return {
...base,
// Persist the supervised entry mode so the evidence file stays
// distinguishable from a mechanically-earned Stage A pass after
// later transitions overwrite lastTransitionId (issue #2755 review).
...(event.supervisedRecovery === true
? { supervisedRecovery: true as const }
: {}),
state: 'pre_check_passed',
};
case 'stage_a_failed':
Expand Down Expand Up @@ -738,9 +779,14 @@ export function reduceTaskWorkflowSnapshot(
state: 'closed',
};
case 'repair_idle': {
// A repair reopens the task for new work, so a prior forced completion no
// longer describes this generation. Drop the field rather than carrying it.
const { forcedCompletion: _cleared, ...withoutForced } = base;
// A repair reopens the task for new work, so prior forced-completion and
// supervised-recovery markers no longer describe this generation. Drop
// them rather than carrying them forward.
const {
forcedCompletion: _cleared,
supervisedRecovery: _clearedMarker,
...withoutForced
} = base;
return {
...withoutForced,
generation: current.generation + 1,
Expand Down
13 changes: 12 additions & 1 deletion src/hooks/delegation-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4455,10 +4455,21 @@ export function createDelegationGateHook(
);
return;
}
// Two distinct remediation fragments (never merged into one
// literal): rework_required has an architect-legal autonomous
// exit (issue #2755), every other pre-Stage-A state still gets
// the attribution-wedge guidance where /swarm recover applies.
const stageARemediation =
workflow.state === 'rework_required'
? 'Remediation: if the Stage B verdict did not require a code change, call recover_rework_task for task ' +
taskId +
' (architect-only, audited; requires green secretscan AND sast_scan evidence newer than the verdict — normally from a fresh pre_check_batch run; unavailable when SAST is disabled), then re-dispatch. ' +
'Otherwise delegate the coder to repair the code, re-run pre_check_batch, and re-dispatch once stage_a_passed has fired.'
: `Remediation: run pre_check_batch on the task's changed files first. If pre_check_batch passes but the task remains coder_delegated (typical after /swarm reset-session), run /swarm recover ${taskId} to repair Stage A attribution, then re-dispatch.`;
throw new Error(
`TASK_WORKFLOW_STAGE_A_REQUIRED: cannot dispatch ${targetAgent} for task ${taskId} from ${workflow.state}. ` +
`Stage B (${targetAgent}) requires the task to be at pre_check_passed (or later) — a state written only by the stage_a_passed transition, which is emitted when pre_check_batch completes with the task correctly attributed. ` +
`Remediation: run pre_check_batch on the task's changed files first. If pre_check_batch passes but the task remains coder_delegated (typical after /swarm reset-session), run /swarm recover ${taskId} to repair Stage A attribution, then re-dispatch.` +
stageARemediation +
(targetAgent === 'reviewer' || targetAgent === 'test_engineer'
? ` For PR-review re-entry outside the task workflow, issue a one-use authorization with authorize_pr_review_reentry immediately before the Task dispatch.`
: ''),
Expand Down
4 changes: 4 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ export {
executeRecordRecurrenceSweep,
record_recurrence_sweep,
} from './record-recurrence-sweep';
export {
executeRecoverReworkTask,
recover_rework_task,
} from './recover-rework-task';
export {
executeRunPrFeedbackStageA,
run_pr_feedback_stage_a,
Expand Down
2 changes: 2 additions & 0 deletions src/tools/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import { record_implementation_review } from './record-implementation-review';
import { record_issue_publication } from './record-issue-publication';
import { record_issue_reproduction } from './record-issue-reproduction';
import { record_recurrence_sweep } from './record-recurrence-sweep';
import { recover_rework_task } from './recover-rework-task';
import { repair_gate_evidence } from './repair-gate-evidence';
import { repair_knowledge_receipt_ledger } from './repair-knowledge-receipt-ledger';
import { repo_map } from './repo-map';
Expand Down Expand Up @@ -198,6 +199,7 @@ export const TOOL_MANIFEST = defineHandlers({
submit_pr_review_result: () => submit_pr_review_result,
approve_plan_critic: () => approve_plan_critic,
approve_retry_sounding_board: () => approve_retry_sounding_board,
recover_rework_task: () => recover_rework_task,
prepare_pr_workflow_checkout: () => prepare_pr_workflow_checkout,
record_implementation_review: () => record_implementation_review,
record_issue_publication: () => record_issue_publication,
Expand Down
Loading
Loading