diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a2ac2f1..dc98f61 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.33", + "version": "0.0.34", "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.33", + "version": "0.0.34", "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.33", + "version": "0.0.34", "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.33", + "version": "0.0.34", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d166e..7b1f482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # changelog +## 0.0.34 + +- the settle-only advisory stops repeating itself: when settlement is legitimately deferred (a package's verification waiting on a sibling landing), the pending-verdict block re-emitted identically at every stop boundary with no new information; it now dedupes by a persisted pending-set signature, the same mechanism the in-flight advisory has used since 0.0.31, emitting once per distinct set and again only when a record enters or leaves; blocking collection demands are unaffected and refresh the signature when they emit combined + ## 0.0.33 - notification collection reads the transcript the notification names: every peer wrapper record in a live traffic window had a null `transcriptPath`, so the completed path extracted nothing, mislabeled clean consult wrappers (nine to eleven turns of a sixty budget, backing peer jobs all clean) as `turn_limit` incomplete, demanded manual raw transcript reads, and skipped peer footer seeding; the reconciler now stamps the record's `transcriptPath` from the notification's own output file element before transition, and a no-text completion classifies `missing_final_text` unless real turn evidence supports `turn_limit` diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 8a23c8f..cc478a2 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.33", + "version": "0.0.34", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/.claude-plugin/plugin.json b/plugins/fusion/.claude-plugin/plugin.json index 3fece2d..e859b1e 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.33", + "version": "0.0.34", "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/scripts/worker-lifecycle.mjs b/plugins/fusion/scripts/worker-lifecycle.mjs index 5bd3bfa..ec4bf15 100644 --- a/plugins/fusion/scripts/worker-lifecycle.mjs +++ b/plugins/fusion/scripts/worker-lifecycle.mjs @@ -1486,6 +1486,24 @@ function clearInFlightAdvisory(sessionId, env) { }); } +function shouldWriteSettleOnlyAdvisory(sessionId, signature, env) { + return readWorkerSessionState(sessionId, env)?.settleOnlyAdvisorySignature !== signature; +} + +function recordSettleOnlyAdvisory(sessionId, signature, env) { + updateWorkerSessionState(sessionId, env, (current) => ({ ...(current ?? {}), settleOnlyAdvisorySignature: signature })); +} + +function clearSettleOnlyAdvisory(sessionId, env) { + updateWorkerSessionState(sessionId, env, (current) => { + if (!current || !("settleOnlyAdvisorySignature" in current)) { + return null; + } + const { settleOnlyAdvisorySignature, ...next } = current; + return next; + }); +} + function handleStop(input, env) { const tasks = Array.isArray(input.background_tasks) ? input.background_tasks : []; reconcileTaskNotifications(input, env); @@ -1538,8 +1556,17 @@ function handleStop(input, env) { } const terminalUncollected = currentRecords.filter(terminalCollectionPending); const settleOnly = settleOnlyRecords(currentRecords); + const settleOnlySignature = settleOnly.length > 0 ? inFlightSignature(settleOnly) : null; + if (!settleOnlySignature) { + clearSettleOnlyAdvisory(input.session_id, env); + } if (terminalUncollected.length + settleOnly.length > 1) { - writeCombinedStopPending(terminalUncollected, settleOnly); + if (terminalUncollected.length > 0 || shouldWriteSettleOnlyAdvisory(input.session_id, settleOnlySignature, env)) { + writeCombinedStopPending(terminalUncollected, settleOnly); + if (settleOnlySignature) { + recordSettleOnlyAdvisory(input.session_id, settleOnlySignature, env); + } + } return; } if (terminalUncollected.length > 0) { diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 39b84cf..2e1cc8d 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.33", + "version": "0.0.34", "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", "author": { "name": "Harry Yep" diff --git a/tests/fusion-worker-lifecycle.test.mjs b/tests/fusion-worker-lifecycle.test.mjs index d98a711..fb42430 100644 --- a/tests/fusion-worker-lifecycle.test.mjs +++ b/tests/fusion-worker-lifecycle.test.mjs @@ -62,6 +62,28 @@ function record(box) { return records[0]; } +function createSettleOnlyRecord(box, taskId) { + const worker = createWorkerRecord({ + taskId, + sessionId: "session-1", + agentType: "fusion:fast-worker", + workspaceRoot: box.cwd + }, envFor(box)); + const collectedAt = new Date().toISOString(); + return updateWorkerRecord(worker.taskId, envFor(box), (current) => ({ ...current, transportStatus: "done", collectedAt, collectionMethod: WORKER_COLLECTION_METHODS.SUBAGENT_STOP, awaitingVerdict: true, awaitingVerdictArmedAt: collectedAt })); +} + +function stop(box) { + return run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: [] + }); +} + test("legacy collection methods normalize when worker records load", (t) => { const box = sandbox(t); const worker = createWorkerRecord({ @@ -3348,6 +3370,86 @@ test("Stop combines multi-record collection and settlement instructions", (t) => assert.match(decision.reason, new RegExp(`settle-only: ${settling.taskId}: /fusion:stats --record ${settling.taskId}=accepted\\|rejected\\|unverified`)); }); +test("Stop emits a settle-only advisory once for an unchanged pending set", (t) => { + const box = sandbox(t); + const first = createSettleOnlyRecord(box, `fusion-${"1".repeat(24)}`); + const second = createSettleOnlyRecord(box, `fusion-${"2".repeat(24)}`); + + const emitted = stop(box); + assert.match(emitted.stdout, new RegExp(`settle-only: ${first.taskId}`)); + assert.match(emitted.stdout, new RegExp(`settle-only: ${second.taskId}`)); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature, [first.taskId, second.taskId].sort().join(",")); + + const repeated = stop(box); + assert.strictEqual(repeated.status, 0); + assert.strictEqual(repeated.stdout, ""); +}); + +test("Stop re-emits a settle-only advisory when a record enters the pending set", (t) => { + const box = sandbox(t); + const first = createSettleOnlyRecord(box, `fusion-${"1".repeat(24)}`); + const second = createSettleOnlyRecord(box, `fusion-${"2".repeat(24)}`); + stop(box); + const added = createSettleOnlyRecord(box, `fusion-${"3".repeat(24)}`); + + const emitted = stop(box); + assert.match(emitted.stdout, new RegExp(`settle-only: ${first.taskId}`)); + assert.match(emitted.stdout, new RegExp(`settle-only: ${second.taskId}`)); + assert.match(emitted.stdout, new RegExp(`settle-only: ${added.taskId}`)); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature, [first.taskId, second.taskId, added.taskId].sort().join(",")); +}); + +test("Stop re-emits a settle-only advisory when a record settles", (t) => { + const box = sandbox(t); + const settled = createSettleOnlyRecord(box, `fusion-${"1".repeat(24)}`); + const firstRemaining = createSettleOnlyRecord(box, `fusion-${"2".repeat(24)}`); + const secondRemaining = createSettleOnlyRecord(box, `fusion-${"3".repeat(24)}`); + stop(box); + recordWorkerAcceptance({ taskId: settled.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + + const emitted = stop(box); + assert.doesNotMatch(emitted.stdout, new RegExp(`settle-only: ${settled.taskId}`)); + assert.match(emitted.stdout, new RegExp(`settle-only: ${firstRemaining.taskId}`)); + assert.match(emitted.stdout, new RegExp(`settle-only: ${secondRemaining.taskId}`)); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature, [firstRemaining.taskId, secondRemaining.taskId].sort().join(",")); +}); + +test("Stop combines a blocking collection demand with an unchanged settle-only advisory", (t) => { + const box = sandbox(t); + const first = createSettleOnlyRecord(box, `fusion-${"1".repeat(24)}`); + const second = createSettleOnlyRecord(box, `fusion-${"2".repeat(24)}`); + stop(box); + const collecting = createWorkerRecord({ + taskId: `fusion-${"3".repeat(24)}`, + sessionId: "session-1", + agentType: "codex:codex-rescue", + workspaceRoot: box.cwd + }, envFor(box)); + updateWorkerRecord(collecting.taskId, envFor(box), (current) => ({ ...current, agentId: "collecting-peer", backgroundTaskId: "collecting-peer", transportStatus: "ready_uncollected", runtimeAsync: true })); + + const emitted = stop(box); + const decision = JSON.parse(emitted.stdout); + assert.strictEqual(decision.decision, "block"); + assert.match(decision.reason, /Call TaskOutput with block=true for terminal owned task collecting-peer/); + assert.match(decision.reason, new RegExp(`settle-only: ${first.taskId}`)); + assert.match(decision.reason, new RegExp(`settle-only: ${second.taskId}`)); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature, [first.taskId, second.taskId].sort().join(",")); +}); + +test("Stop clears the settle-only advisory signature when no pending records remain", (t) => { + const box = sandbox(t); + const first = createSettleOnlyRecord(box, `fusion-${"1".repeat(24)}`); + const second = createSettleOnlyRecord(box, `fusion-${"2".repeat(24)}`); + stop(box); + assert.ok(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature); + recordWorkerAcceptance({ taskId: first.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + recordWorkerAcceptance({ taskId: second.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + + const quiet = stop(box); + assert.strictEqual(quiet.stdout, ""); + assert.strictEqual(Object.hasOwn(readWorkerSessionState("session-1", envFor(box)), "settleOnlyAdvisorySignature"), 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");