diff --git a/CHANGELOG.md b/CHANGELOG.md index d015f3b..59e3a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/LICENSE b/LICENSE index 8326202..c4e7b8f 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index 852f12d..62af396 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/package.json b/package.json index eb0bb37..e33b3f5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/runtime/lifecycle/snapshot/git.ts b/src/runtime/lifecycle/snapshot/git.ts index cfbb7f6..defe2c8 100644 --- a/src/runtime/lifecycle/snapshot/git.ts +++ b/src/runtime/lifecycle/snapshot/git.ts @@ -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. @@ -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 { diff --git a/src/tracking/track-lock-sync.ts b/src/tracking/track-lock-sync.ts index dc008a0..3877ea1 100644 --- a/src/tracking/track-lock-sync.ts +++ b/src/tracking/track-lock-sync.ts @@ -54,6 +54,26 @@ export function withTrackLockSync(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(); + +/** + * 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 @@ -84,6 +104,7 @@ export function withTrackLockSyncBlocking( 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 @@ -94,6 +115,7 @@ export function withTrackLockSyncBlocking( return fn(); } finally { try { unlinkSync(lock); } catch { /* best-effort release */ } + stepAsideIfContended(lock); } } diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 2b906cf..c107536 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1,9 +1,9 @@ -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"; @@ -11,6 +11,13 @@ import { renderSnapshot, withSnapshot } from "../src/runtime/lifecycle/snapshot/ 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-"); @@ -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(""); }); diff --git a/test/track-lock-fairness.test.ts b/test/track-lock-fairness.test.ts new file mode 100644 index 0000000..cc5a3f2 --- /dev/null +++ b/test/track-lock-fairness.test.ts @@ -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 { + 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((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);