diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3a2a270..a2ac2f1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ "email": "hi@okis.dev" }, "description": "Multi-model orchestration marketplace for Claude Code.", - "version": "0.0.32", + "version": "0.0.33", "plugins": [ { "name": "grok", "source": "./plugins/grok", "displayName": "Grok Companion", "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", - "version": "0.0.32", + "version": "0.0.33", "author": { "name": "Harry Yep" }, @@ -34,7 +34,7 @@ "source": "./plugins/codex", "displayName": "Codex Companion", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", - "version": "0.0.32", + "version": "0.0.33", "author": { "name": "Harry Yep" }, @@ -55,7 +55,7 @@ "source": "./plugins/fusion", "displayName": "Fusion Orchestrator", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", - "version": "0.0.32", + "version": "0.0.33", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 3839756..b2d166e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # changelog +## 0.0.33 + +- notification collection reads the transcript the notification names: every peer wrapper record in a live traffic window had a null `transcriptPath`, so the completed path extracted nothing, mislabeled clean consult wrappers (nine to eleven turns of a sixty budget, backing peer jobs all clean) as `turn_limit` incomplete, demanded manual raw transcript reads, and skipped peer footer seeding; the reconciler now stamps the record's `transcriptPath` from the notification's own output file element before transition, and a no-text completion classifies `missing_final_text` unless real turn evidence supports `turn_limit` +- the collection demand is always satisfiable: the old matcher required a full file read of a transcript that can exceed the Read tool's token cap, a physically impossible demand observed re-firing at every stop boundary for an hour; any Read of the record's `outputFile` (or `transcriptPath` when no artifact exists) now marks collection regardless of offset, limit, or a size-refused response, the demand wording drops the full-read requirement, and the final text artifact caps shrink from 192KB to 72KB so a maximum artifact always fits one read +- stray probes against reaped workers are redirected: the guard's PreToolUse now intercepts `TaskOutput` and `TaskStop` aimed at a terminal fusion worker and points at the record's output file and settlement command instead, ending the "No task found" error class the harness's one minute wrapper reap produced +- peer footer capture runs on incomplete completions too, so `peerJobId` seeds even when extraction fails and an engine job id settlement propagates to the wrapper ledger row +- workers get a wind-down instead of an overshoot: at two turns before the budget the lifecycle injects a final-deliverable instruction once per record, replacing the reactive cap that let workers run to sixty-two and sixty-four of sixty +- stop output consolidates: multiple pending records emit one combined block, and rows that are collected but unsettled carry a `settle-only:` prefix so the two obligations read differently + ## 0.0.32 - worker agent turn caps stop undercutting their budgets: the harness enforces frontmatter `maxTurns` as a hard cap that ends an async worker silently as completed mid run, and `fast-worker`'s 40 sat below its own 60 turn base budget, so standard and large briefs were killed with no deliverable while the honest budget classifier watched a limit that could never fire; frontmatter now carries the doubled large sizing values (120, 60, 24) and a consistency test pins them to the budget table diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 391e430..8a23c8f 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "codex", "displayName": "Codex Companion", - "version": "0.0.32", + "version": "0.0.33", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/.claude-plugin/plugin.json b/plugins/fusion/.claude-plugin/plugin.json index 8e1cd5c..3fece2d 100644 --- a/plugins/fusion/.claude-plugin/plugin.json +++ b/plugins/fusion/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "fusion", "displayName": "Fusion Orchestrator", - "version": "0.0.32", + "version": "0.0.33", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/hooks/hooks.json b/plugins/fusion/hooks/hooks.json index 251528d..da8f876 100644 --- a/plugins/fusion/hooks/hooks.json +++ b/plugins/fusion/hooks/hooks.json @@ -73,6 +73,16 @@ "timeout": 10 } ] + }, + { + "matcher": "^(TaskOutput|TaskStop)$", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/inline-delegation-guard.mjs\"", + "timeout": 10 + } + ] } ], "PostToolUse": [ diff --git a/plugins/fusion/scripts/inline-delegation-guard.mjs b/plugins/fusion/scripts/inline-delegation-guard.mjs index 586be28..a79a832 100644 --- a/plugins/fusion/scripts/inline-delegation-guard.mjs +++ b/plugins/fusion/scripts/inline-delegation-guard.mjs @@ -37,11 +37,14 @@ const WRITE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]); const DELEGATION_TOOLS = new Set(["Agent", "Task"]); const BASH_TOOL = "Bash"; const ENFORCEMENT_AUDIT_TOOLS = new Set([...WRITE_TOOLS, BASH_TOOL]); +const TASK_CONTROL_TOOLS = new Set(["TaskOutput", "TaskStop"]); const BUILTIN_LANE = "builtin"; const MAIN_LANE = "main"; const WORKER_TERMINAL_STATUSES = new Set(["done", "incomplete", "failed", "cancelled", "owner_ended"]); +const REAPED_WORKER_TERMINAL_STATUSES = new Set(["done", "incomplete", "failed", "cancelled"]); const NO_OP_BASH_COMMANDS = new Set(["", "true", ":"]); const NO_OP_HEARTBEAT_REASON = "Fusion tasks are in flight for this session, so emit a text-only heartbeat instead of this no-op Bash command."; +const REAPED_WORKER_REDIRECT_AUDIT_DESCRIPTION = "reaped-worker-redirect"; const NARROW_WAVE_ADVISORY_SUFFIX = "consecutive narrow waves; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ."; const MISSING_LAUNCH_RECOVERY_REASON = "missing-launch-recovered"; @@ -126,6 +129,53 @@ function sessionHasInFlightWorkerTasks(sessionId, env = process.env) { return false; } +function extractTaskControlId(toolInput) { + if (!toolInput || typeof toolInput !== "object") { + return null; + } + return typeof toolInput.task_id === "string" && toolInput.task_id.length > 0 ? toolInput.task_id : null; +} + +function findWorkerRecordForTaskControlId(taskId, env = process.env) { + const jobsDir = path.join(resolveWorkerStateDir(env), "jobs"); + let entries; + try { + entries = fs.readdirSync(jobsDir, { withFileTypes: true }); + } catch { + return null; + } + const matches = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith("fusion-") || !entry.name.endsWith(".json")) { + continue; + } + try { + const record = JSON.parse(fs.readFileSync(path.join(jobsDir, entry.name), "utf8")); + if (!record || typeof record !== "object" || Array.isArray(record)) { + continue; + } + if (record.backgroundTaskId === taskId || record.agentId === taskId) { + matches.push(record); + } + } catch { + continue; + } + } + return matches.find((record) => !REAPED_WORKER_TERMINAL_STATUSES.has(record.transportStatus)) ?? matches[0] ?? null; +} + +function reapedWorkerRedirectReason(record, taskId) { + const settlementTaskId = typeof record.taskId === "string" && record.taskId.length > 0 ? record.taskId : taskId; + const settlement = `/fusion:stats --record ${settlementTaskId}=`; + if (typeof record.outputFile === "string" && record.outputFile.length > 0) { + return `This worker already completed and was reaped; its result is at ${record.outputFile}; Read that file and settle via ${settlement} instead of probing the reaped agent.`; + } + if (typeof record.transcriptPath === "string" && record.transcriptPath.length > 0) { + return `This worker already completed and was reaped; its transcript is at ${record.transcriptPath}; Read that file and settle via ${settlement} instead of probing the reaped agent.`; + } + return `This worker already completed and was reaped; settle via ${settlement} instead of probing the reaped agent.`; +} + function extractBashCommand(toolInput) { if (!toolInput || typeof toolInput !== "object") { return null; @@ -417,6 +467,24 @@ function normalizeAuditEvent(event) { ? { schemaVersion: AUDIT_SCHEMA_VERSION, at: new Date(atMs).toISOString(), session, event: "warn", lane, tool: event.tool, reason: MISSING_LAUNCH_RECOVERY_REASON } : null; } + if ( + (event.event === "deny" || event.event === "warn") && + TASK_CONTROL_TOOLS.has(event.tool) && + event.lane === MAIN_LANE && + event.description === REAPED_WORKER_REDIRECT_AUDIT_DESCRIPTION && + (event.mode === "enforce" || event.mode === "advisory") + ) { + return { + schemaVersion: AUDIT_SCHEMA_VERSION, + at: new Date(atMs).toISOString(), + session, + event: event.event, + lane: MAIN_LANE, + tool: event.tool, + description: REAPED_WORKER_REDIRECT_AUDIT_DESCRIPTION, + mode: event.mode + }; + } if ( (event.event === "deny" || event.event === "warn") && ENFORCEMENT_AUDIT_TOOLS.has(event.tool) && @@ -952,6 +1020,33 @@ function runHook(env = process.env, input = readHookInput()) { return; } + if (TASK_CONTROL_TOOLS.has(toolName)) { + if (input.hook_event_name && input.hook_event_name !== "PreToolUse") { + return; + } + const taskId = extractTaskControlId(input.tool_input); + const record = taskId ? findWorkerRecordForTaskControlId(taskId, env) : null; + if (!record || !REAPED_WORKER_TERMINAL_STATUSES.has(record.transportStatus)) { + return; + } + const mode = resolveMode(env); + const reason = reapedWorkerRedirectReason(record, taskId); + recordAuditEvent( + { + at: now, + session: sessionId, + event: mode === "advisory" ? "warn" : "deny", + lane: MAIN_LANE, + tool: toolName, + description: REAPED_WORKER_REDIRECT_AUDIT_DESCRIPTION, + mode + }, + env + ); + process.stdout.write(`${JSON.stringify(mode === "advisory" ? allowOutput(reason) : denyOutput(reason))}\n`); + return; + } + if (toolName === BASH_TOOL) { if (input.hook_event_name && input.hook_event_name !== "PreToolUse") { return; diff --git a/plugins/fusion/scripts/lib/worker-state.mjs b/plugins/fusion/scripts/lib/worker-state.mjs index 71bea3a..2eebaa8 100644 --- a/plugins/fusion/scripts/lib/worker-state.mjs +++ b/plugins/fusion/scripts/lib/worker-state.mjs @@ -517,7 +517,7 @@ export function refreshWorkerTranscript(record, transcriptPath) { snapshot.toolUseIds = snapshot.toolUseIds.slice(-2048); const usageEntries = Object.entries(snapshot.usageMessages).slice(-512); snapshot.usageMessages = Object.fromEntries(usageEntries); - snapshot.turns = snapshot.turnIds.length; + snapshot.turns = Math.max(integer(record.turns), snapshot.turnIds.length); if (record.toolCallsSource !== "tool-response") { snapshot.toolCalls = snapshot.toolUseIds.length; snapshot.toolCallsSource = snapshot.toolUseIds.length > 0 ? "agent-transcript" : record.toolCallsSource; diff --git a/plugins/fusion/scripts/worker-lifecycle.mjs b/plugins/fusion/scripts/worker-lifecycle.mjs index dc83486..5bd3bfa 100644 --- a/plugins/fusion/scripts/worker-lifecycle.mjs +++ b/plugins/fusion/scripts/worker-lifecycle.mjs @@ -38,9 +38,9 @@ const TASK_NOTIFICATION_SCAN_MAX_BYTES = 4 * 1024 * 1024; const TASK_NOTIFICATION_SCAN_CHUNK_BYTES = 64 * 1024; const TASK_NOTIFICATION_MAX_LINE_BYTES = 1024 * 1024; const TASK_NOTIFICATION_FINAL_SCAN_MAX_BYTES = Math.min(TASK_NOTIFICATION_SCAN_MAX_BYTES, 384 * 1024); -const FINAL_TEXT_MAX_BYTES = 192 * 1024; -const FINAL_TEXT_HEAD_BYTES = 64 * 1024; -const FINAL_TEXT_TAIL_BYTES = 128 * 1024; +const FINAL_TEXT_MAX_BYTES = 72 * 1024; +const FINAL_TEXT_HEAD_BYTES = 24 * 1024; +const FINAL_TEXT_TAIL_BYTES = 48 * 1024; const EXECUTION_END_MARKER = /(?:^|\n)delivery:\s*complete\s*\r?\nverification:\s*passed\s*$/i; const COVERAGE_END_MARKER = /(?:^|\n)delivery:\s*complete\s*\r?\ncoverage:\s*complete\s*$/i; const COLLECTOR_END_MARKER = /(?:^|\n)collector:\s*(?:state=|timeout\b|dead\b|status-error\b).*$/i; @@ -608,6 +608,14 @@ function absoluteOutputFile(value) { return typeof value === "string" && path.isAbsolute(value) ? path.resolve(value) : null; } +function regularFileExists(file) { + try { + return typeof file === "string" && fs.statSync(file).isFile(); + } catch { + return false; + } +} + function outputFileFromLaunchResponse(response, nested) { const direct = [response.outputFile, response.output_file, nested.outputFile, nested.output_file].map(absoluteOutputFile).find(Boolean); if (direct) { @@ -734,17 +742,17 @@ function handlePostToolUse(input, env, failed = false) { return false; } if (input.tool_name === "Read") { - const partialRead = Object.hasOwn(input.tool_input ?? {}, "offset") || Object.hasOwn(input.tool_input ?? {}, "limit"); - return !partialRead && terminalCollectionPending(record) && record.outputFile && path.resolve(record.outputFile) === readPath; + const collectionPath = record.outputFile ?? record.transcriptPath; + return terminalCollectionPending(record) && typeof collectionPath === "string" && path.resolve(collectionPath) === readPath; } if (input.tool_name === "TaskOutput" && input.tool_input?.block !== true) { return false; } return taskId && [record.backgroundTaskId, record.agentId, record.taskId].includes(taskId); }); - if (!failed) { + if (!failed || input.tool_name === "Read") { const now = new Date().toISOString(); - const collectedResultText = ["Read", "TaskOutput"].includes(input.tool_name) ? taskOutputResultText(input) : null; + const collectedResultText = !failed && ["Read", "TaskOutput"].includes(input.tool_name) ? taskOutputResultText(input) : null; if (["Read", "TaskOutput"].includes(input.tool_name) && matches.length > 0) { captureCollectionResponse(input, env); } @@ -838,7 +846,7 @@ function handlePostToolUse(input, env, failed = false) { } return; } - if (!isFusionWorkerAgent(input.agent_type)) { + if (!isFusionWorkerAgent(input.agent_type) && !PEER_JOB_FOOTER_AGENTS.has(input.agent_type)) { return; } const record = recordForAgent(input, env); @@ -846,18 +854,25 @@ function handlePostToolUse(input, env, failed = false) { return; } const now = new Date().toISOString(); + let emitWindDown = false; updateWorkerRecord(record.taskId, env, (current) => { const selectedTranscript = isAttributedTranscriptPath(input.transcript_path, record) ? input.transcript_path : null; const refreshed = selectedTranscript ? refreshWorkerTranscript(current ?? record, selectedTranscript) : current ?? record; const progress = !failed && !READ_ONLY_TOOLS.has(input.tool_name); + const windDownThreshold = Math.max(0, (refreshed.limits?.maxTurns ?? Number.POSITIVE_INFINITY) - 2); + emitWindDown = !isTerminalWorkerStatus(refreshed.transportStatus) && refreshed.windDownContextSentAt == null && (refreshed.turns ?? 0) >= windDownThreshold; return { ...refreshed, lastActivityAt: now, inFlightSince: undefined, ...(!failed ? { lastLivenessAt: now } : {}), - ...(progress ? { lastProgressAt: now, progressEvents: (refreshed.progressEvents ?? 0) + 1 } : {}) + ...(progress ? { lastProgressAt: now, progressEvents: (refreshed.progressEvents ?? 0) + 1 } : {}), + ...(emitWindDown ? { windDownContextSentAt: now } : {}) }; }); + if (emitWindDown) { + writeOutput(hookOutput(input.hook_event_name, "Turn budget wind-down: stop making tool calls and write your final deliverable now.")); + } } function handleSubagentStart(input, env) { @@ -1020,8 +1035,9 @@ function taskNotificationsFromTranscriptLine(line) { for (const block of blocks) { const taskId = /\s*([^<\s]+)\s*<\/task-id\s*>/i.exec(block[1])?.[1] ?? null; const status = /\s*([^<]+?)\s*<\/status\s*>/i.exec(block[1])?.[1]?.trim().toLowerCase() ?? null; + const outputFile = absoluteOutputFile(/\s*([^<]+?)\s*<\/output-file\s*>/i.exec(block[1])?.[1]?.trim()); if (taskId && status && (SUCCESSFUL_TASK_NOTIFICATION_STATUSES.has(status) || CANCELLED_TASK_NOTIFICATION_STATUSES.has(status) || FAILED_TASK_NOTIFICATION_STATUSES.has(status))) { - notifications.push({ taskId, status }); + notifications.push({ taskId, status, outputFile }); } } } @@ -1052,7 +1068,7 @@ function scanTaskNotifications(transcriptPath, requestedOffset) { const size = fs.fstatSync(descriptor).size; const startOffset = Math.min(taskNotificationScanOffset(requestedOffset) ?? 0, size); const bytesToScan = Math.min(TASK_NOTIFICATION_SCAN_MAX_BYTES, size - startOffset); - const statuses = new Map(); + const notifications = new Map(); let cursor = startOffset; let safeOffset = startOffset; let pending = Buffer.alloc(0); @@ -1085,7 +1101,7 @@ function scanTaskNotifications(transcriptPath, requestedOffset) { } if (!skippingLongLine && pending.length > 0) { for (const notification of taskNotificationsFromTranscriptLine(pending.toString("utf8").replace(/\r$/, ""))) { - statuses.set(notification.taskId, notification.status); + notifications.set(notification.taskId, notification); } } pending = Buffer.alloc(0); @@ -1099,7 +1115,7 @@ function scanTaskNotifications(transcriptPath, requestedOffset) { safeOffset = cursor; } } - return { statuses, offset: safeOffset }; + return { notifications, offset: safeOffset }; } catch { return null; } finally { @@ -1120,6 +1136,55 @@ function transcriptEntryContent(entry) { return Array.isArray(entry?.content) ? entry.content : []; } +function boundedTranscriptTailText(transcriptPath) { + if (typeof transcriptPath !== "string" || !path.isAbsolute(transcriptPath)) { + return null; + } + let descriptor; + try { + descriptor = fs.openSync(transcriptPath, "r"); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.size === 0) { + return null; + } + const bytesToRead = Math.min(TASK_NOTIFICATION_FINAL_SCAN_MAX_BYTES, stat.size); + const start = stat.size - bytesToRead; + const buffer = Buffer.allocUnsafe(bytesToRead); + const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, start); + let text = buffer.subarray(0, bytesRead).toString("utf8"); + if (start > 0) { + const firstNewline = text.indexOf("\n"); + text = firstNewline === -1 ? "" : text.slice(firstNewline + 1); + } + const parts = []; + for (const line of text.split("\n")) { + if (!line.trim() || Buffer.byteLength(line) > TASK_NOTIFICATION_MAX_LINE_BYTES) { + continue; + } + try { + for (const block of transcriptEntryContent(JSON.parse(line))) { + if (block?.type === "text" && typeof block.text === "string") { + parts.push(block.text); + } + } + } catch { + void 0; + } + } + return parts.length > 0 ? parts.join("\n") : text; + } catch { + return null; + } finally { + if (descriptor != null) { + try { + fs.closeSync(descriptor); + } catch { + void 0; + } + } + } +} + function finalAssistantTextFromTranscript(transcriptPath) { if (typeof transcriptPath !== "string" || !path.isAbsolute(transcriptPath)) { return null; @@ -1180,47 +1245,62 @@ function finalAssistantTextFromTranscript(transcriptPath) { } function taskNotificationTransition(record, status, now, env) { + const refreshed = refreshWorkerTranscript(record, record.transcriptPath); if (SUCCESSFUL_TASK_NOTIFICATION_STATUSES.has(status)) { - const finalText = finalAssistantTextFromTranscript(record.transcriptPath); + const finalText = finalAssistantTextFromTranscript(refreshed.transcriptPath); + const peerIdentity = PEER_JOB_FOOTER_AGENTS.has(refreshed.agentType) + ? capturedPeerIdentity(refreshed.agentType, finalText ?? boundedTranscriptTailText(refreshed.transcriptPath), refreshed.peerEngine) + : {}; + if (!finalText) { + const turnLimited = refreshed.failureKind === "turn_limit" || (refreshed.turns ?? 0) >= (refreshed.limits?.maxTurns ?? Number.POSITIVE_INFINITY); + return { + ...refreshed, + transportStatus: "incomplete", + acceptance: "unverified", + failureKind: turnLimited ? "turn_limit" : "missing_final_text", + ...peerIdentity, + finishedAt: refreshed.finishedAt ?? now, + lastActivityAt: now + }; + } let finalTextFile = null; - if (finalText) { - try { - finalTextFile = writeFinalTextArtifact(record, finalText, env); - } catch { - finalTextFile = null; - } + try { + finalTextFile = writeFinalTextArtifact(refreshed, finalText, env); + } catch { + finalTextFile = null; } if (!finalTextFile) { return { - ...record, + ...refreshed, transportStatus: "incomplete", acceptance: "unverified", - failureKind: "turn_limit", - finishedAt: record.finishedAt ?? now, + failureKind: "delivery", + ...peerIdentity, + finishedAt: refreshed.finishedAt ?? now, lastActivityAt: now }; } - const harnessAsyncDelivery = collectedHarnessAsyncDelivery(record, "done"); + const harnessAsyncDelivery = collectedHarnessAsyncDelivery(refreshed, "done"); return { - ...markWorkerCollected({ ...record, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), + ...markWorkerCollected({ ...refreshed, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), transportStatus: "done", acceptance: "unverified", - failureKind: harnessAsyncDelivery ? null : record.failureKind, + failureKind: harnessAsyncDelivery ? null : refreshed.failureKind, ...(harnessAsyncDelivery ? { deliveryMode: "harness_async" } : {}), outputFile: finalTextFile, - ...(PEER_JOB_FOOTER_AGENTS.has(record.agentType) ? capturedPeerIdentity(record.agentType, finalText, record.peerEngine) : {}), - finishedAt: record.finishedAt ?? now, + ...peerIdentity, + finishedAt: refreshed.finishedAt ?? now, lastActivityAt: now }; } const cancelled = CANCELLED_TASK_NOTIFICATION_STATUSES.has(status); const fallbackFailureKind = cancelled ? "cancelled" : "task_failed"; return { - ...markWorkerCollected({ ...record, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), + ...markWorkerCollected({ ...refreshed, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), transportStatus: cancelled ? "cancelled" : "failed", acceptance: "unverified", - failureKind: record.failureKind && record.failureKind !== "unexpected_async" ? record.failureKind : fallbackFailureKind, - finishedAt: record.finishedAt ?? now, + failureKind: refreshed.failureKind && refreshed.failureKind !== "unexpected_async" ? refreshed.failureKind : fallbackFailureKind, + finishedAt: refreshed.finishedAt ?? now, lastActivityAt: now }; } @@ -1244,13 +1324,25 @@ function reconcileTaskNotifications(input, env) { return; } const now = new Date().toISOString(); - for (const [taskId, status] of scanned.statuses) { + for (const [taskId, notification] of scanned.notifications) { for (const record of candidates.filter((candidate) => [candidate.backgroundTaskId, candidate.agentId].includes(taskId))) { + const stamped = updateWorkerRecord(record.taskId, env, (current) => { + if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { + return null; + } + const currentTranscriptExists = regularFileExists(current.transcriptPath); + return notification.outputFile && (!current.transcriptPath || !currentTranscriptExists) + ? { ...current, transcriptPath: notification.outputFile } + : current; + }); + if (!stamped || stamped.sessionId !== input.session_id || isTerminalWorkerStatus(stamped.transportStatus) || ![stamped.backgroundTaskId, stamped.agentId].includes(taskId)) { + continue; + } updateWorkerRecord(record.taskId, env, (current) => { if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { return null; } - return taskNotificationTransition(current, status, now, env); + return taskNotificationTransition(current, notification.status, now, env); }); } } @@ -1354,6 +1446,24 @@ function writeAcceptanceAdvisory(sessionId, env) { writeOutput(hookOutput("Stop", `Acceptance remains unverified for ${unverified.length} collected Fusion worker${unverified.length === 1 ? "" : "s"}. Settle each row with exactly one command: ${commands.join("; ")}. pairs are = with id either a fusion task id (fusion- plus 24 lowercase hex) or an engine job id (32 lowercase hex), verdict one of accepted|rejected|unverified.`)); } +function terminalCollectionInstruction(record) { + const collectId = record.backgroundTaskId ?? record.agentId; + return record.outputFile + ? `Call Read with file_path=${record.outputFile} to collect the terminal output before finishing.` + : collectId ? `Call TaskOutput with block=true for terminal owned task ${collectId} and collect the result before finishing.` : `Collect terminal owned task ${record.taskId} before finishing.`; +} + +function settleOnlyRecords(records) { + return records.filter((record) => record.completionContract !== "collector" && isTerminalWorkerStatus(record.transportStatus) && record.collectedAt && record.awaitingVerdict === true); +} + +function writeCombinedStopPending(terminalUncollected, settleOnly) { + const lines = terminalUncollected.map((record) => `${record.taskId}: ${terminalCollectionInstruction(record)}`) + .concat(settleOnly.map((record) => `settle-only: ${record.taskId}: /fusion:stats --record ${record.taskId}=accepted|rejected|unverified`)); + const message = `Fusion worker completion is pending for ${lines.length} records:\n${lines.map((line) => `* ${line}`).join("\n")}\nTransport completion remains unverified until every result and its verification evidence are reviewed. Record accepted or rejected explicitly through /fusion:stats.`; + writeOutput(terminalUncollected.length > 0 ? blockStop(message) : hookOutput("Stop", message)); +} + function inFlightSignature(records) { return records.map((record) => record.taskId).sort().join(","); } @@ -1427,13 +1537,13 @@ function handleStop(input, env) { clearInFlightAdvisory(input.session_id, env); } const terminalUncollected = currentRecords.filter(terminalCollectionPending); + const settleOnly = settleOnlyRecords(currentRecords); + if (terminalUncollected.length + settleOnly.length > 1) { + writeCombinedStopPending(terminalUncollected, settleOnly); + return; + } if (terminalUncollected.length > 0) { - const instructions = terminalUncollected.map((record) => { - const collectId = record.backgroundTaskId ?? record.agentId; - return record.outputFile - ? `Call Read with file_path=${record.outputFile} and no offset or limit to collect the full terminal output before finishing.` - : collectId ? `Call TaskOutput with block=true for terminal owned task ${collectId} and collect the result before finishing.` : `Collect terminal owned task ${record.taskId} before finishing.`; - }); + const instructions = terminalUncollected.map(terminalCollectionInstruction); instructions.push( "Transport completion remains unverified until the result and verification evidence are reviewed. Record accepted or rejected explicitly through /fusion:stats." ); diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 440b1f2..39b84cf 100644 --- a/plugins/grok/.claude-plugin/plugin.json +++ b/plugins/grok/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "grok", "displayName": "Grok Companion", - "version": "0.0.32", + "version": "0.0.33", "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", "author": { "name": "Harry Yep" diff --git a/tests/fusion-worker-lifecycle.test.mjs b/tests/fusion-worker-lifecycle.test.mjs index ece4e41..d98a711 100644 --- a/tests/fusion-worker-lifecycle.test.mjs +++ b/tests/fusion-worker-lifecycle.test.mjs @@ -1159,7 +1159,7 @@ test("a completed async worker is collected at SubagentStop and remains verdict assert.strictEqual(allowed.stdout, ""); }); -test("SubagentStop bounds an oversized final-text artifact with a truncation marker", (t) => { +test("SubagentStop bounds an oversized final-text artifact to a 72KB payload with a truncation marker", (t) => { const box = sandbox(t); run(box, dispatch(box)); run(box, { @@ -1184,10 +1184,10 @@ test("SubagentStop bounds an oversized final-text artifact with a truncation mar const artifact = fs.readFileSync(record(box).outputFile); const source = Buffer.from(finalMessage, "utf8"); - const omitted = source.length - 65_536 - 131_072; + const omitted = source.length - 24_576 - 49_152; const marker = Buffer.from(`\n[fusion: truncated ${omitted} bytes]\n`, "utf8"); - assert.deepStrictEqual(artifact, Buffer.concat([source.subarray(0, 65_536), marker, source.subarray(source.length - 131_072)])); - assert.ok(artifact.length < 256 * 1024); + assert.deepStrictEqual(artifact, Buffer.concat([source.subarray(0, 24_576), marker, source.subarray(source.length - 49_152)])); + assert.strictEqual(artifact.length, 72 * 1024 + marker.length); }); test("SubagentStop finalizes the record when the final-text artifact write fails", (t) => { @@ -1522,7 +1522,7 @@ delivery: foreground assert.strictEqual(collected.peerEngine, "codex"); }); -test("a full Read collects terminal output and captures its peer job footer", (t) => { +test("a Read collects terminal output and captures its peer job footer", (t) => { const box = sandbox(t); const peerJobId = "b".repeat(32); const outputFile = path.join(box.root, "tasks", "peer-read.output"); @@ -1621,7 +1621,7 @@ test("Read collection debug capture writes the raw response when enabled", (t) = assert.deepStrictEqual(JSON.parse(fs.readFileSync(debugFile, "utf8")), toolResponse); }); -test("a terminal failed runtimeAsync worker is demanded by Stop and collected by a full Read", (t) => { +test("a terminal failed runtimeAsync worker is demanded by Stop and collected by a Read", (t) => { const box = sandbox(t); const outputFile = path.join(box.root, "tasks", "terminal-failed.output"); const output = "worker final message before it failed\n"; @@ -1682,7 +1682,7 @@ test("a terminal failed runtimeAsync worker is demanded by Stop and collected by assert.doesNotMatch(clearedOutput.hookSpecificOutput.additionalContext, /Call Read with file_path=|TaskOutput/); }); -test("a terminal cancelled runtimeAsync worker is demanded by Stop and collected by a full Read", (t) => { +test("a terminal cancelled runtimeAsync worker is demanded by Stop and collected by a Read", (t) => { const box = sandbox(t); const outputFile = path.join(box.root, "tasks", "terminal-cancelled.output"); const output = "worker final message before it was cancelled\n"; @@ -1743,7 +1743,7 @@ test("a terminal cancelled runtimeAsync worker is demanded by Stop and collected assert.doesNotMatch(clearedOutput.hookSpecificOutput.additionalContext, /Call Read with file_path=|TaskOutput/); }); -test("a partial Read of a terminal runtimeAsync record's output file does not collect it", (t) => { +test("a failed Read of a terminal runtimeAsync record's output file collects with a limit", (t) => { const box = sandbox(t); const outputFile = path.join(box.root, "tasks", "terminal-partial.output"); const output = "worker final message\n"; @@ -1762,32 +1762,49 @@ test("a partial Read of a terminal runtimeAsync record's output file does not co outputFile })); - for (const partialInput of [{ offset: 1 }, { limit: 1 }]) { - run(box, { - hook_event_name: "PostToolUse", - session_id: "session-1", - cwd: box.cwd, - transcript_path: box.transcript, - tool_name: "Read", - tool_input: { file_path: outputFile, ...partialInput }, - tool_response: { type: "text", text: output } - }); - const untouched = record(box); - assert.strictEqual(untouched.collectedAt, null); - assert.strictEqual(untouched.collectionMethod, null); - assert.strictEqual(untouched.transportStatus, "failed"); - } + run(box, { + hook_event_name: "PostToolUseFailure", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + tool_name: "Read", + tool_input: { file_path: outputFile, limit: 1 }, + tool_response: { is_error: true, content: "Read output exceeded the size limit" } + }); - const blocked = run(box, { - hook_event_name: "Stop", + const collected = record(box); + assert.ok(collected.collectedAt); + assert.strictEqual(collected.collectionMethod, "output_file_read"); + assert.strictEqual(collected.transportStatus, "done"); +}); + +test("a Read of a terminal runtimeAsync record's transcript path collects with offset and limit", (t) => { + const box = sandbox(t); + const transcriptPath = path.join(box.root, "tasks", "terminal-transcript.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "worker final message\n", "utf8"); + run(box, dispatch(box)); + updateWorkerRecord(record(box).taskId, envFor(box), (current) => ({ + ...current, + transportStatus: "ready_uncollected", + outputFile: null, + transcriptPath + })); + + run(box, { + hook_event_name: "PostToolUse", session_id: "session-1", cwd: box.cwd, transcript_path: box.transcript, - background_tasks: [] + tool_name: "Read", + tool_input: { file_path: transcriptPath, offset: 1, limit: 1 }, + tool_response: { type: "text", text: "worker" } }); - const blockedOutput = JSON.parse(blocked.stdout); - assert.strictEqual(blockedOutput.decision, "block"); - assert.match(blockedOutput.reason, /Call Read with file_path=/); + + const collected = record(box); + assert.ok(collected.collectedAt); + assert.strictEqual(collected.collectionMethod, "output_file_read"); + assert.strictEqual(collected.transportStatus, "done"); }); test("a Read of an unrelated file does not collect a terminal runtimeAsync record", (t) => { @@ -1838,7 +1855,7 @@ test("a Read of an unrelated file does not collect a terminal runtimeAsync recor assert.match(blockedOutput.reason, /Call Read with file_path=/); }); -test("a ready_uncollected record still collects through a full Read", (t) => { +test("a ready_uncollected record still collects through a Read", (t) => { const box = sandbox(t); const outputFile = path.join(box.root, "tasks", "ready-uncollected-unchanged.output"); const output = "worker final message\ndelivery: complete\nverification: passed\n"; @@ -3054,6 +3071,39 @@ test("Stop settles a delivered task notification once without a TaskOutput probe assert.strictEqual(settled.acceptance, "accepted"); }); +test("a task notification output file stamps a null transcript path and writes final text", (t) => { + const box = sandbox(t); + const workerTranscript = path.join(box.root, "notification-output-file.jsonl"); + const finalMessage = "notification supplied transcript\ndelivery: complete\nverification: passed"; + fs.writeFileSync(workerTranscript, `${JSON.stringify({ type: "assistant", requestId: "turn-1", message: { content: [{ type: "text", text: finalMessage }] } })}\n`, "utf8"); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-output-file" } + }); + const launched = record(box); + assert.strictEqual(launched.transcriptPath, null); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${launched.agentId}\ncompleted\n${workerTranscript}\n` } })}\n`, "utf8"); + + run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: true, + background_tasks: [] + }); + + const collected = record(box); + assert.strictEqual(collected.transcriptPath, workerTranscript); + assert.strictEqual(collected.transportStatus, "done"); + assert.strictEqual(collected.collectionMethod, "task_notification"); + assert.strictEqual(collected.failureKind, null); + assert.strictEqual(fs.readFileSync(collected.outputFile, "utf8"), finalMessage); +}); + test("a completed task notification captures a Codex peer job footer", (t) => { const box = sandbox(t); const peerJobId = "f".repeat(32); @@ -3065,12 +3115,12 @@ test("a completed task notification captures a Codex peer job footer", (t) => { run(box, { ...workerDispatch, hook_event_name: "PostToolUse", - tool_response: { isAsync: true, status: "async_launched", agentId: "notification-codex-rescue", outputFile: workerTranscript } + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-codex-rescue" } }); const launched = record(box); - updateWorkerRecord(launched.taskId, envFor(box), (current) => ({ ...current, transcriptPath: workerTranscript })); + assert.strictEqual(launched.transcriptPath, null); const before = record(box); - fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.agentId}\ncompleted\n` } })}\n`, "utf8"); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.agentId}\ncompleted\n${workerTranscript}\n` } })}\n`, "utf8"); run(box, { hook_event_name: "Stop", @@ -3082,12 +3132,49 @@ test("a completed task notification captures a Codex peer job footer", (t) => { }); const collected = record(box); + assert.strictEqual(collected.transcriptPath, workerTranscript); + assert.strictEqual(collected.transportStatus, "done"); + assert.strictEqual(collected.failureKind, null); assert.strictEqual(collected.peerJobId, peerJobId); assert.strictEqual(collected.peerEngine, "codex"); assert.strictEqual(collected.peerTransportStatus, undefined); assert.strictEqual(collected.peerSemanticStatus, undefined); }); +test("an incomplete peer notification captures its job footer from the bounded transcript tail", (t) => { + const box = sandbox(t); + const peerJobId = "d".repeat(32); + const workerTranscript = path.join(box.root, "notification-peer-dangling.output"); + fs.writeFileSync(workerTranscript, [ + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: `partial rescue\njob: ${peerJobId}` }, { type: "tool_use", id: "tool-1" }] } }), + JSON.stringify({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "tool-1", content: "done" }] }, toolUseResult: { content: "done" } }) + ].join("\n") + "\n", "utf8"); + const workerDispatch = dispatch(box, { subagent_type: "codex:codex-rescue", prompt: "bounded peer brief" }); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-peer-dangling" } + }); + const launched = record(box); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${launched.agentId}\ncompleted\n${workerTranscript}\n` } })}\n`, "utf8"); + + run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: true, + background_tasks: [] + }); + + const incomplete = record(box); + assert.strictEqual(incomplete.transportStatus, "incomplete"); + assert.strictEqual(incomplete.failureKind, "missing_final_text"); + assert.strictEqual(incomplete.peerJobId, peerJobId); + assert.strictEqual(incomplete.peerEngine, "codex"); +}); + test("a completed task notification does not parse peer footers for fusion workers", (t) => { const box = sandbox(t); const workerTranscript = path.join(box.root, "notification-fast-worker.output"); @@ -3134,6 +3221,7 @@ test("a completion notification with a dangling tool result remains incomplete a tool_response: { isAsync: true, status: "async_launched", agentId: "notification-dangling", outputFile: workerTranscript } }); const before = record(box); + updateWorkerRecord(before.taskId, envFor(box), (current) => ({ ...current, turns: 5 })); fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.agentId}\ncompleted\n` } })}\n`, "utf8"); run(box, { @@ -3147,7 +3235,8 @@ test("a completion notification with a dangling tool result remains incomplete a const incomplete = record(box); assert.strictEqual(incomplete.transportStatus, "incomplete"); - assert.strictEqual(incomplete.failureKind, "turn_limit"); + assert.strictEqual(incomplete.failureKind, "missing_final_text"); + assert.strictEqual(incomplete.turns, 5); assert.strictEqual(incomplete.collectionMethod, null); assert.strictEqual(incomplete.collectedAt, null); assert.strictEqual(incomplete.awaitingVerdict, false); @@ -3155,6 +3244,110 @@ test("a completion notification with a dangling tool result remains incomplete a assert.strictEqual(fs.existsSync(path.join(box.state, "jobs", `${incomplete.taskId}.final.txt`)), false); }); +test("a no-text completion at the turn cap remains a turn limit", (t) => { + const box = sandbox(t); + const workerTranscript = path.join(box.root, "notification-turn-limit.output"); + fs.writeFileSync(workerTranscript, `${JSON.stringify({ type: "assistant", message: { content: [{ type: "tool_use", id: "tool-1" }] } })}\n`, "utf8"); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-turn-limit", outputFile: workerTranscript } + }); + const launched = record(box); + updateWorkerRecord(launched.taskId, envFor(box), (current) => ({ ...current, turns: 60 })); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${launched.agentId}\ncompleted\n` } })}\n`, "utf8"); + + run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: true, + background_tasks: [] + }); + + const incomplete = record(box); + assert.strictEqual(incomplete.transportStatus, "incomplete"); + assert.strictEqual(incomplete.failureKind, "turn_limit"); + assert.strictEqual(incomplete.turns, 60); +}); + +test("PostToolUse emits turn wind-down context once at two turns below the cap", (t) => { + const box = sandbox(t); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + run(box, { + hook_event_name: "SubagentStart", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: "wind-down", + agent_type: "fusion:fast-worker" + }); + const workerTranscript = path.join(box.root, "agent-wind-down.jsonl"); + fs.writeFileSync(workerTranscript, Array.from({ length: 58 }, (_, index) => JSON.stringify({ type: "assistant", requestId: `turn-${index}`, message: { content: [{ type: "text", text: `turn ${index}` }] } })).join("\n") + "\n", "utf8"); + const payload = { + hook_event_name: "PostToolUse", + session_id: "session-1", + cwd: box.cwd, + transcript_path: workerTranscript, + agent_id: "wind-down", + agent_type: "fusion:fast-worker", + tool_name: "Read" + }; + + const first = run(box, payload); + const firstOutput = JSON.parse(first.stdout); + assert.match(firstOutput.hookSpecificOutput.additionalContext, /stop making tool calls and write your final deliverable now/); + assert.strictEqual(record(box).turns, 58); + const notifiedAt = record(box).windDownContextSentAt; + assert.ok(notifiedAt); + + const repeated = run(box, payload); + assert.strictEqual(repeated.stdout, ""); + assert.strictEqual(record(box).windDownContextSentAt, notifiedAt); +}); + +test("Stop combines multi-record collection and settlement instructions", (t) => { + const box = sandbox(t); + const outputFile = path.join(box.root, "peer-terminal.output"); + fs.writeFileSync(outputFile, "peer result\n", "utf8"); + const collecting = createWorkerRecord({ + taskId: `fusion-${"1".repeat(24)}`, + sessionId: "session-1", + agentType: "codex:codex-rescue", + workspaceRoot: box.cwd, + outputFile + }, envFor(box)); + updateWorkerRecord(collecting.taskId, envFor(box), (current) => ({ ...current, agentId: "collecting-peer", backgroundTaskId: "collecting-peer", transportStatus: "ready_uncollected", runtimeAsync: true })); + const settling = createWorkerRecord({ + taskId: `fusion-${"2".repeat(24)}`, + sessionId: "session-1", + agentType: "fusion:fast-worker", + workspaceRoot: box.cwd + }, envFor(box)); + const collectedAt = new Date().toISOString(); + updateWorkerRecord(settling.taskId, envFor(box), (current) => ({ ...current, transportStatus: "done", collectedAt, collectionMethod: WORKER_COLLECTION_METHODS.SUBAGENT_STOP, awaitingVerdict: true, awaitingVerdictArmedAt: collectedAt })); + + const stopped = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: [] + }); + + assert.strictEqual(stopped.stdout.trim().split("\n").length, 1); + const decision = JSON.parse(stopped.stdout); + assert.strictEqual(decision.decision, "block"); + assert.match(decision.reason, new RegExp(`Call Read with file_path=${outputFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + assert.doesNotMatch(decision.reason, /no offset or limit/); + assert.match(decision.reason, new RegExp(`settle-only: ${settling.taskId}: /fusion:stats --record ${settling.taskId}=accepted\\|rejected\\|unverified`)); +}); + test("a delivered completion notification prevents Stop from demanding TaskStop for an expired worker", (t) => { const box = sandbox(t); const workerTranscript = path.join(box.root, "notification-expired.output"); diff --git a/tests/inline-delegation-guard.test.mjs b/tests/inline-delegation-guard.test.mjs index b55b4cd..b792e56 100644 --- a/tests/inline-delegation-guard.test.mjs +++ b/tests/inline-delegation-guard.test.mjs @@ -1062,7 +1062,18 @@ function workerStateEnv(sandbox) { return { FUSION_WORKER_STATE_DIR: workerStateDir }; } -function seedInFlightWorker(sandbox, { sessionId = "session-1", taskId = "fusion-abcdef0123456789abcdef01", transportStatus = "pending_async" } = {}) { +function seedInFlightWorker( + sandbox, + { + sessionId = "session-1", + taskId = "fusion-abcdef0123456789abcdef01", + backgroundTaskId = taskId, + agentId, + transportStatus = "pending_async", + outputFile, + transcriptPath + } = {} +) { const jobsDir = path.join(sandbox.root, "workers", "jobs"); fs.mkdirSync(jobsDir, { recursive: true }); fs.writeFileSync( @@ -1071,7 +1082,11 @@ function seedInFlightWorker(sandbox, { sessionId = "session-1", taskId = "fusion schemaVersion: 1, taskId, sessionId, + backgroundTaskId, + ...(agentId ? { agentId } : {}), transportStatus, + ...(outputFile ? { outputFile } : {}), + ...(transcriptPath ? { transcriptPath } : {}), acceptance: "unverified" }), "utf8" @@ -1094,6 +1109,17 @@ function bashPayload(sandbox, { sessionId = "session-1", command = "true", agent return payload; } +function taskControlPayload(sandbox, { sessionId = "session-1", toolName = "TaskOutput", taskId } = {}) { + return { + hook_event_name: "PreToolUse", + session_id: sessionId, + transcript_path: path.join(sandbox.root, "transcript.jsonl"), + cwd: sandbox.workDir, + tool_name: toolName, + tool_input: { task_id: taskId } + }; +} + test("in-flight worker tasks deny no-op Bash true with the heartbeat reason", (t) => { const sandbox = makeSandbox(t); seedInFlightWorker(sandbox); @@ -1159,7 +1185,88 @@ test("no-op Bash deny audits an enforcement event for Bash on the main lane", (t assert.ok(!Object.hasOwn(denies[0], "path")); }); -test("hooks configuration wires PreToolUse write tools, Bash, Agent, and Task through the inline guard", () => { +test("TaskOutput against a terminal Fusion worker redirects to its output file", (t) => { + const sandbox = makeSandbox(t); + const taskId = "fusion-abcdef0123456789abcdef01"; + const backgroundTaskId = "peer-reaped-output"; + const outputFile = path.join(sandbox.root, "tasks", "peer-reaped-output.txt"); + seedInFlightWorker(sandbox, { taskId, backgroundTaskId, transportStatus: "done", outputFile }); + + const result = run(sandbox, taskControlPayload(sandbox, { taskId: backgroundTaskId }), workerStateEnv(sandbox)); + assert.strictEqual(result.status, 0); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, "deny"); + assert.match(output.hookSpecificOutput.permissionDecisionReason, new RegExp(outputFile)); + assert.match(output.hookSpecificOutput.permissionDecisionReason, new RegExp(`/fusion:stats --record ${taskId}=`)); + const redirects = readAuditRecords(sandbox).filter((record) => record.tool === "TaskOutput"); + assert.deepStrictEqual(redirects, [ + { + schemaVersion: 1, + at: redirects[0].at, + session: "session-1", + event: "deny", + lane: "main", + tool: "TaskOutput", + description: "reaped-worker-redirect", + mode: "enforce" + } + ]); +}); + +test("TaskOutput against a running Fusion worker is allowed", (t) => { + const sandbox = makeSandbox(t); + const backgroundTaskId = "peer-running"; + seedInFlightWorker(sandbox, { backgroundTaskId }); + + const result = run(sandbox, taskControlPayload(sandbox, { taskId: backgroundTaskId }), workerStateEnv(sandbox)); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ""); + assert.deepStrictEqual(readAuditRecords(sandbox), []); +}); + +test("TaskOutput with an unknown task id is allowed", (t) => { + const sandbox = makeSandbox(t); + const result = run(sandbox, taskControlPayload(sandbox, { taskId: "not-a-fusion-worker" }), workerStateEnv(sandbox)); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ""); + assert.deepStrictEqual(readAuditRecords(sandbox), []); +}); + +test("TaskStop against a terminal Fusion worker redirects to its transcript", (t) => { + const sandbox = makeSandbox(t); + const taskId = "fusion-abcdef0123456789abcdef01"; + const agentId = "peer-reaped-stop"; + const transcriptPath = path.join(sandbox.root, "tasks", "peer-reaped-stop.jsonl"); + seedInFlightWorker(sandbox, { taskId, agentId, transportStatus: "cancelled", transcriptPath }); + + const result = run(sandbox, taskControlPayload(sandbox, { toolName: "TaskStop", taskId: agentId }), workerStateEnv(sandbox)); + assert.strictEqual(result.status, 0); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, "deny"); + assert.match(output.hookSpecificOutput.permissionDecisionReason, new RegExp(transcriptPath)); +}); + +test("advisory mode warns and allows terminal TaskOutput redirects", (t) => { + const sandbox = makeSandbox(t); + const backgroundTaskId = "peer-advisory"; + seedInFlightWorker(sandbox, { backgroundTaskId, transportStatus: "failed" }); + + const result = run(sandbox, taskControlPayload(sandbox, { taskId: backgroundTaskId }), { + ...workerStateEnv(sandbox), + FUSION_INLINE_GUARD_MODE: "advisory" + }); + assert.strictEqual(result.status, 0); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, "allow"); + assert.match(output.hookSpecificOutput.permissionDecisionReason, /settle via \/fusion:stats --record/); + const warnings = readAuditRecords(sandbox).filter((record) => record.tool === "TaskOutput"); + assert.strictEqual(warnings.length, 1); + assert.strictEqual(warnings[0].event, "warn"); + assert.strictEqual(warnings[0].description, "reaped-worker-redirect"); + assert.strictEqual(warnings[0].mode, "advisory"); +}); + +test("hooks configuration wires PreToolUse write tools, Bash, Agent, Task, TaskOutput, and TaskStop through the inline guard", () => { const hooks = JSON.parse(fs.readFileSync(path.join(repoRoot, "plugins", "fusion", "hooks", "hooks.json"), "utf8")).hooks; const preToolHandlers = hooks.PreToolUse.flatMap((group) => group.hooks.map((hook) => ({ matcher: group.matcher, command: hook.command }))).filter((hook) => hook.command?.includes("inline-delegation-guard.mjs")); assert.deepStrictEqual(preToolHandlers, [ @@ -1170,6 +1277,10 @@ test("hooks configuration wires PreToolUse write tools, Bash, Agent, and Task th { matcher: "^(Agent|Task)$", command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/inline-delegation-guard.mjs"' + }, + { + matcher: "^(TaskOutput|TaskStop)$", + command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/inline-delegation-guard.mjs"' } ]); });