/** * CORE-5 extraction 3: post-execution finalization. * * Lifts the shared post-branch block of `runTeamTask` (yield detection, * diff/patch artifacts, mutation guard, output validation, verification * evidence, terminal state persist, hooks, task.completed/failed events) * into a single {@link finalizeTaskResult} function. * * Takes the pre-execution context ({@link TaskExecutionContext}) + the * branch execution result ({@link TaskExecutionResult}) and returns the * final persisted task state. * * Extracted verbatim from `runTeamTask` — no behavioral changes. The * mutation guard (warn/fail/off) and verification contract are preserved * exactly (char scenarios 5-7, 9, 10 cover them). */ import { readFileSync } from "node:fs"; import { appendHookEvent, executeHook } from "../../hooks/registry.ts"; import { withRunLock } from "../../state/coordination/locks.ts"; import { appendEventAsync } from "../../state/event-log/event-log.ts"; import { writeArtifact } from "../../state/stores/artifact-store.ts"; import { saveRunManifestAsync } from "../../state/stores/state-store.ts"; import type { ArtifactDescriptor, OperationTerminalEvidence, TeamRunManifest, TeamTaskState, VerificationEvidence, } from "../../state/types.ts"; import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync } from "../../worktree/worktree-manager.ts"; import { appendTaskAttentionEvent } from "../attention-events.ts"; import { extractCommandTrace } from "../command-trace.ts"; import { emptyCrewAgentProgress, recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts"; import { crewHooks } from "../crew-hooks.ts"; import { createWorkerHeartbeat, touchWorkerHeartbeat } from "../heartbeat/worker-heartbeat.ts"; import type { ModelAttemptSummary } from "../model/model-fallback.ts"; import { isStderrOnlyResult, type OutputValidationResult, validateWorkerOutput } from "../output/output-validator.ts"; import type { ParsedPiJsonOutput } from "../output/pi-json-output.ts"; import { writeTaskSharedOutput } from "../task-output-context.ts"; import { evaluateCompletionMutationGuard } from "../verification/completion-guard.ts"; import { createVerificationEvidence } from "../verification/green-contract.ts"; import { computeGreenLevelFromResults, executeVerificationCommands } from "../verification/verification-gates.ts"; import { extractYieldResult, hasYieldInOutput, isYieldEvent, type YieldResult } from "../yield-handler.ts"; import { buildWorkerCapabilityInventory } from "./capabilities.ts"; import type { TaskExecutionContext } from "./pre-execution.ts"; import { buildWorkerPromptPipeline } from "./prompt-pipeline.ts"; import { computeSpecGate } from "./spec-evidence.ts"; import { persistSingleTaskUpdate, updateTask } from "./state-helpers.ts"; /** * Branch execution output — the set of variables produced by the * child-process / live-session / scaffold branch dispatch. These flow * into {@link finalizeTaskResult} for post-processing. * * `error`, `exitCode`, and `modelAttempts` are mutated in place by the * mutation guard (fail mode) inside finalizeTaskResult. */ export interface TaskExecutionResult { /** * MuxSurface A1 (spec §7 D3): the worker's pane died with NO worker.completed * inside the classify window — the worker never finished. When set, * {@link finalizeTaskResult} short-circuits into a dedicated degrade * terminalisation (needs_attention, no fabricated result) INSTEAD of the * yield/mutation/output guards, which would misread the loss as an * empty-result failure and trigger autoRetry double-dispatch. */ surfaceLost?: { taskId: string; paneId: string; cause: "pane-closed" | "mux-dead"; exitReason: string; ts: string; }; resultArtifact?: ArtifactDescriptor; logArtifact: ArtifactDescriptor | undefined; transcriptArtifact: ArtifactDescriptor | undefined; exitCode: number | null; error: string | undefined; modelAttempts: ModelAttemptSummary[] | undefined; parsedOutput: ParsedPiJsonOutput | undefined; finalStdout: string; /** Round-1: un-trimmed final assistant text (pre-compaction) — the spec * footer union prefers it, mirroring the result-artifact fallback chain. */ rawFinalText?: string; transcriptPath: string | undefined; terminalEvidence: OperationTerminalEvidence[]; startupEvidence: import("../heartbeat/worker-startup.ts").WorkerStartupEvidence; /** Which child-executor fallback branch produced the result artifact * content (rawFinalText → finalText → session → stdout → stderr → findings → * "(no output)"). Finding #2 (2026-09-29 battery): "stderr"/"none" mean * every authoritative worker output source was empty — finalizeTaskResult * fails those DETERMINISTICALLY; the stderr-noise classifier stays a * defense layer, never the verdict oracle. "session" (W2/P1-1) = the text * was tail-recovered from the worker's session JSONL on a signal-death * crash path — authoritative recovered output, not a failure source. */ resultSource?: ResultSource; } /** Result-artifact provenance tags (see {@link TaskExecutionResult.resultSource}). */ export type ResultSource = "rawFinalText" | "finalText" | "session" | "stdout" | "stderr" | "findings" | "none"; /** Final persisted task state returned by finalizeTaskResult. */ export interface FinalTaskResult { manifest: TeamRunManifest; tasks: TeamTaskState[]; } /** * Execute the shared post-branch phase of `runTeamTask`: yield detection, * diff/patch artifacts, completion mutation guard, output validation, * verification evidence computation, terminal state persistence, hooks, * and task.completed/failed events. * * @param ctx The pre-execution context (task, tasks, manifest, etc.). * `ctx.task` and `ctx.tasks` must be synced to the * post-branch state by the caller before calling this. * @param execResult The branch execution output (resultArtifact, error, * modelAttempts, etc.). `error`/`exitCode`/`modelAttempts` * may be mutated by the mutation guard. * @returns The final persisted manifest + tasks. */ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult: TaskExecutionResult): Promise { const input = ctx.input; let manifest = ctx.manifest; let task = ctx.task; let tasks = ctx.tasks; const runtimeKind = ctx.runtimeKind; const workspace = ctx.workspace; const taskPacket = ctx.taskPacket; const collectYieldEvents = ctx.collectYieldEvents; const collectedJsonEvents = ctx.collectedJsonEvents; const permissionMode = ctx.permissionMode; const skillNames = ctx.skillNames; const skillPaths = ctx.skillPaths; const promptArtifact = ctx.promptArtifact; const breakdownArtifact = ctx.breakdownArtifact; const inputsArtifact = ctx.inputsArtifact; const skillArtifact = ctx.skillArtifact; const coordinationArtifact = ctx.coordinationArtifact; const resultArtifact = execResult.resultArtifact; const logArtifact = execResult.logArtifact; const transcriptArtifact = execResult.transcriptArtifact; let exitCode = execResult.exitCode; let error = execResult.error; let modelAttempts = execResult.modelAttempts; const parsedOutput = execResult.parsedOutput; const finalStdout = execResult.finalStdout; const rawFinalText = execResult.rawFinalText; const transcriptPath = execResult.transcriptPath; const terminalEvidence = execResult.terminalEvidence; const startupEvidence = execResult.startupEvidence; // ── MuxSurface A1 (spec §7 D3): degrade terminalisation ─────────────── // The pane died with NO worker.completed in the classify window: the worker // never finished, so the yield/mutation/output guards would MISREAD this as // an empty-result failure (autoRetry would then double-dispatch and a failed // task could abort the whole run at handleFailedTask). Terminalise as // needs_attention WITHOUT fabricating a result; team-runner's surface-degrade // drain re-queues the unit headless (scratchpad restore + pendingSteers // replay + resume note) WITHOUT consuming retry budget (spec §7 step 5). if (execResult.surfaceLost) { const lost = execResult.surfaceLost; task = { ...task, status: "needs_attention", finishedAt: new Date().toISOString(), exitCode: null, error: undefined, claim: undefined, heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), { alive: false }), terminalEvidence: terminalEvidence.length ? [...(task.terminalEvidence ?? []), ...terminalEvidence] : task.terminalEvidence, diagnostics: { ...(task.diagnostics ?? {}), surfaceLost: { ts: lost.ts, cause: lost.cause, paneId: lost.paneId, exitReason: lost.exitReason }, }, }; tasks = updateTask(tasks, task); // ST-7: durable terminal write under the run lock — crash recovery must // see needs_attention, not a phantom "running" worker. tasks = await withRunLock(manifest, async () => persistSingleTaskUpdate(manifest, tasks, task, undefined, true)); upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind)); await appendEventAsync(manifest.eventsPath, { type: "task.surface_lost", runId: manifest.runId, taskId: task.id, message: `Surface worker lost (${lost.cause}) before completing — re-dispatching headless`, data: { paneId: lost.paneId, cause: lost.cause, exitReason: lost.exitReason, ts: lost.ts }, }); return { manifest, tasks }; } // --- Yield-based completion contract --- // _yieldResult: preserved for future use — yield completion contract not yet wired to task.result let _yieldResult: YieldResult | undefined; let noYield = false; // Child-process workers do not have a submit_result tool — the yield contract // only applies to live-session workers where submit_result is injected by the // runtime. Skipping yield detection for child-process prevents every child // worker from incorrectly being marked needs_attention. const yieldEnabled = collectYieldEvents; if (yieldEnabled && collectedJsonEvents && collectedJsonEvents.length > 0) { if (hasYieldInOutput(collectedJsonEvents)) { const yieldEvent = collectedJsonEvents.find((e) => isYieldEvent(e)); if (yieldEvent) { _yieldResult = extractYieldResult(yieldEvent); } } else if (!error) { noYield = true; await appendEventAsync(manifest.eventsPath, { type: "task.needs_attention", runId: manifest.runId, taskId: task.id, message: "Worker completed without calling submit_result tool.", data: { activityState: "needs_attention", reason: "no_yield", // Bug #21 fix: include result path so downstream tasks can read the output resultPath: resultArtifact?.path, }, }); } } const diffArtifact = workspace.worktreePath ? writeArtifact(manifest.artifactsRoot, { kind: "diff", relativePath: `diffs/${task.id}.diff`, content: await captureWorktreeDiffAsync(workspace.worktreePath), producer: task.id, }) : undefined; const diffStatArtifact = workspace.worktreePath ? writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.diff-stat.json`, content: `${JSON.stringify({ ...(await captureWorktreeDiffStatAsync(workspace.worktreePath)), syntheticPaths: workspace.syntheticPaths ?? [], nodeModulesLinked: workspace.nodeModulesLinked ?? false }, null, 2)}\n`, producer: task.id, }) : undefined; // Capture unified patches from edit tool results const patchArtifact = parsedOutput?.patches?.length ? writeArtifact(manifest.artifactsRoot, { kind: "patch", relativePath: `patches/${task.id}.patch`, content: parsedOutput.patches.join("\n---\n"), producer: task.id, }) : undefined; const mutationGuardMode = input.runtimeConfig?.completionMutationGuard ?? "warn"; const mutationGuard = !error && mutationGuardMode !== "off" ? evaluateCompletionMutationGuard({ role: task.role, taskText: `${task.title}\n${input.step.task}`, transcriptPath: runtimeKind === "child-process" ? transcriptPath : transcriptArtifact?.path, stdout: finalStdout, }) : undefined; if (mutationGuard?.reason === "no_mutation_observed") { appendTaskAttentionEvent({ manifest, taskId: task.id, message: "Implementation-style task completed without an observed mutation tool call.", data: { activityState: "needs_attention", reason: "completion_guard", taskId: task.id, agentName: task.agent, observedTools: mutationGuard.observedTools, suggestedAction: mutationGuardMode === "fail" ? "Review the worker output and rerun with a concrete implementation task." : "Review the worker output; set runtime.completionMutationGuard='fail' to enforce this.", }, }); task = { ...task, agentProgress: { ...(task.agentProgress ?? emptyCrewAgentProgress()), activityState: "needs_attention", }, }; if (mutationGuardMode === "fail") { error = "Completion mutation guard failed: implementation-style task completed without an observed mutation tool call."; exitCode = exitCode === 0 ? 1 : exitCode; if (modelAttempts?.length) { modelAttempts = modelAttempts.map((attempt, index) => index === modelAttempts!.length - 1 ? { ...attempt, success: false, exitCode, error } : attempt, ); } } tasks = updateTask(tasks, task); } // --- Output format validation (caveman Phase 4) --- // Validate worker output against the role's output contract. // On failure: emit attention event but don't fail the task. let outputValidation: OutputValidationResult | undefined; if (!error) { const outputText = parsedOutput?.finalText ?? finalStdout; if (outputText) { outputValidation = validateWorkerOutput(task.role, outputText); if (!outputValidation.valid) { await appendEventAsync(manifest.eventsPath, { type: "task.output_validation", runId: manifest.runId, taskId: task.id, data: { valid: false, formatMatch: outputValidation.formatMatch, structurePreserved: outputValidation.structurePreserved, issues: outputValidation.issues, }, }); task = { ...task, agentProgress: { ...(task.agentProgress ?? emptyCrewAgentProgress()), activityState: "needs_attention", }, }; tasks = updateTask(tasks, task); } } } // --- Result artifact usability check (bug-026 sub-issue A) --- // A corrupted/empty worker payload leaves BOTH authoritative output sources // (parsed finalText + finalStdout) empty while the child-executor result // fallback chain persists session-log stderr noise as the result artifact. // Existence-only validation then marks the task "completed" and downstream // tasks silently consume garbage (evidence: run team_20260815144514, // results/02_explore-core.txt). Two-gate auto-fail — a gate-1 miss alone // (legitimate short result "OK done.") or a gate-2 miss alone (real content // in the artifact) keeps the pre-existing outcome: // gate 1 — finalText AND finalStdout are both trimmed-empty (a legitimate // result ALWAYS surfaces in at least one authoritative source); // gate 2 — the persisted artifact is empty/'(no output)'/whitespace OR // isStderrOnlyResult says every line is strict log noise. // Finding #2 (2026-09-29 battery): gate 2 alone is not a verdict oracle — // the strict-noise classifier missed colon-bearing/camelCase extension // tags (`[pi-crew:crash-recovery.reconcileStaleRuns]`, lock-contention // noise about a SIBLING run), so attempts whose stderr carried them // completed tasks with 100%-noise artifacts while sibling attempts on the // same runs failed — verdict divergence depended on per-attempt stderr // composition. The child-executor fallback chain now tags the winning // branch (resultSource); "stderr"/"none"-sourced artifacts fail // UNCONDITIONALLY (classifier-independent), making empty/stderr-only // results never complete a task, uniformly across roles. // A read error on the artifact is NOT a failure (conservative) — unless // the source tag already proves the artifact is stderr/none-sourced. // Mirrors the mutation-guard fail-mode precedent: error marker + exitCode // bump + last modelAttempt success:false → status flips to "failed" (retryable). if (!error) { const finalTextEmpty = !parsedOutput?.finalText?.trim(); const finalStdoutEmpty = !finalStdout?.trim(); const resultSource = execResult.resultSource; const degradedResultSource = resultSource === "stderr" || resultSource === "none"; if (finalTextEmpty && finalStdoutEmpty && (resultArtifact?.path || degradedResultSource)) { let artifactContent: string | undefined; if (resultArtifact?.path) { try { artifactContent = readFileSync(resultArtifact.path, "utf8"); } catch { artifactContent = undefined; // unreadable artifact — do not fail on read errors } } if (artifactContent !== undefined || degradedResultSource) { const trimmedArtifact = (artifactContent ?? "").trim(); const emptyArtifact = trimmedArtifact === "" || trimmedArtifact === "(no output)"; const stderrOnlyArtifact = !emptyArtifact && isStderrOnlyResult(artifactContent ?? ""); if (degradedResultSource || emptyArtifact || stderrOnlyArtifact) { error = "Result artifact is empty or stderr-only (failureCause: empty-or-stderr-only-result)"; exitCode = exitCode === 0 ? 1 : exitCode; if (modelAttempts?.length) { modelAttempts = modelAttempts.map((attempt, index) => index === modelAttempts!.length - 1 ? { ...attempt, success: false, exitCode, error } : attempt, ); } outputValidation = { valid: false, formatMatch: false, structurePreserved: false, issues: [ `empty-or-stderr-only-result: ${ degradedResultSource && !emptyArtifact && !stderrOnlyArtifact ? `result artifact was sourced from the ${resultSource} fallback (no authoritative worker output)` : emptyArtifact ? "result artifact is empty" : "result artifact contains only stderr/session-log noise" }`, ], }; await appendEventAsync(manifest.eventsPath, { type: "task.output_validation", runId: manifest.runId, taskId: task.id, data: { valid: false, formatMatch: false, structurePreserved: false, issues: outputValidation.issues, failureCause: "empty-or-stderr-only-result", ...(resultSource ? { resultSource } : {}), resultPath: resultArtifact?.path, }, }); } } } } // --- ECC VERIFICATION_LOOP: Compute verification evidence before building task object --- // Compute verification evidence (may be async if verification commands need to run) const baseEvidence = createVerificationEvidence( taskPacket.verification, !error, error ? `Task failed: ${error}` : runtimeKind === "scaffold" ? "Safe scaffold mode; verification commands were not executed." : `${runtimeKind} worker finished without reporting a verification failure.`, ); // Only execute verification commands when: // 1. Task completed successfully (no error) // 2. Verification contract has commands // 3. Not in scaffold mode (scaffold mode intentionally skips execution) let verificationEvidence: VerificationEvidence = baseEvidence; if (runtimeKind !== "scaffold" && taskPacket.verification?.commands?.length) { try { const commandResults = await executeVerificationCommands( taskPacket.verification, task.cwd, manifest.runId, task.id, manifest.artifactsRoot, input.signal, ); // Compute observed green level from results const observedGreenLevel = computeGreenLevelFromResults(commandResults, taskPacket.verification.requiredGreenLevel); // Determine satisfaction based on green level const requiredLevel = taskPacket.verification.requiredGreenLevel; const satisfied = observedGreenLevel === "none" ? false : observedGreenLevel === "targeted" ? requiredLevel === "targeted" : observedGreenLevel === "package" ? ["targeted", "package"].includes(requiredLevel) : observedGreenLevel === "workspace" ? ["targeted", "package", "workspace"].includes(requiredLevel) : observedGreenLevel === "merge_ready"; const allPassed = commandResults.every((r) => r.status === "passed"); const failedCount = commandResults.filter((r) => r.status === "failed").length; verificationEvidence = { requiredGreenLevel: taskPacket.verification.requiredGreenLevel, observedGreenLevel, satisfied: satisfied && allPassed, commands: commandResults, notes: allPassed ? `${commandResults.length} verification commands passed` : `${failedCount}/${commandResults.length} verification commands failed`, }; } catch (execError) { // On execution error, return base evidence with error note verificationEvidence = { ...baseEvidence, notes: `Verification execution failed: ${execError instanceof Error ? execError.message : String(execError)}`, }; } } // --- T4/R6 (ADR-6 §3/§4): SPEC-EVIDENCE gate (coverage default, strict opt-in) --- // Extends the classifier seam above. All mechanics live in computeSpecGate // (unit-testable, round-1 P3): footer = union of every authoritative result // source (rawFinalText/finalText/finalStdout — compaction can empty finalText // while the footer survives elsewhere); sandbox cwd = the TASK workspace // (worktree-aware); scaffold mode + already-failed tasks skip machine-checks; // a strict failure PREFIXES any pre-existing error (round-1 P3). const specGateResult = await computeSpecGate({ packet: taskPacket, rawFinalText, finalText: parsedOutput?.finalText, finalStdout, sandboxCwd: task.cwd, runtimeKind, alreadyFailed: Boolean(error), }); for (const event of specGateResult.events) { await appendEventAsync(manifest.eventsPath, { type: event.type, runId: manifest.runId, taskId: task.id, data: event.data, }); } if (specGateResult.specGate?.badge) { await appendEventAsync(manifest.eventsPath, { type: "task.spec_gate", runId: manifest.runId, taskId: task.id, data: { mode: specGateResult.specGate.mode, badge: specGateResult.specGate.badge, footerPresent: specGateResult.specGate.footerPresent, missingMustIds: specGateResult.specGate.missingMustIds, unknownIds: specGateResult.specGate.unknownIds, ...(specGateResult.specGate.strict ? { strictPassed: specGateResult.specGate.strict.passed } : {}), }, }); } const specGate = specGateResult.specGate; if (specGateResult.gateError) { // Strict gate failure fails the write-gate (ADR §4) — prefix, never // replace, the upstream failure cause (round-1 P3). error = error ? `${error}; ${specGateResult.gateError}` : specGateResult.gateError; exitCode = exitCode === 0 ? 1 : exitCode; } task = { ...task, status: error ? "failed" : noYield ? "needs_attention" : "completed", finishedAt: new Date().toISOString(), exitCode, modelAttempts, usage: parsedOutput?.usage, jsonEvents: parsedOutput?.jsonEvents, agentProgress: error && task.agentProgress?.currentTool ? { ...task.agentProgress, failedTool: task.agentProgress.currentTool, } : task.agentProgress, error, verification: verificationEvidence, ...(specGate ? { specGate } : {}), resultArtifact, claim: undefined, heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), { alive: false }), terminalEvidence: terminalEvidence.length ? [...(task.terminalEvidence ?? []), ...terminalEvidence] : task.terminalEvidence, ...(logArtifact ? { logArtifact } : {}), ...(transcriptArtifact ? { transcriptArtifact } : {}), }; tasks = updateTask(tasks, task); // Emit task completion hooks (100% reliable, fire-and-forget) const hookType = task.status === "completed" ? "task_completed" : task.status === "failed" ? "task_failed" : "task_started"; // T10: attach the VERBATIM command trace (mechanically derived from // recorded tool-call history, never from the worker's self-report) so // event viewers + the orchestrator see exactly which commands ran. const commandTrace = extractCommandTrace(task.agentProgress?.recentTools); crewHooks.emit({ type: hookType, timestamp: task.finishedAt ?? new Date().toISOString(), runId: manifest.runId, taskId: task.id, data: { status: task.status, role: task.role, error: task.error, exitCode: task.exitCode, usage: task.usage, commandTrace, }, }); const packetArtifact = writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.task-packet.json`, content: `${JSON.stringify(task.taskPacket, null, 2)}\n`, producer: task.id, }); const verificationArtifact = writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.verification.json`, content: `${JSON.stringify(task.verification, null, 2)}\n`, producer: task.id, }); const sharedOutputArtifact = writeTaskSharedOutput(manifest, input.step, task); const startupArtifact = writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.startup-evidence.json`, content: `${JSON.stringify(startupEvidence, null, 2)}\n`, producer: task.id, }); const permissionArtifact = writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.permission.json`, content: `${JSON.stringify({ role: task.role, permissionMode }, null, 2)}\n`, producer: task.id, }); const capabilityArtifact = writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.capabilities.json`, content: `${JSON.stringify(buildWorkerCapabilityInventory({ taskId: task.id, role: task.role, agent: input.agent, runtime: runtimeKind, permissionMode, skillNames, skillPaths, skillsDisabled: input.skillOverride === false || input.teamRoleSkills === false, modelOverride: input.modelOverride, teamRoleModel: input.teamRoleModel, stepModel: input.step.model }), null, 2)}\n`, producer: task.id, }); const promptPipelineArtifact = writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.prompt-pipeline.json`, content: `${JSON.stringify(buildWorkerPromptPipeline({ artifactsRoot: manifest.artifactsRoot, taskId: task.id, promptArtifact, inputsArtifact, skillArtifact, capabilityArtifact, coordinationArtifact, skillInstructionCount: skillNames?.length ?? 0, skillsDisabled: input.skillOverride === false || input.teamRoleSkills === false }), null, 2)}\n`, producer: task.id, }); const outputValidationArtifact = outputValidation ? writeArtifact(manifest.artifactsRoot, { kind: "metadata", relativePath: `metadata/${task.id}.output-validation.json`, content: `${JSON.stringify(outputValidation, null, 2)}\n`, producer: task.id, }) : undefined; manifest = { ...manifest, updatedAt: new Date().toISOString(), artifacts: [ ...manifest.artifacts, promptArtifact, ...(breakdownArtifact ? [breakdownArtifact] : []), ...(resultArtifact ? [resultArtifact] : []), inputsArtifact, coordinationArtifact, ...(skillArtifact ? [skillArtifact] : []), packetArtifact, verificationArtifact, startupArtifact, permissionArtifact, capabilityArtifact, promptPipelineArtifact, ...(outputValidationArtifact ? [outputValidationArtifact] : []), ...(sharedOutputArtifact ? [sharedOutputArtifact] : []), ...(logArtifact ? [logArtifact] : []), ...(transcriptArtifact ? [transcriptArtifact] : []), ...(diffArtifact ? [diffArtifact] : []), ...(diffStatArtifact ? [diffStatArtifact] : []), ...(patchArtifact ? [patchArtifact] : []), ], }; // NEW-C3: persist manifest + tasks atomically under the run lock. Without this, // the unlocked saveRunManifest here races with the team-runner batch merge path // (which writes the manifest under withRunLock) — a parallel batch could read a // stale manifest and overwrite this task's freshly-written artifacts, silently // losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard), // so nesting it inside this lock is a no-op re-acquire, not a deadlock. // ST-7: this is a terminal transition (task.status is completed/failed/needs_attention), // pass skipCoalesce=true so the terminal update is durable — a SIGKILL in the // 50ms coalesce window must NOT leave tasks.json showing the prior non-terminal // status, otherwise crash recovery would see inconsistency between tasks.json // (running) and events.jsonl (terminal). tasks = await withRunLock(manifest, async () => { await saveRunManifestAsync(manifest); return persistSingleTaskUpdate(manifest, tasks, task, undefined, true); }); upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind)); // Execute task_result hook before emitting terminal event const hookReport = await executeHook("task_result", { runId: manifest.runId, taskId: task.id, cwd: manifest.cwd, }); appendHookEvent(manifest, hookReport); await appendEventAsync(manifest.eventsPath, { type: error ? "task.failed" : noYield ? "task.needs_attention" : "task.completed", runId: manifest.runId, taskId: task.id, message: error, // bug-026 sub-issue B: surface the classified fatal-fs cause (enospc/ // edquot/emfile/enfile) on the failure event so operators see "disk // full" instead of a generic timeout diagnostic. ...(task.failureCause ? { data: { failureCause: task.failureCause } } : {}), }); // Execute after_task_complete lifecycle hook (non-blocking) const afterTaskReport = await executeHook("after_task_complete", { runId: manifest.runId, taskId: task.id, cwd: manifest.cwd, status: error ? "failed" : noYield ? "needs_attention" : "completed", }); appendHookEvent(manifest, afterTaskReport); return { manifest, tasks }; }