Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/motion/pre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
90 changes: 90 additions & 0 deletions test/motion-dup-pre.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, now: number = T0): Promise<string> {
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<string, unknown>, now: number = T0): Promise<string> {
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();
});
Loading