diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 03d772c..3a2a270 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.31", + "version": "0.0.32", "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.31", + "version": "0.0.32", "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.31", + "version": "0.0.32", "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.31", + "version": "0.0.32", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6598e..3839756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # changelog +## 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 +- notification driven collection produces a readable deliverable: the task notification completed path extracts the final assistant text from a bounded transcript tail, writes the bounded final text artifact, and points `outputFile` at it, matching the `SubagentStop` path; a transcript ending on a dangling tool result with no final text classifies as `incomplete` with `turn_limit` instead of done and collected, so a truncated worker no longer reports as a success with nothing to read +- the wrapper footer is parsed on the notification path too: peer job identity seeds from the extracted final text for rescue wrappers, so an engine job id settlement propagates to the wrapper ledger row instead of leaving it demanding a second manual settle +- fleet wave width measures launches, not completions: the guard records dispatches at `PreToolUse` launch time and confirms them at `PostToolUse`, because async completion delivery arrives minutes after a batched launch and made every genuine wide wave read as staggered solo dispatches, inflating the narrow streak counter to nine in a session whose transcript showed batched waves throughout; wave derivation counts confirmed entries only, launches that never confirm expire after fifteen minutes, a missing launch recovers in place with a warn audit event instead of silently leaving the write budget exhausted, and the narrow wave advisory re fires at every even streak milestone +- `collectionMethod` is one enum: writers canonicalize to lowercase snake case, legacy spellings fold on load and in stats reads, because four different literals were live in one ninety minute production window +- codex acceptance carries provenance: `record-acceptance` stamps `acceptanceSource` and `acceptanceRecordedAt` on the job record, every writeback path enforces the `--accept-failed-transport` gate, and fusion-stats threads `--source` through the companion argv, closing a split brain where the fusion ledger said `collector` or `main-loop` while the codex record said `stats` +- stats gains an `--unsettled` view listing terminal jobs without an explicit verdict and renders acceptance provenance when present +- grok `turn_limit` survives into history output instead of coercing to null, the review runner treats an empty raw request as the default managed review instead of stopping to ask, and both rescue agents relay companion stdout verbatim in a fenced block after a production wrapper retyped `foreground` as `preground` +- the orchestration rules describe the async wire shape without weakening obligations: a launch receipt is not delivery, completion notifications arm mandatory collection, and a dispatch whose receipt is never followed by same goal collection and verification is the contract failure; new rules cover settling consumed reviews, a broad verification pass after any timeout salvage in a wave, and checking the codex single flight slot before dispatch; troubleshooting documents the two worker truncation signatures with the SendMessage recovery recipe, and the README development section names the bench manifest check CI already runs + ## 0.0.31 - the fleet default gets a per turn signal: a new `fleet-posture.mjs` UserPromptSubmit hook injects one advisory context line each turn naming the three package threshold, `/fusion:ultra`, and the `fleet-decline: ` protocol, because the default previously lived as one sentence deep inside a document loaded once at session start and fired almost never; `FUSION_FLEET_MODE=off` or a `fleet-mode` state file disables it, and the compact recovery echo now restates the fleet default at the moment of maximum context loss diff --git a/README.md b/README.md index 5e88a63..4726849 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ The Fusion plugin keeps model routing and reconciliation data under `~/.claude/p `npm test` runs the suite against fake Codex and Grok binaries with no real peer CLI or network. `claude plugin validate plugins/codex`, `claude plugin validate plugins/grok`, `claude plugin validate plugins/fusion`, and `claude plugin validate .` check every plugin and the marketplace. Iterate with `claude --plugin-dir ./plugins/codex --plugin-dir ./plugins/grok --plugin-dir ./plugins/fusion` plus `/reload-plugins`; after committing and pushing, `claude plugin marketplace update claude-code-fusion` refreshes the installed copy. +CI also runs `node bench/manifest.mjs --check`; contributors should run it after touching `bench/tasks`. + ## Layout - `.claude-plugin/marketplace.json`: the marketplace manifest; installs as marketplace `claude-code-fusion`. diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 4d3c73f..391e430 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.31", + "version": "0.0.32", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", "author": { "name": "Harry Yep" diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 8e23674..2867f4e 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -24,6 +24,7 @@ Forwarding rules: - Do not use Read for any path except the newly allocated empty transport file, and do not read it after writing. Do not search, run Git, execute tests, inspect job state, or perform any check, collection, cancellation, or companion operation beyond the fixed task operation. - When an explicit background request returns a receipt, return it unchanged. A direct slash command user inspects progress through status and collects the deliverable through result; when Fusion is installed, its monitor can notify them of completion. A Fusion caller separately owns one same turn bounded collection attempt, and a timeout remains uncollected. - Return the companion stdout exactly as received. Do not summarize, paraphrase, prefix, suffix, or continue the work. +- Relay the companion's stdout verbatim inside a fenced block. Never retype, summarize, or re-spell any part of it, including footers. Put commentary outside the fence. - If the companion invocation fails, return the failure exactly as Bash reports it. Do not generate a substitute answer. Response style: diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index b7b77ab..0373c05 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -94,6 +94,7 @@ const TRANSPORT_OWNER_MAX_BYTES = 4096; const TRANSPORT_MAX_AGE_MS = 60 * 60 * 1000; const RECORD_ACCEPTANCE_JOB_ID_PATTERN = /^[a-f0-9]{32}$/; const RECORD_ACCEPTANCE_VALUES = new Set(["accepted", "rejected", "unverified"]); +const RECORD_ACCEPTANCE_SOURCES = new Set(["collector", "main-loop", "stats"]); const MODELS_CACHE_SCHEMA_DRIFT_NEXT_STEP = "Codex CLI cannot parse the current models cache (schema drift). Upgrade the Codex CLI, then run /codex:setup again."; const SETUP_LOG_PROBE_BYTES = 64 * 1024; const SETUP_LOG_PROBE_LIMIT = 5; @@ -117,7 +118,7 @@ function printUsage() { " node scripts/codex-companion.mjs status [job-id] [--all] [--cwd ] [--json]", " node scripts/codex-companion.mjs result [--wait] [--wait-timeout-ms ] [--cwd ] [--json]", " node scripts/codex-companion.mjs cancel [--cwd ] [--json]", - " node scripts/codex-companion.mjs record-acceptance --job-id <32 lowercase hex> --acceptance [--reason ] [--accept-failed-transport]", + " node scripts/codex-companion.mjs record-acceptance --job-id <32 lowercase hex> --acceptance [--reason ] [--source ] [--accept-failed-transport]", " node scripts/codex-companion.mjs history [--json]", " node scripts/codex-companion.mjs setup [--cwd ] [--json]" ].join("\n") + "\n" @@ -161,9 +162,9 @@ function recordAcceptanceArgs(argv) { options.acceptFailedTransport = true; continue; } - const key = token === "--job-id" ? "jobId" : token === "--acceptance" ? "acceptance" : token === "--reason" ? "reason" : null; + const key = token === "--job-id" ? "jobId" : token === "--acceptance" ? "acceptance" : token === "--reason" ? "reason" : token === "--source" ? "source" : null; if (!key) { - throw new CompanionError("The record-acceptance command accepts only --job-id, --acceptance, --reason, and --accept-failed-transport.", "input"); + throw new CompanionError("The record-acceptance command accepts only --job-id, --acceptance, --reason, --source, and --accept-failed-transport.", "input"); } if (Object.hasOwn(options, key)) { throw new CompanionError(`${token} may be provided only once.`, "input"); @@ -181,6 +182,10 @@ function recordAcceptanceArgs(argv) { if (!RECORD_ACCEPTANCE_VALUES.has(options.acceptance)) { throw new CompanionError("The --acceptance option must be accepted, rejected, or unverified.", "input"); } + options.source ??= "stats"; + if (!RECORD_ACCEPTANCE_SOURCES.has(options.source)) { + throw new CompanionError("The --source option must be collector, main-loop, or stats.", "input"); + } return options; } @@ -1993,7 +1998,7 @@ async function handleCancel(rawArgv) { } async function handleRecordAcceptance(argv) { - const { acceptance, acceptFailedTransport, jobId, reason } = recordAcceptanceArgs(argv); + const { acceptance, acceptFailedTransport, jobId, reason, source } = recordAcceptanceArgs(argv); const found = findJobRecordById(resolveDataDir(), jobId); if (!found) { throw new CompanionError(`No job record found for ${jobId}.`, "input"); @@ -2004,6 +2009,8 @@ async function handleRecordAcceptance(argv) { } if (acceptance !== "rejected") { return { + acceptanceRecordedAt: nowIso(), + acceptanceSource: source, semanticFailureKind: null, semanticFailureMessage: null, semanticStatus: acceptance @@ -2011,6 +2018,8 @@ async function handleRecordAcceptance(argv) { } return { ...(reason === undefined ? {} : { semanticFailureMessage: reason }), + acceptanceRecordedAt: nowIso(), + acceptanceSource: source, semanticStatus: acceptance }; }, { allowTerminal: true }); diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 34eb2b2..a9c47f0 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -21,6 +21,7 @@ const STATE_MAX_RECORDS_ENV = "CODEX_COMPANION_HISTORY_MAX_RECORDS"; const JOB_STATUSES = new Set(["running", "done", "error", "cancelled"]); const DELIVERY_MODES = new Set(["foreground", "manual", "managed"]); const SEMANTIC_STATUSES = new Set(["accepted", "rejected", "unverified"]); +const ACCEPTANCE_SOURCES = new Set(["collector", "main-loop", "stats"]); const JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const WORKSPACE_SLUG_PATTERN = /^.+-[a-f0-9]{16}$/; @@ -433,6 +434,12 @@ function assertJobRecord(record) { if (record.semanticStatus != null && !SEMANTIC_STATUSES.has(record.semanticStatus)) { throw new TypeError("Job semantic status is invalid."); } + if (record.acceptanceSource != null && !ACCEPTANCE_SOURCES.has(record.acceptanceSource)) { + throw new TypeError("Job acceptance source is invalid."); + } + if (record.acceptanceRecordedAt != null && (typeof record.acceptanceRecordedAt !== "string" || !Number.isFinite(Date.parse(record.acceptanceRecordedAt)))) { + throw new TypeError("Job acceptance recorded timestamp is invalid."); + } } function ownerSessionId(record) { @@ -447,6 +454,8 @@ function normalizeRecord(record) { delivery: record.delivery ?? (record.background ? "manual" : "foreground"), deliveryCollectedAt: record.deliveryCollectedAt ?? null, schemaVersion: record.schemaVersion ?? JOB_SCHEMA_VERSION, + acceptanceSource: record.acceptanceSource ?? null, + acceptanceRecordedAt: record.acceptanceRecordedAt ?? null, semanticStatus: record.semanticStatus ?? "unverified", semanticFailureKind: record.semanticFailureKind ?? null, semanticFailureMessage: record.semanticFailureMessage ?? null, @@ -486,6 +495,8 @@ function terminalPatch(existing, patch, timestamp) { codexPidOwnsProcessGroup: false, finishedAt: existing.finishedAt ?? patch.finishedAt ?? timestamp, updatedAt: patch.updatedAt ?? timestamp, + acceptanceSource: patch.acceptanceSource ?? existing.acceptanceSource ?? null, + acceptanceRecordedAt: patch.acceptanceRecordedAt ?? existing.acceptanceRecordedAt ?? null, failureKind: status === "cancelled" ? "cancelled" : patch.failureKind ?? existing.failureKind ?? null, cancelRequestedAt: null }; @@ -698,6 +709,8 @@ export function createJobRecord(fields) { resolvedEffort: fields.resolvedEffort ?? fields.request?.effort ?? null, ...(fields.modelDrift ? { modelDrift: fields.modelDrift } : {}), rolloutRecoveryStatus: fields.rolloutRecoveryStatus ?? null, + acceptanceSource: fields.acceptanceSource ?? null, + acceptanceRecordedAt: fields.acceptanceRecordedAt ?? null, semanticStatus: fields.semanticStatus ?? "unverified", semanticFailureKind: fields.semanticFailureKind ?? null, semanticFailureMessage: fields.semanticFailureMessage ?? null, diff --git a/plugins/fusion/.claude-plugin/plugin.json b/plugins/fusion/.claude-plugin/plugin.json index 93ad42a..8e1cd5c 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.31", + "version": "0.0.32", "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/agents/deep-reasoner.md b/plugins/fusion/agents/deep-reasoner.md index 03180d4..ded5bfe 100644 --- a/plugins/fusion/agents/deep-reasoner.md +++ b/plugins/fusion/agents/deep-reasoner.md @@ -3,7 +3,7 @@ name: deep-reasoner description: Read only advisory lane on Fable at maximum effort. Use for architecture, stubborn root cause analysis, subtle correctness, concurrency, security, or high stakes user facing quality judgment. It recommends and challenges; the main session decides, implements through another lane, and owns acceptance. model: fable effort: xhigh -maxTurns: 30 +maxTurns: 60 background: false tools: Read, Grep, Glob --- diff --git a/plugins/fusion/agents/fast-worker.md b/plugins/fusion/agents/fast-worker.md index 77bc0c0..78128f3 100644 --- a/plugins/fusion/agents/fast-worker.md +++ b/plugins/fusion/agents/fast-worker.md @@ -3,7 +3,7 @@ name: fast-worker description: Claude execution fallback on Sonnet for resolved implementation briefs that require the Claude Code tool surface (hooks, subagent files, MCP), remain inside the Claude privacy boundary, or cannot use an eligible peer lane. It is not the default for spec grade multi file packages or quick scoped fixes, which use the Codex primary lane or an eligible Grok protected role. Use for bounded edits, refactors with a clear recipe, approved plan packages, test fixes, codemods, boilerplate, and docs. Every brief includes a verification command; do not send open ended design work. model: sonnet effort: medium -maxTurns: 40 +maxTurns: 120 background: false disallowedTools: Agent --- diff --git a/plugins/fusion/agents/trivial-worker.md b/plugins/fusion/agents/trivial-worker.md index 005f496..b055e36 100644 --- a/plugins/fusion/agents/trivial-worker.md +++ b/plugins/fusion/agents/trivial-worker.md @@ -3,7 +3,7 @@ name: trivial-worker description: Fallback tier only for exact, low risk, tiny packages when no eligible peer lane is available or Claude-only tools or privacy are required. Cheapest tier worker pinned to true Haiku for trivial single file edits, renames, small doc fixes, log digestion, and short mechanical checks where speed and cost matter more than depth. Change briefs include a verification command; analysis, drafting, and research briefs include explicit acceptance criteria. Anything ambiguous goes to fast-worker instead. model: claude-haiku-4-5 effort: low -maxTurns: 12 +maxTurns: 24 background: false disallowedTools: Agent --- diff --git a/plugins/fusion/hooks/hooks.json b/plugins/fusion/hooks/hooks.json index 9f71364..251528d 100644 --- a/plugins/fusion/hooks/hooks.json +++ b/plugins/fusion/hooks/hooks.json @@ -63,6 +63,16 @@ "timeout": 10 } ] + }, + { + "matcher": "^(Agent|Task)$", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/inline-delegation-guard.mjs\"", + "timeout": 10 + } + ] } ], "PostToolUse": [ diff --git a/plugins/fusion/rules-manifest.json b/plugins/fusion/rules-manifest.json index deadfae..c5b13f0 100644 --- a/plugins/fusion/rules-manifest.json +++ b/plugins/fusion/rules-manifest.json @@ -13,6 +13,7 @@ "8294a7d94ed809153bbe005ac02ade391d810a767ec019d1580ad344cff1863d", "8a1187402340f5d6c9c01ff2eb966d2a1c0420b0879b236f9ec7ff0e8be50ae7", "8f6e24a12f32b5ad6099b7f9e16ebf751c8d1bc22313848bbd9811dbb059bdc2", + "93e7b5692737fcd82946ab75ee173783c2fbdefd5f3da954d4d676c97101c4f3", "955aea2e9890bf79c5ebabb499e9ae170c4bfe372d7e35f44e6f3c4096225a83", "9581481f2f57e522fbfa6c120056289ddcc36c29bd7aade8575308a3045e64c1", "9b6d1f1e857020604219a1095928748dcad5d25070a2b33a2e9747273370038b", diff --git a/plugins/fusion/rules/orchestration.md b/plugins/fusion/rules/orchestration.md index 736217c..aaf3cb7 100644 --- a/plugins/fusion/rules/orchestration.md +++ b/plugins/fusion/rules/orchestration.md @@ -145,9 +145,9 @@ Every brief is self contained: goal, constraints, relevant paths, and what done - Peer sandboxes cannot run process dependent suites or reach user toolchains; a peer implementation brief states that such failures are reported as environment findings with passing counts, and the orchestrator reruns the exact verification in a full environment before acceptance. - Before a fleet wave over an uncommitted baseline, the orchestrator records a checkpoint so isolated worktrees fork from the accumulated state; when no commit is authorized, the baseline rides as an applied and staged patch in each worktree and the package delta is exactly the unstaged diff. - Independent packages and independent sections of one task are decomposed and dispatched in a single message; sequential dispatch of independent work is a defect. Repeated narrow waves on a fleet shaped goal without a visible `fleet-decline: ` line are the same defect. -- Agent scheduling, companion delivery, engine process supervision, and Grok's native background tool waiting are separate layers. Every ordinary Agent is foreground delivery. Launch independent Agents together in one tool message for concurrency, but do not set `run_in_background` and do not accept an async launch receipt as delivery. `grok:grok-review-runner` is the sole managed exception allowed to use Agent `run_in_background`. Codex and Grok wrapper Agents always remain foreground. Only an incoming user request that explicitly contains companion `--background` authorizes the companion child to detach inside its foreground wrapper and return a manual receipt; it never authorizes background wrapper delivery. Fusion records every Claude worker as an owned task; if Claude Code still launches one asynchronously, the Stop hook blocks turn completion and requires `TaskOutput` collection, or `TaskStop` when a lifecycle budget has expired. Codex companion Bash calls stay foreground with the 10 minute tool timeout. Grok companion calls also remain foreground inside their owning Agent; expected duration does not authorize managed detachment. Ordinary non-tournament Grok calls prefer `--no-wait-for-background` because their hard `Agent` deny also removes the native task lifecycle; a binary without that flag gets an explicit `--background-wait-timeout` shorter than the companion's outer deadline. Best-of-n keeps native Agent support and requires that bounded timeout. That bounded internal wait is not companion detachment. The CLI child running in its own supervised process group is never by itself a user background job. The main loop never runs a work package or launches a detached shell. The coordinate micro step and single triage edit are not work packages. +- Agent scheduling, companion delivery, engine process supervision, and Grok's native background tool waiting are separate layers. Every ordinary Agent is foreground delivery. The harness launches Agent calls asynchronously by default, but a launch receipt is not delivery: completion arrives as a task notification, which arms mandatory collection. Every dispatch still requires collection and verification before the package counts as done. Launch independent Agents together in one tool message for concurrency, but do not set `run_in_background` and do not accept an async launch receipt as delivery. `grok:grok-review-runner` is the sole managed exception allowed to use Agent `run_in_background`. Codex and Grok wrapper Agents always remain foreground. Only an incoming user request that explicitly contains companion `--background` authorizes the companion child to detach inside its foreground wrapper and return a manual receipt; it never authorizes background wrapper delivery. Fusion records every Claude worker as an owned task; if Claude Code still launches one asynchronously, the Stop hook blocks turn completion and requires `TaskOutput` collection, or `TaskStop` when a lifecycle budget has expired. Codex companion Bash calls stay foreground with the 10 minute tool timeout. Grok companion calls also remain foreground inside their owning Agent; expected duration does not authorize managed detachment. Ordinary non-tournament Grok calls prefer `--no-wait-for-background` because their hard `Agent` deny also removes the native task lifecycle; a binary without that flag gets an explicit `--background-wait-timeout` shorter than the companion's outer deadline. Best-of-n keeps native Agent support and requires that bounded timeout. That bounded internal wait is not companion detachment. The CLI child running in its own supervised process group is never by itself a user background job. The main loop never runs a work package or launches a detached shell. The coordinate micro step and single triage edit are not work packages. - Heartbeat rule: a legitimate watch style wakeup on delegated work in flight emits one short user visible status line naming what is still in flight and when the next check happens. A wakeup with only tool calls and no visible text is a silent turn and is banned, and so is a hand rolled Bash or ScheduleWakeup polling loop used in place of fusion:job-collector. A wakeup with in flight work emits its status line and otherwise takes no tool action; informationless tool calls on advisory wakeups are a defect. -- Same turn collection attempt: this obligation applies to a manual receipt that crosses into the main session from a detached job launched by Fusion orchestration. Follow it in the same turn through fusion:job-collector with a closed direct prompt containing exactly one standalone `engine: codex|grok` line and one standalone `job: <32 lowercase hexadecimal characters>` line. Do not describe or repeat either identity elsewhere in the prompt. The lifecycle binds the collected marker to this dispatch identity. The collector resolves the engine companion itself, performs global job lookup without a working-directory selector, and invokes status plus blocking result through fixed argv without shell command strings. Its single collection window is at most 540000ms. A timeout, dead job, or status error leaves the package uncollected and must reach the user with the Fusion task id, peer job id, the literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. Direct Codex and Grok slash commands with explicit `--background` return manual receipts for the user to inspect and collect through their status and result commands; monitors are notification only. A foreground peer call that returns only a receipt is a contract failure. +- Same turn collection attempt: this obligation applies to a manual receipt that crosses into the main session from a detached job launched by Fusion orchestration. Follow it in the same turn through fusion:job-collector with a closed direct prompt containing exactly one standalone `engine: codex|grok` line and one standalone `job: <32 lowercase hexadecimal characters>` line. Do not describe or repeat either identity elsewhere in the prompt. The lifecycle binds the collected marker to this dispatch identity. The collector resolves the engine companion itself, performs global job lookup without a working-directory selector, and invokes status plus blocking result through fixed argv without shell command strings. Its single collection window is at most 540000ms. A timeout, dead job, or status error leaves the package uncollected and must reach the user with the Fusion task id, peer job id, the literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. Direct Codex and Grok slash commands with explicit `--background` return manual receipts for the user to inspect and collect through their status and result commands; monitors are notification only. A dispatch whose receipt is never followed by same-goal collection and verification is a contract failure. - Transport completion is not semantic acceptance. The main session verifies every peer and Claude worker result before relying on it. After collecting and verifying any delegated result, settle its verdict in one call with `/fusion:stats --record =`, where the id is the fusion task id or the engine job id; one settle writes the worker ledger and the linked engine record together, strict record forms may pass as direct arguments, and anything carrying free text still uses the raw-args transport. Record accepted only when the verification command or explicit acceptance criteria pass. Record `rejected` when the collected result or verification fails. Leave it `unverified` only when no reliable judgment was possible. Stats default every terminal job without a recorded judgment to `unverified`; never infer acceptance from `state: done`, `delivery: complete`, or any transport status. - A non deliverable final message starts an obligation, not an outcome: a background receipt created by Fusion and a truncated run ending in forward looking narration or missing verification are both unfinished. Resume a truncated, non forwarder agent with SendMessage to finish and report with verification; for a background receipt created by Fusion, dispatch fusion:job-collector instead. A foreground Codex receipt remains a contract failure under the preceding rule. - Related follow up packages reuse the existing worker thread via SendMessage instead of cold starting a new agent, unless the thread rotation rules in the fusion plugin's troubleshooting rules (shipped with the plugin, consulted on demand) say otherwise. @@ -159,5 +159,8 @@ Every brief is self contained: goal, constraints, relevant paths, and what done - A future Grok ACP adapter must use one process pool per canonical cwd and sandbox profile. Each daemon starts directly with `grok --cwd --sandbox agent --no-leader stdio`; a global bridge or leader reuse is forbidden because sandbox installation happens once at process start and an existing leader can retain a different cwd or sandbox. ACP permission requests and live tool events improve visibility, but do not weaken the companion's tool, worktree, background, collection, or semantic acceptance boundaries. - Never delegate to codex or grok anything touching secrets, credentials, or context that cannot be compressed into a brief. - Runtime guard: the hook permits five main loop write calls in each dispatch window by default, warns when that budget is reached, then denies further main loop writes until an Agent or Task call completes successfully. Subagent writes never enter this budget. Each successful dispatch starts a fresh window. `FUSION_INLINE_WRITE_BUDGET` changes the limit. `FUSION_INLINE_GUARD_MODE=advisory` preserves the former nonblocking behavior for compatibility. +- A review job whose findings are consumed by a follow-up dispatch is settled like any other package; consuming the findings without recording the review's verdict is a settlement gap. +- When any package in a wave terminates by timeout with partial output, rerun that package's verification plus one broad typecheck or test pass over the merged tree before accepting sibling packages that share the subsystem. +- Before dispatching Codex into a workspace, confirm its single-flight slot is free or route down the admission ladder; a dispatch bounced with an already-running error is a routing error, not a retry candidate. Escalation ladders, the failure kind circuit breaker table, died process detection, and warm thread rotation math: see the fusion plugin's troubleshooting rules (shipped with the plugin, consulted on demand). diff --git a/plugins/fusion/rules/troubleshooting.md b/plugins/fusion/rules/troubleshooting.md index ebfdc30..44115ec 100644 --- a/plugins/fusion/rules/troubleshooting.md +++ b/plugins/fusion/rules/troubleshooting.md @@ -42,6 +42,16 @@ Fixture heavy test authoring briefs are the dominant `turn_limit` and 570 second The Grok headless JSON has no top-level `model` field. A model name appears only as a key in `modelUsage` when usage attaches, so model capture depends on that map. Error-path capture also depends on salvaging the envelope before classifying the turn limit failure. Missing `modelUsage` means that the resolved model remains unavailable. +## Worker truncation signatures + +Two truncation classes verified in production require recovery. A hard cap cut ends the transcript on a dangling `tool_result` at exactly the Agent frontmatter `maxTurns`, has no final text, and still reports completion in its notification. A narration end leaves the worker's last message as forward looking narration such as "Now let me verify..." at high tool counts. + +For either signature, resume with SendMessage and instruct `write the deliverable now, no more scanning`. The resumed worker receives fresh turn headroom and typically finishes in minutes. Ledger turn counts after resuming hide the original cut, so diagnose it from the `.output` transcript tail. + +The Fusion job collection bounded window is 540s while the Codex companion result wait allows 570s, so a job finishing inside that 30s gap times out the collector and is collected manually via `/codex:result`. + +The fleet mode state file has no writer within the plugin, so disable the fleet default with `FUSION_FLEET_MODE=off` or write that file externally. + ## Codex startup and cache compatibility Codex exec refuses to start outside a directory with an ancestor `.git` entry. The `projects` trust map in `config.toml` is not consulted by exec. Only `--skip-git-repo-check` or the dangerous bypass flag skips this gate. The companion forwards `--skip-git-repo-check`, and its failure output names that remedy. A directory that is trusted in configuration is not therefore a valid exec directory without an ancestor Git entry. diff --git a/plugins/fusion/scripts/fusion-stats.mjs b/plugins/fusion/scripts/fusion-stats.mjs index 1809511..1265df3 100644 --- a/plugins/fusion/scripts/fusion-stats.mjs +++ b/plugins/fusion/scripts/fusion-stats.mjs @@ -112,6 +112,42 @@ function nonEmptyString(value) { return text ? text : null; } +export function normalizeCollectionMethod(value) { + const method = nonEmptyString(value); + if (!method) { + return null; + } + if (method.toLowerCase() === "agent") { + return "agent_result"; + } + const normalized = method + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); + return normalized; +} + +function semanticAcceptance(raw, acceptanceObservation = null) { + const recorded = raw?.acceptance; + if (recorded === "accepted" || recorded === "rejected") { + return recorded; + } + const observed = acceptanceObservation?.acceptance; + if (CODEX_ACCEPTANCE_STATES.has(observed)) { + return observed; + } + return "unverified"; +} + +function acceptanceProvenance(raw) { + const acceptanceSource = nonEmptyString(raw?.acceptanceSource); + const acceptanceRecordedAt = nonEmptyString(raw?.acceptanceRecordedAt); + return { + ...(acceptanceSource ? { acceptanceSource } : {}), + ...(acceptanceRecordedAt ? { acceptanceRecordedAt } : {}) + }; +} + export function resolveCodexJobModel(raw, observation) { const resolved = nonEmptyString(raw?.resolvedModel); if (resolved) { @@ -460,12 +496,12 @@ function grokAcceptanceSubcommandUnavailable(result) { return /\b(?:unknown|unsupported|unrecognized)\s+(?:subcommand|command)\b/i.test(message) || /\brecord-acceptance\b.*\b(?:unknown|unsupported|unavailable|not found|not supported)\b/i.test(message) || /\b(?:cannot find module|ERR_MODULE_NOT_FOUND)\b/i.test(message); } -function recordCodexCompanionAcceptance({ jobId, acceptance, reason, acceptFailedTransport, workspaceRoot, env }) { +function recordCodexCompanionAcceptance({ jobId, acceptance, source, reason, acceptFailedTransport, workspaceRoot, env }) { const bin = newestCodexCompanion(env); if (!bin) { return { updated: false }; } - const argv = [bin, "record-acceptance", "--job-id", jobId, "--acceptance", acceptance, ...(reason ? ["--reason", reason] : []), ...(acceptFailedTransport ? ["--accept-failed-transport"] : [])]; + const argv = [bin, "record-acceptance", "--job-id", jobId, "--acceptance", acceptance, ...(source ? ["--source", source] : []), ...(reason ? ["--reason", reason] : []), ...(acceptFailedTransport ? ["--accept-failed-transport"] : [])]; const result = spawnSync(process.execPath, argv, { cwd: workspaceRoot, encoding: "utf8", env }); if (!result.error && result.status === 0) { return { updated: true }; @@ -1270,15 +1306,14 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en const jobId = nonEmptyString(raw?.id); const acceptanceObservation = jobId ? scopedObservationForJob(acceptanceObservations, raw, observationRepositoryCache) : null; if (CODEX_TERMINAL_STATUSES.has(job.status)) { - const acceptance = acceptanceObservation?.acceptance; - bump(byAcceptance, CODEX_ACCEPTANCE_STATES.has(acceptance) ? acceptance : "unverified"); + bump(byAcceptance, semanticAcceptance(raw, acceptanceObservation)); } else { pendingTransportJobs += 1; } - if (jobId && job.status === "error" && acceptanceObservation?.acceptance === "accepted") { + if (jobId && job.status === "error" && semanticAcceptance(raw, acceptanceObservation) === "accepted") { acceptedWithErrorTransport.push(jobId); } - if (jobId && job.status === "done" && !acceptanceObservation) { + if (jobId && job.status === "done" && semanticAcceptance(raw, acceptanceObservation) === "unverified") { doneWithoutAcceptance.push(jobId); } } @@ -1394,6 +1429,7 @@ export function claudeWorkerStats({ all = false, env = process.env, cwd = proces const byFailureKind = {}; const byDelivery = {}; const byModel = {}; + const byCollectionMethod = {}; let usage = { inputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, outputTokens: 0, totalTokens: 0, uncachedTokens: 0 }; let usageAggregationOverflow = false; let pendingTransportJobs = 0; @@ -1410,6 +1446,10 @@ export function claudeWorkerStats({ all = false, env = process.env, cwd = proces bump(byStatus, status); bump(byAgent, nonEmptyString(record.agentType) ?? "unknown"); bump(byDelivery, nonEmptyString(record.expectedDelivery) ?? "unknown"); + const collectionMethod = normalizeCollectionMethod(record.collectionMethod); + if (collectionMethod) { + bump(byCollectionMethod, collectionMethod); + } if (record.resolvedModel) { bump(byModel, record.resolvedModel); } @@ -1461,15 +1501,20 @@ export function claudeWorkerStats({ all = false, env = process.env, cwd = proces .slice() .sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? "")) .slice(0, 100) - .map((record) => ({ - taskId: record.taskId, - sessionId: record.sessionId ?? null, - agentId: record.agentId ?? null, - backgroundTaskId: record.backgroundTaskId ?? null, - agentType: record.agentType, - transportStatus: record.transportStatus, - acceptance: record.acceptance ?? "unverified" - })); + .map((record) => { + const collectionMethod = normalizeCollectionMethod(record.collectionMethod); + return { + taskId: record.taskId, + sessionId: record.sessionId ?? null, + agentId: record.agentId ?? null, + backgroundTaskId: record.backgroundTaskId ?? null, + agentType: record.agentType, + transportStatus: record.transportStatus, + acceptance: record.acceptance ?? "unverified", + ...(collectionMethod ? { collectionMethod } : {}), + ...acceptanceProvenance(record) + }; + }); return { available: true, totalJobs: records.length, @@ -1482,6 +1527,7 @@ export function claudeWorkerStats({ all = false, env = process.env, cwd = proces harnessAsyncDeliveries, byDelivery, byModel, + byCollectionMethod, usage: usageAggregationOverflow ? null : usage, usageCoverage: { availability: usageAggregationOverflow ? "overflow" : records.length > 0 && completeUsage === records.length ? "available" : completeUsage > 0 ? "partial" : "unavailable", @@ -1895,8 +1941,8 @@ function sessionScopedEngineStats(descriptor, sessionId, env) { bump(byStatus, status); if (descriptor.id === "codex") { if (CODEX_TERMINAL_STATUSES.has(status)) { - const acceptance = scopedObservationForJob(acceptanceObservations, raw, observationRepositoryCache)?.acceptance; - bump(byAcceptance, CODEX_ACCEPTANCE_STATES.has(acceptance) ? acceptance : "unverified"); + const acceptanceObservation = scopedObservationForJob(acceptanceObservations, raw, observationRepositoryCache); + bump(byAcceptance, semanticAcceptance(raw, acceptanceObservation)); } else { pendingTransportJobs += 1; } @@ -1946,6 +1992,7 @@ export function renderSessionReport(report) { lines.push("", "## Claude workers (session scope)", "", `Total jobs: ${report.workers.totalJobs}`); renderCounts(lines, "By transport status", report.workers.byTransportStatus); renderCounts(lines, "By semantic acceptance", report.workers.byAcceptance); + renderCounts(lines, "By collection method", report.workers.byCollectionMethod); lines.push("", `Semantic acceptance scope: ${report.workers.acceptanceScope}; ${report.workers.pendingTransportJobs} non-terminal job${report.workers.pendingTransportJobs === 1 ? "" : "s"} excluded`); renderWorkerIdentities(lines, report.workers.identities); } @@ -2165,6 +2212,7 @@ function renderEngine(lines, name, stats) { renderCounts(lines, "By mode", stats.byMode ?? {}); renderCounts(lines, "By agent", stats.byAgent ?? {}); renderCounts(lines, "By delivery", stats.byDelivery ?? {}); + renderCounts(lines, "By collection method", stats.byCollectionMethod ?? {}); renderCounts(lines, "By kind", stats.byKind ?? {}); renderCounts(lines, "By model", stats.byModel ?? {}); renderCounts(lines, "By effort", stats.byEffort ?? {}); @@ -2202,9 +2250,11 @@ function renderWorkerIdentities(lines, identities) { if (!Array.isArray(identities) || identities.length === 0) { return; } - lines.push("", "Task identity map:", "task | session | agent | background task | transport | acceptance"); + const includesCollectionMethod = identities.some((identity) => identity.collectionMethod); + const includesAcceptanceProvenance = identities.some((identity) => identity.acceptanceSource || identity.acceptanceRecordedAt); + lines.push("", "Task identity map:", `task | session | agent | background task | transport${includesCollectionMethod ? " | collection" : ""} | acceptance${includesAcceptanceProvenance ? " | acceptance source | acceptance recorded at" : ""}`); for (const identity of identities) { - lines.push(`${identity.taskId} | ${identity.sessionId ?? ""} | ${identity.agentId ?? ""} | ${identity.backgroundTaskId ?? ""} | ${identity.transportStatus} | ${identity.acceptance}`); + lines.push(`${identity.taskId} | ${identity.sessionId ?? ""} | ${identity.agentId ?? ""} | ${identity.backgroundTaskId ?? ""} | ${identity.transportStatus}${includesCollectionMethod ? ` | ${identity.collectionMethod ?? ""}` : ""} | ${identity.acceptance}${includesAcceptanceProvenance ? ` | ${identity.acceptanceSource ?? ""} | ${identity.acceptanceRecordedAt ?? ""}` : ""}`); } } @@ -2374,6 +2424,87 @@ export function renderFusionStats(report) { return `${lines.join("\n")}\n`; } +function unsettledFinishedAt(raw) { + return nonEmptyString(raw?.finishedAt ?? raw?.completedAt ?? raw?.updatedAt); +} + +function unsettledTimestamp(entry) { + const timestamp = Date.parse(entry.finishedAt ?? ""); + return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY; +} + +function unsettledWorkerEntries(env) { + return readWorkerRecords(env) + .filter((record) => isTerminalWorkerStatus(record.transportStatus) && semanticAcceptance(record) === "unverified") + .map((record) => ({ + engine: "fusion", + id: nonEmptyString(record.taskId) ?? "unknown", + description: nonEmptyString(record.description) ?? nonEmptyString(record.agentType) ?? "unknown", + finishedAt: unsettledFinishedAt(record), + acceptance: "unverified" + })); +} + +function unsettledEngineEntries(descriptor, env) { + const root = resolveStateRoot(descriptor, env); + let jobs; + try { + jobs = descriptor.enumerateJobs(root, { all: true, env }); + } catch { + return []; + } + const scoped = selectPreferredJobs(jobs, descriptor, () => true); + const acceptanceObservations = descriptor.id === "codex" ? loadAllObservationCandidates(env, ACCEPTANCE_FILENAME, loadAcceptanceObservations) : new Map(); + const observationRepositoryCache = new Map(); + return scoped.flatMap((raw) => { + if (!descriptor.isTerminal(raw)) { + return []; + } + const acceptanceObservation = descriptor.id === "codex" ? scopedObservationForJob(acceptanceObservations, raw, observationRepositoryCache) : null; + if (semanticAcceptance(raw, acceptanceObservation) !== "unverified") { + return []; + } + const id = nonEmptyString(raw?.id); + if (!id) { + return []; + } + return [{ + engine: descriptor.id, + id, + kind: descriptor.normalizeJob(raw).kind, + finishedAt: unsettledFinishedAt(raw), + acceptance: "unverified", + ...(descriptor.id === "codex" ? acceptanceProvenance(raw) : {}) + }]; + }); +} + +export function buildUnsettledReport({ env = process.env } = {}) { + const unsettled = [ + ...unsettledWorkerEntries(env), + ...unsettledEngineEntries(FILE_ENGINE_DESCRIPTORS.grok, env), + ...unsettledEngineEntries(FILE_ENGINE_DESCRIPTORS.codex, env) + ].sort((left, right) => unsettledTimestamp(right) - unsettledTimestamp(left)); + return { unsettled }; +} + +export function renderUnsettledReport(report) { + const lines = ["# Fusion unsettled jobs"]; + if (report.unsettled.length === 0) { + lines.push("", "No terminal jobs are awaiting semantic acceptance."); + return `${lines.join("\n")}\n`; + } + lines.push("", "Engine | ID | Description or kind | Finished at | Semantic verdict"); + for (const entry of report.unsettled) { + const provenance = [ + entry.acceptanceSource ? `acceptance source: ${entry.acceptanceSource}` : null, + entry.acceptanceRecordedAt ? `acceptance recorded at: ${entry.acceptanceRecordedAt}` : null + ].filter(Boolean).join(", "); + lines.push(`${entry.engine} | ${entry.id} | ${entry.description ?? entry.kind ?? "unknown"} | ${entry.finishedAt ?? ""} | ${entry.acceptance}${provenance ? ` (${provenance})` : ""}`); + } + return `${lines.join("\n")}\n`; +} + function argumentValue(argv, flag) { const index = argv.indexOf(flag); if (index === -1) { @@ -2471,7 +2602,7 @@ function isStrictDirectRecordArguments(argv) { } } -function recordEngineAcceptance({ engine, jobId, verdict, reason, acceptFailedTransport = false, workspaceRoot, asJson, env }) { +function recordEngineAcceptance({ engine, jobId, verdict, source, reason, acceptFailedTransport = false, workspaceRoot, asJson, env }) { if (engine === "grok") { recordGrokCompanionAcceptance({ jobId, @@ -2487,6 +2618,7 @@ function recordEngineAcceptance({ engine, jobId, verdict, reason, acceptFailedTr const companion = recordCodexCompanionAcceptance({ jobId, acceptance: verdict, + source, reason, acceptFailedTransport, workspaceRoot, @@ -2518,7 +2650,7 @@ function settleWorkerRecord({ taskId, verdict, source, reason, acceptFailedTrans return [worker]; } const engine = worker.peerEngine === "codex" || worker.peerEngine === "grok" ? worker.peerEngine : resolveEngineJob(peerJobId, env); - recordEngineAcceptance({ engine, jobId: peerJobId, verdict, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + recordEngineAcceptance({ engine, jobId: peerJobId, verdict, source, reason, acceptFailedTransport, workspaceRoot, asJson, env }); writeRecordConfirmation({ kind: "engine", engine, jobId: peerJobId, worker, asJson, stdout }); return [worker, { engine, jobId: peerJobId, acceptance: verdict }]; } @@ -2526,7 +2658,7 @@ function settleWorkerRecord({ taskId, verdict, source, reason, acceptFailedTrans function settleEngineRecord({ jobId, verdict, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { const engine = resolveEngineJob(jobId, env); const confirmation = { engine, jobId, acceptance: verdict }; - recordEngineAcceptance({ engine, jobId, verdict, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + recordEngineAcceptance({ engine, jobId, verdict, source, reason, acceptFailedTransport, workspaceRoot, asJson, env }); writeRecordConfirmation({ kind: "engine", engine, jobId, worker: confirmation, asJson, stdout }); const writes = [confirmation]; for (const record of readWorkerRecords(env).filter((candidate) => candidate.peerJobId === jobId)) { @@ -2605,6 +2737,7 @@ export function main(argv = process.argv.slice(2), { env = process.env, cwd = pr const companion = recordCodexCompanionAcceptance({ jobId: preparedAcceptance.observation.jobId, acceptance: preparedAcceptance.observation.acceptance, + source: preparedAcceptance.normalizedSource, reason: preparedAcceptance.normalizedReason, acceptFailedTransport: argv.includes("--accept-failed-transport"), workspaceRoot: preparedAcceptance.normalizedRoot, @@ -2638,6 +2771,12 @@ export function main(argv = process.argv.slice(2), { env = process.env, cwd = pr return result; } + if (argv.includes("--unsettled")) { + const report = buildUnsettledReport({ env: effectiveEnv }); + stdout.write(asJson ? `${JSON.stringify(report, null, 2)}\n` : renderUnsettledReport(report)); + return report; + } + if (argv.includes("--trace")) { const report = buildTraceReport({ env: effectiveEnv, cwd, sessionId: sessionIdFromArgs(argv, effectiveEnv) }); stdout.write(asJson ? `${JSON.stringify(report, null, 2)}\n` : renderTraceReport(report)); diff --git a/plugins/fusion/scripts/inline-delegation-guard.mjs b/plugins/fusion/scripts/inline-delegation-guard.mjs index 96195d1..586be28 100644 --- a/plugins/fusion/scripts/inline-delegation-guard.mjs +++ b/plugins/fusion/scripts/inline-delegation-guard.mjs @@ -20,6 +20,7 @@ const DEFAULT_AUDIT_RETENTION_DAYS = 180; const DEFAULT_AUDIT_MAX_BYTES = 2 * 1024 * 1024; const DEFAULT_AUDIT_MAX_FILES = 256; const DEFAULT_FLEET_WAVE_GAP_MS = 120000; +const LAUNCHED_DISPATCH_TTL_MS = 15 * 60 * 1000; const AUDIT_SCHEMA_VERSION = 1; const STALE_MS = 48 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000; @@ -41,7 +42,8 @@ const MAIN_LANE = "main"; const WORKER_TERMINAL_STATUSES = new Set(["done", "incomplete", "failed", "cancelled", "owner_ended"]); 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 NARROW_WAVE_ADVISORY_LINE = "second consecutive narrow wave; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ."; +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"; function resolveStateDir(env = process.env) { const override = env[STATE_ENV]; @@ -409,6 +411,12 @@ function normalizeAuditEvent(event) { const description = sanitizeAuditText(event.description); return lane ? { schemaVersion: AUDIT_SCHEMA_VERSION, at: new Date(atMs).toISOString(), session, event: "dispatch", lane, tool: event.tool, ...(description ? { description } : {}) } : null; } + if (event.event === "warn" && DELEGATION_TOOLS.has(event.tool) && event.reason === MISSING_LAUNCH_RECOVERY_REASON) { + const lane = normalizeLane(event.lane); + return lane + ? { 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") && ENFORCEMENT_AUDIT_TOOLS.has(event.tool) && @@ -644,6 +652,7 @@ function defaultState(now) { lastDispatchAt: null, fleetWaveWidth: 0, consecutiveNarrowWaves: 0, + lastAdvisedNarrowWaveStreak: 0, dispatches: {}, dispatchLog: [], advisedMultiples: [], @@ -679,9 +688,13 @@ function normalizeDispatchLog(value) { const lane = normalizeLane(raw.lane) ?? BUILTIN_LANE; const subagentType = sanitizeIdentifier(raw.subagentType); const description = sanitizeAuditText(raw.description); + const toolUseId = sanitizeIdentifier(raw.toolUseId, 200); + const phase = raw.phase === "launched" ? "launched" : "confirmed"; entries.push({ at: Number.isFinite(atMs) ? new Date(atMs).toISOString() : null, lane, + phase, + ...(toolUseId ? { toolUseId } : {}), ...(subagentType ? { subagentType } : {}), ...(description ? { description } : {}) }); @@ -689,6 +702,20 @@ function normalizeDispatchLog(value) { return entries; } +function pruneExpiredLaunchedDispatches(dispatchLog, now) { + const nowMs = Date.parse(now); + if (!Number.isFinite(nowMs)) { + return dispatchLog; + } + return dispatchLog.filter((entry) => { + if (entry.phase === "confirmed") { + return true; + } + const launchedAtMs = Date.parse(entry.at); + return Number.isFinite(launchedAtMs) && nowMs - launchedAtMs <= LAUNCHED_DISPATCH_TTL_MS; + }); +} + function normalizeAdvisedMultiples(value) { if (!Array.isArray(value)) { return []; @@ -696,11 +723,42 @@ function normalizeAdvisedMultiples(value) { return [...new Set(value.filter((multiple) => Number.isInteger(multiple) && multiple > 0))]; } +function extractToolUseId(input) { + return sanitizeIdentifier(input?.tool_use_id, 200); +} + +function latestConfirmedDispatchAt(dispatchLog) { + let latestAtMs = null; + for (const entry of dispatchLog) { + if (entry.phase !== "confirmed") { + continue; + } + const atMs = Date.parse(entry.at); + if (Number.isFinite(atMs) && (latestAtMs === null || atMs > latestAtMs)) { + latestAtMs = atMs; + } + } + return latestAtMs === null ? null : new Date(latestAtMs).toISOString(); +} + +function findLaunchedDispatch(dispatchLog, toolUseId, lane) { + if (toolUseId) { + const byToolUseId = dispatchLog.findLast((entry) => entry.phase === "launched" && entry.toolUseId === toolUseId); + if (byToolUseId) { + return byToolUseId; + } + } + return dispatchLog.findLast((entry) => entry.phase === "launched" && entry.lane === lane) ?? null; +} + function deriveFleetWaveState(dispatchLog, waveGapMs) { let fleetWaveWidth = 0; let consecutiveNarrowWaves = 0; let previousDispatchAtMs = null; for (const entry of dispatchLog) { + if (entry.phase !== "confirmed") { + continue; + } const dispatchAtMs = Date.parse(entry.at); if (!Number.isFinite(dispatchAtMs)) { continue; @@ -728,13 +786,15 @@ function normalizeState(existing, now, waveGapMs = DEFAULT_FLEET_WAVE_GAP_MS) { const hasPeriodCounter = Number.isFinite(existing.writesSinceDispatch) && existing.writesSinceDispatch >= 0; const writesSinceDispatch = hasPeriodCounter ? Math.floor(existing.writesSinceDispatch) : dispatchCount === 0 ? writeCount : 0; const advisedMultiples = hasPeriodCounter || dispatchCount === 0 ? normalizeAdvisedMultiples(existing.advisedMultiples) : []; - const dispatchLog = normalizeDispatchLog(existing.dispatchLog); + const dispatchLog = pruneExpiredLaunchedDispatches(normalizeDispatchLog(existing.dispatchLog), now); const createdAtMs = Date.parse(existing.createdAt); const lastDispatchAtMs = Date.parse(existing.lastDispatchAt); const derivedFleetWaveState = deriveFleetWaveState(dispatchLog, waveGapMs); const fleetWaveWidth = Number.isInteger(existing.fleetWaveWidth) && existing.fleetWaveWidth >= 0 ? existing.fleetWaveWidth : derivedFleetWaveState.fleetWaveWidth; const consecutiveNarrowWaves = Number.isInteger(existing.consecutiveNarrowWaves) && existing.consecutiveNarrowWaves >= 0 ? existing.consecutiveNarrowWaves : derivedFleetWaveState.consecutiveNarrowWaves; + const lastAdvisedNarrowWaveStreak = + Number.isInteger(existing.lastAdvisedNarrowWaveStreak) && existing.lastAdvisedNarrowWaveStreak >= 0 ? existing.lastAdvisedNarrowWaveStreak : 0; return { writeCount, writesSinceDispatch, @@ -742,6 +802,7 @@ function normalizeState(existing, now, waveGapMs = DEFAULT_FLEET_WAVE_GAP_MS) { lastDispatchAt: Number.isFinite(lastDispatchAtMs) ? new Date(lastDispatchAtMs).toISOString() : null, fleetWaveWidth, consecutiveNarrowWaves, + lastAdvisedNarrowWaveStreak, dispatches, dispatchLog, advisedMultiples, @@ -802,47 +863,91 @@ function runHook(env = process.env, input = readHookInput()) { const now = new Date().toISOString(); if (DELEGATION_TOOLS.has(toolName)) { - if (input.hook_event_name && input.hook_event_name !== "PostToolUse") { + if (input.hook_event_name && input.hook_event_name !== "PreToolUse" && input.hook_event_name !== "PostToolUse") { return; } const subagentType = extractSubagentType(input.tool_input); const safeSubagentType = sanitizeIdentifier(subagentType); const lane = laneForSubagentType(subagentType); const description = extractDispatchDescription(input.tool_input); + const toolUseId = extractToolUseId(input); const waveGapMs = resolveFleetWaveGapMs(env); - recordAuditEvent( - { at: now, session: sessionId, event: "dispatch", lane, tool: toolName, ...(description ? { description } : {}) }, - env - ); - const waveAdvisory = withStateLock(file, () => { + + if (input.hook_event_name === "PreToolUse") { + withStateLock(file, () => { + const state = normalizeState(readState(file), now, waveGapMs); + state.dispatchLog.push({ + at: now, + lane, + phase: "launched", + ...(toolUseId ? { toolUseId } : {}), + ...(safeSubagentType ? { subagentType: safeSubagentType } : {}), + ...(description ? { description } : {}) + }); + if (state.dispatchLog.length > DISPATCH_LOG_LIMIT) { + state.dispatchLog.splice(0, state.dispatchLog.length - DISPATCH_LOG_LIMIT); + } + writeState(file, state); + }); + return; + } + + const confirmation = withStateLock(file, () => { const state = normalizeState(readState(file), now, waveGapMs); - const previousDispatchAtMs = Date.parse(state.lastDispatchAt); - const nowMs = Date.parse(now); - const isSameWave = Number.isFinite(previousDispatchAtMs) && Number.isFinite(nowMs) && Math.abs(nowMs - previousDispatchAtMs) <= waveGapMs; - let shouldAdvise = false; - if (isSameWave) { - state.fleetWaveWidth += 1; - } else if (state.fleetWaveWidth > 0) { - state.consecutiveNarrowWaves = state.fleetWaveWidth <= 2 ? state.consecutiveNarrowWaves + 1 : 0; - shouldAdvise = state.consecutiveNarrowWaves === 2; - state.fleetWaveWidth = 1; + let launched = findLaunchedDispatch(state.dispatchLog, toolUseId, lane); + const recoveredMissingLaunch = launched === null; + if (recoveredMissingLaunch) { + launched = { + at: now, + lane, + phase: "confirmed", + ...(toolUseId ? { toolUseId } : {}), + ...(safeSubagentType ? { subagentType: safeSubagentType } : {}), + ...(description ? { description } : {}) + }; + state.dispatchLog.push(launched); + if (state.dispatchLog.length > DISPATCH_LOG_LIMIT) { + state.dispatchLog.splice(0, state.dispatchLog.length - DISPATCH_LOG_LIMIT); + } } else { - state.fleetWaveWidth = 1; + launched.phase = "confirmed"; + if (description) { + launched.description = description; + } + } + const fleetWaveState = deriveFleetWaveState(state.dispatchLog, waveGapMs); + state.fleetWaveWidth = fleetWaveState.fleetWaveWidth; + state.consecutiveNarrowWaves = fleetWaveState.consecutiveNarrowWaves; + if (state.consecutiveNarrowWaves === 0) { + state.lastAdvisedNarrowWaveStreak = 0; } - state.dispatches[lane] = (state.dispatches[lane] ?? 0) + 1; + const shouldAdvise = + state.consecutiveNarrowWaves > 0 && + state.consecutiveNarrowWaves % 2 === 0 && + state.lastAdvisedNarrowWaveStreak !== state.consecutiveNarrowWaves; + if (shouldAdvise) { + state.lastAdvisedNarrowWaveStreak = state.consecutiveNarrowWaves; + } + state.dispatches[launched.lane] = (state.dispatches[launched.lane] ?? 0) + 1; state.dispatchEpoch += 1; - state.lastDispatchAt = now; + state.lastDispatchAt = latestConfirmedDispatchAt(state.dispatchLog); state.writesSinceDispatch = 0; state.advisedMultiples = []; - state.dispatchLog.push({ at: now, lane, ...(safeSubagentType ? { subagentType: safeSubagentType } : {}), ...(description ? { description } : {}) }); - if (state.dispatchLog.length > DISPATCH_LOG_LIMIT) { - state.dispatchLog.splice(0, state.dispatchLog.length - DISPATCH_LOG_LIMIT); - } writeState(file, state); - return shouldAdvise; + return { shouldAdvise, streak: state.consecutiveNarrowWaves, lane: launched.lane, description: launched.description ?? description, recoveredMissingLaunch }; }); - if (waveAdvisory) { - process.stdout.write(`${JSON.stringify(postToolAdvisoryOutput(NARROW_WAVE_ADVISORY_LINE))}\n`); + if (confirmation.recoveredMissingLaunch) { + recordAuditEvent( + { at: now, session: sessionId, event: "warn", lane: confirmation.lane, tool: toolName, reason: MISSING_LAUNCH_RECOVERY_REASON }, + env + ); + } + recordAuditEvent( + { at: now, session: sessionId, event: "dispatch", lane: confirmation.lane, tool: toolName, ...(confirmation.description ? { description: confirmation.description } : {}) }, + env + ); + if (confirmation.shouldAdvise) { + process.stdout.write(`${JSON.stringify(postToolAdvisoryOutput(`${confirmation.streak} ${NARROW_WAVE_ADVISORY_SUFFIX}`))}\n`); } return; } diff --git a/plugins/fusion/scripts/lib/worker-state.mjs b/plugins/fusion/scripts/lib/worker-state.mjs index d0ad883..71bea3a 100644 --- a/plugins/fusion/scripts/lib/worker-state.mjs +++ b/plugins/fusion/scripts/lib/worker-state.mjs @@ -17,6 +17,27 @@ const TASK_OUTPUT_TRANSCRIPT_CHUNK_BYTES = 64 * 1024; const TASK_OUTPUT_TRANSCRIPT_MAX_LINE_BYTES = 1024 * 1024; const TERMINAL_STATUSES = new Set(["done", "incomplete", "failed", "cancelled", "owner_ended"]); const ACCEPTANCE_STATES = new Set(["accepted", "rejected", "unverified"]); +export const WORKER_COLLECTION_METHODS = Object.freeze({ + SUBAGENT_STOP: "subagent_stop", + TASK_NOTIFICATION: "task_notification", + TASK_OUTPUT: "task_output", + OUTPUT_FILE_READ: "output_file_read", + AGENT_RESULT: "agent_result", + TASK_STOP: "task_stop", + TASK_REAPED: "task_reaped" +}); +const COLLECTION_METHODS_BY_KEY = new Map([ + ["subagentstop", WORKER_COLLECTION_METHODS.SUBAGENT_STOP], + ["tasknotification", WORKER_COLLECTION_METHODS.TASK_NOTIFICATION], + ["taskoutput", WORKER_COLLECTION_METHODS.TASK_OUTPUT], + ["read", WORKER_COLLECTION_METHODS.OUTPUT_FILE_READ], + ["outputfileread", WORKER_COLLECTION_METHODS.OUTPUT_FILE_READ], + ["agent", WORKER_COLLECTION_METHODS.AGENT_RESULT], + ["agentresult", WORKER_COLLECTION_METHODS.AGENT_RESULT], + ["taskstop", WORKER_COLLECTION_METHODS.TASK_STOP], + ["reaped", WORKER_COLLECTION_METHODS.TASK_REAPED], + ["taskreaped", WORKER_COLLECTION_METHODS.TASK_REAPED] +]); const PEER_JOB_FOOTER_AGENT_TYPES = new Set(["codex:codex-rescue", "grok:grok-rescue", "grok:grok-review-runner"]); const AGENT_TYPES = new Map([ ["fusion:fast-worker", "fusion:fast-worker"], @@ -176,10 +197,22 @@ export function writePrivateJson(file, value) { writePrivateFile(file, `${JSON.stringify(value, null, 2)}\n`); } +function canonicalCollectionMethod(value) { + return typeof value === "string" ? COLLECTION_METHODS_BY_KEY.get(value.replace(/[^a-z0-9]/gi, "").toLowerCase()) ?? null : null; +} + +function normalizeWorkerRecord(value) { + if (!Object.hasOwn(value, "collectionMethod")) { + return value; + } + const collectionMethod = canonicalCollectionMethod(value.collectionMethod); + return collectionMethod === value.collectionMethod ? value : { ...value, collectionMethod }; +} + export function readWorkerRecordFile(file) { try { const value = JSON.parse(fs.readFileSync(file, "utf8")); - return value && typeof value === "object" && !Array.isArray(value) ? value : null; + return value && typeof value === "object" && !Array.isArray(value) ? normalizeWorkerRecord(value) : null; } catch { return null; } @@ -214,7 +247,7 @@ export function updateWorkerRecord(taskId, env, updater) { if (!next || typeof next !== "object" || Array.isArray(next)) { return current; } - const updated = { ...next, updatedAt: new Date().toISOString() }; + const updated = normalizeWorkerRecord({ ...next, updatedAt: new Date().toISOString() }); writePrivateJson(file, updated); return updated; }); @@ -222,9 +255,13 @@ export function updateWorkerRecord(taskId, env, updater) { export function markWorkerCollected(record, method, collectedAt = new Date().toISOString()) { const awaitingVerdict = !record.collectedAt && record.acceptance === "unverified"; + const collectionMethod = canonicalCollectionMethod(record.collectionMethod) ?? canonicalCollectionMethod(method); + if (!collectionMethod) { + throw new TypeError("Fusion worker collection method is invalid."); + } return { ...record, - collectionMethod: record.collectionMethod ?? method, + collectionMethod, collectedAt: record.collectedAt ?? collectedAt, awaitingCollection: false, awaitingCollectionArmedAt: null, diff --git a/plugins/fusion/scripts/worker-lifecycle.mjs b/plugins/fusion/scripts/worker-lifecycle.mjs index 8a6c7a2..dc83486 100644 --- a/plugins/fusion/scripts/worker-lifecycle.mjs +++ b/plugins/fusion/scripts/worker-lifecycle.mjs @@ -19,6 +19,7 @@ import { resolveFusionDataDir, updateWorkerRecord, updateWorkerSessionState, + WORKER_COLLECTION_METHODS, workerRecordFile, writePrivateJson, writePrivateText @@ -36,6 +37,7 @@ const DEFAULT_BRIEF_MAX_BYTES = 16 * 1024; 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; @@ -641,6 +643,12 @@ function boundedFinalText(message) { ]); } +function writeFinalTextArtifact(record, message, env) { + const file = finalTextArtifactPath(record, env); + writePrivateText(file, boundedFinalText(message)); + return file; +} + function collectionResponseDebugEnabled(env) { return /^(?:1|true|yes)$/i.test(String(env[COLLECTION_RESPONSE_DEBUG_ENV] ?? "").trim()); } @@ -713,7 +721,7 @@ function handlePostToolUse(input, env, failed = false) { failureKind: "task_reaped", finishedAt: now, collectedAt: now, - collectionMethod: "reaped", + collectionMethod: WORKER_COLLECTION_METHODS.TASK_REAPED, awaitingVerdict: true, awaitingVerdictArmedAt: now }; @@ -744,9 +752,14 @@ function handlePostToolUse(input, env, failed = false) { updateWorkerRecord(record.taskId, env, (current) => { const worker = current ?? record; const transportStatus = input.tool_name === "TaskStop" ? "cancelled" : "done"; + const collectionMethod = input.tool_name === "Read" + ? WORKER_COLLECTION_METHODS.OUTPUT_FILE_READ + : input.tool_name === "TaskOutput" + ? WORKER_COLLECTION_METHODS.TASK_OUTPUT + : WORKER_COLLECTION_METHODS.TASK_STOP; const harnessAsyncDelivery = collectedHarnessAsyncDelivery(worker, transportStatus); const updated = { - ...markWorkerCollected({ ...worker, acceptance: "unverified" }, input.tool_name, now), + ...markWorkerCollected({ ...worker, acceptance: "unverified" }, collectionMethod, now), transportStatus, acceptance: "unverified", failureKind: input.tool_name === "TaskStop" ? worker.failureKind ?? "cancelled" : harnessAsyncDelivery ? null : worker.failureKind, @@ -807,7 +820,7 @@ function handlePostToolUse(input, env, failed = false) { updateWorkerRecord(pending.taskId, env, (current) => { const worker = current ?? pending; return { - ...(!failed && completed ? markWorkerCollected(worker, input.tool_name, now) : worker), + ...(!failed && completed ? markWorkerCollected(worker, WORKER_COLLECTION_METHODS.AGENT_RESULT, now) : worker), ...(agentId ? { agentId, backgroundTaskId: isAsync ? agentId : worker.backgroundTaskId } : {}), ...(outputFile ? { transcriptPath: outputFile } : {}), ...(resolvedModel ? { resolvedModel } : {}), @@ -888,7 +901,7 @@ function handleSubagentStop(input, env) { let finalTextFile = isFusionWorkerAgent(refreshed.agentType) && message.length > 0 ? finalTextArtifactPath(refreshed, env) : null; if (finalTextFile) { try { - writePrivateText(finalTextFile, boundedFinalText(message)); + finalTextFile = writeFinalTextArtifact(refreshed, message, env); updateWorkerRecord(refreshed.taskId, env, (current) => ({ ...(current ?? refreshed), outputFile: finalTextFile })); } catch { finalTextFile = null; @@ -921,7 +934,7 @@ function handleSubagentStop(input, env) { const successfulCompletion = complete && !failure && trustedCollectorReport?.kind !== "outcome" && !collectorProtocolFailure; const subagentStopCollected = successfulCompletion && finalTextFile != null && !collectorContract; return { - ...(runtimeAsync && !subagentStopCollected ? worker : markWorkerCollected({ ...worker, acceptance: "unverified" }, subagentStopCollected ? "subagent_stop" : "SubagentStop", now)), + ...(runtimeAsync && !subagentStopCollected ? worker : markWorkerCollected({ ...worker, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.SUBAGENT_STOP, now)), transportStatus: complete && (trustedCollectorReport?.kind === "outcome" || collectorProtocolFailure) ? "incomplete" : complete ? runtimeAsync && !subagentStopCollected ? "ready_uncollected" : "done" : failure ? "failed" : "incomplete", acceptance: "unverified", failureKind: failure?.failureKind ?? (trustedCollectorReport?.kind === "outcome" ? `collection_${trustedCollectorReport.collectionOutcome}` : collectorProtocolFailure ? "collection_protocol" : complete ? null : "delivery"), @@ -1100,15 +1113,102 @@ function scanTaskNotifications(transcriptPath, requestedOffset) { } } -function taskNotificationTransition(record, status, now) { +function transcriptEntryContent(entry) { + if (Array.isArray(entry?.message?.content)) { + return entry.message.content; + } + return Array.isArray(entry?.content) ? entry.content : []; +} + +function finalAssistantTextFromTranscript(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 lines = text.split("\n"); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index].replace(/\r$/, ""); + if (!line.trim() || Buffer.byteLength(line) > TASK_NOTIFICATION_MAX_LINE_BYTES) { + continue; + } + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const content = transcriptEntryContent(entry); + if (entry?.type === "assistant") { + if (content.some((block) => block?.type === "tool_use")) { + return null; + } + const blocks = content.filter((block) => block?.type === "text" && typeof block.text === "string" && block.text.length > 0).map((block) => block.text); + if (blocks.length > 0) { + return blocks.join("\n"); + } + } else if (entry?.type === "user" && (entry.toolUseResult != null || content.some((block) => block?.type === "tool_result"))) { + return null; + } + } + return null; + } catch { + return null; + } finally { + if (descriptor != null) { + try { + fs.closeSync(descriptor); + } catch { + void 0; + } + } + } +} + +function taskNotificationTransition(record, status, now, env) { if (SUCCESSFUL_TASK_NOTIFICATION_STATUSES.has(status)) { + const finalText = finalAssistantTextFromTranscript(record.transcriptPath); + let finalTextFile = null; + if (finalText) { + try { + finalTextFile = writeFinalTextArtifact(record, finalText, env); + } catch { + finalTextFile = null; + } + } + if (!finalTextFile) { + return { + ...record, + transportStatus: "incomplete", + acceptance: "unverified", + failureKind: "turn_limit", + finishedAt: record.finishedAt ?? now, + lastActivityAt: now + }; + } const harnessAsyncDelivery = collectedHarnessAsyncDelivery(record, "done"); return { - ...markWorkerCollected({ ...record, acceptance: "unverified" }, "task_notification", now), + ...markWorkerCollected({ ...record, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), transportStatus: "done", acceptance: "unverified", failureKind: harnessAsyncDelivery ? null : record.failureKind, ...(harnessAsyncDelivery ? { deliveryMode: "harness_async" } : {}), + outputFile: finalTextFile, + ...(PEER_JOB_FOOTER_AGENTS.has(record.agentType) ? capturedPeerIdentity(record.agentType, finalText, record.peerEngine) : {}), finishedAt: record.finishedAt ?? now, lastActivityAt: now }; @@ -1116,7 +1216,7 @@ function taskNotificationTransition(record, status, now) { const cancelled = CANCELLED_TASK_NOTIFICATION_STATUSES.has(status); const fallbackFailureKind = cancelled ? "cancelled" : "task_failed"; return { - ...markWorkerCollected({ ...record, acceptance: "unverified" }, "task_notification", now), + ...markWorkerCollected({ ...record, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), transportStatus: cancelled ? "cancelled" : "failed", acceptance: "unverified", failureKind: record.failureKind && record.failureKind !== "unexpected_async" ? record.failureKind : fallbackFailureKind, @@ -1150,7 +1250,7 @@ function reconcileTaskNotifications(input, env) { if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { return null; } - return taskNotificationTransition(current, status, now); + return taskNotificationTransition(current, status, now, env); }); } } diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 29a4752..440b1f2 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.31", + "version": "0.0.32", "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/plugins/grok/agents/grok-rescue.md b/plugins/grok/agents/grok-rescue.md index b655a5e..ff0108c 100644 --- a/plugins/grok/agents/grok-rescue.md +++ b/plugins/grok/agents/grok-rescue.md @@ -40,3 +40,4 @@ Forwarding rules: Response style: - Do not add commentary before or after the forwarded `grok-companion` output. +- Relay the companion's stdout verbatim inside a fenced block; never retype, summarize, or re-spell any part of it, footers included. Commentary goes outside the fence. diff --git a/plugins/grok/agents/grok-review-runner.md b/plugins/grok/agents/grok-review-runner.md index 8120979..a753b51 100644 --- a/plugins/grok/agents/grok-review-runner.md +++ b/plugins/grok/agents/grok-review-runner.md @@ -20,3 +20,4 @@ Rules: - Return the terminal output or cleanup-required failure receipt exactly as-is, with no commentary before or after it. A receipt such as "started", "waiting", or "will report when it completes" is never a valid final message. - Do not use Read for any path except the newly allocated empty transport file, and do not read it after writing. Do not inspect the repository, interpret findings, retry with modified flags, poll status, cancel jobs, or do any follow-up work. - A nonzero companion exit with terminal `state` and `failure` lines, a `phase: cleanup-required` receipt, or top level JSON `status` and `failureKind` fields is a typed outcome and must be returned verbatim. Return exactly one line, `grok unavailable: `, only when the command cannot be invoked and no typed text or JSON outcome exists. +- An empty raw request after the delimiter is valid and means the default managed review of the current tree; proceed without asking or stopping. diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index e5acb8d..6402194 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -116,7 +116,8 @@ const HISTORY_FAILURE_KINDS = new Set([ "resource", "sandbox", "transport", - "policy" + "policy", + "turn_limit" ]); const GROK_ROLES = new Set(["burst", "independence", "live-web", "large-context", "best-of-n"]); const REVIEW_OUTPUT_SCHEMA = { diff --git a/tests/agent-budget-consistency.test.mjs b/tests/agent-budget-consistency.test.mjs new file mode 100644 index 0000000..37e12dd --- /dev/null +++ b/tests/agent-budget-consistency.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const repoRoot = path.join(import.meta.dirname, ".."); + +function readMaxTurns(agent) { + const contents = fs.readFileSync(path.join(repoRoot, "plugins", "fusion", "agents", `${agent}.md`), "utf8"); + const frontmatter = contents.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + assert.ok(frontmatter, `${agent} must have frontmatter`); + const maxTurns = frontmatter[1].match(/^maxTurns:\s*(\d+)\s*$/m); + assert.ok(maxTurns, `${agent} frontmatter must define maxTurns`); + return Number.parseInt(maxTurns[1], 10); +} + +// These must stay at 2x the base workerLimits turn budgets in worker-lifecycle.mjs (fast-worker 60, deep-reasoner 30, trivial-worker 12) so a sizing: large brief cannot be undercut by the harness frontmatter cap. +test("agent frontmatter turn caps accommodate large briefs", () => { + assert.equal(readMaxTurns("fast-worker"), 120); + assert.equal(readMaxTurns("deep-reasoner"), 60); + assert.equal(readMaxTurns("trivial-worker"), 24); +}); diff --git a/tests/codex-companion.test.mjs b/tests/codex-companion.test.mjs index f1d6af0..741649a 100644 --- a/tests/codex-companion.test.mjs +++ b/tests/codex-companion.test.mjs @@ -777,13 +777,14 @@ test("trusted-directory failures store and render the skip-git remedy", (t) => { assert.match(result.stdout, /--skip-git-repo-check/); }); -function seedTerminalJob(sandbox, { id, status }) { +function seedTerminalJob(sandbox, { id, status, ...fields }) { const record = createJobRecord({ id, cwd: sandbox.workDir, finishedAt: "2026-01-01T00:00:00.000Z", request: { effort: "high", model: "gpt-test" }, - status + status, + ...fields }); writeJobRecordFile(jobFilePath(sandbox.dataDir, sandbox.workDir, id), record); } @@ -811,10 +812,38 @@ test("record-acceptance stores accepted and rejected semantic verdicts", (t) => assert.equal(record.semanticFailureMessage, "Verification did not pass."); }); +test("record-acceptance stamps default stats provenance", (t) => { + const sandbox = makeSandbox(t); + const id = "e".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "accepted"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 0, result.stderr); + const [record] = jobRecords(sandbox); + assert.equal(record.acceptanceSource, "stats"); + assert.match(record.acceptanceRecordedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); +}); + +test("record-acceptance stamps explicit provenance", (t) => { + const sandbox = makeSandbox(t); + const id = "f".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "accepted", "--source", "main-loop"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 0, result.stderr); + const [record] = jobRecords(sandbox); + assert.equal(record.acceptanceSource, "main-loop"); + assert.match(record.acceptanceRecordedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); +}); + test("record-acceptance blocks accepted verdicts on failed transport by default", (t) => { const sandbox = makeSandbox(t); const id = "b".repeat(32); - seedTerminalJob(sandbox, { id, status: "error" }); + seedTerminalJob(sandbox, { id, status: "error", exitCode: 1, failureKind: "timeout" }); const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "accepted"], { cwd: sandbox.workDir, env: envFor(sandbox) diff --git a/tests/codex-state.test.mjs b/tests/codex-state.test.mjs index d6647b5..984e40d 100644 --- a/tests/codex-state.test.mjs +++ b/tests/codex-state.test.mjs @@ -217,6 +217,8 @@ test("job ids contain 128 bits and records expose the canonical Codex fields", ( assert.strictEqual(record.status, "running"); assert.strictEqual(record.delivery, "foreground"); assert.strictEqual(record.deliveryCollectedAt, null); + assert.strictEqual(record.acceptanceSource, null); + assert.strictEqual(record.acceptanceRecordedAt, null); assert.strictEqual(record.semanticStatus, "unverified"); assert.strictEqual(record.serviceTier, null); assert.strictEqual(record.appliedServiceTier, null); @@ -372,6 +374,16 @@ test("terminal finalization preserves the first finished timestamp", (t) => { assert.equal(second.finishedAt, firstFinishedAt); }); +test("terminal finalization preserves acceptance provenance", (t) => { + const sandbox = makeSandbox(t); + const acceptanceRecordedAt = "2026-07-19T07:50:39.000Z"; + const { file } = makeRecord(sandbox, { acceptanceRecordedAt, acceptanceSource: "collector" }); + const finished = finishJobRecordFile(file, { status: "done" }); + + assert.equal(finished.acceptanceSource, "collector"); + assert.equal(finished.acceptanceRecordedAt, acceptanceRecordedAt); +}); + test("terminal collection is an atomic idempotent lifecycle transition", (t) => { const sandbox = makeSandbox(t); const running = makeRecord(sandbox, { id: "running-collection" }); diff --git a/tests/fusion-raw-args-transport.test.mjs b/tests/fusion-raw-args-transport.test.mjs index d05ec1f..58589fc 100644 --- a/tests/fusion-raw-args-transport.test.mjs +++ b/tests/fusion-raw-args-transport.test.mjs @@ -149,5 +149,5 @@ test("the stats CLI forwards --accept-failed-transport from staged arguments unc const result = spawnSync(process.execPath, [SCRIPT, "--raw-args-token", transport.token], { cwd: directory, env, encoding: "utf8" }); assert.strictEqual(result.status, 0, result.stderr); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--accept-failed-transport"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "collector", "--accept-failed-transport"]); }); diff --git a/tests/fusion-stats.test.mjs b/tests/fusion-stats.test.mjs index f35d0b8..1299b9e 100644 --- a/tests/fusion-stats.test.mjs +++ b/tests/fusion-stats.test.mjs @@ -23,6 +23,7 @@ import { fusionRepositoryKey, newestCodexCompanion, newestGrokCompanion, + normalizeCollectionMethod, normalizeCodexTokenUsage, pruneDeadWorkspaces, recordCodexAcceptance, @@ -1512,7 +1513,7 @@ test("--record-acceptance updates the Codex job record before appending the ledg assert.strictEqual(result.status, 0, result.stderr); assert.strictEqual(result.stdout, `Recorded accepted for Codex job ${jobId}.\n`); assert.strictEqual(result.stderr, ""); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--reason", "verification passed"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "main-loop", "--reason", "verification passed"]); assert.strictEqual(fs.readFileSync(acceptanceSidecarPath(dir, { FUSION_DATA_DIR: fusionData }), "utf8").trim().split("\n").length, 1); }); @@ -1571,7 +1572,26 @@ test("--record-acceptance forwards --accept-failed-transport unchanged", (t) => ); assert.strictEqual(result.status, 0, result.stderr); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--accept-failed-transport"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "collector", "--accept-failed-transport"]); +}); + +test("--record-acceptance forwards an explicit source to the Codex companion", (t) => { + const dir = sandbox(t); + const stateRoot = path.join(dir, "state"); + const fusionData = path.join(dir, "fusion"); + const jobId = "e".repeat(32); + const companion = writeCodexAcceptanceCompanion(dir); + const argsFile = path.join(dir, "companion-args.json"); + writeCodexJob(stateRoot, dir, jobId, { status: "done", jobClass: "task" }); + + const result = run( + { cwd: dir, codexState: stateRoot }, + ["--record-acceptance", jobId, "accepted", "--source", "main-loop"], + { FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } + ); + + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "main-loop"]); }); test("--record-acceptance resolves a Grok job after a Codex lookup miss", (t) => { @@ -1640,7 +1660,7 @@ test("--record settles a Fusion task and its Codex peer with direct strict argv" assert.strictEqual(result.status, 0, result.stderr); assert.strictEqual(result.stdout, `Recorded accepted for Fusion worker task ${taskId}.\nRecorded accepted for Codex job ${jobId}.\n`); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "main-loop"]); assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "accepted"); }); @@ -1667,7 +1687,7 @@ test("--record resolves a bare Codex job and settles matching worker rows", (t) { kind: "engine", engine: "codex", jobId, acceptance: "rejected" }, { kind: "worker", taskId, acceptance: "rejected" } ]); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected", "--source", "main-loop"]); assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "rejected"); }); @@ -2427,6 +2447,96 @@ test("Claude worker stats expose lifecycle, exact usage, acceptance, and the uni assert.strictEqual(fs.statSync(jobFile).mode & 0o777, 0o600); }); +test("Claude worker stats normalize legacy collection methods for grouping and display", (t) => { + const dir = sandbox(t); + const env = { FUSION_WORKER_STATE_DIR: path.join(dir, "worker-state") }; + const workers = [ + ["fusion-legacy-stop", "SubagentStop"], + ["fusion-legacy-agent", "Agent"] + ]; + for (const [taskId, collectionMethod] of workers) { + createWorkerRecord({ taskId, sessionId: "session-workers", dispatchToolUseId: taskId, agentType: "fusion:fast-worker", workspaceRoot: dir, limits: {} }, env); + updateWorkerRecord(taskId, env, (record) => ({ ...record, transportStatus: "done", collectionMethod })); + } + + const stats = claudeWorkerStats({ all: true, env, cwd: dir }); + assert.strictEqual(normalizeCollectionMethod("SubagentStop"), "subagent_stop"); + assert.strictEqual(normalizeCollectionMethod("Agent"), "agent_result"); + assert.strictEqual(normalizeCollectionMethod("aGeNt"), "agent_result"); + assert.deepStrictEqual(stats.byCollectionMethod, { subagent_stop: 1, agent_result: 1 }); + assert.deepStrictEqual(stats.identities.map((identity) => identity.collectionMethod).sort(), ["agent_result", "subagent_stop"]); + assert.match(renderFusionStats({ scope: dir, claudeWorkers: stats }), /By collection method:\n- agent_result: 1\n- subagent_stop: 1/); +}); + +test("--unsettled lists terminal unverified jobs and preserves Codex acceptance provenance", (t) => { + const dir = sandbox(t); + const stateRoot = path.join(dir, "codex-state"); + const workerState = path.join(dir, "worker-state"); + const grokData = path.join(dir, "grok-data"); + const codexUnsettledId = "a".repeat(32); + const codexNoProvenanceId = "b".repeat(32); + const codexSettledId = "c".repeat(32); + + writeCodexJob(stateRoot, dir, codexUnsettledId, { + status: "done", + jobClass: "task", + acceptance: "unverified", + acceptanceSource: "collector", + acceptanceRecordedAt: "2026-07-21T00:03:00.000Z", + finishedAt: "2026-07-21T00:03:00.000Z" + }); + writeCodexJob(stateRoot, dir, codexNoProvenanceId, { + status: "completed", + jobClass: "review", + acceptance: "unverified", + finishedAt: "2026-07-21T00:00:00.000Z" + }); + writeCodexJob(stateRoot, dir, codexSettledId, { + status: "done", + jobClass: "task", + acceptance: "accepted", + finishedAt: "2026-07-21T00:04:00.000Z" + }); + writeGrokJob(grokData, dir, "grok-unsettled", { + status: "done", + mode: "consult", + acceptance: "unverified", + finishedAt: "2026-07-21T00:02:00.000Z" + }); + writeGrokJob(grokData, dir, "grok-settled", { + status: "done", + mode: "consult", + acceptance: "rejected", + finishedAt: "2026-07-21T00:04:00.000Z" + }); + createWorkerRecord({ taskId: "fusion-unsettled", sessionId: "session-workers", dispatchToolUseId: "tool-unsettled", agentType: "fusion:fast-worker", description: "worker task", workspaceRoot: dir, limits: {} }, { FUSION_WORKER_STATE_DIR: workerState }); + updateWorkerRecord("fusion-unsettled", { FUSION_WORKER_STATE_DIR: workerState }, (record) => ({ ...record, transportStatus: "done", finishedAt: "2026-07-21T00:01:00.000Z" })); + createWorkerRecord({ taskId: "fusion-settled", sessionId: "session-workers", dispatchToolUseId: "tool-settled", agentType: "fusion:fast-worker", workspaceRoot: dir, limits: {} }, { FUSION_WORKER_STATE_DIR: workerState }); + updateWorkerRecord("fusion-settled", { FUSION_WORKER_STATE_DIR: workerState }, (record) => ({ ...record, transportStatus: "done", acceptance: "rejected", finishedAt: "2026-07-21T00:04:00.000Z" })); + + const extraEnv = { FUSION_WORKER_STATE_DIR: workerState, GROK_COMPANION_DATA: grokData }; + const json = run({ cwd: dir, codexState: stateRoot }, ["--unsettled", "--json"], extraEnv); + assert.strictEqual(json.status, 0, json.stderr); + const report = JSON.parse(json.stdout); + assert.deepStrictEqual(report.unsettled.map((entry) => [entry.engine, entry.id]), [ + ["codex", codexUnsettledId], + ["grok", "grok-unsettled"], + ["fusion", "fusion-unsettled"], + ["codex", codexNoProvenanceId] + ]); + const withProvenance = report.unsettled.find((entry) => entry.id === codexUnsettledId); + assert.strictEqual(withProvenance.acceptanceSource, "collector"); + assert.strictEqual(withProvenance.acceptanceRecordedAt, "2026-07-21T00:03:00.000Z"); + const withoutProvenance = report.unsettled.find((entry) => entry.id === codexNoProvenanceId); + assert.strictEqual(Object.hasOwn(withoutProvenance, "acceptanceSource"), false); + assert.strictEqual(Object.hasOwn(withoutProvenance, "acceptanceRecordedAt"), false); + + const rendered = run({ cwd: dir, codexState: stateRoot }, ["--unsettled"], extraEnv); + assert.strictEqual(rendered.status, 0, rendered.stderr); + assert.match(rendered.stdout, /acceptance source: collector, acceptance recorded at: 2026-07-21T00:03:00.000Z/); + assert.doesNotMatch(rendered.stdout.split("\n").find((line) => line.includes(codexNoProvenanceId)), /acceptance source|acceptance recorded at/); +}); + test("Claude worker stats report completed harness async deliveries separately from failures", (t) => { const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); diff --git a/tests/fusion-worker-lifecycle.test.mjs b/tests/fusion-worker-lifecycle.test.mjs index 447e24c..ece4e41 100644 --- a/tests/fusion-worker-lifecycle.test.mjs +++ b/tests/fusion-worker-lifecycle.test.mjs @@ -6,7 +6,7 @@ import path from "node:path"; import { test } from "node:test"; import { recordCodexAcceptance } from "../plugins/fusion/scripts/fusion-stats.mjs"; -import { createWorkerRecord, readWorkerSessionState, readWorkerRecords, recordWorkerAcceptance, updateWorkerRecord } from "../plugins/fusion/scripts/lib/worker-state.mjs"; +import { createWorkerRecord, readWorkerSessionState, readWorkerRecords, recordWorkerAcceptance, updateWorkerRecord, WORKER_COLLECTION_METHODS } from "../plugins/fusion/scripts/lib/worker-state.mjs"; import { validateWorkerBrief, workerBudgetFailure, workerLimits } from "../plugins/fusion/scripts/worker-lifecycle.mjs"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -62,6 +62,41 @@ function record(box) { return records[0]; } +test("legacy collection methods normalize when worker records load", (t) => { + const box = sandbox(t); + const worker = createWorkerRecord({ + taskId: `fusion-${"a".repeat(24)}`, + sessionId: "session-1", + agentType: "fusion:fast-worker", + workspaceRoot: box.cwd + }, envFor(box)); + const file = path.join(box.state, "jobs", `${worker.taskId}.json`); + const stored = JSON.parse(fs.readFileSync(file, "utf8")); + fs.writeFileSync(file, `${JSON.stringify({ ...stored, collectionMethod: "SubagentStop" }, null, 2)}\n`, "utf8"); + + assert.strictEqual(record(box).collectionMethod, WORKER_COLLECTION_METHODS.SUBAGENT_STOP); +}); + +test("collection method write sites use the canonical set", () => { + const allowed = new Set(Object.values(WORKER_COLLECTION_METHODS)); + assert.strictEqual(allowed.size, Object.keys(WORKER_COLLECTION_METHODS).length); + for (const method of allowed) { + assert.match(method, /^[a-z]+(?:_[a-z]+)*$/); + } + + const sources = [ + fs.readFileSync(script, "utf8"), + fs.readFileSync(path.join(repoRoot, "plugins", "fusion", "scripts", "lib", "worker-state.mjs"), "utf8") + ]; + const assignments = sources.flatMap((source) => [...source.matchAll(/collectionMethod:\s*([^,\n}]+)/g)].map((match) => match[1].trim())); + assert.ok(assignments.length > 0); + assert.ok(assignments.every((expression) => expression === "null" || expression === "collectionMethod" || /^WORKER_COLLECTION_METHODS\.[A-Z_]+$/.test(expression))); + + const calls = sources.flatMap((source) => source.split("\n").filter((line) => line.includes("markWorkerCollected(") && !line.includes("export function"))); + assert.ok(calls.length > 0); + assert.ok(calls.every((line) => line.includes("WORKER_COLLECTION_METHODS.") || line.includes(", collectionMethod, now"))); +}); + test("worker limits preserve the default budgets without a sizing hint and retain environment overrides", () => { assert.deepStrictEqual(workerLimits("fusion:fast-worker", {}), { wallClockMs: 1_200_000, @@ -493,7 +528,7 @@ test("an unexpected async peer wrapper allows Stop while in flight and remains o assert.strictEqual(record(box).transportStatus, "done"); assert.strictEqual(record(box).failureKind, null); assert.strictEqual(record(box).deliveryMode, "harness_async"); - assert.strictEqual(record(box).collectionMethod, "TaskOutput"); + assert.strictEqual(record(box).collectionMethod, "task_output"); assert.ok(record(box).collectedAt); const collectedAt = record(box).collectedAt; @@ -506,7 +541,7 @@ test("an unexpected async peer wrapper allows Stop while in flight and remains o tool_input: { task_id: "peer-async", block: true }, tool_response: { status: "completed", content: "peer result" } }); - assert.strictEqual(record(box).collectionMethod, "TaskOutput"); + assert.strictEqual(record(box).collectionMethod, "task_output"); assert.strictEqual(record(box).collectedAt, collectedAt); }); @@ -698,7 +733,7 @@ test("SubagentStop keeps a newly evaluated budget failure instead of recovering const completed = record(box); assert.strictEqual(completed.transportStatus, "done"); assert.strictEqual(completed.failureKind, "timeout"); - assert.strictEqual(completed.collectionMethod, "SubagentStop"); + assert.strictEqual(completed.collectionMethod, "subagent_stop"); assert.ok(completed.collectedAt); assert.strictEqual(completed.recoveredFailureKind, undefined); }); @@ -728,7 +763,7 @@ test("SubagentStop retains failed and incomplete workers on their existing colle }, limits); assert.strictEqual(record(failed).transportStatus, "failed"); assert.strictEqual(record(failed).failureKind, "timeout"); - assert.strictEqual(record(failed).collectionMethod, "SubagentStop"); + assert.strictEqual(record(failed).collectionMethod, "subagent_stop"); assert.ok(record(failed).collectedAt); const incomplete = sandbox(t); @@ -753,7 +788,7 @@ test("SubagentStop retains failed and incomplete workers on their existing colle }); assert.strictEqual(record(incomplete).transportStatus, "incomplete"); assert.strictEqual(record(incomplete).failureKind, "delivery"); - assert.strictEqual(record(incomplete).collectionMethod, "SubagentStop"); + assert.strictEqual(record(incomplete).collectionMethod, "subagent_stop"); assert.ok(record(incomplete).collectedAt); }); @@ -780,7 +815,7 @@ test("a TaskOutput no-task error marks a matching unexpected async peer wrapper const reaped = record(box); assert.strictEqual(reaped.transportStatus, "failed"); assert.strictEqual(reaped.failureKind, "task_reaped"); - assert.strictEqual(reaped.collectionMethod, "reaped"); + assert.strictEqual(reaped.collectionMethod, "task_reaped"); assert.ok(reaped.finishedAt); assert.ok(reaped.collectedAt); assert.strictEqual(reaped.agentId, "peer-reaped-output"); @@ -810,7 +845,7 @@ test("a TaskStop no-task error marks a matching unexpected async peer wrapper as const reaped = record(box); assert.strictEqual(reaped.transportStatus, "failed"); assert.strictEqual(reaped.failureKind, "task_reaped"); - assert.strictEqual(reaped.collectionMethod, "reaped"); + assert.strictEqual(reaped.collectionMethod, "task_reaped"); assert.ok(reaped.finishedAt); assert.ok(reaped.collectedAt); }); @@ -1230,7 +1265,7 @@ test("Stop advises on collected unverified workers without blocking and stays qu }); const completed = record(box); assert.ok(completed.collectedAt); - assert.strictEqual(completed.collectionMethod, "Agent"); + assert.strictEqual(completed.collectionMethod, "agent_result"); assert.strictEqual(completed.awaitingVerdict, true); assert.ok(completed.awaitingVerdictArmedAt); @@ -1531,7 +1566,7 @@ test("a full Read collects terminal output and captures its peer job footer", (t }); const collected = record(box); - assert.strictEqual(collected.collectionMethod, "Read"); + assert.strictEqual(collected.collectionMethod, "output_file_read"); assert.ok(collected.collectedAt); assert.strictEqual(collected.transportStatus, "done"); assert.strictEqual(collected.failureKind, null); @@ -1629,7 +1664,7 @@ test("a terminal failed runtimeAsync worker is demanded by Stop and collected by }); const collected = record(box); - assert.strictEqual(collected.collectionMethod, "Read"); + assert.strictEqual(collected.collectionMethod, "output_file_read"); assert.ok(collected.collectedAt); assert.strictEqual(collected.transportStatus, "done"); assert.strictEqual(collected.failureKind, "task_reaped"); @@ -1690,7 +1725,7 @@ test("a terminal cancelled runtimeAsync worker is demanded by Stop and collected }); const collected = record(box); - assert.strictEqual(collected.collectionMethod, "Read"); + assert.strictEqual(collected.collectionMethod, "output_file_read"); assert.ok(collected.collectedAt); assert.strictEqual(collected.transportStatus, "done"); assert.strictEqual(collected.failureKind, "cancelled"); @@ -1828,7 +1863,7 @@ test("a ready_uncollected record still collects through a full Read", (t) => { const collected = record(box); assert.strictEqual(collected.transportStatus, "done"); - assert.strictEqual(collected.collectionMethod, "Read"); + assert.strictEqual(collected.collectionMethod, "output_file_read"); assert.ok(collected.collectedAt); }); @@ -1979,7 +2014,7 @@ test("SubagentStop leaves a malformed peer footer null while arming its verdict" }); const collected = record(box); - assert.strictEqual(collected.collectionMethod, "SubagentStop"); + assert.strictEqual(collected.collectionMethod, "subagent_stop"); assert.strictEqual(collected.peerJobId, null); assert.strictEqual(collected.awaitingVerdict, true); assert.ok(collected.awaitingVerdictArmedAt); @@ -2158,7 +2193,7 @@ test("job collector completion requires a terminal marker and fails closed on th assert.strictEqual(stopped.stdout, ""); assert.strictEqual(record(box).transportStatus, "incomplete"); assert.strictEqual(record(box).failureKind, "collection_protocol"); - assert.strictEqual(record(box).collectionMethod, "SubagentStop"); + assert.strictEqual(record(box).collectionMethod, "subagent_stop"); assert.strictEqual(record(box).peerJobId, undefined); assert.strictEqual(record(box).retryCount, 1); }); @@ -2305,7 +2340,7 @@ test("an async collector keeps its Read collection requirement", (t) => { }); const collected = record(box); assert.strictEqual(collected.transportStatus, "done"); - assert.strictEqual(collected.collectionMethod, "Read"); + assert.strictEqual(collected.collectionMethod, "output_file_read"); assert.ok(collected.collectedAt); }); @@ -2961,12 +2996,19 @@ test("an explicitly authorized completed worker is collected by SubagentStop bef test("Stop settles a delivered task notification once without a TaskOutput probe", (t) => { const box = sandbox(t); + const workerTranscript = path.join(box.root, "notification-worker.output"); + const finalMessage = "bounded notification result\ndelivery: complete\nverification: passed"; + fs.writeFileSync(workerTranscript, [ + JSON.stringify({ type: "assistant", message: { content: [{ 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" } }), + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "bounded notification result" }, { type: "text", text: "delivery: complete\nverification: passed" }] } }) + ].join("\n") + "\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-worker" } + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-worker", outputFile: workerTranscript } }); const before = record(box); fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.agentId}\ncompleted\n` } })}\n`, "utf8"); @@ -2988,6 +3030,9 @@ test("Stop settles a delivered task notification once without a TaskOutput probe assert.strictEqual(collected.failureKind, null); assert.strictEqual(collected.deliveryMode, null); assert.strictEqual(collected.awaitingVerdict, true); + assert.strictEqual(collected.outputFile, path.join(box.state, "jobs", `${collected.taskId}.final.txt`)); + assert.notStrictEqual(collected.outputFile, workerTranscript); + assert.strictEqual(fs.readFileSync(collected.outputFile, "utf8"), finalMessage); const scanState = readWorkerSessionState("session-1", envFor(box)); assert.strictEqual(scanState.taskNotificationTranscriptPath, box.transcript); @@ -3009,15 +3054,118 @@ test("Stop settles a delivered task notification once without a TaskOutput probe assert.strictEqual(settled.acceptance, "accepted"); }); +test("a completed task notification captures a Codex peer job footer", (t) => { + const box = sandbox(t); + const peerJobId = "f".repeat(32); + const workerTranscript = path.join(box.root, "notification-codex-rescue.output"); + const finalMessage = `completed rescue\ncodex-session: session-1\njob: ${peerJobId}\nsemantic: unverified\nstate: done`; + fs.writeFileSync(workerTranscript, `${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: finalMessage }] } })}\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-codex-rescue", outputFile: workerTranscript } + }); + const launched = record(box); + updateWorkerRecord(launched.taskId, envFor(box), (current) => ({ ...current, transcriptPath: workerTranscript })); + const before = record(box); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.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 collected = record(box); + assert.strictEqual(collected.peerJobId, peerJobId); + assert.strictEqual(collected.peerEngine, "codex"); + assert.strictEqual(collected.peerTransportStatus, undefined); + assert.strictEqual(collected.peerSemanticStatus, undefined); +}); + +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"); + const finalMessage = `completed worker\ncodex-session: session-1\njob: ${"e".repeat(32)}\nsemantic: unverified\nstate: done`; + fs.writeFileSync(workerTranscript, `${JSON.stringify({ type: "assistant", 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-fast-worker", outputFile: workerTranscript } + }); + const before = record(box); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.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 collected = record(box); + assert.strictEqual(collected.peerJobId, undefined); + assert.strictEqual(collected.peerEngine, undefined); + assert.strictEqual(collected.peerTransportStatus, undefined); + assert.strictEqual(collected.peerSemanticStatus, undefined); +}); + +test("a completion notification with a dangling tool result remains incomplete and uncollected", (t) => { + const box = sandbox(t); + const workerTranscript = path.join(box.root, "notification-dangling.output"); + fs.writeFileSync(workerTranscript, [ + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "working" }, { 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); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-dangling", outputFile: workerTranscript } + }); + const before = record(box); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.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.collectionMethod, null); + assert.strictEqual(incomplete.collectedAt, null); + assert.strictEqual(incomplete.awaitingVerdict, false); + assert.strictEqual(incomplete.outputFile, null); + assert.strictEqual(fs.existsSync(path.join(box.state, "jobs", `${incomplete.taskId}.final.txt`)), false); +}); + 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"); + fs.writeFileSync(workerTranscript, `${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "expired worker result" }] } })}\n`, "utf8"); const limits = { FUSION_WORKER_WALL_CLOCK_MS: "1", FUSION_WORKER_STALL_MS: "999999" }; const workerDispatch = dispatch(box); run(box, workerDispatch, limits); run(box, { ...workerDispatch, hook_event_name: "PostToolUse", - tool_response: { isAsync: true, status: "async_launched", agentId: "notification-expired" } + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-expired", outputFile: workerTranscript } }, limits); const before = record(box); updateWorkerRecord(before.taskId, envFor(box, limits), (current) => ({ ...current, startedAt: new Date(Date.now() - 1_000).toISOString() })); diff --git a/tests/history.test.mjs b/tests/history.test.mjs index b5d3fa2..340672c 100644 --- a/tests/history.test.mjs +++ b/tests/history.test.mjs @@ -435,6 +435,28 @@ test("history ignores mismatched filenames and native Grok sessions while narrow assert.equal(malformed.resumable, false); }); +test("history surfaces turn_limit failureKind from a terminal job record", (t) => { + const sandbox = makeSandbox(t); + const jobId = "a1b2c3d4e5f60718293a4b5c6d7e8f90"; + seedJob(sandbox, { + id: jobId, + status: "error", + failureKind: "turn_limit", + sessionId: "a1b2c3d4-e5f6-7182-93a4-b5c6d7e8f901", + createdAt: "2026-07-16T00:00:00.000Z" + }); + + const result = runCompanion(["history", "--json"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.jobs.length, 1); + assert.equal(payload.jobs[0].jobId, jobId); + assert.equal(payload.jobs[0].failureKind, "turn_limit"); +}); + test("history text rendering escapes every dynamic markdown table cell", () => { const output = renderHistoryReport( { diff --git a/tests/inline-delegation-guard.test.mjs b/tests/inline-delegation-guard.test.mjs index 7a5fef0..b55b4cd 100644 --- a/tests/inline-delegation-guard.test.mjs +++ b/tests/inline-delegation-guard.test.mjs @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { test } from "node:test"; +import { pathToFileURL } from "node:url"; import { DEFAULT_LOCK_TIMEOUT_MS, readAuditEvents, @@ -13,6 +14,7 @@ import { const repoRoot = path.join(import.meta.dirname, ".."); const script = path.join(repoRoot, "plugins", "fusion", "scripts", "inline-delegation-guard.mjs"); +let toolUseSequence = 0; function makeSandbox(t) { const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "inline-guard-test-"))); @@ -20,20 +22,30 @@ function makeSandbox(t) { const stateDir = path.join(root, "state"); const auditDir = path.join(root, "audit"); const workDir = path.join(root, "work"); + const clockFile = path.join(root, "clock.mjs"); fs.mkdirSync(workDir, { recursive: true }); - return { root, stateDir, auditDir, workDir }; + fs.writeFileSync( + clockFile, + "const NativeDate = Date; const now = NativeDate.parse(process.env.FUSION_TEST_NOW); globalThis.Date = class extends NativeDate { constructor(...args) { super(...(args.length === 0 ? [now] : args)); } static now() { return now; } };\n", + "utf8" + ); + return { root, stateDir, auditDir, workDir, clockFile }; } function envFor(sandbox, extra = {}) { - return { + const env = { ...process.env, FUSION_INLINE_GUARD_STATE: sandbox.stateDir, FUSION_INLINE_GUARD_AUDIT_DIR: sandbox.auditDir, ...extra }; + if (extra.FUSION_TEST_NOW) { + env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ""} --import=${pathToFileURL(sandbox.clockFile).href}`.trim(); + } + return env; } -function run(sandbox, payload, extraEnv = {}) { +function runRaw(sandbox, payload, extraEnv = {}) { return spawnSync(process.execPath, [script], { input: payload === undefined ? "" : JSON.stringify(payload), env: envFor(sandbox, extraEnv), @@ -41,7 +53,17 @@ function run(sandbox, payload, extraEnv = {}) { }); } -async function runAsync(sandbox, payload, extraEnv = {}) { +function run(sandbox, payload, extraEnv = {}) { + if (payload?.hook_event_name === "PostToolUse" && (payload.tool_name === "Agent" || payload.tool_name === "Task")) { + const launch = runRaw(sandbox, { ...payload, hook_event_name: "PreToolUse" }, extraEnv); + if (launch.status !== 0 || launch.stdout || launch.stderr) { + return launch; + } + } + return runRaw(sandbox, payload, extraEnv); +} + +async function runAsyncRaw(sandbox, payload, extraEnv = {}) { const child = spawn(process.execPath, [script], { env: envFor(sandbox, extraEnv), stdio: ["pipe", "pipe", "pipe"] @@ -59,6 +81,16 @@ async function runAsync(sandbox, payload, extraEnv = {}) { return { status, stdout, stderr }; } +async function runAsync(sandbox, payload, extraEnv = {}) { + if (payload?.hook_event_name === "PostToolUse" && (payload.tool_name === "Agent" || payload.tool_name === "Task")) { + const launch = await runAsyncRaw(sandbox, { ...payload, hook_event_name: "PreToolUse" }, extraEnv); + if (launch.status !== 0 || launch.stdout || launch.stderr) { + return launch; + } + } + return runAsyncRaw(sandbox, payload, extraEnv); +} + function writePayload(sandbox, { sessionId = "session-1", toolName = "Edit", filePath, cwd, agentId } = {}) { const targetPath = filePath ?? path.join(sandbox.workDir, "file.txt"); const payload = { @@ -83,6 +115,7 @@ function dispatchPayload(sandbox, { sessionId = "session-1", toolName = "Agent", transcript_path: path.join(sandbox.root, "transcript.jsonl"), cwd: sandbox.workDir, tool_name: toolName, + tool_use_id: `tool-use-${++toolUseSequence}`, tool_input: { description, prompt: "do the thing" } }; if (subagentType) { @@ -106,7 +139,10 @@ function readState(sandbox, sessionId) { function completeActiveWave(sandbox, gapMs) { const file = stateFileFor(sandbox, "session-1"); const state = readState(sandbox, "session-1"); - state.lastDispatchAt = new Date(Date.now() - gapMs - 1000).toISOString(); + for (const entry of state.dispatchLog) { + entry.at = new Date(Date.parse(entry.at) - gapMs - 1000).toISOString(); + } + state.lastDispatchAt = new Date(Date.parse(state.lastDispatchAt) - gapMs - 1000).toISOString(); fs.writeFileSync(file, JSON.stringify(state), "utf8"); } @@ -194,6 +230,45 @@ test("the default mode denies writes after the dispatch window budget and audits assert.strictEqual(readState(sandbox, "session-1").writesSinceDispatch, 1); }); +test("a PostToolUse-only dispatch recovers a missing launch and reopens an exhausted write budget", (t) => { + const sandbox = makeSandbox(t); + for (let index = 0; index < 5; index += 1) { + run(sandbox, writePayload(sandbox)); + } + const denied = run(sandbox, writePayload(sandbox)); + assert.strictEqual(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, "deny"); + + const recovered = runRaw(sandbox, dispatchPayload(sandbox, { subagentType: "fusion:fast-worker", description: "recover the missing launch" })); + assert.strictEqual(recovered.status, 0); + assert.strictEqual(recovered.stdout, ""); + assert.strictEqual(recovered.stderr, ""); + + const recoveredState = readState(sandbox, "session-1"); + assert.strictEqual(recoveredState.writesSinceDispatch, 0); + assert.strictEqual(recoveredState.dispatchEpoch, 1); + assert.deepStrictEqual(recoveredState.dispatches, { "fusion:fast-worker": 1 }); + assert.strictEqual(recoveredState.dispatchLog.length, 1); + assert.strictEqual(recoveredState.dispatchLog[0].phase, "confirmed"); + assert.strictEqual(recoveredState.dispatchLog[0].description, "recover the missing launch"); + + const recoveryWarnings = readAuditRecords(sandbox).filter((record) => record.event === "warn" && record.reason === "missing-launch-recovered"); + assert.deepStrictEqual(recoveryWarnings, [ + { + schemaVersion: 1, + at: recoveryWarnings[0].at, + session: "session-1", + event: "warn", + lane: "fusion:fast-worker", + tool: "Agent", + reason: "missing-launch-recovered" + } + ]); + + const allowed = run(sandbox, writePayload(sandbox)); + assert.strictEqual(allowed.stdout, ""); + assert.strictEqual(readState(sandbox, "session-1").writesSinceDispatch, 1); +}); + test("enforcement fails closed when guard state is unavailable while advisory compatibility remains fail open", (t) => { const sandbox = makeSandbox(t); fs.writeFileSync(sandbox.stateDir, "not a directory", "utf8"); @@ -366,7 +441,7 @@ test("two narrow dispatch waves attach the fleet advisory to the following dispa assert.strictEqual(output.hookSpecificOutput.permissionDecision, undefined); assert.strictEqual( output.hookSpecificOutput.additionalContext, - "second consecutive narrow wave; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ." + "2 consecutive narrow waves; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ." ); assert.strictEqual(readState(sandbox, "session-1").consecutiveNarrowWaves, 2); }); @@ -392,6 +467,99 @@ test("a wide dispatch wave resets the narrow-wave advisory run", (t) => { assert.strictEqual(readState(sandbox, "session-1").consecutiveNarrowWaves, 1); }); +test("launch timestamps preserve a four-wide fleet wave when confirmations span ten minutes", (t) => { + const sandbox = makeSandbox(t); + const baseMs = Date.parse("2026-07-21T00:00:00.000Z"); + const payloads = Array.from({ length: 4 }, (_, index) => + dispatchPayload(sandbox, { subagentType: "fusion:fast-worker", description: `package ${index + 1}` }) + ); + + for (const [index, payload] of payloads.entries()) { + const launched = runRaw(sandbox, { ...payload, hook_event_name: "PreToolUse" }, { FUSION_TEST_NOW: new Date(baseMs + index * 1000).toISOString() }); + assert.strictEqual(launched.status, 0); + assert.strictEqual(launched.stdout, ""); + } + + for (const [index, payload] of payloads.entries()) { + const confirmed = runRaw(sandbox, payload, { FUSION_TEST_NOW: new Date(baseMs + index * 200000).toISOString() }); + assert.strictEqual(confirmed.status, 0); + assert.strictEqual(confirmed.stdout, ""); + } + + const state = readState(sandbox, "session-1"); + assert.strictEqual(state.fleetWaveWidth, 4); + assert.strictEqual(state.consecutiveNarrowWaves, 0); + assert.strictEqual(state.dispatchLog.length, 4); + assert.ok(state.dispatchLog.every((entry) => entry.phase === "confirmed")); + assert.deepStrictEqual( + state.dispatchLog.map((entry) => entry.at), + Array.from({ length: 4 }, (_, index) => new Date(baseMs + index * 1000).toISOString()) + ); +}); + +test("abandoned launches do not widen fleet waves or reset the narrow streak and expire after the launch TTL", (t) => { + const sandbox = makeSandbox(t); + const gapMs = 120000; + const baseMs = Date.parse("2026-07-21T04:00:00.000Z"); + const extraEnv = { FUSION_FLEET_WAVE_GAP_MS: String(gapMs) }; + + for (let index = 0; index < 3; index += 1) { + const now = new Date(baseMs + index * 180000).toISOString(); + const payload = dispatchPayload(sandbox, { description: `confirmed solo ${index + 1}` }); + assert.strictEqual(runRaw(sandbox, { ...payload, hook_event_name: "PreToolUse" }, { ...extraEnv, FUSION_TEST_NOW: now }).stdout, ""); + const confirmation = runRaw(sandbox, payload, { ...extraEnv, FUSION_TEST_NOW: now }); + assert.strictEqual(confirmation.status, 0); + if (index < 2) { + assert.strictEqual(confirmation.stdout, ""); + } else { + assert.match(JSON.parse(confirmation.stdout).hookSpecificOutput.additionalContext, /^2 consecutive narrow waves/); + } + } + assert.strictEqual(readState(sandbox, "session-1").consecutiveNarrowWaves, 2); + + for (let index = 0; index < 4; index += 1) { + const payload = dispatchPayload(sandbox, { description: `abandoned launch ${index + 1}` }); + const now = new Date(baseMs + 540000 + index * 1000).toISOString(); + assert.strictEqual(runRaw(sandbox, { ...payload, hook_event_name: "PreToolUse" }, { ...extraEnv, FUSION_TEST_NOW: now }).stdout, ""); + } + + const soloNow = new Date(baseMs + 720000).toISOString(); + const solo = dispatchPayload(sandbox, { description: "confirmed solo after abandoned launches" }); + assert.strictEqual(runRaw(sandbox, { ...solo, hook_event_name: "PreToolUse" }, { ...extraEnv, FUSION_TEST_NOW: soloNow }).stdout, ""); + assert.strictEqual(runRaw(sandbox, solo, { ...extraEnv, FUSION_TEST_NOW: soloNow }).stdout, ""); + + const state = readState(sandbox, "session-1"); + assert.strictEqual(state.fleetWaveWidth, 1); + assert.strictEqual(state.consecutiveNarrowWaves, 3); + assert.strictEqual(state.dispatchLog.filter((entry) => entry.phase === "launched").length, 4); + + const afterTtl = new Date(baseMs + 30 * 60000).toISOString(); + assert.strictEqual(run(sandbox, writePayload(sandbox), { ...extraEnv, FUSION_TEST_NOW: afterTtl }).stdout, ""); + assert.strictEqual(readState(sandbox, "session-1").dispatchLog.filter((entry) => entry.phase === "launched").length, 0); +}); + +test("solo launch waves re-nudge at consecutive narrow streaks two and four", (t) => { + const sandbox = makeSandbox(t); + const baseMs = Date.parse("2026-07-21T02:00:00.000Z"); + const outputs = []; + + for (let index = 0; index < 5; index += 1) { + const now = new Date(baseMs + index * 121000).toISOString(); + const payload = dispatchPayload(sandbox, { description: `solo package ${index + 1}` }); + assert.strictEqual(runRaw(sandbox, { ...payload, hook_event_name: "PreToolUse" }, { FUSION_TEST_NOW: now }).stdout, ""); + outputs.push(runRaw(sandbox, payload, { FUSION_TEST_NOW: now }).stdout); + } + + assert.strictEqual(outputs[0], ""); + assert.strictEqual(outputs[1], ""); + assert.strictEqual(JSON.parse(outputs[2]).hookSpecificOutput.additionalContext, "2 consecutive narrow waves; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ."); + assert.strictEqual(outputs[3], ""); + assert.strictEqual(JSON.parse(outputs[4]).hookSpecificOutput.additionalContext, "4 consecutive narrow waves; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ."); + const state = readState(sandbox, "session-1"); + assert.strictEqual(state.consecutiveNarrowWaves, 4); + assert.strictEqual(state.lastAdvisedNarrowWaveStreak, 4); +}); + test("an Agent PreToolUse attempt does not reset the write window before dispatch succeeds", (t) => { const sandbox = makeSandbox(t); for (let index = 0; index < 5; index += 1) { @@ -402,6 +570,7 @@ test("an Agent PreToolUse attempt does not reset the write window before dispatc run(sandbox, attempted); assert.deepStrictEqual(readState(sandbox, "session-1").dispatches, {}); + assert.strictEqual(readState(sandbox, "session-1").dispatchLog[0].phase, "launched"); const denied = run(sandbox, writePayload(sandbox)); assert.strictEqual(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, "deny"); }); @@ -417,12 +586,16 @@ test("Agent and Task dispatches append ledger entries with their computed lane", assert.deepStrictEqual(state.dispatchLog[0], { at: state.dispatchLog[0].at, lane: "grok", + phase: "confirmed", + toolUseId: state.dispatchLog[0].toolUseId, subagentType: "grok:grok-rescue", description: "inspect the failure" }); assert.deepStrictEqual(state.dispatchLog[1], { at: state.dispatchLog[1].at, lane: "codex", + phase: "confirmed", + toolUseId: state.dispatchLog[1].toolUseId, subagentType: "codex:codex-rescue", description: "implement the repair" }); @@ -634,6 +807,34 @@ test("legacy state without a period counter starts counting future writes after assert.deepStrictEqual(state.advisedMultiples, [1]); }); +test("legacy dispatch ledger entries normalize as confirmed launches", (t) => { + const sandbox = makeSandbox(t); + fs.mkdirSync(sandbox.stateDir, { recursive: true }); + fs.writeFileSync( + stateFileFor(sandbox, "session-1"), + JSON.stringify({ + writeCount: 0, + writesSinceDispatch: 0, + dispatches: { builtin: 2 }, + dispatchLog: [ + { at: "2026-07-21T00:00:00.000Z", lane: "builtin", description: "first legacy launch" }, + { at: "2026-07-21T00:03:00.000Z", lane: "builtin", description: "second legacy launch" } + ], + advisedMultiples: [], + createdAt: "2026-07-21T00:00:00.000Z", + updatedAt: "2026-07-21T00:03:00.000Z" + }), + "utf8" + ); + + const result = run(sandbox, writePayload(sandbox)); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stderr, ""); + const state = readState(sandbox, "session-1"); + assert.ok(state.dispatchLog.every((entry) => entry.phase === "confirmed")); + assert.ok(state.dispatchLog.every((entry) => !Object.hasOwn(entry, "toolUseId"))); +}); + test("a Skill invocation is ignored and does not touch the counters", (t) => { const sandbox = makeSandbox(t); for (let i = 0; i < 3; i += 1) { @@ -958,13 +1159,17 @@ 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 and Bash through the inline guard", () => { +test("hooks configuration wires PreToolUse write tools, Bash, Agent, and Task 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, [ { matcher: "^(Edit|Write|NotebookEdit|MultiEdit|Bash)$", command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/inline-delegation-guard.mjs"' + }, + { + matcher: "^(Agent|Task)$", + command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/inline-delegation-guard.mjs"' } ]); });