Skip to content
Open
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
19 changes: 19 additions & 0 deletions docs/releases/pending/guardrails-coder-mutation-guidance-2758.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Correct Stage A coder-mutation guidance

## What

Stage A guardrails now distinguish a missing accepted coder mutation from a
genuine attribution-recovery failure.

## Why

When a task requires rework, `/swarm recover` cannot satisfy the reducer's
requirement for a new coder mutation. The architect now receives bounded
guidance to dispatch a coder for a real code change or mark the task blocked
when no valid change exists, while genuine attribution failures retain their
recovery guidance.

## Migration

No migration is required. This is an internal guardrail message correction;
workflow persistence and command behavior are unchanged.
51 changes: 38 additions & 13 deletions src/hooks/guardrails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,18 @@ export function emitDurableAttributionAdvisory(
}

/**
* Stage A workflow-transition write errors that indicate an attribution or
* correlation miss — the post-reset wedge signature. These escalate beyond a
* log line because every silent one is exactly how tasks wedge at
* coder_delegated with no diagnostic. Duplicate transitions never throw
* (isDuplicateTransition returns the existing evidence), so any throw here is
* abnormal; TASK_WORKFLOW_TERMINAL and fencing codes stay warn-only because a
* late gate result after close/settlement is expected churn, not a wedge.
* Stage A workflow-transition write errors are split by the remediation they
* require. Coder-mutation-required is a reducer precondition: the architect
* must dispatch a coder for a real change before another Stage A result can
* be recorded. Stage-A-required is the attribution-recovery category. It is
* defensive at these two Stage A catches (the wrapped stage_a_passed and
* stage_a_failed writes currently cannot throw it), but remains live in the
* Stage B/delegation recovery surfaces.
*
* Duplicate transitions never throw (isDuplicateTransition returns the
* existing evidence), so any throw here is abnormal; TASK_WORKFLOW_TERMINAL
* and fencing codes stay warn-only because a late gate result after
* close/settlement is expected churn, not a wedge.
*
* `TASK_WORKFLOW_GENERATION_MISMATCH` is deliberately EXCLUDED: it is a CAS
* fencing code that fires during ordinary concurrent/parallel-lane operation
Expand All @@ -198,8 +203,11 @@ export function emitDurableAttributionAdvisory(
* turn a routine race into a false "run /swarm recover" advisory during
* normal, non-reset operation.
*/
export const STAGE_A_ATTRIBUTION_MISS_CODES = new Set([
export const STAGE_A_CODER_MUTATION_REQUIRED_CODES = new Set([
'TASK_WORKFLOW_CODER_MUTATION_REQUIRED',
]);

export const STAGE_A_ATTRIBUTION_MISS_CODES = new Set([
'TASK_WORKFLOW_STAGE_A_REQUIRED',
]);

Expand Down Expand Up @@ -1254,7 +1262,15 @@ export function createGuardrailsHooks(
emitStageARoute('pre_check_failed', taskId);
} catch (err) {
const code = stageAWriteErrorCode(err);
if (code && STAGE_A_ATTRIBUTION_MISS_CODES.has(code)) {
if (code && STAGE_A_CODER_MUTATION_REQUIRED_CODES.has(code)) {
logger.criticalWarn(
`[guardrails] Stage A failure write failed for task ${taskId}: ${code}. An accepted coder mutation is required before Stage A can be recorded again. Dispatch a coder for a real code change, or mark the task blocked if no valid change exists.`,
);
pushAdvisory(
session,
`STAGE A WRITE FAILED (${code}) for task ${taskId}: pre_check_batch failed but an accepted coder mutation is required before Stage A can be recorded again. Dispatch a coder for a real code change, or mark the task blocked if no valid change exists.`,
);
} else if (code && STAGE_A_ATTRIBUTION_MISS_CODES.has(code)) {
logger.criticalWarn(
`[guardrails] Stage A failure write failed for task ${taskId}: ${code}. Run /swarm recover ${taskId}.`,
);
Expand Down Expand Up @@ -1307,10 +1323,11 @@ export function createGuardrailsHooks(
emitStageARoute('valid_pass', taskId);
} catch (err) {
// Duplicate transitions return existing evidence without
// throwing, so any error here is abnormal. Attribution-miss
// throwing, so any error here is abnormal. These category-specific
// codes are exactly how tasks silently wedge at
// coder_delegated post-reset — escalate them to a visible
// advisory instead of swallowing (TASK_WORKFLOW_TERMINAL and
// coder_delegated post-reset — escalate both category-specific
// routing codes to a visible advisory instead of swallowing
// (TASK_WORKFLOW_TERMINAL and
// WAL-fencing codes stay log-only: late gate results after
// close/settlement are expected churn, not a wedge).
const code = stageAWriteErrorCode(err);
Expand All @@ -1319,7 +1336,15 @@ export function createGuardrailsHooks(
// a late result that must not advance the task.
emitStageARoute('late_result', taskId);
}
if (code && STAGE_A_ATTRIBUTION_MISS_CODES.has(code)) {
if (code && STAGE_A_CODER_MUTATION_REQUIRED_CODES.has(code)) {
logger.criticalWarn(
`[guardrails] Stage A write failed for task ${taskId}: ${code} — an accepted coder mutation is required before Stage A can be recorded again. Dispatch a coder for a real code change, or mark the task blocked if no valid change exists.`,
);
pushAdvisory(
session,
`STAGE A WRITE FAILED (${code}) for task ${taskId}: pre_check_batch passed but an accepted coder mutation is required before Stage A can be recorded again. Dispatch a coder for a real code change, or mark the task blocked if no valid change exists.`,
);
} else if (code && STAGE_A_ATTRIBUTION_MISS_CODES.has(code)) {
logger.criticalWarn(
`[guardrails] Stage A write failed for task ${taskId}: ${code} — pre_check_batch result was NOT attributed. Run /swarm recover ${taskId}.`,
);
Expand Down
63 changes: 61 additions & 2 deletions tests/unit/hooks/guardrails-durable-stage-a-attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* of a swallowed warn;
* - the normal correlated flow is unchanged.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import type { GuardrailsConfig } from '../../../src/config/schema';
import {
getTaskWorkflowSnapshot,
Expand All @@ -27,6 +27,7 @@ import {
resetSwarmState,
swarmState,
} from '../../../src/state';
import * as logger from '../../../src/utils/logger';
import { createSafeTestDir } from '../../helpers/safe-test-dir';

// Deterministic fixture instant (explicit-arg Date constructor, not a raw
Expand Down Expand Up @@ -351,7 +352,7 @@ describe('durable Stage A attribution', () => {
).toBe(true);
});

test('attribution-miss write failure escalates to a visible advisory', async () => {
test('coder-mutation write failure escalates to a visible advisory', async () => {
// Correlated task with NO durable coder_delegated evidence: the Stage A
// pass transition throws TASK_WORKFLOW_CODER_MUTATION_REQUIRED — the
// exact error class previously swallowed into an invisible warn.
Expand All @@ -378,6 +379,64 @@ describe('durable Stage A attribution', () => {
).toBe(true);
});

test('rework_required pass identifies the required coder mutation without recovery advice', async () => {
await transitionTaskWorkflowEvidence(directory, '9.10', {
type: 'accepted_mutation',
agentType: 'coder',
expectedGeneration: 0,
transitionId: 'coder:setup-9.10',
});
await transitionTaskWorkflowEvidence(directory, '9.10', {
type: 'stage_a_failed',
expectedGeneration: 1,
transitionId: 'stage-a:setup-9.10',
});
expect(
getTaskWorkflowSnapshot(await readTaskEvidence(directory, '9.10')).state,
).toBe('rework_required');
ensureAgentSession('architect').currentTaskId = '9.10';

const hooks = createGuardrailsHooks(directory, defaultConfig());
const criticalWarnSpy = spyOn(logger, 'criticalWarn').mockImplementation(
() => {},
);

try {
await hooks.toolBefore(
{ tool: 'pre_check_batch', sessionID: 'architect', callID: 'c5' },
{ args: {} },
);
await hooks.toolAfter(
{ tool: 'pre_check_batch', sessionID: 'architect', callID: 'c5' },
{ title: '', output: PASS_PAYLOAD, metadata: null },
);

const messages =
swarmState.agentSessions.get('architect')?.pendingAdvisoryMessages ??
[];
const advisory = messages.find((message) =>
message.includes('TASK_WORKFLOW_CODER_MUTATION_REQUIRED'),
);
expect(advisory).toBeDefined();
expect(advisory).toContain('accepted coder mutation');
expect(advisory).toContain('before Stage A');
expect(advisory).not.toContain('NOT attributed');
expect(advisory).not.toContain('/swarm recover');
expect(criticalWarnSpy).toHaveBeenCalledTimes(1);
expect(criticalWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('accepted coder mutation'),
);
expect(criticalWarnSpy.mock.calls[0]?.[0]).not.toContain(
'NOT attributed',
);
expect(criticalWarnSpy.mock.calls[0]?.[0]).not.toContain(
'/swarm recover',
);
} finally {
criticalWarnSpy.mockRestore();
}
});

test('normal correlated flow is unchanged (no fallback needed)', async () => {
await settleTask('3.1');
const session = ensureAgentSession('architect');
Expand Down
110 changes: 110 additions & 0 deletions tests/unit/hooks/guardrails-stage-a-coder-mutation-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/** Regression coverage for the failure-side coder-mutation workflow guard. */
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import type { GuardrailsConfig } from '../../../src/config/schema';
import {
getTaskWorkflowSnapshot,
readTaskEvidence,
} from '../../../src/gate-evidence';
import { createGuardrailsHooks } from '../../../src/hooks/guardrails';
import {
ensureAgentSession,
resetSwarmState,
swarmState,
} from '../../../src/state';
import * as logger from '../../../src/utils/logger';
import { createSafeTestDir } from '../../helpers/safe-test-dir';

const FAIL_PAYLOAD = JSON.stringify({
gates_passed: false,
total_duration_ms: 1,
batch_status: 'completed',
lint: { ran: true, duration_ms: 1 },
secretscan: {
ran: true,
duration_ms: 1,
result: {
count: 1,
findings: ['test-secret'],
files_scanned: 1,
incomplete_files: 0,
incomplete_paths: [],
},
},
sast_scan: { ran: true, duration_ms: 1, result: { verdict: 'pass' } },
quality_budget: { ran: false, duration_ms: 0 },
});

function defaultConfig(): GuardrailsConfig {
return {
enabled: true,
max_tool_calls: 200,
max_duration_minutes: 30,
idle_timeout_minutes: 60,
max_repetitions: 10,
max_consecutive_errors: 5,
warning_threshold: 0.75,
};
}

let cleanup: () => void;
let directory: string;

beforeEach(() => {
({ dir: directory, cleanup } = createSafeTestDir(
'guardrails-coder-mutation',
));
resetSwarmState();
});

afterEach(() => {
cleanup();
resetSwarmState();
});

describe('coder-mutation-required Stage A failure guidance', () => {
test('does not mislabel a failed Stage A write as an attribution miss', async () => {
ensureAgentSession('architect').currentTaskId = '9.11';
expect(
getTaskWorkflowSnapshot(await readTaskEvidence(directory, '9.11')).state,
).toBe('idle');
const hooks = createGuardrailsHooks(directory, defaultConfig());
const criticalWarnSpy = spyOn(logger, 'criticalWarn').mockImplementation(
() => {},
);

try {
await hooks.toolBefore(
{ tool: 'pre_check_batch', sessionID: 'architect', callID: 'c-fail' },
{ args: {} },
);
await hooks.toolAfter(
{ tool: 'pre_check_batch', sessionID: 'architect', callID: 'c-fail' },
{ title: '', output: FAIL_PAYLOAD, metadata: null },
);

const messages =
swarmState.agentSessions.get('architect')?.pendingAdvisoryMessages ??
[];
const advisory = messages.find((message) =>
message.includes('TASK_WORKFLOW_CODER_MUTATION_REQUIRED'),
);
expect(advisory).toBeDefined();
expect(advisory).toContain('accepted coder mutation');
expect(advisory).toContain('before Stage A');
expect(advisory).not.toContain('NOT attributed');
expect(advisory).not.toContain('/swarm recover');
expect(criticalWarnSpy).toHaveBeenCalledTimes(1);
expect(criticalWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('accepted coder mutation'),
);
expect(criticalWarnSpy.mock.calls[0]?.[0]).not.toContain(
'NOT attributed',
);
expect(criticalWarnSpy.mock.calls[0]?.[0]).not.toContain(
'/swarm recover',
);
} finally {
criticalWarnSpy.mockRestore();
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** Preserves genuine Stage A attribution/recovery classification semantics. */
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as guardrails from '../../../src/hooks/guardrails/index';
import { STAGE_A_ATTRIBUTION_MISS_CODES } from '../../../src/hooks/guardrails/index';
import { createSafeTestDir } from '../../helpers/safe-test-dir';

let cleanup: () => void;

beforeEach(() => {
({ cleanup } = createSafeTestDir('stage-a-attribution-class'));
});

afterEach(() => {
cleanup();
});

function optionalCoderMutationCodes(): Set<string> {
const exported = (guardrails as unknown as Record<string, unknown>)[
'STAGE_A_CODER_MUTATION_REQUIRED_CODES'
];
return exported instanceof Set ? new Set(exported as Set<string>) : new Set();
}

describe('preserving Stage A attribution classification', () => {
test('keeps TASK_WORKFLOW_STAGE_A_REQUIRED in attribution recovery only', () => {
const attributionCode = 'TASK_WORKFLOW_STAGE_A_REQUIRED';
expect(STAGE_A_ATTRIBUTION_MISS_CODES.has(attributionCode)).toBe(true);
expect(optionalCoderMutationCodes().has(attributionCode)).toBe(false);
});
});
Loading
Loading