From cd2caad407220d445707a6c16af99f302aa73a06 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Sun, 11 Oct 2026 17:30:14 +0200 Subject: [PATCH 1/2] fix(motion): a PreToolUse delivered twice with the same tool_use_id is idempotent --- src/runtime/motion/pre.ts | 16 +++++++ test/motion-dup-pre.test.ts | 90 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 test/motion-dup-pre.test.ts diff --git a/src/runtime/motion/pre.ts b/src/runtime/motion/pre.ts index b63371e..61c8cbd 100644 --- a/src/runtime/motion/pre.ts +++ b/src/runtime/motion/pre.ts @@ -145,6 +145,22 @@ export function motionPre(id: string, call: MotionCall, opts: HandleOptions, res pruned = true; } if (pruned) refreshMotionState(state, home); + // Idempotent re-delivery: a host with native ids may deliver the SAME + // PreToolUse twice (same tool_use_id, e.g. a retry of the same tool call). + // When that id already owns a FRESH reservation and its receipt matches + // session, stage and command, the second pass replays the first verdict + // (the same "Motion render" note) without a new reservation — budget and + // state stay byte-identical. Any mismatch (other command/session/stage) or + // an id present only in `expired` falls through to the existing refusals; a + // `noid:` id derives from session+command, so a repeat is indistinguishable + // from a real second call and stays refused below. + const toolId = event.toolUseId; + const receipt = toolId ? state.renders[toolId] : undefined; + if (toolId && !toolId.startsWith("noid:") && Object.hasOwn(live, toolId) + && receipt?.session === event.sessionId && receipt.stage === stage && receipt.command === command) { + const replay: Prompt = { kind: "inform", title: "Motion render", reason: budgetLine(loadBudget(project.root, home)) }; + return respond(id, replay, respondAs); + } if (Object.entries(state.renders).some(([id, entry]) => entry.stage === stage && Object.hasOwn(live, id))) { return respond(id, { kind: "block", title: "Motion concurrent render", reason: "A render of this protected stage is still active (includes in-flight reservations); concurrent writers to the same outputs cannot be safely attributed." }, respondAs); } diff --git a/test/motion-dup-pre.test.ts b/test/motion-dup-pre.test.ts new file mode 100644 index 0000000..60614ed --- /dev/null +++ b/test/motion-dup-pre.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { handleHook } from "../src/runtime/handle"; +import { loadBudget } from "../src/policy/motion/budget"; +import { budgetPath } from "../src/policy/motion/budget-store"; +import { rootKey } from "../src/policy/motion/hash"; +import { motionHome } from "../src/policy/motion/store"; +import { canonicalRoot } from "../src/runtime/prd/prd-canon"; +import { bash, codeOf, isDeny, makeFixture, prompt } from "./motion-handle-fixture"; +import type { MotionFixture } from "./motion-handle-fixture"; +import { denied, shell } from "./motion-hosts-fixture"; + +const STILLS = "bash render.sh --stage stills"; +const STILLS_ALT = "bash render.sh"; +const DRAFT = "bash render.sh --stage draft"; +const T0 = 1_000_000; + +/** Run one claude-code hook payload with an injected clock and home. */ +async function runAt(fx: MotionFixture, payload: Record, now: number = T0): Promise { + return (await handleHook("claude-code", payload, { now, cwd: fx.proj, home: fx.home, scope: "motion" })).stdout; +} + +/** Run one gemini-cli hook payload (no native tool_use_id -> `noid:` render id) with an injected clock and home. */ +async function runGemini(fx: MotionFixture, payload: Record, now: number = T0): Promise { + return (await handleHook("gemini-cli", payload, { now, cwd: fx.proj, home: fx.home, scope: "motion" })).stdout; +} + +/** Harness-owned on-disk state of the fixture project (budget + motion state registry). */ +function diskState(fx: MotionFixture): { budget: string; state: string } { + const root = canonicalRoot(fx.proj); + return { + budget: readFileSync(budgetPath(root, fx.home), "utf8"), + state: readFileSync(join(motionHome(fx.home), "state", `${rootKey(root)}.json`), "utf8"), + }; +} + +test("a duplicated PreToolUse with the same native tool_use_id is idempotent", async () => { + const fx = makeFixture(); + const pre = bash("s1", STILLS, { tool_use_id: "tu-dup" }); + rmSync(fx.draft); // no cold artifact: the one-time first-observation notice is not emitted + const first = await runAt(fx, pre); + expect(isDeny(first)).toBe(false); + const before = diskState(fx); + const second = await runAt(fx, pre); + expect(isDeny(second)).toBe(false); + expect(second).toBe(first); // the same "Motion render" note as the first pass + expect(diskState(fx)).toEqual(before); // budget + state unchanged, byte for byte + const inflight = Object.keys(loadBudget(canonicalRoot(fx.proj), fx.home).inflight); + expect(inflight).toHaveLength(1); // a single reservation, under the scoped id + expect(inflight[0]).toContain("tu-dup"); +}); + +test("another tool_use_id of the same stage is still refused while the reservation is fresh", async () => { + const fx = makeFixture(); + expect(isDeny(await runAt(fx, bash("s1", STILLS, { tool_use_id: "tu-a" })))).toBe(false); + const concurrent = await runAt(fx, bash("s1", STILLS, { tool_use_id: "tu-b" })); + expect(isDeny(concurrent)).toBe(true); + expect(concurrent).toContain("Motion concurrent render"); +}); + +test("the same tool_use_id with a different command, session or stage keeps the existing refusal", async () => { + const fx = makeFixture(); + expect(isDeny(await runAt(fx, bash("s1", STILLS, { tool_use_id: "tu-a" })))).toBe(false); + expect(isDeny(await runAt(fx, bash("s1", STILLS_ALT, { tool_use_id: "tu-a" })))).toBe(true); // other command + expect(isDeny(await runAt(fx, bash("s2", STILLS, { tool_use_id: "tu-a" })))).toBe(true); // other session + const gate = await runAt(fx, bash("s1", DRAFT, { tool_use_id: "tu-gate" })); // approve stills so draft passes G1 + expect(isDeny(gate)).toBe(true); + expect(await runAt(fx, prompt("s1", `MOTION-APPROVE stills ${codeOf(gate)}`))).toContain("approved"); + expect(isDeny(await runAt(fx, bash("s1", DRAFT, { tool_use_id: "tu-a" })))).toBe(true); // other stage +}); + +test("a host without native ids (gemini-cli) still refuses the second identical pre", async () => { + const fx = makeFixture(); + const pre = shell("gemini-cli", "g1", STILLS, fx.proj); + expect(denied("gemini-cli", await runGemini(fx, pre))).toBe(false); + expect(denied("gemini-cli", await runGemini(fx, pre))).toBe(true); +}); + +test("PostToolUse after a duplicated pre counts the render exactly once", async () => { + const fx = makeFixture(); + const pre = bash("s1", STILLS, { tool_use_id: "tu-dup" }); + expect(isDeny(await runAt(fx, pre))).toBe(false); + expect(isDeny(await runAt(fx, pre))).toBe(false); + const post = await runAt(fx, { ...pre, hook_event_name: "PostToolUse" }); + expect(post).toContain("renders stills 1"); + const budget = loadBudget(canonicalRoot(fx.proj), fx.home); + expect(budget.renders.stills).toBe(1); + expect(budget.inflight["tu-dup"]).toBeUndefined(); +}); From f57423f033c1e191fbd41be2172040f2dc781b57 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Sun, 11 Oct 2026 17:30:14 +0200 Subject: [PATCH 2/2] chore: bump version and CHANGELOG to 0.1.105 --- CHANGELOG.md | 6 ++++++ docs/motion.md | 7 ++++++- package.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce631c..7fec7d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.105] - 2026-10-11 + +### Fixed + +- **motion: a PreToolUse delivered twice with the same tool_use_id is idempotent instead of blocking itself** (`src/runtime/motion/pre.ts`) — Claude Code may deliver the SAME PreToolUse Bash twice (same session, same `tool_use_id`, same command): the first pass reserved the render and answered with the "Motion render" note, and the second pass was denied by its own reservation ("Motion concurrent render"), each retry opening a new reservation. On a host with a native id (not `noid:`), a second pass whose id already owns a FRESH in-flight reservation AND whose receipt matches session, stage and command now replays the first verdict — the same "Motion render" budget note — with no new reservation and budget and state byte-identical. Unchanged: another `tool_use_id` of the same stage is still denied while a reservation is fresh; the same id with a different command, session or stage keeps the existing refusals; `noid:` ids (derived from session + command, a real second call is indistinguishable) and ids present only in `expired` stay refused; approvals G1/G2, the master cap, quarantine and the TTL are untouched, and a corrupt budget stays fail-closed. Test: `motion-dup-pre`. + ## [0.1.104] - 2026-10-11 ### Fixed diff --git a/docs/motion.md b/docs/motion.md index 79a08e4..d517c26 100644 --- a/docs/motion.md +++ b/docs/motion.md @@ -180,7 +180,12 @@ writes); a failed tool call releases the reservation without counting. Correlation uses each host's native call id (`tool_use_id`, Kimi `tool_call_id`), scoped by session; hosts without one (gemini-cli, cline, hermes, cursor without id) use a single-flight identity derived from session, -working directory and command. A corrupt budget file is never repaired: renders +working directory and command. A host with a native id may deliver the same +PreToolUse twice (same `tool_use_id`): while the reservation is fresh and the +recorded receipt matches session, stage and command, the second delivery is +idempotent — no new reservation, budget and state byte-identical, same "Motion +render" note; any mismatch (or a `noid:` id) keeps the existing refusals. +A corrupt budget file is never repaired: renders stay denied until the owner fixes it. There is no wall time cap. `FUSE_MOTION_MAX_MASTER_RENDERS` sets the master-render count cap: a positive integer denies further masters once reached; unset, empty, diff --git a/package.json b/package.json index 39a44a8..baf9130 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.104", + "version": "0.1.105", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts",