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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https://

## [Unreleased]

## [0.1.117] - 2026-11-11

### Changed

- **license: copyright holder is now Fusengine Sàrl** (`LICENSE`) — LICENSE copyright holder is now Fusengine Sàrl.

### Fixed

- **tracking: track lock fairness** (`src/tracking/track-lock-sync.ts`) — a process that saw contention steps aside 1-4 ms after release so waiters can acquire (mitigates the 'Motion budget lock busy' starvation seen on the Publish runner). Tests: `track-lock-fairness`.
- **snapshot: configurable git timeout** (`src/runtime/lifecycle/snapshot/git.ts`) — snapshot git timeout is configurable via `FUSE_SNAPSHOT_GIT_TIMEOUT_MS` (default 150 ms unchanged); tests no longer depend on runner latency for the Git section. Tests: `snapshot`.

## [0.1.116] - 2026-11-10

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 Fusengine
Copyright (c) 2026 Fusengine Sàrl

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
3 changes: 3 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@
- 2026-11-10 14:48 — Owner (directly): « on a tous finis il manque les commit cycle » → run the commit cycle on what remains uncommitted: this journal (docs-only) committed on a fresh docs branch from origin/main via fuse-ai-pilot:commit, pushed, PR, merged (merge, no squash); no version bump or tag for a docs-only change. MEMORY/LESSON.md, .kimi/, .kimi-code/ stay out of every commit.
- 2026-11-10 22:37 — Owner (relayed by the claude-plugins lead, typed there after seeing the prompt): « donc je doit accepté tous le temps » then « fais corrigé » → fix the false "[CONFIRM] Bash file write — Shell redirect to file detected" on a command whose only redirection is `2>&1` inside `( … 2>&1) || true` (harness 0.1.115). fd-duplication targets and /dev/null are not file writes → no prompt; a `>` inside quotes or a heredoc body is not a redirection; every real file write keeps today's verdict (code-file block stays a block, 0.1.115 scratchpad window unchanged), no relaxation elsewhere. Branch `fix/fd-dup-redirect-confirm` from main, tests first, sniper, challenger, WIP. Release 0.1.116 only on the owner's direct yes in this session (a relay is no release go).
- 2026-11-10 23:12 — Owner (directly, in this session): « Oui, publie 0.1.116 » → release the fd-dup fix (WIP b36af37) as 0.1.116: push branch, PR, CI green, merge (no squash), PATCH bump + CHANGELOG, tag v0.1.116, npm Publish watched to green; then tell the claude-plugins lead the version.
- 2026-11-11 00:17 — Owner (relayed by the claude-plugins lead): « met les licences sous harnes kimi codex claude cursor », legal holder « Fusengine Sàrl » → branch `chore/license-holder`: LICENSE `Copyright (c) 2026 Fusengine` → `Copyright (c) 2026 Fusengine Sàrl` (UTF-8, year unchanged; only copyright line in the repo outside node_modules); author fields and prose untouched. Same batch: stabilise the two Publish-job flaky tests (renderSnapshot `### Git`, Motion budget lock busy) without loosening any bound. WIP commit only; release only on the owner's direct yes in this session.
- 2026-11-11 — Owner (directly, in this session): « Oui, stabilise » → confirms stabilising the two Publish flakes in the chore/license-holder batch.
- 2026-11-11 — Owner (directly, in this session): « Oui, publie 0.1.117 » → release chore/license-holder (WIP 0bb24bb) as 0.1.117: PR, CI green, merge (no squash), PATCH bump + CHANGELOG, tag v0.1.117 as for v0.1.116, npm Publish watched to green (a flaky failure is rerun once and reported, never loosened); then tell the claude-plugins lead.
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.116",
"version": "0.1.117",
"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
8 changes: 7 additions & 1 deletion src/runtime/lifecycle/snapshot/git.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { execSync } from "node:child_process";
import { parseEnvInt } from "../../../config/env";

/** Env var overriding the per-git-call timeout (ms); tests pin it so a loaded runner cannot drop the section. */
export const GIT_TIMEOUT_ENV = "FUSE_SNAPSHOT_GIT_TIMEOUT_MS";
/** Default per-git-call timeout: the SessionStart hook must stay snappy, a slow git omits the section. */
const DEFAULT_GIT_TIMEOUT_MS = 150;

/**
* Run a git subcommand at `root` with a short timeout, returning trimmed stdout.
Expand All @@ -14,7 +20,7 @@ function git(root: string, args: string): string {
return execSync(`git ${args}`, {
cwd: root,
encoding: "utf8",
timeout: 150,
timeout: parseEnvInt(process.env[GIT_TIMEOUT_ENV], DEFAULT_GIT_TIMEOUT_MS),
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
Expand Down
22 changes: 22 additions & 0 deletions src/tracking/track-lock-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ export function withTrackLockSync<T>(dir: string, fn: () => T): T | typeof LOCK_
}
}

/** How long after its own last failed attempt a process still counts the lock as contended. */
const CONTENDED_WINDOW_MS = 100;
/** Upper bound (4 ms) of the post-release step-aside: of the same order as the 1-8 ms waiter backoff, so at least some waiter polls inside it. */
const STEP_ASIDE_MAX_MS = 4;
/** Per-process: lockfile path -> `performance.now()` of this process's last failed attempt on it. */
const lastContended = new Map<string, number>();

/**
* Fairness: a bare `wx` lock lets the releasing process re-take a free lock
* microseconds later, before any sleeping waiter polls, so under N writers one
* can starve past its budget. A process that saw contention on `lock` recently
* steps aside 1..{@link STEP_ASIDE_MAX_MS} ms after releasing it; uncontended
* callers never pay it.
*/
function stepAsideIfContended(lock: string): void {
const at = lastContended.get(lock);
if (at === undefined || performance.now() - at > CONTENDED_WINDOW_MS) return;
sleepSync(1 + Math.floor(Math.random() * STEP_ASIDE_MAX_MS));
}

/**
* Blocking twin of {@link withTrackLockSync}: waits out an in-flight
* compaction (same `track.lock`) so an append never races its
Expand Down Expand Up @@ -84,6 +104,7 @@ export function withTrackLockSyncBlocking<T>(
break;
} catch {
if (isStale(lock)) { try { unlinkSync(lock); } catch { /* raced */ } }
lastContended.set(lock, performance.now());
const left = deadline - performance.now();
if (left <= 0) return LOCK_FAILED;
const step = Math.min(1 << Math.min(attempt, 3), 8); // 1,2,4,8,8… ms
Expand All @@ -94,6 +115,7 @@ export function withTrackLockSyncBlocking<T>(
return fn();
} finally {
try { unlinkSync(lock); } catch { /* best-effort release */ }
stepAsideIfContended(lock);
}
}

34 changes: 31 additions & 3 deletions test/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { test, expect } from "bun:test";
import { test, expect, beforeAll, afterAll } from "bun:test";
import { execSync } from "node:child_process";
import { tmpdir } from "node:os";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { collectGit } from "../src/runtime/lifecycle/snapshot/git";
import { collectGit, GIT_TIMEOUT_ENV } from "../src/runtime/lifecycle/snapshot/git";
import { collectVersion } from "../src/runtime/lifecycle/snapshot/version";
import { collectBoard } from "../src/runtime/lifecycle/snapshot/board";
import { renderSections, attachSnapshot } from "../src/runtime/lifecycle/snapshot/format";
import { renderSnapshot, withSnapshot } from "../src/runtime/lifecycle/snapshot/index";

const tmp = (p: string): string => mkdtempSync(join(tmpdir(), p));

// These tests assert git CONTENT, not latency: the production 150 ms per-git-call
// timeout silently omits the section on a loaded runner (Publish flake), so pin a
// generous one here. The default stays untouched in production code.
let prevGitTimeout: string | undefined;
beforeAll(() => { prevGitTimeout = process.env[GIT_TIMEOUT_ENV]; process.env[GIT_TIMEOUT_ENV] = "10000"; });
afterAll(() => { if (prevGitTimeout === undefined) delete process.env[GIT_TIMEOUT_ENV]; else process.env[GIT_TIMEOUT_ENV] = prevGitTimeout; });

/** A committed git repo with one staged + one untracked file. */
function repo(): string {
const dir = tmp("fh-snap-git-");
Expand All @@ -32,6 +39,27 @@ test("collectGit: branch, recent commit, WIP counts", () => {
expect(out).toContain("1 untracked");
});

test("fail-open: a git slower than the DEFAULT 150 ms budget omits the Git section and never throws", () => {
// execSync resolves the bare `git` through process.env.PATH (Node docs: child_process), so a
// fake `git` that sleeps 1 s is deterministic: it always exceeds the default budget.
const bin = tmp("fh-snap-fakegit-");
writeFileSync(join(bin, "git"), "#!/bin/sh\nexec sleep 1\n");
chmodSync(join(bin, "git"), 0o755);
const dir = repo(); // built with the real git, before PATH is swapped
const prevPath = process.env.PATH;
delete process.env[GIT_TIMEOUT_ENV]; // default 150 ms
process.env.PATH = `${bin}:${prevPath ?? ""}`;
try {
expect(collectGit(dir)).toBe("");
const out = renderSnapshot(dir, import.meta.url);
expect(out).not.toContain("### Git");
expect(out).toContain("### Version"); // the other sections still render
} finally {
process.env.PATH = prevPath;
process.env[GIT_TIMEOUT_ENV] = "10000";
}
});

test("collectGit: non-repo omits the section", () => {
expect(collectGit(tmp("fh-snap-nogit-"))).toBe("");
});
Expand Down
58 changes: 58 additions & 0 deletions test/track-lock-fairness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Fairness of the blocking track lock: 8 processes hammering one lock must all
* finish inside the (unchanged) 1000 ms budget. A bare `wx` lock lets the releaser
* re-take it microseconds later, starving sleeping waiters (Motion D5 Publish flake).
* The 5 ms hold models a slow-disk critical section: before the step-aside fix this
* shape produced LOCK_FAILED in every run; the real D5 hold is ~0.3 ms.
*/
import { expect, test } from "bun:test";
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

const WORKERS = 8, ITERATIONS = 40, HOLD_MS = 5, BUDGET_MS = 1000;

/** Spawn one worker that waits for the barrier, then takes the lock ITERATIONS times; resolves its LOCK_FAILED count. */
function worker(id: number, dir: string, home: string, barrier: string): Promise<number> {
const script = `import {withTrackLockSyncBlocking,LOCK_FAILED} from ${JSON.stringify(resolve("src/tracking/track-lock-sync.ts"))};
import {existsSync,writeFileSync} from 'node:fs';
const id=${id};
writeFileSync(${JSON.stringify(home)}+'/ready-'+id,'');
const deadline=Date.now()+8000;
while(!existsSync(${JSON.stringify(barrier)})) { if(Date.now()>deadline) process.exit(2); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,2); }
let fail=0;
for(let i=0;i<${ITERATIONS};i++) {
const r=withTrackLockSyncBlocking(${JSON.stringify(dir)},()=>{Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,${HOLD_MS});return 1;},${BUDGET_MS});
if(r===LOCK_FAILED) fail++;
}
console.log(fail);`;
return new Promise((resolveWorker, reject) => {
const child = spawn(process.execPath, ["--eval", script], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, HOME: home } });
const out: string[] = [], err: string[] = [];
child.stdout.on("data", (d: Buffer) => out.push(d.toString()));
child.stderr.on("data", (d: Buffer) => err.push(d.toString()));
const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("worker timed out")); }, 9000);
child.on("exit", (code) => {
clearTimeout(timer);
if (code !== 0) reject(new Error(`worker exited ${code}: ${err.join("")}`));
else resolveWorker(Number(out.join("").trim()));
});
});
}

test("track lock fairness: 8 concurrent writers, 5 ms hold, 0 LOCK_FAILED within the unchanged 1000 ms budget", async () => {
const home = realpathSync(mkdtempSync(join(tmpdir(), "fh-lockfair-home-")));
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fh-lockfair-dir-")));
const barrier = join(home, "start");
const runs = Array.from({ length: WORKERS }, (_, i) => worker(i, dir, home, barrier));
const deadline = Date.now() + 8000;
while (!Array.from({ length: WORKERS }, (_, i) => existsSync(join(home, `ready-${i}`))).every(Boolean)) {
if (Date.now() > deadline) throw new Error("workers not ready");
await new Promise<void>((r) => setTimeout(r, 5));
}
writeFileSync(barrier, "");
const fails = await Promise.all(runs);
expect(fails).toEqual(Array(WORKERS).fill(0));
expect(existsSync(join(dir, "track.lock"))).toBe(false); // every holder released
}, 10000);
Loading