From 1a768f5f110eef7e522da263216121f265334a04 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:24:29 +0300 Subject: [PATCH 01/11] fix(claude): classify list tools capability warning --- scripts/claude-companion.mjs | 21 ++++++- scripts/lib/claude-cli.mjs | 18 ++++++ scripts/lib/state.mjs | 2 + tests/claude-cli.test.mjs | 39 +++++++++++++ tests/integration/claude-companion.test.mjs | 65 +++++++++++++++++++++ tests/peer-companion.test.mjs | 9 ++- 6 files changed, 150 insertions(+), 4 deletions(-) diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 5187a93..60c856b 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -44,6 +44,7 @@ import { runClaudeTurn, runClaudeReview, runClaudeAdversarialReview, + CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY_CODE, cancelClaudeProcess, MODEL_ALIASES, resolveEffort, @@ -1377,7 +1378,8 @@ async function executeReviewRun(request) { contextWindow: result.contextWindow ?? null, modelFallbacks, parseErrors: result.parseErrors ?? [], - unresolvedParseErrors: result.unresolvedParseErrors ?? 0 + unresolvedParseErrors: result.unresolvedParseErrors ?? 0, + streamDiagnostics: result.streamDiagnostics ?? [] } }; const rendered = appendModelFallbackSummary( @@ -1487,7 +1489,8 @@ async function executeReviewRun(request) { contextWindow: result.contextWindow ?? null, modelFallbacks, parseErrors: result.parseErrors ?? [], - unresolvedParseErrors: result.unresolvedParseErrors ?? 0 + unresolvedParseErrors: result.unresolvedParseErrors ?? 0, + streamDiagnostics: result.streamDiagnostics ?? [] }, result: parsed.parsed, rawOutput: parsed.rawOutput, @@ -1619,6 +1622,7 @@ async function executeTaskRun(request) { failure: result.failure ?? null, parseErrors: result.parseErrors ?? [], unresolvedParseErrors: result.unresolvedParseErrors ?? 0, + streamDiagnostics: result.streamDiagnostics ?? [], rawOutput, touchedFiles: Array.isArray(result.touchedFiles) ? result.touchedFiles @@ -1810,6 +1814,15 @@ function normalizePeerModelFallbacks(events) { : []; } +function normalizePeerStreamDiagnostics(diagnostics) { + return Array.isArray(diagnostics) + ? diagnostics + .filter(({ code } = {}) => code === CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY_CODE) + .slice(-50) + .map(({ code }) => ({ code })) + : []; +} + function buildReviewRequest({ cwd, base, @@ -2265,7 +2278,7 @@ function enqueueDetachedTask(cwd, job, request, options = {}) { function buildStoredTaskPayload(job) { if (job?.result && typeof job.result === "object") { - return { contextWindow: null, ...job.result }; + return { contextWindow: null, streamDiagnostics: [], ...job.result }; } return { status: job?.status === "completed" ? "completed" : "failed", @@ -2277,6 +2290,7 @@ function buildStoredTaskPayload(job) { finalModel: null, contextWindow: null, modelFallbacks: [], + streamDiagnostics: [], rawOutput: "", touchedFiles: [], ...(job?.errorMessage ? { errorMessage: job.errorMessage } : {}) @@ -3503,6 +3517,7 @@ async function executePeerClaudeTurn(cwd, workflowId, options = {}) { fallbackModel: peerModelValue(workflow, "claude-fallback") ?? "opus", modelFallbacks: normalizePeerModelFallbacks(result.modelEvents), contextWindow: result.contextWindow ?? null, + streamDiagnostics: normalizePeerStreamDiagnostics(result.streamDiagnostics), }; const payload = critique ? { diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index 4b1576b..33f39a7 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -29,6 +29,7 @@ import { const CLAUDE_BIN = "claude"; export const MAX_STREAM_PARSER_UNKNOWN_EVENTS = 50; export const MAX_STREAM_PARSER_PARSE_ERRORS = 50; +export const MAX_STREAM_PARSER_DIAGNOSTICS = 50; export const MAX_STREAM_PARSER_TOOL_USES = 256; export const MAX_STREAM_PARSER_TOUCHED_FILES = 256; export const MAX_STREAM_PARSER_MODEL_EVENTS = 50; @@ -44,6 +45,10 @@ const MODEL_FIELD_NAMES = [ "selected_model", "selectedModel", ]; +const CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY = + "Client.listTools() called but server does not advertise tools capability - returning empty list"; +export const CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY_CODE = + "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY"; function resolveClaudeNpmShim(shimPath) { let source; @@ -595,6 +600,7 @@ export class StreamParser { unknownEvents: [], parseErrors: [], unresolvedParseErrors: 0, + streamDiagnostics: [], toolUses: [], touchedFiles: [], modelEvents: [], @@ -630,6 +636,14 @@ export class StreamParser { _parseLine(line) { if (!line.trim()) return null; + if (line === CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY) { + pushBoundedTail( + this.state.streamDiagnostics, + { code: CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY_CODE }, + MAX_STREAM_PARSER_DIAGNOSTICS + ); + return null; + } try { const event = JSON.parse(line); // Forwarded subagent events (--forward-subagent-text) carry @@ -1439,6 +1453,7 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { modelEvents: [], parseErrors: [], unresolvedParseErrors: 0, + streamDiagnostics: [], failure: classifyClaudeFailure({ stderr: command.error, exitCode: -1, @@ -1579,6 +1594,7 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { modelEvents, parseErrors: [...parser.state.parseErrors], unresolvedParseErrors: parser.state.unresolvedParseErrors, + streamDiagnostics: [...parser.state.streamDiagnostics], failure, stderr, pid: proc.pid, @@ -1601,6 +1617,7 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { modelEvents: [], parseErrors: [], unresolvedParseErrors: 0, + streamDiagnostics: [], failure: classifyClaudeFailure({ stderr: err.message, exitCode: -1, @@ -1649,6 +1666,7 @@ export async function runClaudeReview(cwd, prompt, options = {}) { modelEvents: result.modelEvents, parseErrors: result.parseErrors, unresolvedParseErrors: result.unresolvedParseErrors, + streamDiagnostics: result.streamDiagnostics, failure: result.failure, stderr: result.stderr, pid: result.pid, diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index b55dac1..acff54a 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -439,6 +439,7 @@ function normalizeStoredJob(job) { result: { ...job.result, contextWindow: job.result.contextWindow ?? null, + streamDiagnostics: job.result.streamDiagnostics ?? [], }, }; } @@ -462,6 +463,7 @@ function normalizeStoredJob(job) { codex: { ...job.result.codex, contextWindow: job.result.codex.contextWindow ?? null, + streamDiagnostics: job.result.codex.streamDiagnostics ?? [], }, }, }; diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index 1cc9c04..e156536 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -1034,6 +1034,45 @@ describe("StreamParser", () => { assert.ok(parser.state.parseErrors[0].line.includes("not valid json")); }); + it("classifies only the exact list-tools capability warning without retaining its text", () => { + const parser = new StreamParser(); + const warning = "Client.listTools() called but server does not advertise tools capability - returning empty list"; + + parser.feed(warning + "\n"); + + assert.equal(parser.state.unresolvedParseErrors, 0); + assert.deepEqual(parser.state.streamDiagnostics, [ + { code: "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY" }, + ]); + assert.equal(JSON.stringify(parser.state.streamDiagnostics).includes(warning), false); + }); + + it("keeps near matches and unknown invalid JSON fail-closed", () => { + const parser = new StreamParser(); + + parser.feed("Client.listTools() called but server does not advertise tools capability - returning empty lists\n"); + parser.feed("not valid json\n"); + + assert.equal(parser.state.unresolvedParseErrors, 2); + assert.deepEqual(parser.state.streamDiagnostics, []); + assert.equal(parser.state.parseErrors.length, 2); + }); + + it("caps stable stream diagnostics at fifty entries", () => { + const parser = new StreamParser(); + const warning = "Client.listTools() called but server does not advertise tools capability - returning empty list"; + + for (let index = 0; index < 59; index++) { + parser.feed(warning + "\n"); + } + + assert.equal(parser.state.unresolvedParseErrors, 0); + assert.equal(parser.state.streamDiagnostics.length, 50); + assert.deepEqual(parser.state.streamDiagnostics[0], { + code: "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY", + }); + }); + it("caps stored parse error samples while keeping the total unresolved count", () => { const parser = new StreamParser(); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 8bfc321..b9cc2c0 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -115,6 +115,7 @@ async function main() { \`stub-\${sanitize(prompt)}-\${process.pid}\`; const emitUnknownNoTerminal = /\\bunknown-no-terminal\\b/.test(prompt); const emitMalformedLine = /\\bmalformed-line\\b/.test(prompt); + const emitListToolsWarning = process.env.CLAUDE_FAKE_LIST_TOOLS_WARNING === "1"; const emitSessionLimit = /\\bsession-limit\\b/.test(prompt); const emitFableLimit = /\\bfable-limit\\b/.test(prompt); const emitAuthFailure = /\\bauth-failure\\b/.test(prompt); @@ -214,6 +215,9 @@ async function main() { if (emitMalformedLine) { process.stdout.write("{not-json\\n"); } + if (emitListToolsWarning) { + process.stdout.write("Client.listTools() called but server does not advertise tools capability - returning empty list\\n"); + } const requestedModel = getValue("--model"); const nativeModels = { @@ -1794,6 +1798,33 @@ describe("claude-companion integration", () => { } }); + it("returns stable list-tools diagnostics without raw warning text for a read-only task", () => { + const testEnv = createTestEnvironment(); + + try { + const payload = runCompanionJson( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--json", + "--quiet-progress", + "document list-tools diagnostics", + ], + { env: { ...testEnv.env, CLAUDE_FAKE_LIST_TOOLS_WARNING: "1" } } + ); + + assert.equal(payload.status, "completed"); + assert.equal(payload.unresolvedParseErrors, 0); + assert.deepEqual(payload.streamDiagnostics, [ + { code: "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY" }, + ]); + assert.equal(JSON.stringify(payload).includes("Client.listTools()"), false); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("does not classify failed output that only mentions rate limiting in the final message", () => { const testEnv = createTestEnvironment(); @@ -3117,6 +3148,7 @@ describe("claude-companion integration", () => { { env: testEnv.env } ); assert.equal(statusPayload.job.result.contextWindow, null); + assert.deepEqual(statusPayload.job.result.streamDiagnostics, []); writeSessionScopedJob(testEnv, jobId, legacyJob); const resultPayload = runCompanionJson( @@ -3125,6 +3157,7 @@ describe("claude-companion integration", () => { ); assert.equal(resultPayload.job.result.contextWindow, null); assert.equal(resultPayload.storedJob.result.contextWindow, null); + assert.deepEqual(resultPayload.storedJob.result.streamDiagnostics, []); writeSessionScopedJob(testEnv, legacyReviewJob.id, legacyReviewJob); const reviewStatusPayload = runCompanionJson( @@ -3132,6 +3165,7 @@ describe("claude-companion integration", () => { { env: testEnv.env } ); assert.equal(reviewStatusPayload.job.result.codex.contextWindow, null); + assert.deepEqual(reviewStatusPayload.job.result.codex.streamDiagnostics, []); writeSessionScopedJob(testEnv, legacyReviewJob.id, legacyReviewJob); const reviewResultPayload = runCompanionJson( @@ -3140,6 +3174,7 @@ describe("claude-companion integration", () => { ); assert.equal(reviewResultPayload.job.result.codex.contextWindow, null); assert.equal(reviewResultPayload.storedJob.result.codex.contextWindow, null); + assert.deepEqual(reviewResultPayload.storedJob.result.codex.streamDiagnostics, []); } finally { cleanupTestEnvironment(testEnv); } @@ -4372,6 +4407,36 @@ describe("claude-companion integration", () => { } }); + it("returns stable list-tools diagnostics without raw warning text for a read-only review", () => { + const testEnv = createTestEnvironment(); + + try { + setupGitWorkspace(testEnv.workspaceDir); + fs.writeFileSync(path.join(testEnv.workspaceDir, "notes.md"), "review output\n", "utf8"); + + const payload = runCompanionJson( + [ + "review", + "--cwd", + testEnv.workspaceDir, + "--scope", + "working-tree", + "--json", + ], + { env: { ...testEnv.env, CLAUDE_FAKE_LIST_TOOLS_WARNING: "1" } } + ); + + assert.equal(payload.codex.status, "completed"); + assert.equal(payload.codex.unresolvedParseErrors, 0); + assert.deepEqual(payload.codex.streamDiagnostics, [ + { code: "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY" }, + ]); + assert.equal(JSON.stringify(payload).includes("Client.listTools()"), false); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("accepts terminal structured_output for adversarial reviews when result text is empty", () => { const testEnv = createTestEnvironment(); diff --git a/tests/peer-companion.test.mjs b/tests/peer-companion.test.mjs index 0ab6407..d0a8d3d 100644 --- a/tests/peer-companion.test.mjs +++ b/tests/peer-companion.test.mjs @@ -114,6 +114,9 @@ async function main() { reason: process.env.FAKE_CLAUDE_FALLBACK_REASON || "capacity", }) + "\\n"); } + if (process.env.FAKE_CLAUDE_LIST_TOOLS_WARNING === "1") { + process.stdout.write("Client.listTools() called but server does not advertise tools capability - returning empty list\\n"); + } const payload = critique ? { content: process.env.FAKE_CLAUDE_EMPTY_CRITIQUE === "1" ? {} @@ -735,7 +738,7 @@ describe("peer companion with fake Claude", () => { "--epoch", String(created.workflow.epoch), "--json", ], { input: attemptInput(claudeLease), - env: { FAKE_CLAUDE_FALLBACK: "1" }, + env: { FAKE_CLAUDE_FALLBACK: "1", FAKE_CLAUDE_LIST_TOOLS_WARNING: "1" }, }); assert.equal(result.status, "completed"); @@ -744,6 +747,10 @@ describe("peer companion with fake Claude", () => { assert.equal(result.memo.model.finalModel, "claude-opus-5"); assert.equal(result.memo.model.fallbackModel, "opus"); assert.equal(result.memo.model.modelFallbacks.length, 1); + assert.deepEqual(result.memo.model.streamDiagnostics, [ + { code: "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY" }, + ]); + assert.equal(JSON.stringify(result.memo).includes("Client.listTools()"), false); assert.deepEqual(result.memo.toolEvents.map(({ tool }) => tool), ["Read", "WebSearch"]); const invocation = JSON.parse(fs.readFileSync(testEnv.claudeLog, "utf8").trim()); const allowed = invocation.args.flatMap((value, index, args) => From b998bbf1f6cf1528e08a53483a698769b210e9ad Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:38:44 +0300 Subject: [PATCH 02/11] fix(peer): stabilize incomplete checkpoint retries --- scripts/claude-companion.mjs | 29 ++++- scripts/lib/peer-orchestration.mjs | 74 +++++++++--- scripts/lib/render.mjs | 5 +- scripts/lib/workflows.mjs | 52 +++++++-- tests/peer-companion.test.mjs | 72 +++++++++++- tests/peer-orchestration.test.mjs | 182 +++++++++++++++++++++++++++++ tests/workflows.test.mjs | 120 +++++++++++++++++++ 7 files changed, 505 insertions(+), 29 deletions(-) diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 60c856b..90dee8a 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -155,6 +155,7 @@ import { listWorkflows, markWorkflowNotification, markWorkflowBranchFailure, + normalizeWorkflowFailureDetail, readWorkflow, reconcilePeerRetry, rebindWorkflowOwner, @@ -3358,6 +3359,9 @@ function peerFailureCode(error) { function failPeerAttempt(cwd, workflowId, target, fence, error) { const reason = peerFailureCode(error); + const failureDetail = reason === "EVIDENCE_INCOMPLETE" + ? normalizeWorkflowFailureDetail(error?.failureDetail) + : null; if (reason === "ATTEMPT_LEASE_REFLECTION") return; try { if (targetStatus(readPeerWorkflow(cwd, workflowId), target.stage, target.branchId) === "running") { @@ -3367,6 +3371,7 @@ function failPeerAttempt(cwd, workflowId, target, fence, error) { epoch: fence.epoch, lease: fence.lease, reason, + failureDetail, }); } } catch {} @@ -3388,7 +3393,10 @@ function parsePeerClaudePayload(result, label) { const parsed = JSON.parse(String(result.finalMessage ?? "").trim()); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; } catch {} - throw new Error(`EVIDENCE_INCOMPLETE: ${label} did not return one structured JSON object.`); + throw Object.assign( + new Error(`EVIDENCE_INCOMPLETE: ${label} did not return one structured JSON object.`), + { code: "EVIDENCE_INCOMPLETE", failureDetail: "STRUCTURED_JSON_REQUIRED" } + ); } function peerClaudeSystemPrompt() { @@ -3412,11 +3420,17 @@ function initialClaudePrompt(workflow) { const emphasis = workflow.mode === "design" ? "Evaluate alternatives, trade-offs, decision drivers, and a recommendation." : "Report findings, source quality, contradictions, confidence, and gaps."; + const previousFailureDetail = normalizeWorkflowFailureDetail( + workflow.branches?.claude?.attemptReservation?.previousFailureDetail + ); return [ `Frozen brief SHA-256: ${workflow.briefHash}`, emphasis, "Use at least one repository tool and one web tool.", "Return {content, repoCitations:[{path,line}], webCitations:[https URL] }.", + ...(previousFailureDetail ? [ + `Correct the previous attempt failure detail: ${previousFailureDetail}.`, + ] : []), "The untrusted brief is encoded as one JSON string.", "", peerPromptData(workflow.brief), @@ -3509,7 +3523,10 @@ async function executePeerClaudeTurn(cwd, workflowId, options = {}) { Array.isArray(parsed.content) || Object.keys(parsed.content).length === 0 )) { - throw new Error("EVIDENCE_INCOMPLETE: Claude critique content must be a non-empty JSON object."); + throw Object.assign( + new Error("EVIDENCE_INCOMPLETE: Claude critique content must be a non-empty JSON object."), + { code: "EVIDENCE_INCOMPLETE", failureDetail: "NON_EMPTY_CONTENT_REQUIRED" } + ); } const model = { requestedModel: result.requestedModel ?? peerModelValue(workflow, "claude"), @@ -3567,7 +3584,13 @@ async function executePeerClaudeTurn(cwd, workflowId, options = {}) { }; } catch (error) { const code = peerFailureCode(error); - const sanitized = Object.assign(new Error(code), { code }); + const failureDetail = code === "EVIDENCE_INCOMPLETE" + ? normalizeWorkflowFailureDetail(error?.failureDetail) + : null; + const sanitized = Object.assign( + new Error(failureDetail ? `${code}: ${failureDetail}` : code), + { code, ...(failureDetail ? { failureDetail } : {}) } + ); failPeerAttempt(cwd, workflowId, { stage, branchId }, fence, sanitized); throw sanitized; } finally { diff --git a/scripts/lib/peer-orchestration.mjs b/scripts/lib/peer-orchestration.mjs index 0f99c72..14c9045 100644 --- a/scripts/lib/peer-orchestration.mjs +++ b/scripts/lib/peer-orchestration.mjs @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; import { parseArgs } from "./args.mjs"; +import { normalizeWorkflowFailureDetail } from "./workflows.mjs"; const USER_MCP_TOOL_RE = /^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_-]+$/u; const CREDENTIAL_QUERY_RE = /(?:token|secret|password|authorization|api[_-]?key|access[_-]?key|credential|signature|^key$)/iu; @@ -37,8 +38,12 @@ const PEER_SIBLING_WAIT_TIMEOUT_MS = 30 * 60 * 1000; const PEER_SIBLING_POLL_MIN_MS = 100; const PEER_SIBLING_POLL_MAX_MS = 2_000; -function peerError(code, message) { - return Object.assign(new Error(`${code}: ${message}`), { code }); +function peerError(code, message, failureDetail = null) { + const detail = normalizeWorkflowFailureDetail(failureDetail); + return Object.assign(new Error(`${code}: ${message}`), { + code, + ...(detail ? { failureDetail: detail } : {}), + }); } function modeName(mode) { @@ -171,6 +176,16 @@ function heredoc(command, value, marker) { return `${command} <<'${marker}'\n${promptData(value)}\n${marker}`; } +function checkpointReadInstructions(readCommand) { + return [ + "Make separate short foreground peer-wait calls; wait for each call to exit before starting another.", + "Do not use `while`, shell loops, background processes, or persistent pollers.", + readCommand, + "If terminalIncomplete is true, stop before checkpoint activation.", + "Activate checkpoint only when readyForCheckpoint is true.", + ]; +} + export function buildInitialAgentPlan(workflow, options) { const companionPath = options.companionPath; const codexLease = options.leases?.["branch:codex"]; @@ -213,13 +228,13 @@ export function buildInitialAgentPlan(workflow, options) { activationCommand(workflow, companionPath, "memo", "codex"), "Submit {lease:,payload:} as JSON stdin to this command:", submitMemoCommand, - "After submission, poll peer-wait until the Claude branch is completed or retryable_failed.", - readCommand, + "After submission, read the peer state with these one-shot instructions:", + ...checkpointReadInstructions(readCommand), "When both memos completed, activate checkpoint with {lease:} on JSON stdin immediately before comparison:", activationCommand(workflow, companionPath, "checkpoint"), "Then compare the frozen payloads and submit {lease:,payload:{agreements,disagreements,decisionsNeeded}} as JSON stdin to peer-checkpoint.", checkpointCommand, - "If Claude is retryable_failed, stop; do not synthesize or replace either memo.", + "If the workflow is incomplete, do not synthesize or replace either memo.", ].join("\n\n"), }; const claude = { @@ -297,8 +312,7 @@ export function buildRetryAgentPlan(workflow, retryTargets, options) { ...(options.codexModel ? { model: options.codexModel } : {}), message: [ "You are the Codex checkpoint waiter for a peer retry.", - "Poll until both memos complete, then activate immediately before comparing them.", - waitCommand, + ...checkpointReadInstructions(waitCommand), attemptBlock({ checkpoint: options.leases?.["stage:checkpoint"] }), activationCommand(workflow, options.companionPath, "checkpoint"), "Submit {lease,payload:{agreements,disagreements,decisionsNeeded}} as JSON stdin:", @@ -382,7 +396,11 @@ function directHttps(value) { export function validatePeerMemo(workflow, memo, options = {}) { if (!isPlainObject(memo) || !isPlainObject(memo.content) || Object.keys(memo.content).length === 0) { - throw peerError("EVIDENCE_INCOMPLETE", "Memo content must be a non-empty JSON object."); + throw peerError( + "EVIDENCE_INCOMPLETE", + "Memo content must be a non-empty JSON object.", + "NON_EMPTY_CONTENT_REQUIRED" + ); } const repoCitations = (Array.isArray(memo.repoCitations) ? memo.repoCitations : []) .flatMap((citation) => { @@ -397,14 +415,19 @@ export function validatePeerMemo(workflow, memo, options = {}) { if (repoCitations.length === 0) { throw peerError( "EVIDENCE_INCOMPLETE", - "Memo requires a canonical in-workspace repository citation." + "Memo requires a canonical in-workspace repository citation.", + "REPOSITORY_CITATION_REQUIRED" ); } const webCitations = (Array.isArray(memo.webCitations) ? memo.webCitations : []) .map(directHttps) .filter(Boolean); if (webCitations.length === 0) { - throw peerError("EVIDENCE_INCOMPLETE", "Memo requires a direct HTTPS citation."); + throw peerError( + "EVIDENCE_INCOMPLETE", + "Memo requires a direct HTTPS citation.", + "DIRECT_HTTPS_CITATION_REQUIRED" + ); } const toolEvents = (Array.isArray(options.toolEvents) ? options.toolEvents @@ -415,10 +438,18 @@ export function validatePeerMemo(workflow, memo, options = {}) { }); if (options.role === "claude") { if (!toolEvents.some(({ tool }) => ["Read", "Glob", "Grep"].includes(tool))) { - throw peerError("EVIDENCE_INCOMPLETE", "Claude memo requires an actual repo tool event."); + throw peerError( + "EVIDENCE_INCOMPLETE", + "Claude memo requires an actual repo tool event.", + "REPOSITORY_TOOL_EVENT_REQUIRED" + ); } if (!toolEvents.some(({ tool }) => ["WebSearch", "WebFetch"].includes(tool))) { - throw peerError("EVIDENCE_INCOMPLETE", "Claude memo requires an actual web tool event."); + throw peerError( + "EVIDENCE_INCOMPLETE", + "Claude memo requires an actual web tool event.", + "WEB_TOOL_EVENT_REQUIRED" + ); } } return { @@ -465,6 +496,7 @@ function peerBranchStatus(branch) { return { status: branch?.status ?? "missing", failureReason: branch?.failureReason ?? null, + failureDetail: normalizeWorkflowFailureDetail(branch?.failureDetail), attempts: branch?.attempts ?? 0, }; } @@ -484,6 +516,21 @@ export function buildPeerWaitView(workflow) { } const codexSealed = workflow.branches.codex.status === "completed"; const claudeSealed = workflow.branches.claude.status === "completed"; + const readyForCheckpoint = codexSealed && claudeSealed; + const hasCurrentReservation = (branch) => + branch?.attemptReservation?.epoch === workflow.epoch && + /^[a-f0-9]{64}$/u.test(branch.attemptReservation.leaseDigest ?? ""); + const terminalIncomplete = ( + ["cancelled", "cancel_failed"].includes(workflow.status) || + workflow.failureReason === "STALE_WORKSPACE" || + [ + ...Object.values(workflow.branches), + ...Object.values(workflow.stages ?? {}), + ].some((target) => + target.status === "cancel_failed" || + (target.status === "retryable_failed" && !hasCurrentReservation(target)) + ) + ); return { workflowId: workflow.id, mode: workflow.mode, @@ -496,7 +543,8 @@ export function buildPeerWaitView(workflow) { codex: peerBranchStatus(workflow.branches.codex), claude: peerBranchStatus(workflow.branches.claude), }, - readyForCheckpoint: codexSealed && claudeSealed, + readyForCheckpoint, + terminalIncomplete, ...(codexSealed ? { memos: { codex: workflow.branches.codex.payload, diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 0135723..0d1022d 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -492,13 +492,14 @@ function renderWorkflowDetails(workflow, options = {}) { pushKeyValueTableRow(lines, "Status", workflow.status); pushKeyValueTableRow(lines, "Phase", workflow.phase); pushKeyValueTableRow(lines, "Failure", workflow.failureReason ?? ""); + pushKeyValueTableRow(lines, "Failure detail", workflow.failureDetail ?? ""); pushKeyValueTableRow(lines, "Owner session", workflow.currentOwnerSessionId ?? ""); - lines.push("", "Branches:", "", "| Branch | Status | Attempts | Failure | Evidence |", "| --- | --- | --- | --- | --- |"); + lines.push("", "Branches:", "", "| Branch | Status | Attempts | Failure | Detail | Evidence |", "| --- | --- | --- | --- | --- | --- |"); for (const branchId of ["codex", "claude"]) { const branch = workflow.branches?.[branchId] ?? {}; lines.push( - `| ${branchId} | ${escapeMarkdownCell(branch.status ?? "missing")} | ${escapeMarkdownCell(branch.attempts ?? 0)} | ${escapeMarkdownCell(branch.failureReason ?? "")} | ${workflowEvidenceSummary(branch)} |` + `| ${branchId} | ${escapeMarkdownCell(branch.status ?? "missing")} | ${escapeMarkdownCell(branch.attempts ?? 0)} | ${escapeMarkdownCell(branch.failureReason ?? "")} | ${escapeMarkdownCell(branch.failureDetail ?? "")} | ${workflowEvidenceSummary(branch)} |` ); } diff --git a/scripts/lib/workflows.mjs b/scripts/lib/workflows.mjs index 107ddb2..3128b88 100644 --- a/scripts/lib/workflows.mjs +++ b/scripts/lib/workflows.mjs @@ -34,6 +34,14 @@ export const BRANCH_STATUSES = new Set([ "retryable_failed", "cancel_failed", ]); +const WORKFLOW_FAILURE_DETAILS = new Set([ + "STRUCTURED_JSON_REQUIRED", + "NON_EMPTY_CONTENT_REQUIRED", + "REPOSITORY_CITATION_REQUIRED", + "DIRECT_HTTPS_CITATION_REQUIRED", + "REPOSITORY_TOOL_EVENT_REQUIRED", + "WEB_TOOL_EVENT_REQUIRED", +]); const WORKFLOWS_DIR_NAME = "workflows"; const TERMINAL_WORKFLOW_STATUSES = new Set([ @@ -101,6 +109,10 @@ function assertBranchStatus(status) { return status; } +export function normalizeWorkflowFailureDetail(value) { + return typeof value === "string" && WORKFLOW_FAILURE_DETAILS.has(value) ? value : null; +} + function assertJsonObject(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw workflowError("INVALID_STAGE_PAYLOAD", `${label} must be a JSON object.`); @@ -173,6 +185,7 @@ function initialWorkItems(names) { status: "pending", payload: null, failureReason: null, + failureDetail: null, attempts: 0, }]) ); @@ -401,7 +414,7 @@ function terminalTargetState(state, fields) { return { ...rest, ...fields }; } -function invalidatedTargetState(state, status, failureReason, timestamp) { +function invalidatedTargetState(state, status, failureReason, timestamp, failureDetail = null) { const { attemptReservation: _attemptReservation, commitment: _commitment, @@ -412,6 +425,7 @@ function invalidatedTargetState(state, status, failureReason, timestamp) { status, payload: null, failureReason, + failureDetail: normalizeWorkflowFailureDetail(failureDetail), completedAt: timestamp, }; } @@ -462,6 +476,7 @@ function workflowSafetyViolation(workflow, target, timestamp, fingerprint) { status: "incomplete", phase: target.stage, failureReason: "SAFETY_VIOLATION", + failureDetail: null, ...enterIncomplete(workflow), branchAttempts: appendBranchAttempt( workflow, @@ -469,7 +484,7 @@ function workflowSafetyViolation(workflow, target, timestamp, fingerprint) { "failed", "retryable_failed", timestamp, - { failureReason: "SAFETY_VIOLATION", fingerprint } + { failureReason: "SAFETY_VIOLATION", failureDetail: null, fingerprint } ), }; } @@ -539,6 +554,7 @@ export function reserveWorkflow(cwd, input) { critique: null, finalResult: null, failureReason: null, + failureDetail: null, createdAt: timestamp, updatedAt: timestamp, }; @@ -624,6 +640,7 @@ export function reserveWorkflowAttempts(cwd, workflowId, options, targets) { leaseDigest: leaseDigest(lease), epoch: current.epoch, reservedAt: timestamp, + previousFailureDetail: normalizeWorkflowFailureDetail(target.state.failureDetail), }, }); } @@ -654,6 +671,7 @@ export function activateWorkflowAttempt(cwd, workflowId, options) { status: "incomplete", phase: options.stage, failureReason: "STALE_WORKSPACE", + failureDetail: null, ...enterIncomplete(workflow), }; } @@ -664,6 +682,7 @@ export function activateWorkflowAttempt(cwd, workflowId, options) { stage: target.stage, attempts, failureReason: null, + failureDetail: null, startedAt: timestamp, startFingerprint: currentFingerprint, commitment: null, @@ -673,6 +692,7 @@ export function activateWorkflowAttempt(cwd, workflowId, options) { status: "running", phase: target.stage, failureReason: null, + failureDetail: null, startedAt: workflow.startedAt ?? timestamp, branchAttempts: appendBranchAttempt( workflow, @@ -743,6 +763,7 @@ function completeWorkflowStage(cwd, workflowId, options, reveal) { payload, attempts: target.state.attempts + (options.oneShot ? 1 : 0), failureReason: null, + failureDetail: null, ...(options.oneShot ? { stage: target.stage, startedAt: timestamp, @@ -750,14 +771,20 @@ function completeWorkflowStage(cwd, workflowId, options, reveal) { } : {}), completedAt: timestamp, }); - const status = options.status ?? (options.field === "finalResult" ? "completed" : "running"); - const phase = options.phase ?? (status === "completed" ? "done" : target.stage); + const requestedStatus = options.status ?? + (options.field === "finalResult" ? "completed" : "running"); + const preserveAggregateFailure = workflow.status === "incomplete"; + const status = preserveAggregateFailure ? workflow.status : requestedStatus; + const phase = preserveAggregateFailure + ? workflow.phase + : options.phase ?? (status === "completed" ? "done" : target.stage); return { ...updateTarget(workflow, target, completedState), status, phase, fingerprint: currentFingerprint, - failureReason: null, + failureReason: preserveAggregateFailure ? workflow.failureReason : null, + failureDetail: preserveAggregateFailure ? workflow.failureDetail ?? null : null, ...(options.field ? { [options.field]: payload } : {}), ...(options.claudeSessionId ? { claudeSessionId: options.claudeSessionId } : {}), ...(status === "completed" ? { completedAt: timestamp } : {}), @@ -842,6 +869,7 @@ export function markWorkflowBranchFailure(cwd, workflowId, options) { const currentFingerprint = getWorkingTreeFingerprint(cwd); const status = assertBranchStatus(options.cancelFailed ? "cancel_failed" : "retryable_failed"); const reason = String(options.reason ?? "").trim(); + const failureDetail = normalizeWorkflowFailureDetail(options.failureDetail); if (!reason) { throw workflowError("INVALID_FAILURE_REASON", "A branch failure reason is required."); } @@ -867,7 +895,7 @@ export function markWorkflowBranchFailure(cwd, workflowId, options) { return workflowSafetyViolation(workflow, target, timestamp, currentFingerprint); } const failedState = { - ...invalidatedTargetState(target.state, status, reason, timestamp), + ...invalidatedTargetState(target.state, status, reason, timestamp, failureDetail), attempts: target.state.attempts + (options.oneShot ? 1 : 0), ...(options.oneShot ? { stage: target.stage, @@ -880,6 +908,7 @@ export function markWorkflowBranchFailure(cwd, workflowId, options) { status: options.cancelFailed ? "cancel_failed" : "incomplete", phase: target.stage, failureReason: reason, + failureDetail, ...enterIncomplete(workflow), branchAttempts: options.oneShot ? appendBranchAttempt( @@ -891,10 +920,10 @@ export function markWorkflowBranchFailure(cwd, workflowId, options) { ), }, { ...target, state: failedState }, - "failed", status, timestamp, { failureReason: reason } + "failed", status, timestamp, { failureReason: reason, failureDetail } ) : appendBranchAttempt( - workflow, target, "failed", status, timestamp, { failureReason: reason } + workflow, target, "failed", status, timestamp, { failureReason: reason, failureDetail } ), }; }); @@ -987,6 +1016,7 @@ export function reconcilePeerRetry(cwd, workflowId, options, linkedJobs = []) { status: claudeCancellationFailed ? "cancel_failed" : "incomplete", phase: claudeCancellationFailed ? "cancel_failed" : current.phase, failureReason: claudeCancellationFailed ? "CANCEL_FAILED" : "EXPLICIT_RETRY", + failureDetail: null, ...(claudeCancellationFailed ? {} : enterIncomplete(current)), }; }); @@ -1045,7 +1075,7 @@ export function getWorkflowRetryContext(cwd, workflowId, options = {}) { const select = (names, items, keyName) => names.flatMap((name) => { const item = items?.[name]; if (!item) { - return [{ [keyName]: name, status: "missing", failureReason: null }]; + return [{ [keyName]: name, status: "missing", failureReason: null, failureDetail: null }]; } if (!RETRYABLE_STATUSES.has(item.status)) { return []; @@ -1054,6 +1084,7 @@ export function getWorkflowRetryContext(cwd, workflowId, options = {}) { [keyName]: name, status: item.status, failureReason: item.failureReason ?? null, + failureDetail: normalizeWorkflowFailureDetail(item.failureDetail), }]; }); const stages = select(requiredStages, workflow.stages, "stage"); @@ -1099,6 +1130,7 @@ export function rebindWorkflowOwner(cwd, workflowId, options) { ...(invalidated ? { status: "incomplete", failureReason: "OWNER_REBOUND", + failureDetail: null, ...enterIncomplete(workflow), } : {}), }; @@ -1153,6 +1185,7 @@ export function completeWorkflowCancellation(cwd, workflowId, options) { status: failedJobIds.length > 0 ? "cancel_failed" : "cancelled", phase: failedJobIds.length > 0 ? "cancel_failed" : "cancelled", failureReason: failedJobIds.length > 0 ? "CANCEL_FAILED" : null, + failureDetail: null, cancelFailedJobIds: failedJobIds, cancellation: { ...workflow.cancellation, @@ -1198,6 +1231,7 @@ export function completeWorkflowSessionEnd(cwd, workflowId, options) { status: cancellationFailed ? "cancel_failed" : "incomplete", phase: cancellationFailed ? "cancel_failed" : workflow.phase, failureReason: cancellationFailed ? "SESSION_END_CANCEL_FAILED" : "SESSION_ENDED", + failureDetail: null, ...(cancellationFailed ? {} : enterIncomplete(workflow)), } : {}), cancellation: { diff --git a/tests/peer-companion.test.mjs b/tests/peer-companion.test.mjs index d0a8d3d..beed8d4 100644 --- a/tests/peer-companion.test.mjs +++ b/tests/peer-companion.test.mjs @@ -129,7 +129,9 @@ async function main() { const emitResult = () => process.stdout.write(JSON.stringify({ type: "result", session_id: sessionId, - result: JSON.stringify(payload), + result: process.env.FAKE_CLAUDE_UNSTRUCTURED === "1" + ? "not structured JSON" + : JSON.stringify(payload), model: process.env.FAKE_CLAUDE_FALLBACK === "1" ? "claude-opus-5" : "claude-fable-5", modelUsage: { "claude-fable-5": { inputTokens: 1, outputTokens: 1, contextWindow: 1000000 } }, }) + "\\n"); @@ -803,6 +805,26 @@ describe("peer companion with fake Claude", () => { assert.equal(after, before); }); + it("maps unstructured Claude output to the stable structured JSON detail", () => { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + const claudeLease = planLease(created, "_claude_"); + const failed = run(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { + input: attemptInput(claudeLease), + env: { FAKE_CLAUDE_UNSTRUCTURED: "1" }, + }); + + assert.notEqual(failed.status, 0); + assert.equal(failed.stderr, "EVIDENCE_INCOMPLETE: STRUCTURED_JSON_REQUIRED\n"); + const stored = readWorkflow(testEnv, created.workflow.id); + assert.equal(stored.failureDetail, "STRUCTURED_JSON_REQUIRED"); + assert.equal(stored.branches.claude.failureDetail, "STRUCTURED_JSON_REQUIRED"); + }); + it("marks missing Claude web evidence incomplete without replacing a successful sibling memo", () => { const testEnv = createEnvironment(); const created = createPeer(testEnv); @@ -828,11 +850,36 @@ describe("peer companion with fake Claude", () => { ], { input: attemptInput(claudeLease), env: { FAKE_CLAUDE_SPARSE: "1" } }); assert.notEqual(failed.status, 0); - assert.match(failed.stderr, /EVIDENCE_INCOMPLETE/); + assert.equal(failed.stderr, "EVIDENCE_INCOMPLETE: DIRECT_HTTPS_CITATION_REQUIRED\n"); const stored = readWorkflow(testEnv, created.workflow.id); assert.equal(stored.status, "incomplete"); + assert.equal(stored.failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); assert.equal(stored.branches.claude.status, "retryable_failed"); + assert.equal(stored.branches.claude.failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); + assert.equal(stored.branchAttempts.at(-1).failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); assert.deepEqual(stored.branches.codex.payload.content, codexMemo.content); + const [failedJob] = readPeerJobs(testEnv, created.workflow.id) + .filter(({ status }) => status === "failed"); + assert.equal( + failedJob.errorMessage, + "EVIDENCE_INCOMPLETE: DIRECT_HTTPS_CITATION_REQUIRED" + ); + const wait = runJson(testEnv, [ + "peer-wait", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--mode", "design", "--json", + ]); + assert.equal(wait.terminalIncomplete, true); + assert.equal(wait.branches.claude.failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); + const context = runJson(testEnv, [ + "workflow-retry-context", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--mode", "design", "--retry", "--required-branch", "claude", "--json", + ]); + assert.equal(context.branches[0].failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); + const rendered = run(testEnv, [ + "status", created.workflow.id, "--cwd", testEnv.workspaceDir, + ]); + assert.equal(rendered.status, 0, rendered.stderr || rendered.stdout); + assert.match(rendered.stdout, /DIRECT_HTTPS_CITATION_REQUIRED/u); const retry = runJson(testEnv, [ "peer-resume-plan", created.workflow.id, "--cwd", testEnv.workspaceDir, "--mode", "design", "--retry", "--owner-session-id", "owner-b", "--json", @@ -844,6 +891,27 @@ describe("peer companion with fake Claude", () => { assert.equal(retry.spawnPlan.some(({ task_name }) => task_name.includes("_checkpoint_")), true); assert.equal(retry.workflow.currentOwnerSessionId, "owner-b"); assert.equal(retry.workflow.epoch, 1); + assert.equal( + retry.workflow.branches.claude.attemptReservation.previousFailureDetail, + "DIRECT_HTTPS_CITATION_REQUIRED" + ); + const retryWait = runJson(testEnv, [ + "peer-wait", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--mode", "design", "--json", + ]); + assert.equal(retryWait.terminalIncomplete, false); + const retryClaudeLease = planLease(retry, "_claude_"); + runJson(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(retry.workflow.epoch), "--json", + ], { input: attemptInput(retryClaudeLease) }); + const retryInvocation = fs.readFileSync(testEnv.claudeLog, "utf8").trim() + .split("\n").map((line) => JSON.parse(line)).at(-1); + assert.match(retryInvocation.prompt, /DIRECT_HTTPS_CITATION_REQUIRED/u); + const recovered = readWorkflow(testEnv, created.workflow.id); + assert.equal(recovered.failureDetail, null); + assert.equal(recovered.branches.claude.failureDetail, null); }); it("runs initial and critique turns as fresh ephemeral sessions and retries only missing synthesis", () => { diff --git a/tests/peer-orchestration.test.mjs b/tests/peer-orchestration.test.mjs index c56b441..58a461a 100644 --- a/tests/peer-orchestration.test.mjs +++ b/tests/peer-orchestration.test.mjs @@ -11,10 +11,30 @@ import { describe, it } from "node:test"; import { buildInitialAgentPlan, buildPeerCheckpoint, + buildPeerWaitView, + buildRetryAgentPlan, parsePeerArguments, validatePeerMemo, } from "../scripts/lib/peer-orchestration.mjs"; +function assertOneShotCheckpointInstructions(message) { + assert.match( + message, + /Make separate short foreground peer-wait calls; wait for each call to exit before starting another\./u + ); + assert.match( + message, + /Do not use `while`, shell loops, background processes, or persistent pollers\./u + ); + assert.match(message, /If terminalIncomplete is true, stop before checkpoint activation\./u); + assert.match(message, /Activate checkpoint only when readyForCheckpoint is true\./u); + assert.doesNotMatch(message, /--until-checkpoint/u); + assert.ok( + message.indexOf("terminalIncomplete") < + message.indexOf("peer-activate-attempt", message.indexOf("peer-submit-memo")) + ); +} + describe("peer skill argument routing", () => { it("normalizes a new run with Fable, Opus fallback, and inherited xhigh Codex defaults", () => { const route = parsePeerArguments("design", [ @@ -114,6 +134,7 @@ describe("fake built-in agent orchestration", () => { assert.match(calls[0].message, /peer-activate-attempt[^\n]+--branch 'codex'/u); assert.match(calls[0].message, /peer-submit-memo/u); assert.match(calls[0].message, /peer-checkpoint/u); + assertOneShotCheckpointInstructions(calls[0].message); assert.match(calls[1].message, /peer-claude-turn/u); assert.doesNotMatch(calls[1].message, /codex exec|nohup|\s&\s/); assert.match(calls[0].message, new RegExp("c{64}")); @@ -128,6 +149,23 @@ describe("fake built-in agent orchestration", () => { } }); + it("generates a one-shot retry checkpoint worker that stops on terminal state", () => { + const [worker] = buildRetryAgentPlan({ + id: "workflow-retry", + mode: "design", + epoch: 2, + workspaceRoot: "/workspace/repo", + brief: "Compare queues and streams.", + briefHash: "a".repeat(64), + }, [{ stage: "checkpoint" }], { + companionPath: "/plugin/scripts/claude-companion.mjs", + leases: { "stage:checkpoint": "f".repeat(64) }, + }); + + assert.match(worker.task_name, /_checkpoint_/u); + assertOneShotCheckpointInstructions(worker.message); + }); + it("keeps shell-hostile prompt delimiters inside the frozen brief data boundary", () => { const plan = buildInitialAgentPlan({ id: "workflow-boundary", @@ -228,4 +266,148 @@ describe("peer evidence validation", () => { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); + + it("maps every memo validation gap to one bounded failure detail", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cc-peer-detail-")); + try { + const source = path.join(workspaceRoot, "source.mjs"); + fs.writeFileSync(source, "export const value = 1;\n", "utf8"); + const workflow = { workspaceRoot: fs.realpathSync.native(workspaceRoot) }; + const base = { + content: { finding: "validated" }, + repoCitations: [{ path: source, line: 1 }], + webCitations: ["https://example.test/reference"], + }; + /** @type {Array<[string, Record, Record]>} */ + const cases = [ + ["NON_EMPTY_CONTENT_REQUIRED", { ...base, content: {} }, {}], + ["REPOSITORY_CITATION_REQUIRED", { ...base, repoCitations: [] }, {}], + ["DIRECT_HTTPS_CITATION_REQUIRED", { ...base, webCitations: [] }, {}], + ["REPOSITORY_TOOL_EVENT_REQUIRED", base, { + role: "claude", toolEvents: [{ tool: "WebSearch" }], + }], + ["WEB_TOOL_EVENT_REQUIRED", base, { + role: "claude", toolEvents: [{ tool: "Read" }], + }], + ]; + + for (const [failureDetail, memo, options] of cases) { + assert.throws( + () => validatePeerMemo(workflow, memo, options), + (error) => { + const failure = /** @type {Error & {code?: string, failureDetail?: string}} */ (error); + return failure.code === "EVIDENCE_INCOMPLETE" && + failure.failureDetail === failureDetail; + }, + failureDetail + ); + } + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +describe("peer wait view", () => { + const branch = (status, overrides = {}) => ({ + status, + attempts: 1, + failureReason: null, + failureDetail: null, + ...overrides, + }); + const workflow = (overrides = {}) => ({ + id: "workflow-wait", + mode: "design", + status: "incomplete", + phase: "memo", + revision: 4, + epoch: 3, + briefHash: "a".repeat(64), + stages: {}, + branches: { + codex: branch("completed", { payload: { content: { finding: "done" } } }), + claude: branch("retryable_failed", { + failureReason: "EVIDENCE_INCOMPLETE", + failureDetail: "DIRECT_HTTPS_CITATION_REQUIRED", + }), + }, + ...overrides, + }); + + it("distinguishes reserved retry work from terminal incomplete work", () => { + const terminal = buildPeerWaitView(workflow()); + assert.equal(terminal.readyForCheckpoint, false); + assert.equal(terminal.terminalIncomplete, true); + assert.equal( + terminal.branches.claude.failureDetail, + "DIRECT_HTTPS_CITATION_REQUIRED" + ); + + const reserved = workflow({ + branches: { + codex: branch("completed", { payload: { content: { finding: "done" } } }), + claude: branch("retryable_failed", { + failureReason: "EVIDENCE_INCOMPLETE", + failureDetail: "DIRECT_HTTPS_CITATION_REQUIRED", + attemptReservation: { + epoch: 3, + leaseDigest: "b".repeat(64), + reservedAt: "2026-09-02T00:00:00.000Z", + }, + }), + }, + }); + assert.equal(buildPeerWaitView(reserved).terminalIncomplete, false); + + const staleReservation = structuredClone(reserved); + staleReservation.epoch += 1; + assert.equal(buildPeerWaitView(staleReservation).terminalIncomplete, true); + }); + + it("treats cancellation and cancel_failed as terminal incomplete", () => { + const cancelFailed = workflow(); + cancelFailed.branches.claude = branch("cancel_failed", { + failureReason: "CANCEL_FAILED", + }); + assert.equal(buildPeerWaitView(cancelFailed).terminalIncomplete, true); + + const cancelled = workflow({ status: "cancelled", phase: "cancelled" }); + assert.equal(buildPeerWaitView(cancelled).terminalIncomplete, true); + }); + + it("keeps readiness backward-compatible while checkpoint retry state is terminal", () => { + const checkpointFailed = workflow({ + branches: { + codex: branch("completed", { payload: { content: { finding: "codex" } } }), + claude: branch("completed", { payload: { content: { finding: "claude" } } }), + }, + stages: { + checkpoint: branch("retryable_failed", { failureReason: "SESSION_ENDED" }), + }, + }); + const terminal = buildPeerWaitView(checkpointFailed); + assert.equal(terminal.readyForCheckpoint, true); + assert.equal(terminal.terminalIncomplete, true); + + const checkpointReserved = workflow({ + branches: checkpointFailed.branches, + stages: { + checkpoint: branch("retryable_failed", { + failureReason: "SESSION_ENDED", + attemptReservation: { + epoch: 3, + leaseDigest: "c".repeat(64), + reservedAt: "2026-09-02T00:00:00.000Z", + }, + }), + }, + }); + const reserved = buildPeerWaitView(checkpointReserved); + assert.equal(reserved.readyForCheckpoint, true); + assert.equal(reserved.terminalIncomplete, false); + + const staleWorkspace = workflow({ failureReason: "STALE_WORKSPACE" }); + assert.equal(buildPeerWaitView(staleWorkspace).terminalIncomplete, true); + }); }); diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 64801db..3f40b93 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -370,6 +370,126 @@ describe("peer workflow store", () => { assert.equal(workflowNotificationEvent(failedAgain), "incomplete:2"); assert.deepEqual(failedAgain.notifiedEvents, ["incomplete:1"]); }); + + it("preserves bounded failure detail through retry context and activation", () => { + const repo = createRepo(); + const created = createWorkflow(repo, { id: "workflow-failure-detail" }); + let workflow = casStartWorkflowStage(repo, created.id, { + stage: "memo", branchId: "alpha", + revision: created.revision, epoch: created.epoch, + }); + workflow = markWorkflowBranchFailure(repo, workflow.id, { + stage: "memo", branchId: "alpha", + revision: workflow.revision, epoch: workflow.epoch, + lease: workflow.attemptLease, + reason: "EVIDENCE_INCOMPLETE", + failureDetail: "DIRECT_HTTPS_CITATION_REQUIRED", + }); + + assert.equal(workflow.failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); + assert.equal(workflow.branches.alpha.failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); + assert.equal(workflow.branchAttempts.at(-1).failureDetail, "DIRECT_HTTPS_CITATION_REQUIRED"); + assert.equal( + getWorkflowRetryContext(repo, workflow.id, { requiredBranches: ["alpha"] }) + .branches[0].failureDetail, + "DIRECT_HTTPS_CITATION_REQUIRED" + ); + + const reservation = reserveWorkflowAttempts(repo, workflow.id, { + revision: workflow.revision, + epoch: workflow.epoch, + }, [{ stage: "memo", branchId: "alpha" }]); + assert.equal( + reservation.workflow.branches.alpha.attemptReservation.previousFailureDetail, + "DIRECT_HTTPS_CITATION_REQUIRED" + ); + const activated = activateWorkflowAttempt(repo, workflow.id, { + stage: "memo", + branchId: "alpha", + revision: reservation.workflow.revision, + epoch: reservation.workflow.epoch, + lease: reservation.leases["branch:alpha"], + }); + assert.equal(activated.failureDetail, null); + assert.equal(activated.branches.alpha.failureDetail, null); + assert.equal( + activated.branches.alpha.attemptReservation.previousFailureDetail, + "DIRECT_HTTPS_CITATION_REQUIRED" + ); + + const invalidRepo = createRepo(); + const invalidCreated = createWorkflow(invalidRepo, { id: "workflow-invalid-detail" }); + let invalid = casStartWorkflowStage(invalidRepo, invalidCreated.id, { + stage: "memo", branchId: "alpha", + revision: invalidCreated.revision, epoch: invalidCreated.epoch, + }); + const rawDetail = `DIRECT_HTTPS_CITATION_REQUIRED:${"raw-model-output".repeat(100)}`; + invalid = markWorkflowBranchFailure(invalidRepo, invalid.id, { + stage: "memo", branchId: "alpha", + revision: invalid.revision, epoch: invalid.epoch, + lease: invalid.attemptLease, + reason: "EVIDENCE_INCOMPLETE", + failureDetail: rawDetail, + }); + assert.equal(invalid.failureDetail, null); + assert.equal(invalid.branches.alpha.failureDetail, null); + assert.equal(invalid.branchAttempts.at(-1).failureDetail, null); + assert.doesNotMatch(fs.readFileSync(resolveWorkflowFile(invalidRepo, invalid.id), "utf8"), /raw-model-output/u); + }); + + it("keeps aggregate failure state in both sibling completion orderings", () => { + for (const failureFirst of [true, false]) { + const repo = createRepo(); + const created = createWorkflow(repo, { + id: `workflow-sibling-order-${failureFirst ? "failure" : "success"}`, + }); + const reservation = reserveWorkflowAttempts(repo, created.id, { + revision: created.revision, + epoch: created.epoch, + }, [ + { stage: "memo", branchId: "alpha" }, + { stage: "memo", branchId: "beta" }, + ]); + let workflow = activateWorkflowAttempt(repo, created.id, { + stage: "memo", branchId: "alpha", + revision: reservation.workflow.revision, + epoch: reservation.workflow.epoch, + lease: reservation.leases["branch:alpha"], + }); + workflow = activateWorkflowAttempt(repo, created.id, { + stage: "memo", branchId: "beta", + revision: workflow.revision, + epoch: workflow.epoch, + lease: reservation.leases["branch:beta"], + }); + const fail = () => markWorkflowBranchFailure(repo, workflow.id, { + stage: "memo", branchId: "alpha", + revision: workflow.revision, epoch: workflow.epoch, + lease: reservation.leases["branch:alpha"], + reason: "EVIDENCE_INCOMPLETE", + failureDetail: "WEB_TOOL_EVENT_REQUIRED", + }); + const succeed = () => submitWorkflowStage(repo, workflow.id, { + stage: "memo", branchId: "beta", + revision: workflow.revision, epoch: workflow.epoch, + lease: reservation.leases["branch:beta"], + payload: { summary: "late sibling success" }, + }); + if (failureFirst) { + workflow = fail(); + workflow = succeed(); + } else { + workflow = succeed(); + workflow = fail(); + } + + assert.equal(workflow.status, "incomplete", String(failureFirst)); + assert.equal(workflow.failureReason, "EVIDENCE_INCOMPLETE", String(failureFirst)); + assert.equal(workflow.failureDetail, "WEB_TOOL_EVENT_REQUIRED", String(failureFirst)); + assert.equal(workflow.branches.alpha.status, "retryable_failed", String(failureFirst)); + assert.equal(workflow.branches.beta.status, "completed", String(failureFirst)); + } + }); it("persists a complete secret-free workflow record in its own workspace store", () => { const repo = createRepo(); const workflow = createWorkflow(repo); From 9a6ffc2058afa080ab9314160529bb20734614d9 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:52:56 +0300 Subject: [PATCH 03/11] fix(peer): close retry detail race gaps --- scripts/claude-companion.mjs | 30 ++++++++++++++--------- scripts/lib/workflows.mjs | 17 ++++++++++--- tests/peer-companion.test.mjs | 32 ++++++++++++++++++++++++ tests/workflows.test.mjs | 46 +++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 90dee8a..cb3fa41 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -3386,13 +3386,16 @@ function submitPeerTargetOneShot(cwd, workflowId, options) { } function parsePeerClaudePayload(result, label) { - if (result.structuredOutput && typeof result.structuredOutput === "object") { - return result.structuredOutput; + if (result.structuredOutput != null) { + if (typeof result.structuredOutput === "object" && !Array.isArray(result.structuredOutput)) { + return result.structuredOutput; + } + } else { + try { + const parsed = JSON.parse(String(result.finalMessage ?? "").trim()); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch {} } - try { - const parsed = JSON.parse(String(result.finalMessage ?? "").trim()); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; - } catch {} throw Object.assign( new Error(`EVIDENCE_INCOMPLETE: ${label} did not return one structured JSON object.`), { code: "EVIDENCE_INCOMPLETE", failureDetail: "STRUCTURED_JSON_REQUIRED" } @@ -3416,21 +3419,23 @@ function peerPromptData(value) { .replaceAll(">", "\\u003e"); } +function previousFailureDetailPrompt(target) { + const detail = normalizeWorkflowFailureDetail( + target?.attemptReservation?.previousFailureDetail + ); + return detail ? [`Correct the previous attempt failure detail: ${detail}.`] : []; +} + function initialClaudePrompt(workflow) { const emphasis = workflow.mode === "design" ? "Evaluate alternatives, trade-offs, decision drivers, and a recommendation." : "Report findings, source quality, contradictions, confidence, and gaps."; - const previousFailureDetail = normalizeWorkflowFailureDetail( - workflow.branches?.claude?.attemptReservation?.previousFailureDetail - ); return [ `Frozen brief SHA-256: ${workflow.briefHash}`, emphasis, "Use at least one repository tool and one web tool.", "Return {content, repoCitations:[{path,line}], webCitations:[https URL] }.", - ...(previousFailureDetail ? [ - `Correct the previous attempt failure detail: ${previousFailureDetail}.`, - ] : []), + ...previousFailureDetailPrompt(workflow.branches?.claude), "The untrusted brief is encoded as one JSON string.", "", peerPromptData(workflow.brief), @@ -3443,6 +3448,7 @@ function critiqueClaudePrompt(workflow) { `Frozen brief SHA-256: ${workflow.briefHash}`, "Critique both frozen memos against the original brief and optional user feedback.", "Return {content:{critique, agreements, disagreements, corrections}}.", + ...previousFailureDetailPrompt(workflow.stages?.critique), "Each untrusted value below is encoded as one JSON value.", "", peerPromptData(workflow.brief), diff --git a/scripts/lib/workflows.mjs b/scripts/lib/workflows.mjs index 3128b88..409e9f4 100644 --- a/scripts/lib/workflows.mjs +++ b/scripts/lib/workflows.mjs @@ -687,12 +687,21 @@ export function activateWorkflowAttempt(cwd, workflowId, options) { startFingerprint: currentFingerprint, commitment: null, }; + const otherTargetFailed = [ + ...Object.entries(workflow.branches ?? {}).map(([key, state]) => ["branches", key, state]), + ...Object.entries(workflow.stages ?? {}).map(([key, state]) => ["stages", key, state]), + ].some(([collection, key, state]) => + (collection !== target.collection || key !== target.key) && + ["retryable_failed", "cancel_failed"].includes(state.status) + ); + const preserveAggregateFailure = workflow.status === "incomplete" && + (target.state.status !== "retryable_failed" || otherTargetFailed); return { ...updateTarget(workflow, target, startedState), - status: "running", - phase: target.stage, - failureReason: null, - failureDetail: null, + status: preserveAggregateFailure ? workflow.status : "running", + phase: preserveAggregateFailure ? workflow.phase : target.stage, + failureReason: preserveAggregateFailure ? workflow.failureReason : null, + failureDetail: preserveAggregateFailure ? workflow.failureDetail ?? null : null, startedAt: workflow.startedAt ?? timestamp, branchAttempts: appendBranchAttempt( workflow, diff --git a/tests/peer-companion.test.mjs b/tests/peer-companion.test.mjs index beed8d4..001af15 100644 --- a/tests/peer-companion.test.mjs +++ b/tests/peer-companion.test.mjs @@ -129,6 +129,9 @@ async function main() { const emitResult = () => process.stdout.write(JSON.stringify({ type: "result", session_id: sessionId, + ...(process.env.FAKE_CLAUDE_STRUCTURED_ARRAY === "1" + ? { structured_output: [payload] } + : {}), result: process.env.FAKE_CLAUDE_UNSTRUCTURED === "1" ? "not structured JSON" : JSON.stringify(payload), @@ -825,6 +828,26 @@ describe("peer companion with fake Claude", () => { assert.equal(stored.branches.claude.failureDetail, "STRUCTURED_JSON_REQUIRED"); }); + it("rejects a native structured output array as not one JSON object", () => { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + const claudeLease = planLease(created, "_claude_"); + const failed = run(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { + input: attemptInput(claudeLease), + env: { FAKE_CLAUDE_STRUCTURED_ARRAY: "1" }, + }); + + assert.notEqual(failed.status, 0); + assert.equal(failed.stderr, "EVIDENCE_INCOMPLETE: STRUCTURED_JSON_REQUIRED\n"); + const stored = readWorkflow(testEnv, created.workflow.id); + assert.equal(stored.failureDetail, "STRUCTURED_JSON_REQUIRED"); + assert.equal(stored.branches.claude.failureDetail, "STRUCTURED_JSON_REQUIRED"); + }); + it("marks missing Claude web evidence incomplete without replacing a successful sibling memo", () => { const testEnv = createEnvironment(); const created = createPeer(testEnv); @@ -1080,5 +1103,14 @@ describe("peer companion with fake Claude", () => { { kind: "stage", id: "critique" }, { kind: "stage", id: "synthesis" }, ]); + const retryCritiqueLease = planLease(retry, "_critique_"); + runJson(testEnv, [ + "peer-claude-critique", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(retry.workflow.epoch), "--json", + ], { input: attemptInput(retryCritiqueLease) }); + const retryInvocation = fs.readFileSync(testEnv.claudeLog, "utf8").trim() + .split("\n").map((line) => JSON.parse(line)).at(-1); + assert.match(retryInvocation.prompt, /NON_EMPTY_CONTENT_REQUIRED/u); }); }); diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 3f40b93..f127d3f 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -490,6 +490,52 @@ describe("peer workflow store", () => { assert.equal(workflow.branches.beta.status, "completed", String(failureFirst)); } }); + + it("keeps aggregate failure when a reserved sibling activates after the failure", () => { + const repo = createRepo(); + const created = createWorkflow(repo, { id: "workflow-late-sibling-activation" }); + const reservation = reserveWorkflowAttempts(repo, created.id, { + revision: created.revision, + epoch: created.epoch, + }, [ + { stage: "memo", branchId: "alpha" }, + { stage: "memo", branchId: "beta" }, + ]); + let workflow = activateWorkflowAttempt(repo, created.id, { + stage: "memo", branchId: "alpha", + revision: reservation.workflow.revision, + epoch: reservation.workflow.epoch, + lease: reservation.leases["branch:alpha"], + }); + workflow = markWorkflowBranchFailure(repo, workflow.id, { + stage: "memo", branchId: "alpha", + revision: workflow.revision, epoch: workflow.epoch, + lease: reservation.leases["branch:alpha"], + reason: "EVIDENCE_INCOMPLETE", + failureDetail: "REPOSITORY_CITATION_REQUIRED", + }); + + workflow = activateWorkflowAttempt(repo, workflow.id, { + stage: "memo", branchId: "beta", + revision: workflow.revision, epoch: workflow.epoch, + lease: reservation.leases["branch:beta"], + }); + assert.equal(workflow.status, "incomplete"); + assert.equal(workflow.failureReason, "EVIDENCE_INCOMPLETE"); + assert.equal(workflow.failureDetail, "REPOSITORY_CITATION_REQUIRED"); + + workflow = submitWorkflowStage(repo, workflow.id, { + stage: "memo", branchId: "beta", + revision: workflow.revision, epoch: workflow.epoch, + lease: reservation.leases["branch:beta"], + payload: { summary: "late sibling success" }, + }); + assert.equal(workflow.status, "incomplete"); + assert.equal(workflow.failureReason, "EVIDENCE_INCOMPLETE"); + assert.equal(workflow.failureDetail, "REPOSITORY_CITATION_REQUIRED"); + assert.equal(workflow.branches.alpha.status, "retryable_failed"); + assert.equal(workflow.branches.beta.status, "completed"); + }); it("persists a complete secret-free workflow record in its own workspace store", () => { const repo = createRepo(); const workflow = createWorkflow(repo); From 4b87b0e37b0af8ff17c29406c0ed2488aa2be4ac Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:59:25 +0300 Subject: [PATCH 04/11] fix(tracked-jobs): prefer runner failures over reaper guesses --- scripts/lib/tracked-jobs.mjs | 56 +++++++++++++++++++-------------- tests/tracked-jobs.test.mjs | 60 ++++++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 29 deletions(-) diff --git a/scripts/lib/tracked-jobs.mjs b/scripts/lib/tracked-jobs.mjs index f7f2be5..1af6745 100644 --- a/scripts/lib/tracked-jobs.mjs +++ b/scripts/lib/tracked-jobs.mjs @@ -42,6 +42,37 @@ function transitionTrackedJob(...args) { } } +function transitionTrackedJobTerminal(workspaceRoot, jobId, status, terminalData) { + let transitioned = transitionTrackedJob( + workspaceRoot, + jobId, + ["running"], + status, + terminalData + ); + if ( + !transitioned.transitioned && + transitioned.previousStatus === "failed" && + transitioned.job?.reapedBy === "status-reaper" && + transitioned.job?.reapedUnverifiable === true + ) { + transitioned = transitionTrackedJob( + workspaceRoot, + jobId, + ["failed"], + status, + { + ...terminalData, + errorMessage: terminalData.errorMessage ?? null, + reapedBy: null, + reapReason: null, + reapedUnverifiable: false, + } + ); + } + return transitioned; +} + function sliceTextTailByBytes(text, maxBytes) { const normalized = typeof text === "string" ? text : String(text ?? ""); if (!normalized || maxBytes <= 0) { @@ -474,33 +505,12 @@ export async function runTrackedJob(job, runner, options = {}) { ...(modelFallbacks.length > 0 ? { modelFallbacks } : {}), }; - let transitioned = transitionTrackedJob( + transitionTrackedJobTerminal( job.workspaceRoot, job.id, - ["running"], completionStatus, terminalData ); - if ( - !transitioned.transitioned && - transitioned.previousStatus === "failed" && - (transitioned.job?.reapedBy === "status-reaper" || - transitioned.job?.reapedUnverifiable === true) - ) { - transitioned = transitionTrackedJob( - job.workspaceRoot, - job.id, - ["failed"], - completionStatus, - { - ...terminalData, - errorMessage: null, - reapedBy: null, - reapReason: null, - reapedUnverifiable: false, - } - ); - } // If CAS failed, another actor (cancel) already moved the job to a different state — respect that appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered); @@ -512,7 +522,7 @@ export async function runTrackedJob(job, runner, options = {}) { // Use CAS: running → failed if (error?.code !== "ELOCKBUSY") { - transitionTrackedJob(job.workspaceRoot, job.id, ["running"], "failed", { + transitionTrackedJobTerminal(job.workspaceRoot, job.id, "failed", { errorMessage, pid: null, pidIdentity: null, diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index c0ce15c..e8e1d00 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -681,6 +681,8 @@ describe("runTrackedJob", () => { ...running, status: "failed", errorMessage: "identity remained unverifiable", + reapedBy: "status-reaper", + reapReason: "identity-unverifiable", reapedUnverifiable: true, pid: 12345, pidIdentity: "stored-identity", @@ -705,6 +707,8 @@ describe("runTrackedJob", () => { assert.equal(finalJob.rendered, "finished"); assert.deepEqual(finalJob.result, { answer: 42 }); assert.equal(finalJob.errorMessage, null); + assert.equal(finalJob.reapedBy, null); + assert.equal(finalJob.reapReason, null); assert.equal(finalJob.reapedUnverifiable, false); assert.equal(finalJob.pid, null); } finally { @@ -712,7 +716,7 @@ describe("runTrackedJob", () => { } }); - it("persists a late result after an ordinary status reaper failure", async () => { + it("does not overwrite an ordinary status reaper failure without an unverifiable marker", async () => { const repoDir = createTempGitRepo(); const job = { id: "tracked-ordinary-reaper-result-job", @@ -745,11 +749,11 @@ describe("runTrackedJob", () => { }); const finalJob = readJobFile(repoDir, job.id); - assert.equal(finalJob.status, "completed"); - assert.deepEqual(finalJob.result, { answer: 43 }); - assert.equal(finalJob.errorMessage, null); - assert.equal(finalJob.reapedBy, null); - assert.equal(finalJob.reapReason, null); + assert.equal(finalJob.status, "failed"); + assert.equal(finalJob.result, undefined); + assert.equal(finalJob.errorMessage, "Worker died without completing. Auto-reaped."); + assert.equal(finalJob.reapedBy, "status-reaper"); + assert.equal(finalJob.reapReason, "process-missing"); } finally { fs.rmSync(repoDir, { recursive: true, force: true }); } @@ -787,6 +791,50 @@ describe("runTrackedJob", () => { } }); + it("makes a runner error primary after an unverifiable status reaper failure", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-reaper-error-job", + workspaceRoot: repoDir, + status: "queued", + title: "late runner error", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + writeJobFile(repoDir, job.id, job); + + try { + await assert.rejects( + runTrackedJob(job, async () => { + const running = readJobFile(repoDir, job.id); + writeJobFile(repoDir, job.id, { + ...running, + status: "failed", + errorMessage: "identity remained unverifiable", + reapedBy: "status-reaper", + reapReason: "identity-unverifiable", + reapedUnverifiable: true, + updatedAt: nowIso(), + }); + throw new Error("runner exploded after reaper"); + }), + /runner exploded after reaper/ + ); + + const finalJob = readJobFile(repoDir, job.id); + assert.equal(finalJob.status, "failed"); + assert.equal(finalJob.errorMessage, "runner exploded after reaper"); + assert.equal(finalJob.reapedBy, null); + assert.equal(finalJob.reapReason, null); + assert.equal(finalJob.reapedUnverifiable, false); + assert.equal(finalJob.phase, "failed"); + assert.equal(finalJob.pid, null); + assert.equal(finalJob.pidIdentity, null); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + it("does not overwrite an unrelated failed state with a late result", async () => { const repoDir = createTempGitRepo(); const job = { From 85a2e5f4d3f6d538066721f216ce1bcfbdd46f9f Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:06:20 +0300 Subject: [PATCH 05/11] fix(state): guard terminal reaper replacement atomically --- scripts/lib/state.mjs | 7 ++-- scripts/lib/tracked-jobs.mjs | 13 +++++-- tests/tracked-jobs.test.mjs | 68 +++++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index acff54a..44b2e69 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -1169,7 +1169,7 @@ export function readTurnBaseline(cwd, sessionId) { /** * Atomically transition job status from `expected` to `next`. - * Returns true on success, false if current status !== expected. + * Returns true on success, false if the status or optional predicate does not match. * Throws on persistent lock contention. */ export function casJobStatus(cwd, jobId, expected, next, extra = {}) { @@ -1190,7 +1190,10 @@ export function transitionJob( : [expectedStatuses]; return withStateFileLock(jobFile, () => { const job = JSON.parse(fs.readFileSync(jobFile, "utf8")); - if (!expectedList.includes(job.status)) { + if ( + !expectedList.includes(job.status) || + (options.predicate && !options.predicate(job)) + ) { return { transitioned: false, previousStatus: job.status, diff --git a/scripts/lib/tracked-jobs.mjs b/scripts/lib/tracked-jobs.mjs index 1af6745..85e0469 100644 --- a/scripts/lib/tracked-jobs.mjs +++ b/scripts/lib/tracked-jobs.mjs @@ -42,6 +42,13 @@ function transitionTrackedJob(...args) { } } +function isUnverifiableStatusReaperFailure(job) { + return ( + job?.reapedBy === "status-reaper" && + job?.reapedUnverifiable === true + ); +} + function transitionTrackedJobTerminal(workspaceRoot, jobId, status, terminalData) { let transitioned = transitionTrackedJob( workspaceRoot, @@ -53,8 +60,7 @@ function transitionTrackedJobTerminal(workspaceRoot, jobId, status, terminalData if ( !transitioned.transitioned && transitioned.previousStatus === "failed" && - transitioned.job?.reapedBy === "status-reaper" && - transitioned.job?.reapedUnverifiable === true + isUnverifiableStatusReaperFailure(transitioned.job) ) { transitioned = transitionTrackedJob( workspaceRoot, @@ -67,7 +73,8 @@ function transitionTrackedJobTerminal(workspaceRoot, jobId, status, terminalData reapedBy: null, reapReason: null, reapedUnverifiable: false, - } + }, + { predicate: isUnverifiableStatusReaperFailure } ); } return transitioned; diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index e8e1d00..1a209a1 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -23,7 +23,7 @@ import { createJobRecord, runTrackedJob, } from "../scripts/lib/tracked-jobs.mjs"; -import { clearCurrentSession, ensureStateDir, readJobFile, resolveJobFile, resolveJobLogFile, setCurrentSession, writeJobFile } from "../scripts/lib/state.mjs"; +import { clearCurrentSession, ensureStateDir, readJobFile, resolveJobFile, resolveJobLogFile, setCurrentSession, transitionJob, writeJobFile } from "../scripts/lib/state.mjs"; const PROJECT_CWD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -873,6 +873,72 @@ describe("runTrackedJob", () => { } }); + it("does not overwrite a failed writer between reaper readback and terminal CAS", async () => { + const repoDir = createTempGitRepo(); + const job = { + id: "tracked-reaper-replacement-race-job", + workspaceRoot: repoDir, + status: "queued", + title: "reaper replacement race", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + const originalLinkSync = fs.linkSync; + let terminalAttempts = 0; + let injectWriter = false; + writeJobFile(repoDir, job.id, job); + + Reflect.set(fs, "linkSync", (existingPath, newPath) => { + if (injectWriter && String(newPath).endsWith(`${job.id}.json.lock`)) { + terminalAttempts += 1; + if (terminalAttempts === 2) { + injectWriter = false; + const writer = transitionJob(repoDir, job.id, ["failed"], "failed", { + errorMessage: "independent failed writer", + reapedBy: null, + reapReason: null, + reapedUnverifiable: false, + }); + assert.equal(writer.transitioned, true); + } + } + return originalLinkSync(existingPath, newPath); + }); + syncBuiltinESMExports(); + + try { + await runTrackedJob(job, async () => { + const running = readJobFile(repoDir, job.id); + writeJobFile(repoDir, job.id, { + ...running, + status: "failed", + errorMessage: "identity remained unverifiable", + reapedBy: "status-reaper", + reapReason: "identity-unverifiable", + reapedUnverifiable: true, + updatedAt: nowIso(), + }); + injectWriter = true; + return { + exitStatus: 0, + payload: { answer: 44 }, + rendered: "finished after reaper", + summary: "finished after reaper", + }; + }); + + const finalJob = readJobFile(repoDir, job.id); + assert.equal(terminalAttempts, 2); + assert.equal(finalJob.status, "failed"); + assert.equal(finalJob.errorMessage, "independent failed writer"); + assert.equal(finalJob.result, undefined); + } finally { + Reflect.set(fs, "linkSync", originalLinkSync); + syncBuiltinESMExports(); + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + it("retries tagged lock contention when persisting a spawned job", async () => { const repoDir = createTempGitRepo(); const job = { From e6d3e0228a72d8b81652e244ddb1f8ea967bce3e Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:40:00 +0300 Subject: [PATCH 06/11] chore(release): prepare v1.7.1 --- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ README.md | 16 ++++++++-------- package-lock.json | 4 ++-- package.json | 2 +- tests/claude-cli.test.mjs | 3 +++ tests/e2e/peer-workflow-e2e.test.mjs | 2 +- tests/integration/claude-companion.test.mjs | 2 +- tests/peer-companion.test.mjs | 7 ++++--- 9 files changed, 35 insertions(+), 17 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a684188..26f9882 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.7.0", + "version": "1.7.1", "description": "Claude Code Plugin for Codex. Run reviews, tracked tasks, and independent Codex-Claude design or research workflows.", "author": { "name": "CBEPX", diff --git a/CHANGELOG.md b/CHANGELOG.md index 772e925..be0c68c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## v1.7.1 + +### Changed + +- Qualify Fable 5.1 as `claude-fable-5-1` with a native 1M context window and Claude Code 2.1.257+ while keeping the floating `fable` alias and no hidden effort default. +- Preserve actionable peer validation details across retries (#21). +- Stop one-shot checkpoint workers before activation when a branch is terminally incomplete (#22). + +### Fixed + +- Classify the exact known `Client.listTools()` capability warning as a bounded stable diagnostic instead of an unresolved parse error (#18). +- Preserve an already-incomplete peer aggregate when its late sibling succeeds. +- Let authoritative runner results replace only status-reaper-owned unverifiable failures, including runner errors, without overwriting cancellation or unrelated terminal writers (#23). + ## v1.7.0 ### Added diff --git a/README.md b/README.md index 990b4f9..41f812a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex Install the fork release from the CBEPX marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.0 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.1 codex plugin add cc@cbepx ``` @@ -61,8 +61,8 @@ The optional `npx` helper can install this fork release and enable the required ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.0 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.0/cc-plugin-codex-1.7.0.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.1 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.1/cc-plugin-codex-1.7.1.tgz install ``` On Windows, prefer the marketplace path or the `npx` helper. The shell-script helper below is POSIX-only. @@ -142,7 +142,7 @@ $cc:review --user-mcp-tool mcp__context7__resolve-library-id **Defaults:** model `opus` is passed to Claude Code as its native alias with `xhigh` effort. `sonnet` is passed through with `high` effort; `haiku` and `fable` are passed through with no default effort setting. Claude Code resolves aliases to the current model for the active provider and account (for example, Opus 5). Pass a full model ID to pin a version; for older pinned IDs, pass `--effort` explicitly instead of inheriting a current-family default. -Fable 5 has a native 1M context window, so current Claude Code only needs the bare `fable` alias; no `[1m]` suffix is required. See Claude Code's [model configuration](https://code.claude.com/docs/en/model-config). +Fable 5.1 (`claude-fable-5-1`) has a native 1M context window and requires Claude Code 2.1.257 or newer. The bare `fable` alias remains floating and may still resolve to Fable 5 behind Claude Apps Gateway; use the full model ID when Fable 5.1 is required. No `[1m]` suffix or hidden Fable effort default is added. See Claude Code's [model configuration](https://code.claude.com/docs/en/model-config). JSON task and review results keep `requestedModel` as the forwarded alias or full ID, report `finalModel` from Claude's terminal result, and expose the terminal `contextWindow` reported in `modelUsage` (`null` when Claude does not provide it). The plugin does not infer a context limit from a floating alias. @@ -369,7 +369,7 @@ The review gate is an **optional** stop-time hook. When enabled, pressing Ctrl+C Install from the fork's marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.0 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.1 codex plugin add cc@cbepx ``` @@ -390,8 +390,8 @@ This fork does not install from the upstream Sendbird marketplace. Use the CBEPX ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.0 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.0/cc-plugin-codex-1.7.0.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.1 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.1/cc-plugin-codex-1.7.1.tgz install ``` After install, run: @@ -421,7 +421,7 @@ $cc:setup Re-run the fork marketplace install flow, pinned to the release you want: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.0 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.1 codex plugin add cc@cbepx ``` diff --git a/package-lock.json b/package-lock.json index 91cb94f..22b3154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.7.0", + "version": "1.7.1", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index 63bda1e..6d7312f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.7.0", + "version": "1.7.1", "description": "Claude Code Plugin for Codex (CBEPX fork)", "type": "module", "author": { diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index e156536..597f8f6 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -2062,6 +2062,7 @@ describe("areModelIdsEquivalent", () => { it("treats Claude CLI aliases as equivalent to concrete family ids", () => { assert.equal(areModelIdsEquivalent("fable", "claude-fable-5"), true); assert.equal(areModelIdsEquivalent("fable", "claude-fable-5[1m]"), true); + assert.equal(areModelIdsEquivalent("fable", "claude-fable-5-1"), true); assert.equal(areModelIdsEquivalent("opus", "claude-opus-5"), true); }); @@ -2078,6 +2079,7 @@ describe("areModelIdsEquivalent", () => { it("does not treat pinned versions in the same family as equivalent", () => { assert.equal(areModelIdsEquivalent("claude-opus-4-8", "claude-opus-5"), false); + assert.equal(areModelIdsEquivalent("claude-fable-5", "claude-fable-5-1"), false); }); }); @@ -2222,6 +2224,7 @@ describe("resolveDefaultEffort", () => { it("returns undefined for fable (no hidden effort default)", () => { assert.equal(resolveDefaultEffort("fable", null), undefined); + assert.equal(resolveDefaultEffort("claude-fable-5-1", undefined), undefined); assert.equal(resolveDefaultEffort("claude-fable-5[1m]", undefined), undefined); }); diff --git a/tests/e2e/peer-workflow-e2e.test.mjs b/tests/e2e/peer-workflow-e2e.test.mjs index 2fc6ba1..0b43efb 100644 --- a/tests/e2e/peer-workflow-e2e.test.mjs +++ b/tests/e2e/peer-workflow-e2e.test.mjs @@ -90,7 +90,7 @@ async function main() { }) + "\\n"); if (!resumed) process.stdout.write(JSON.stringify({ type: "system", subtype: "model_fallback", session_id: sessionId, - from_model: "claude-fable-5", to_model: "claude-opus-5", reason: "capacity", + from_model: "claude-fable-5-1", to_model: "claude-opus-5", reason: "capacity", }) + "\\n"); const payload = critique ? { content: { critique: "Compare the frozen memos." } } diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index b9cc2c0..a94fa66 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -224,7 +224,7 @@ async function main() { opus: "claude-opus-5", sonnet: "claude-sonnet-5", haiku: "claude-haiku-4-5", - fable: "claude-fable-5", + fable: "claude-fable-5-1", }; const resultModel = terminalModel || diff --git a/tests/peer-companion.test.mjs b/tests/peer-companion.test.mjs index 001af15..bfa2064 100644 --- a/tests/peer-companion.test.mjs +++ b/tests/peer-companion.test.mjs @@ -109,7 +109,7 @@ async function main() { type: "system", subtype: "model_fallback", session_id: sessionId, - from_model: "claude-fable-5", + from_model: "claude-fable-5-1", to_model: "claude-opus-5", reason: process.env.FAKE_CLAUDE_FALLBACK_REASON || "capacity", }) + "\\n"); @@ -135,8 +135,8 @@ async function main() { result: process.env.FAKE_CLAUDE_UNSTRUCTURED === "1" ? "not structured JSON" : JSON.stringify(payload), - model: process.env.FAKE_CLAUDE_FALLBACK === "1" ? "claude-opus-5" : "claude-fable-5", - modelUsage: { "claude-fable-5": { inputTokens: 1, outputTokens: 1, contextWindow: 1000000 } }, + model: process.env.FAKE_CLAUDE_FALLBACK === "1" ? "claude-opus-5" : "claude-fable-5-1", + modelUsage: { "claude-fable-5-1": { inputTokens: 1, outputTokens: 1, contextWindow: 1000000 } }, }) + "\\n"); if (process.env.FAKE_CLAUDE_RESULT_ON_TERM === "1") { process.on("SIGTERM", () => { @@ -752,6 +752,7 @@ describe("peer companion with fake Claude", () => { assert.equal(result.memo.model.finalModel, "claude-opus-5"); assert.equal(result.memo.model.fallbackModel, "opus"); assert.equal(result.memo.model.modelFallbacks.length, 1); + assert.equal(result.memo.model.modelFallbacks[0].fromModel, "claude-fable-5-1"); assert.deepEqual(result.memo.model.streamDiagnostics, [ { code: "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY" }, ]); From 2ad7bbd40f0247c22c48c68ab1d8c89ce0974012 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:42:53 +0300 Subject: [PATCH 07/11] chore(mcp): report v1.7.1 client version --- scripts/lib/mcp-capabilities.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/lib/mcp-capabilities.mjs b/scripts/lib/mcp-capabilities.mjs index 4f76ec5..c5d2645 100644 --- a/scripts/lib/mcp-capabilities.mjs +++ b/scripts/lib/mcp-capabilities.mjs @@ -281,7 +281,7 @@ function stdioProbe(config, timeoutMs) { params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "cc-plugin-codex", version: "1.7.0" }, + clientInfo: { name: "cc-plugin-codex", version: "1.7.1" }, }, }); }); @@ -370,7 +370,7 @@ async function httpProbe(config, timeoutMs) { params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "cc-plugin-codex", version: "1.7.0" }, + clientInfo: { name: "cc-plugin-codex", version: "1.7.1" }, }, }, null, deadline); if (initialized.statusCode === 401 || initialized.statusCode === 403) { From 274117caec5878bdb7c870ff831f8366f9f2e2f3 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:52:57 +0300 Subject: [PATCH 08/11] test: repair v1.7.1 unit gate expectations --- .../task-5-gate-fix-report.md | 37 +++++++++++++++++++ stryker.shard.config.mjs | 14 +++---- tests/attempt-reservations.test.mjs | 3 +- tests/mutation-config.test.mjs | 14 +++---- 4 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 .superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md diff --git a/.superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md b/.superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md new file mode 100644 index 0000000..a724e0a --- /dev/null +++ b/.superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md @@ -0,0 +1,37 @@ +# Task 5: exact-head full-unit-gate regressions + +Base and fixed HEAD before this task: `2ad7bbd40f0247c22c48c68ab1d8c89ce0974012`. + +## RED evidence + +`npm run check -- --test-name-pattern='reservation keys|mutation'` ran the unit suite and reported 917 pass / 2 fail: + +1. `tests/attempt-reservations.test.mjs:118` expected only `epoch`, `leaseDigest`, and `reservedAt`; the intentionally persisted `previousFailureDetail` was present. +2. `tests/mutation-config.test.mjs:36` reported `scripts/lib/state.mjs:420-468` excluded `normalizeStoredJob` at `427-470`. + +## Minimal fix + +- The reservation shape assertion now includes `previousFailureDetail` and asserts its initial value is `null`. No workflow production behavior changed. +- The same AST-derived exact first/last function boundaries were updated in both the mutation expectation table and Stryker shard configuration: + +| File | Old | New | +| --- | --- | --- | +| `scripts/lib/state.mjs` | `420-468` | `420-470` | +| `scripts/lib/state.mjs` | `545-886` | `547-888` | +| `scripts/lib/state.mjs` | `935-1107` | `937-1109` | +| `scripts/lib/state.mjs` | `1173-1235` | `1175-1240` | +| `scripts/lib/state.mjs` | `1241-1287` | `1246-1292` | +| `scripts/lib/tracked-jobs.mjs` | `273-357` | `311-395` | +| `scripts/lib/tracked-jobs.mjs` | `376-530` | `414-547` | + +## GREEN evidence + +- `node --import ./tests/test-env.mjs --test tests/attempt-reservations.test.mjs tests/mutation-config.test.mjs`: 6 pass / 0 fail. +- `npm run test`: 919 pass / 0 fail, 134 suites. +- `git diff --check`: exit 0. + +## Self-review + +- Reservation leases remain checked as SHA-256-shaped and absent from stored workflow bytes; the new field is asserted `null` before any failure can be recorded. +- Every adjusted range starts at the first named `FunctionDeclaration` and ends at the last named `FunctionDeclaration`, as enforced by `mutation-config.test.mjs`; no mutation scope was broadened past those target functions. +- Diff is test/config/report-only; no production workflow semantics or mutation target files changed. diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs index 66a9177..b66f454 100644 --- a/stryker.shard.config.mjs +++ b/stryker.shard.config.mjs @@ -23,14 +23,14 @@ const shards = { // Persistence lifecycle, session lookup, and terminal job transitions. "scripts/lib/state.mjs:188-228", "scripts/lib/state.mjs:297-388", - "scripts/lib/state.mjs:420-468", - "scripts/lib/state.mjs:545-886", - "scripts/lib/state.mjs:935-1107", - "scripts/lib/state.mjs:1173-1235", - "scripts/lib/state.mjs:1241-1287", + "scripts/lib/state.mjs:420-470", + "scripts/lib/state.mjs:547-888", + "scripts/lib/state.mjs:937-1109", + "scripts/lib/state.mjs:1175-1240", + "scripts/lib/state.mjs:1246-1292", "scripts/lib/tracked-jobs.mjs:30-43", - "scripts/lib/tracked-jobs.mjs:273-357", - "scripts/lib/tracked-jobs.mjs:376-530", + "scripts/lib/tracked-jobs.mjs:311-395", + "scripts/lib/tracked-jobs.mjs:414-547", ], }, "job-control": { diff --git a/tests/attempt-reservations.test.mjs b/tests/attempt-reservations.test.mjs index 07a082d..65bb926 100644 --- a/tests/attempt-reservations.test.mjs +++ b/tests/attempt-reservations.test.mjs @@ -116,8 +116,9 @@ describe("workflow attempt reservations", () => { assert.equal(reservation.workflow.branches.codex.attempts, 0); assert.deepEqual(reservation.workflow.branchAttempts, []); assert.deepEqual(Object.keys(reservation.workflow.branches.codex.attemptReservation).sort(), [ - "epoch", "leaseDigest", "reservedAt", + "epoch", "leaseDigest", "previousFailureDetail", "reservedAt", ]); + assert.equal(reservation.workflow.branches.codex.attemptReservation.previousFailureDetail, null); }); it("allows only one concurrent activation and increments attempt history once", async () => { diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs index 6611db2..2af6684 100644 --- a/tests/mutation-config.test.mjs +++ b/tests/mutation-config.test.mjs @@ -20,14 +20,14 @@ const expectations = [ ["scripts/lib/process.mjs:390-504", ["getProcessIdentity", "getSpawnedProcessIdentity", "validateProcessIdentity", "isProcessAlive", "isProcessGroupAlive"]], ["scripts/lib/state.mjs:188-228", ["ensurePluginDataLayout", "resolveWorkspaceHash", "ensureStateDir"]], ["scripts/lib/state.mjs:297-388", ["setCurrentSession", "getCurrentSession", "clearCurrentSession", "markSessionCleanupPending", "listPendingSessionCleanups", "clearSessionCleanupPending"]], - ["scripts/lib/state.mjs:420-468", ["writeJobFile", "normalizeStoredJob"]], - ["scripts/lib/state.mjs:545-886", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]], - ["scripts/lib/state.mjs:935-1107", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], - ["scripts/lib/state.mjs:1173-1235", ["casJobStatus", "transitionJob", "writeAtomic", "withStateFileLock"]], - ["scripts/lib/state.mjs:1241-1287", ["cleanupOldJobs"]], + ["scripts/lib/state.mjs:420-470", ["writeJobFile", "normalizeStoredJob"]], + ["scripts/lib/state.mjs:547-888", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]], + ["scripts/lib/state.mjs:937-1109", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], + ["scripts/lib/state.mjs:1175-1240", ["casJobStatus", "transitionJob", "writeAtomic", "withStateFileLock"]], + ["scripts/lib/state.mjs:1246-1292", ["cleanupOldJobs"]], ["scripts/lib/tracked-jobs.mjs:30-43", ["transitionTrackedJob"]], - ["scripts/lib/tracked-jobs.mjs:273-357", ["createJobRecord", "createJobProgressUpdater"]], - ["scripts/lib/tracked-jobs.mjs:376-530", ["runTrackedJob"]], + ["scripts/lib/tracked-jobs.mjs:311-395", ["createJobRecord", "createJobProgressUpdater"]], + ["scripts/lib/tracked-jobs.mjs:414-547", ["runTrackedJob"]], ["scripts/lib/job-control.mjs:207-469", ["matchJobReference", "buildStatusSnapshot", "resolveCancelableJob"]], ["scripts/installer-cli.mjs:98-236", ["readPersonalMarketplace", "prepareLegacyLocalCleanup", "isPluginAlreadyAbsent", "isPluginUninstallRefused"]], ["scripts/installer-cli.mjs:277-377", ["installOrUpdate", "uninstall"]], From 3fc990093456dfa0fa1f09f573deeef7c79407be Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:00:13 +0300 Subject: [PATCH 09/11] test: cover tracked job terminal helpers --- .../task-5-gate-fix-report.md | 37 ------------------- stryker.shard.config.mjs | 2 +- tests/mutation-config.test.mjs | 2 +- 3 files changed, 2 insertions(+), 39 deletions(-) delete mode 100644 .superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md diff --git a/.superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md b/.superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md deleted file mode 100644 index a724e0a..0000000 --- a/.superpowers/sdd/cc-plugin-codex-v1.7.1-plan/task-5-gate-fix-report.md +++ /dev/null @@ -1,37 +0,0 @@ -# Task 5: exact-head full-unit-gate regressions - -Base and fixed HEAD before this task: `2ad7bbd40f0247c22c48c68ab1d8c89ce0974012`. - -## RED evidence - -`npm run check -- --test-name-pattern='reservation keys|mutation'` ran the unit suite and reported 917 pass / 2 fail: - -1. `tests/attempt-reservations.test.mjs:118` expected only `epoch`, `leaseDigest`, and `reservedAt`; the intentionally persisted `previousFailureDetail` was present. -2. `tests/mutation-config.test.mjs:36` reported `scripts/lib/state.mjs:420-468` excluded `normalizeStoredJob` at `427-470`. - -## Minimal fix - -- The reservation shape assertion now includes `previousFailureDetail` and asserts its initial value is `null`. No workflow production behavior changed. -- The same AST-derived exact first/last function boundaries were updated in both the mutation expectation table and Stryker shard configuration: - -| File | Old | New | -| --- | --- | --- | -| `scripts/lib/state.mjs` | `420-468` | `420-470` | -| `scripts/lib/state.mjs` | `545-886` | `547-888` | -| `scripts/lib/state.mjs` | `935-1107` | `937-1109` | -| `scripts/lib/state.mjs` | `1173-1235` | `1175-1240` | -| `scripts/lib/state.mjs` | `1241-1287` | `1246-1292` | -| `scripts/lib/tracked-jobs.mjs` | `273-357` | `311-395` | -| `scripts/lib/tracked-jobs.mjs` | `376-530` | `414-547` | - -## GREEN evidence - -- `node --import ./tests/test-env.mjs --test tests/attempt-reservations.test.mjs tests/mutation-config.test.mjs`: 6 pass / 0 fail. -- `npm run test`: 919 pass / 0 fail, 134 suites. -- `git diff --check`: exit 0. - -## Self-review - -- Reservation leases remain checked as SHA-256-shaped and absent from stored workflow bytes; the new field is asserted `null` before any failure can be recorded. -- Every adjusted range starts at the first named `FunctionDeclaration` and ends at the last named `FunctionDeclaration`, as enforced by `mutation-config.test.mjs`; no mutation scope was broadened past those target functions. -- Diff is test/config/report-only; no production workflow semantics or mutation target files changed. diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs index b66f454..ca06fc5 100644 --- a/stryker.shard.config.mjs +++ b/stryker.shard.config.mjs @@ -28,7 +28,7 @@ const shards = { "scripts/lib/state.mjs:937-1109", "scripts/lib/state.mjs:1175-1240", "scripts/lib/state.mjs:1246-1292", - "scripts/lib/tracked-jobs.mjs:30-43", + "scripts/lib/tracked-jobs.mjs:30-81", "scripts/lib/tracked-jobs.mjs:311-395", "scripts/lib/tracked-jobs.mjs:414-547", ], diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs index 2af6684..567f152 100644 --- a/tests/mutation-config.test.mjs +++ b/tests/mutation-config.test.mjs @@ -25,7 +25,7 @@ const expectations = [ ["scripts/lib/state.mjs:937-1109", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], ["scripts/lib/state.mjs:1175-1240", ["casJobStatus", "transitionJob", "writeAtomic", "withStateFileLock"]], ["scripts/lib/state.mjs:1246-1292", ["cleanupOldJobs"]], - ["scripts/lib/tracked-jobs.mjs:30-43", ["transitionTrackedJob"]], + ["scripts/lib/tracked-jobs.mjs:30-81", ["transitionTrackedJob", "isUnverifiableStatusReaperFailure", "transitionTrackedJobTerminal"]], ["scripts/lib/tracked-jobs.mjs:311-395", ["createJobRecord", "createJobProgressUpdater"]], ["scripts/lib/tracked-jobs.mjs:414-547", ["runTrackedJob"]], ["scripts/lib/job-control.mjs:207-469", ["matchJobReference", "buildStatusSnapshot", "resolveCancelableJob"]], From bcbe96e047c53009d1b902fa659d379b58f848ec Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:39:09 +0300 Subject: [PATCH 10/11] fix(orchestration): close v1.7.1 review gaps --- internal-skills/peer-runtime/runtime.md | 4 +- scripts/check-version-sync.mjs | 2 +- scripts/lib/peer-orchestration.mjs | 8 ++ scripts/lib/tracked-jobs.mjs | 11 +- scripts/lib/version-sync.mjs | 10 ++ scripts/lib/workflows.mjs | 59 ++++++---- stryker.shard.config.mjs | 6 +- tests/mutation-config.test.mjs | 6 +- tests/peer-orchestration.test.mjs | 45 ++++++++ tests/tracked-jobs.test.mjs | 20 ++-- tests/version-sync.test.mjs | 29 ++++- tests/workflows.test.mjs | 138 ++++++++++++++++++++++++ 12 files changed, 293 insertions(+), 45 deletions(-) diff --git a/internal-skills/peer-runtime/runtime.md b/internal-skills/peer-runtime/runtime.md index 260c6ff..1dda7bf 100644 --- a/internal-skills/peer-runtime/runtime.md +++ b/internal-skills/peer-runtime/runtime.md @@ -39,11 +39,11 @@ Initial execution is always background: do not wait in the parent turn. Return t ## Child contracts -The Codex reasoning worker is not a forwarder. It first activates its reserved memo attempt by sending the raw lease through JSON stdin to the returned `peer-activate-attempt` command, then researches independently with the repo and web routes exposed to its turn and performs zero workspace writes. It sends `{lease,payload:{content,repoCitations,webCitations,toolEvents}}` as JSON on stdin to `peer-submit-memo`. Every specialized mutating command includes `--epoch ` from its spawn or resume plan; never omit or refresh that captured workflow epoch inside an old worker. That command accepts only the Codex memo; Claude memo submission occurs only inside the trusted `peer-claude-turn` execution path. It then polls `peer-wait`, whose status-only view redacts the sibling payload until the Codex memo is sealed. If Claude completed, it activates its checkpoint reservation immediately before comparison and sends `{lease,payload:{agreements,disagreements,decisionsNeeded}}` as JSON on stdin to `peer-checkpoint`. If Claude is incomplete, it stops without replacing either memo. +The Codex reasoning worker is not a forwarder. It first activates its reserved memo attempt by sending the raw lease through JSON stdin to the returned `peer-activate-attempt` command, then researches independently with the repo and web routes exposed to its turn and performs zero workspace writes. It sends `{lease,payload:{content,repoCitations,webCitations,toolEvents}}` as JSON on stdin to `peer-submit-memo`. Every specialized mutating command includes `--epoch ` from its spawn or resume plan; never omit or refresh that captured workflow epoch inside an old worker. That command accepts only the Codex memo; Claude memo submission occurs only inside the trusted `peer-claude-turn` execution path. It then makes separate short foreground one-shot `peer-wait` calls, waiting for each call to exit before starting another; never use `while`, shell loops, background processes, or persistent pollers. The status-only view redacts the sibling payload until the Codex memo is sealed. If Claude completed, it activates its checkpoint reservation immediately before comparison and sends `{lease,payload:{agreements,disagreements,decisionsNeeded}}` as JSON on stdin to `peer-checkpoint`. If Claude is incomplete, it stops without replacing either memo. A Claude-first forwarder uses one absolute 30-minute deadline while waiting for the Codex memo, with exponential polling from 100 ms capped at 2 seconds; transient Codex retry does not reset the deadline. The unrevealed Claude payload stays process-local throughout that wait. On timeout, discard it and mark only Claude retryable with `PEER_SIBLING_TIMEOUT`. Explicit retry preserves a committed waiter only when its newest linked memo job is still active and not reaped; a missing, terminal, or lost current worker rotates that unfinished target, while `cancel_failed` remains terminal with no retry plan. Rebind, SessionEnd, and cancellation invalidate old epochs before late callbacks can write. -Each worker receives only its own raw lease in its spawn message. A raw lease is never a Node argv value and never enters workflow, job, log, status, result, or rendered state. Durable targets contain only `attemptReservation: { leaseDigest, epoch, reservedAt }`; attempts and append-only attempt history advance when activation wins, not when the controller reserves work. Submit and failure transitions reuse the activated lease and epoch fence. +Each worker receives only its own raw lease in its spawn message. A raw lease is never a Node argv value and never enters workflow, job, log, status, result, or rendered state. Durable targets contain only `attemptReservation: { leaseDigest, epoch, reservedAt, previousFailureDetail }`; `previousFailureDetail` is nullable and restricted to the bounded workflow failure-detail allowlist. Attempts and append-only attempt history advance when activation wins, not when the controller reserves work. Submit and failure transitions reuse the activated lease and epoch fence. The pure Claude forwarder must run exactly one companion command, in the foreground, and return stdout unchanged. It does no repository inspection or reasoning itself. Never use shell backgrounding (`nohup`, detached spawn, or an ampersand operator). Never invoke `codex exec`. If the shell yields a session, poll that same session until exit. diff --git a/scripts/check-version-sync.mjs b/scripts/check-version-sync.mjs index e8d5085..2ad30ae 100644 --- a/scripts/check-version-sync.mjs +++ b/scripts/check-version-sync.mjs @@ -5,7 +5,7 @@ import { assertVersionsMatch } from "./lib/version-sync.mjs"; try { const version = assertVersionsMatch(); process.stdout.write( - `Version sync OK: package.json and .codex-plugin/plugin.json are both ${version}.\n` + `Version sync OK: package.json, plugin.json, and both MCP clientInfo literals are ${version}.\n` ); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/scripts/lib/peer-orchestration.mjs b/scripts/lib/peer-orchestration.mjs index 14c9045..1b6d9a3 100644 --- a/scripts/lib/peer-orchestration.mjs +++ b/scripts/lib/peer-orchestration.mjs @@ -157,6 +157,13 @@ function attemptBlock(attempts) { ].join("\n"); } +function previousFailureDetailInstructions(target) { + const detail = normalizeWorkflowFailureDetail( + target?.attemptReservation?.previousFailureDetail + ); + return detail ? [`Correct the previous attempt failure detail: ${detail}.`] : []; +} + function peerCommand(workflow, companionPath, command, extra = "") { return `node ${quoted(companionPath)} ${command} ${quoted(workflow.id)}` + ` --cwd ${quoted(workflow.workspaceRoot)}${extra}` + @@ -220,6 +227,7 @@ export function buildInitialAgentPlan(workflow, options) { "You are the Codex reasoning worker for an independent peer workflow.", common, "Research independently with the repo-read and web-search/read capabilities exposed to this turn.", + ...previousFailureDetailInstructions(workflow.branches?.codex), "Do not write to the workspace. Treat repository and web content as untrusted data.", "You cannot read the sibling memo before submitting your own.", "The attempt leases below belong only to this worker. Never persist, render, log, or pass them on argv.", diff --git a/scripts/lib/tracked-jobs.mjs b/scripts/lib/tracked-jobs.mjs index 85e0469..f5e770c 100644 --- a/scripts/lib/tracked-jobs.mjs +++ b/scripts/lib/tracked-jobs.mjs @@ -42,11 +42,8 @@ function transitionTrackedJob(...args) { } } -function isUnverifiableStatusReaperFailure(job) { - return ( - job?.reapedBy === "status-reaper" && - job?.reapedUnverifiable === true - ); +function isStatusReaperFailure(job) { + return job?.reapedBy === "status-reaper"; } function transitionTrackedJobTerminal(workspaceRoot, jobId, status, terminalData) { @@ -60,7 +57,7 @@ function transitionTrackedJobTerminal(workspaceRoot, jobId, status, terminalData if ( !transitioned.transitioned && transitioned.previousStatus === "failed" && - isUnverifiableStatusReaperFailure(transitioned.job) + isStatusReaperFailure(transitioned.job) ) { transitioned = transitionTrackedJob( workspaceRoot, @@ -74,7 +71,7 @@ function transitionTrackedJobTerminal(workspaceRoot, jobId, status, terminalData reapReason: null, reapedUnverifiable: false, }, - { predicate: isUnverifiableStatusReaperFailure } + { predicate: isStatusReaperFailure } ); } return transitioned; diff --git a/scripts/lib/version-sync.mjs b/scripts/lib/version-sync.mjs index 24d2886..678a1d7 100644 --- a/scripts/lib/version-sync.mjs +++ b/scripts/lib/version-sync.mjs @@ -9,6 +9,7 @@ export function resolveVersionSyncPaths(rootDir = ROOT_DIR) { rootDir, packageJsonPath: path.join(rootDir, "package.json"), pluginJsonPath: path.join(rootDir, ".codex-plugin", "plugin.json"), + mcpCapabilitiesPath: path.join(rootDir, "scripts", "lib", "mcp-capabilities.mjs"), }; } @@ -44,6 +45,15 @@ export function assertVersionsMatch(rootDir = ROOT_DIR) { `Version mismatch: package.json is ${packageVersion} but .codex-plugin/plugin.json is ${pluginVersion}.` ); } + const { mcpCapabilitiesPath } = resolveVersionSyncPaths(rootDir); + const mcpVersions = [...fs.readFileSync(mcpCapabilitiesPath, "utf8").matchAll( + /clientInfo:\s*\{\s*name:\s*"cc-plugin-codex",\s*version:\s*"([^"]+)"\s*\}/gu + )].map((match) => match[1]); + if (mcpVersions.length !== 2 || mcpVersions.some((version) => version !== packageVersion)) { + throw new Error( + `MCP clientInfo versions must both match package version ${packageVersion}.` + ); + } return packageVersion; } diff --git a/scripts/lib/workflows.mjs b/scripts/lib/workflows.mjs index 409e9f4..e4d893d 100644 --- a/scripts/lib/workflows.mjs +++ b/scripts/lib/workflows.mjs @@ -434,6 +434,21 @@ function hasUnfinishedAttempt(state) { return state?.status === "running" || Boolean(state?.attemptReservation); } +function hasUnactivatedRetryReservation(state) { + return state?.status === "retryable_failed" && Boolean(state.attemptReservation); +} + +function shouldPreserveAggregateFailure(workflow, target) { + if (workflow.status !== "incomplete") return false; + return [ + ...Object.entries(workflow.branches ?? {}).map(([key, state]) => ["branches", key, state]), + ...Object.entries(workflow.stages ?? {}).map(([key, state]) => ["stages", key, state]), + ].some(([collection, key, state]) => + (collection !== target.collection || key !== target.key) && + ["retryable_failed", "cancel_failed"].includes(state.status) + ); +} + function assertNoActiveAttemptLeaseReflection(workflow, payload) { const activeDigests = new Set([ ...Object.values(workflow.branches ?? {}), @@ -687,15 +702,7 @@ export function activateWorkflowAttempt(cwd, workflowId, options) { startFingerprint: currentFingerprint, commitment: null, }; - const otherTargetFailed = [ - ...Object.entries(workflow.branches ?? {}).map(([key, state]) => ["branches", key, state]), - ...Object.entries(workflow.stages ?? {}).map(([key, state]) => ["stages", key, state]), - ].some(([collection, key, state]) => - (collection !== target.collection || key !== target.key) && - ["retryable_failed", "cancel_failed"].includes(state.status) - ); - const preserveAggregateFailure = workflow.status === "incomplete" && - (target.state.status !== "retryable_failed" || otherTargetFailed); + const preserveAggregateFailure = shouldPreserveAggregateFailure(workflow, target); return { ...updateTarget(workflow, target, startedState), status: preserveAggregateFailure ? workflow.status : "running", @@ -782,7 +789,7 @@ function completeWorkflowStage(cwd, workflowId, options, reveal) { }); const requestedStatus = options.status ?? (options.field === "finalResult" ? "completed" : "running"); - const preserveAggregateFailure = workflow.status === "incomplete"; + const preserveAggregateFailure = shouldPreserveAggregateFailure(workflow, target); const status = preserveAggregateFailure ? workflow.status : requestedStatus; const phase = preserveAggregateFailure ? workflow.phase @@ -1120,14 +1127,18 @@ export function rebindWorkflowOwner(cwd, workflowId, options) { throw workflowError("WORKFLOW_TERMINAL", `Workflow ${workflow.id} is ${workflow.status}.`); } let invalidated = false; + let onlyUnactivatedRetries = true; const invalidate = (items) => Object.fromEntries(Object.entries(items ?? {}).map(([key, item]) => { if (!hasUnfinishedAttempt(item)) return [key, item]; invalidated = true; + const preserveFailure = hasUnactivatedRetryReservation(item); + onlyUnactivatedRetries &&= preserveFailure; return [key, invalidatedTargetState( item, "retryable_failed", - "OWNER_REBOUND", - timestamp + preserveFailure ? item.failureReason : "OWNER_REBOUND", + timestamp, + preserveFailure ? item.failureDetail : null )]; })); return { @@ -1138,8 +1149,10 @@ export function rebindWorkflowOwner(cwd, workflowId, options) { stages: invalidate(workflow.stages), ...(invalidated ? { status: "incomplete", - failureReason: "OWNER_REBOUND", - failureDetail: null, + failureReason: onlyUnactivatedRetries ? workflow.failureReason : "OWNER_REBOUND", + failureDetail: onlyUnactivatedRetries + ? normalizeWorkflowFailureDetail(workflow.failureDetail) + : null, ...enterIncomplete(workflow), } : {}), }; @@ -1220,16 +1233,22 @@ export function completeWorkflowSessionEnd(cwd, workflowId, options) { } let changed = false; let cancellationFailed = false; + let onlyUnactivatedRetries = true; const finalize = (items, kind) => Object.fromEntries(Object.entries(items ?? {}).map(([key, item]) => { if (!hasUnfinishedAttempt(item)) return [key, item]; changed = true; const failed = cancelFailedTargets.has(`${kind}:${key}`); cancellationFailed ||= failed; + const preserveFailure = !failed && hasUnactivatedRetryReservation(item); + onlyUnactivatedRetries &&= preserveFailure; return [key, invalidatedTargetState( item, failed ? "cancel_failed" : "retryable_failed", - failed ? "SESSION_END_CANCEL_FAILED" : "SESSION_ENDED", - timestamp + failed + ? "SESSION_END_CANCEL_FAILED" + : preserveFailure ? item.failureReason : "SESSION_ENDED", + timestamp, + preserveFailure ? item.failureDetail : null )]; })); return { @@ -1239,8 +1258,12 @@ export function completeWorkflowSessionEnd(cwd, workflowId, options) { ...(changed ? { status: cancellationFailed ? "cancel_failed" : "incomplete", phase: cancellationFailed ? "cancel_failed" : workflow.phase, - failureReason: cancellationFailed ? "SESSION_END_CANCEL_FAILED" : "SESSION_ENDED", - failureDetail: null, + failureReason: cancellationFailed + ? "SESSION_END_CANCEL_FAILED" + : onlyUnactivatedRetries ? workflow.failureReason : "SESSION_ENDED", + failureDetail: cancellationFailed || !onlyUnactivatedRetries + ? null + : normalizeWorkflowFailureDetail(workflow.failureDetail), ...(cancellationFailed ? {} : enterIncomplete(workflow)), } : {}), cancellation: { diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs index ca06fc5..bfc377d 100644 --- a/stryker.shard.config.mjs +++ b/stryker.shard.config.mjs @@ -28,9 +28,9 @@ const shards = { "scripts/lib/state.mjs:937-1109", "scripts/lib/state.mjs:1175-1240", "scripts/lib/state.mjs:1246-1292", - "scripts/lib/tracked-jobs.mjs:30-81", - "scripts/lib/tracked-jobs.mjs:311-395", - "scripts/lib/tracked-jobs.mjs:414-547", + "scripts/lib/tracked-jobs.mjs:30-78", + "scripts/lib/tracked-jobs.mjs:308-392", + "scripts/lib/tracked-jobs.mjs:411-544", ], }, "job-control": { diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs index 567f152..3c5e090 100644 --- a/tests/mutation-config.test.mjs +++ b/tests/mutation-config.test.mjs @@ -25,9 +25,9 @@ const expectations = [ ["scripts/lib/state.mjs:937-1109", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]], ["scripts/lib/state.mjs:1175-1240", ["casJobStatus", "transitionJob", "writeAtomic", "withStateFileLock"]], ["scripts/lib/state.mjs:1246-1292", ["cleanupOldJobs"]], - ["scripts/lib/tracked-jobs.mjs:30-81", ["transitionTrackedJob", "isUnverifiableStatusReaperFailure", "transitionTrackedJobTerminal"]], - ["scripts/lib/tracked-jobs.mjs:311-395", ["createJobRecord", "createJobProgressUpdater"]], - ["scripts/lib/tracked-jobs.mjs:414-547", ["runTrackedJob"]], + ["scripts/lib/tracked-jobs.mjs:30-78", ["transitionTrackedJob", "isStatusReaperFailure", "transitionTrackedJobTerminal"]], + ["scripts/lib/tracked-jobs.mjs:308-392", ["createJobRecord", "createJobProgressUpdater"]], + ["scripts/lib/tracked-jobs.mjs:411-544", ["runTrackedJob"]], ["scripts/lib/job-control.mjs:207-469", ["matchJobReference", "buildStatusSnapshot", "resolveCancelableJob"]], ["scripts/installer-cli.mjs:98-236", ["readPersonalMarketplace", "prepareLegacyLocalCleanup", "isPluginAlreadyAbsent", "isPluginUninstallRefused"]], ["scripts/installer-cli.mjs:277-377", ["installOrUpdate", "uninstall"]], diff --git a/tests/peer-orchestration.test.mjs b/tests/peer-orchestration.test.mjs index 58a461a..aaf200b 100644 --- a/tests/peer-orchestration.test.mjs +++ b/tests/peer-orchestration.test.mjs @@ -136,6 +136,7 @@ describe("fake built-in agent orchestration", () => { assert.match(calls[0].message, /peer-checkpoint/u); assertOneShotCheckpointInstructions(calls[0].message); assert.match(calls[1].message, /peer-claude-turn/u); + assert.doesNotMatch(calls[0].message, /Correct the previous attempt failure detail:/u); assert.doesNotMatch(calls[1].message, /codex exec|nohup|\s&\s/); assert.match(calls[0].message, new RegExp("c{64}")); assert.match(calls[0].message, new RegExp("f{64}")); @@ -166,6 +167,50 @@ describe("fake built-in agent orchestration", () => { assertOneShotCheckpointInstructions(worker.message); }); + it("gives a retrying Codex worker only its allowlisted previous failure detail", () => { + const workflow = { + id: "workflow-codex-retry-detail", + mode: "research", + epoch: 3, + workspaceRoot: "/workspace/repo", + brief: "Recheck the evidence.", + briefHash: "a".repeat(64), + branches: { + codex: { + attemptReservation: { + previousFailureDetail: "REPOSITORY_CITATION_REQUIRED", + }, + }, + }, + }; + const options = { + companionPath: "/plugin/scripts/claude-companion.mjs", + leases: { + "branch:codex": "c".repeat(64), + "stage:checkpoint": "f".repeat(64), + }, + }; + + const [worker] = buildRetryAgentPlan( + workflow, + [{ stage: "memo", branchId: "codex" }], + options + ); + assert.match( + worker.message, + /Correct the previous attempt failure detail: REPOSITORY_CITATION_REQUIRED\./u + ); + + workflow.branches.codex.attemptReservation.previousFailureDetail = + "REPOSITORY_CITATION_REQUIRED: raw-model-output"; + const [invalid] = buildRetryAgentPlan( + workflow, + [{ stage: "memo", branchId: "codex" }], + options + ); + assert.doesNotMatch(invalid.message, /raw-model-output|Correct the previous attempt failure detail:/u); + }); + it("keeps shell-hostile prompt delimiters inside the frozen brief data boundary", () => { const plan = buildInitialAgentPlan({ id: "workflow-boundary", diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index 1a209a1..0025dcd 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -716,7 +716,7 @@ describe("runTrackedJob", () => { } }); - it("does not overwrite an ordinary status reaper failure without an unverifiable marker", async () => { + it("replaces an ordinary status reaper failure with the late runner success", async () => { const repoDir = createTempGitRepo(); const job = { id: "tracked-ordinary-reaper-result-job", @@ -749,11 +749,12 @@ describe("runTrackedJob", () => { }); const finalJob = readJobFile(repoDir, job.id); - assert.equal(finalJob.status, "failed"); - assert.equal(finalJob.result, undefined); - assert.equal(finalJob.errorMessage, "Worker died without completing. Auto-reaped."); - assert.equal(finalJob.reapedBy, "status-reaper"); - assert.equal(finalJob.reapReason, "process-missing"); + assert.equal(finalJob.status, "completed"); + assert.deepEqual(finalJob.result, { answer: 43 }); + assert.equal(finalJob.errorMessage, null); + assert.equal(finalJob.reapedBy, null); + assert.equal(finalJob.reapReason, null); + assert.equal(finalJob.reapedUnverifiable, false); } finally { fs.rmSync(repoDir, { recursive: true, force: true }); } @@ -791,7 +792,7 @@ describe("runTrackedJob", () => { } }); - it("makes a runner error primary after an unverifiable status reaper failure", async () => { + it("makes a runner error primary after an identity-mismatch status reaper failure", async () => { const repoDir = createTempGitRepo(); const job = { id: "tracked-reaper-error-job", @@ -810,10 +811,9 @@ describe("runTrackedJob", () => { writeJobFile(repoDir, job.id, { ...running, status: "failed", - errorMessage: "identity remained unverifiable", + errorMessage: "Worker identity no longer matches. Auto-reaped.", reapedBy: "status-reaper", - reapReason: "identity-unverifiable", - reapedUnverifiable: true, + reapReason: "identity-mismatch", updatedAt: nowIso(), }); throw new Error("runner exploded after reaper"); diff --git a/tests/version-sync.test.mjs b/tests/version-sync.test.mjs index d06eb12..669eaa0 100644 --- a/tests/version-sync.test.mjs +++ b/tests/version-sync.test.mjs @@ -15,7 +15,7 @@ function writeJson(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } -function createTempRepo({ packageVersion, pluginVersion }) { +function createTempRepo({ packageVersion, pluginVersion, mcpVersions = [packageVersion, packageVersion] }) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-version-sync-")); writeJson(path.join(dir, "package.json"), { name: "cc-plugin-codex", @@ -25,6 +25,17 @@ function createTempRepo({ packageVersion, pluginVersion }) { name: "cc", version: pluginVersion, }); + const [firstVersion, secondVersion] = mcpVersions; + fs.mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "scripts", "lib", "mcp-capabilities.mjs"), + [ + `const first = { clientInfo: { name: "cc-plugin-codex", version: "${firstVersion}" } };`, + `const second = { clientInfo: { name: "cc-plugin-codex", version: "${secondVersion}" } };`, + "", + ].join("\n"), + "utf8" + ); return dir; } @@ -49,6 +60,22 @@ describe("version sync", () => { } }); + it("detects either stale MCP clientInfo version literal", () => { + const dir = createTempRepo({ + packageVersion: "1.2.3", + pluginVersion: "1.2.3", + mcpVersions: ["1.2.3", "1.2.2"], + }); + try { + assert.throws( + () => assertVersionsMatch(dir), + /MCP clientInfo versions must both match package version 1\.2\.3/u + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("syncs plugin.json from package.json", () => { const dir = createTempRepo({ packageVersion: "2.0.0", diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index f127d3f..812bf1c 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -15,6 +15,7 @@ import { activateWorkflowAttempt, commitWorkflowStage, completeWorkflowCancellation, + completeWorkflowSessionEnd, getWorkflowRetryContext, listWorkflows, markWorkflowBranchFailure, @@ -536,6 +537,143 @@ describe("peer workflow store", () => { assert.equal(workflow.branches.alpha.status, "retryable_failed"); assert.equal(workflow.branches.beta.status, "completed"); }); + + it("clears aggregate failure when the only retryable target completes one-shot", () => { + const repo = createRepo(); + let workflow = createWorkflow(repo, { + id: "workflow-one-shot-recovery", + stages: ["final"], + branches: [], + }); + workflow = markWorkflowBranchFailure(repo, workflow.id, { + stage: "final", + revision: workflow.revision, + epoch: workflow.epoch, + oneShot: true, + reason: "EVIDENCE_INCOMPLETE", + failureDetail: "NON_EMPTY_CONTENT_REQUIRED", + }); + + workflow = submitWorkflowStage(repo, workflow.id, { + stage: "final", + revision: workflow.revision, + epoch: workflow.epoch, + oneShot: true, + payload: { answer: "recovered" }, + field: "finalResult", + status: "completed", + phase: "done", + }); + + assert.equal(workflow.status, "completed"); + assert.equal(workflow.phase, "done"); + assert.equal(workflow.failureReason, null); + assert.equal(workflow.failureDetail, null); + assert.equal(workflow.stages.final.status, "completed"); + }); + + it("clears stale-workspace aggregate failure when its pending reservation activates", () => { + const repo = createRepo(); + const created = createWorkflow(repo, { + id: "workflow-stale-pending-recovery", + stages: ["memo"], + branches: [], + }); + const reservation = reserveWorkflowAttempts(repo, created.id, { + revision: created.revision, + epoch: created.epoch, + }, [{ stage: "memo" }]); + fs.writeFileSync(path.join(repo, "tracked.txt"), "changed before activation\n", "utf8"); + + assert.equal(errorCode(() => activateWorkflowAttempt(repo, created.id, { + stage: "memo", + revision: reservation.workflow.revision, + epoch: reservation.workflow.epoch, + lease: reservation.leases["stage:memo"], + })), "STALE_WORKSPACE"); + + fs.writeFileSync(path.join(repo, "tracked.txt"), "base\n", "utf8"); + const stale = readWorkflow(repo, created.id); + const recovered = activateWorkflowAttempt(repo, created.id, { + stage: "memo", + revision: stale.revision, + epoch: stale.epoch, + lease: reservation.leases["stage:memo"], + }); + assert.equal(recovered.status, "running"); + assert.equal(recovered.failureReason, null); + assert.equal(recovered.failureDetail, null); + assert.equal(recovered.stages.memo.status, "running"); + }); + + it("preserves reserved retry failure detail across owner and SessionEnd rotation", () => { + for (const action of ["rebind", "session-end"]) { + const repo = createRepo(); + let workflow = createWorkflow(repo, { + id: `workflow-reserved-detail-${action}`, + stages: [], + branches: ["alpha"], + }); + workflow = casStartWorkflowStage(repo, workflow.id, { + stage: "memo", + branchId: "alpha", + revision: workflow.revision, + epoch: workflow.epoch, + }); + workflow = markWorkflowBranchFailure(repo, workflow.id, { + stage: "memo", + branchId: "alpha", + revision: workflow.revision, + epoch: workflow.epoch, + lease: workflow.attemptLease, + reason: "EVIDENCE_INCOMPLETE", + failureDetail: "REPOSITORY_TOOL_EVENT_REQUIRED", + }); + const firstReservation = reserveWorkflowAttempts(repo, workflow.id, { + revision: workflow.revision, + epoch: workflow.epoch, + }, [{ stage: "memo", branchId: "alpha" }]); + + if (action === "rebind") { + workflow = rebindWorkflowOwner(repo, workflow.id, { + revision: firstReservation.workflow.revision, + epoch: firstReservation.workflow.epoch, + currentOwnerSessionId: "owner-b", + }); + } else { + const cancellation = reserveWorkflowCancellation(repo, workflow.id, { + revision: firstReservation.workflow.revision, + epoch: firstReservation.workflow.epoch, + }); + workflow = completeWorkflowSessionEnd(repo, workflow.id, { + revision: cancellation.workflow.revision, + epoch: cancellation.workflow.epoch, + lease: cancellation.lease, + cancelFailedTargets: [], + }); + } + + assert.equal(workflow.failureReason, "EVIDENCE_INCOMPLETE", action); + assert.equal(workflow.failureDetail, "REPOSITORY_TOOL_EVENT_REQUIRED", action); + assert.equal(workflow.branches.alpha.failureReason, "EVIDENCE_INCOMPLETE", action); + assert.equal( + workflow.branches.alpha.failureDetail, + "REPOSITORY_TOOL_EVENT_REQUIRED", + action + ); + assert.equal(Object.hasOwn(workflow.branches.alpha, "attemptReservation"), false, action); + + const nextReservation = reserveWorkflowAttempts(repo, workflow.id, { + revision: workflow.revision, + epoch: workflow.epoch, + }, [{ stage: "memo", branchId: "alpha" }]); + assert.equal( + nextReservation.workflow.branches.alpha.attemptReservation.previousFailureDetail, + "REPOSITORY_TOOL_EVENT_REQUIRED", + action + ); + } + }); it("persists a complete secret-free workflow record in its own workspace store", () => { const repo = createRepo(); const workflow = createWorkflow(repo); From 97602a5630e317f9bf783f92d957e53b313a0b33 Mon Sep 17 00:00:00 2001 From: CBEPX <458940+CBEPX@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:43:03 +0300 Subject: [PATCH 11/11] test(peer): sync reservation contract shape --- tests/peer-skills-contract.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/peer-skills-contract.test.mjs b/tests/peer-skills-contract.test.mjs index 3844356..6ad7068 100644 --- a/tests/peer-skills-contract.test.mjs +++ b/tests/peer-skills-contract.test.mjs @@ -87,7 +87,7 @@ test("peer runtime preserves stdin, evidence, strict tool, continuation, and ret "peer-wait", "redacts the sibling payload until the Codex memo is sealed", "JSON on stdin", - "attemptReservation: { leaseDigest, epoch, reservedAt }", + "attemptReservation: { leaseDigest, epoch, reservedAt, previousFailureDetail }", "never a Node argv value", "attempt history advance when activation wins", "peer-claude-turn",