diff --git a/Justfile b/Justfile index 1037a50d..e308ec0e 100644 --- a/Justfile +++ b/Justfile @@ -539,6 +539,12 @@ coord-claims: coord-claim task: @bash -c 'source ~/.config/coord-tui/coord-hooks.sh 2>/dev/null && coord-claim "{{task}}" || echo "Hooks not installed — run: just coord-hooks"' +# Claim a task AND provision an isolated git worktree for it. +# Creates ../-worktrees/ on branch agent//. +# Usage: just coord-worktree refactor/dispatcher-rewrite +coord-worktree task: + @bash -c 'source ~/.config/coord-tui/coord-hooks.sh 2>/dev/null && coord-worktree "{{task}}" || echo "Hooks not installed — run: just coord-hooks"' + # Set your peer status message (visible to all in the TUI) # Usage: just coord-status "working on rebalancer strategy B" coord-status status: diff --git a/README.adoc b/README.adoc index 889c5305..6c319008 100644 --- a/README.adoc +++ b/README.adoc @@ -60,6 +60,49 @@ Highlights: Formally verified core in Idris2 (`cartridges/local-coord-mcp/abi/LocalCoord/`); Zig FFI; Deno/Node MCP bridge with input hardening (rate limiting, prompt-injection detection with unicode-normalisation, error sanitisation). +=== Parallel agents and git + +"Claim tasks without collision" is a *task-level* guarantee, not a +git-level one. `coord_claim` ensures two peers never own the same +task-id at the same time; it does not lock files, branches, or the +working tree. If two journeymen claim *different* tasks that happen to +touch the same file, vanilla git merge conflicts can still occur. + +The supported pattern for parallel work is: + +* *Branch-per-claim + per-peer worktree.* `just coord-worktree + ` claims the task and provisions an isolated + `git worktree` at `../-worktrees/` on branch + `agent//`, so two journeymen on the same checkout + never share a working tree. The recipe is a thin wrapper over + `coord-tui`'s shell helper of the same name — both refuse to + provision when the claim is refused by the backend. +* *Advisory path-claims.* `coord_claim_task` accepts an optional + `paths` array declaring the working-tree files the claim expects to + touch. The bridge keeps an in-memory map of active path-claims and + annotates the response with `path_overlap` warnings (segment-aware + prefix match) when another active claim covers any of those paths. + *Advisory by design*: warnings never block the claim — the Idris2- + verified backend remains the source of truth for task ownership, and + this layer is the early-warning signal that lets the holder split + the task, hand off, or accept the merge cost knowingly. +* *Master-gated integration.* `coord_approve` is the serialisation + point: the master peer reviews, rebases or asks the journeyman to + rebase, and merges in a defined order. Two approved branches that + conflict are resolved at this step, not in the cartridge. +* *Drift signal, not lock.* `coord_scan_suggestions` emits `drift` warn + envelopes when affinities or confidence diverge — that's an *advisory* + signal to re-route or split a task, not a hard lock against file + overlap. + +What `local-coord-mcp` *does not* do today: hard file-range locks, +automatic rebase, or conflict resolution. The path-overlap layer is a +hint, not a mutex — two journeymen can still both proceed against +overlapping files and conflict at merge. Those final steps stay with +the master peer (or human integrator), in line with the supervision +model. If you need stricter isolation than path-claims + worktrees, +partition tasks by directory before issuing them. + === coord-tui — human interface for local-coord-mcp `coord-tui` is the companion terminal UI for `local-coord-mcp`. It lives diff --git a/README.md b/README.md index 75cdce86..06efa392 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,19 @@ Highlights: Formally verified core in Idris2 (`cartridges/local-coord-mcp/abi/LocalCoord/`); Zig FFI; Deno/Node MCP bridge with input hardening (rate limiting, prompt-injection detection with unicode-normalisation, error sanitisation). +### Parallel agents and git + +"Claim tasks without collision" is a **task-level** guarantee, not a git-level one. `coord_claim` ensures two peers never own the same task-id at the same time; it does not lock files, branches, or the working tree. If two journeymen claim *different* tasks that happen to touch the same file, vanilla git merge conflicts can still occur. + +The supported pattern for parallel work is: + +- **Branch-per-claim + per-peer worktree.** `just coord-worktree ` claims the task and provisions an isolated `git worktree` at `../-worktrees/` on branch `agent//`, so two journeymen on the same checkout never share a working tree. The recipe is a thin wrapper over `coord-tui`'s shell helper of the same name — both refuse to provision when the claim is refused by the backend. +- **Advisory path-claims.** `coord_claim_task` accepts an optional `paths` array declaring the working-tree files the claim expects to touch. The bridge keeps an in-memory map of active path-claims and annotates the response with `path_overlap` warnings (segment-aware prefix match) when another active claim covers any of those paths. **Advisory by design**: warnings never block the claim — the Idris2-verified backend remains the source of truth for task ownership, and this layer is the early-warning signal that lets the holder split the task, hand off, or accept the merge cost knowingly. +- **Master-gated integration.** `coord_approve` is the serialisation point: the master peer reviews, rebases or asks the journeyman to rebase, and merges in a defined order. Two approved branches that conflict are resolved at this step, not in the cartridge. +- **Drift signal, not lock.** `coord_scan_suggestions` emits `drift` warn envelopes when affinities or confidence diverge — that's an *advisory* signal to re-route or split a task, not a hard lock against file overlap. + +What `local-coord-mcp` **does not** do today: hard file-range locks, automatic rebase, or conflict resolution. The path-overlap layer is a hint, not a mutex — two journeymen can still both proceed against overlapping files and conflict at merge. Those final steps stay with the master peer (or human integrator), in line with the supervision model. If you need stricter isolation than path-claims + worktrees, partition tasks by directory before issuing them. + ## Glama AAA posture This server targets Glama's AAA tier. Posture: diff --git a/cartridges/local-coord-mcp/cartridge.json b/cartridges/local-coord-mcp/cartridge.json index 5b7e09dc..001a10cf 100644 --- a/cartridges/local-coord-mcp/cartridge.json +++ b/cartridges/local-coord-mcp/cartridge.json @@ -161,7 +161,7 @@ "name": "coord_receive" }, { - "description": "Attempt to claim a task (mutex-style). If the task is unclaimed, this peer becomes the holder. If another peer holds it, the claim is denied. Idempotent if already held by caller. Task #15: optional confidence, dispatch_preference (deliberate/broadcast/auto), task_difficulty (trivial/routine/challenging/novel) — default policy broadcasts trivial+routine, deliberates on challenging+novel. Claim rejection triggers a per-client_kind rate-limit: 5 rejections / 10 min => 30s cooldown before the next attempt.", + "description": "Attempt to claim a task (mutex-style). If the task is unclaimed, this peer becomes the holder. If another peer holds it, the claim is denied. Idempotent if already held by caller. Task #15: optional confidence, dispatch_preference (deliberate/broadcast/auto), task_difficulty (trivial/routine/challenging/novel) — default policy broadcasts trivial+routine, deliberates on challenging+novel. Claim rejection triggers a per-client_kind rate-limit: 5 rejections / 10 min => 30s cooldown before the next attempt. Optional `paths` declares working-tree files this claim expects to touch; the bridge layer returns advisory `path_overlap` warnings when overlap is detected with other active claims. Bridge-only — backend ignores the field.", "inputSchema": { "properties": { "confidence": { @@ -179,6 +179,16 @@ ], "type": "string" }, + "paths": { + "description": "Optional advisory list of working-tree paths this claim expects to touch. Bridge-layer hint only — not enforced by the backend.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, "task": { "description": "Task identifier to claim (e.g. 'audit-boj-server')", "type": "string" diff --git a/coord-tui/shell/coord-hooks.sh b/coord-tui/shell/coord-hooks.sh index 700897f1..a1a70383 100644 --- a/coord-tui/shell/coord-hooks.sh +++ b/coord-tui/shell/coord-hooks.sh @@ -12,6 +12,7 @@ # - coord-peers — list all active peers (no TUI needed) # - coord-claims — list all active task claims # - coord-claim — claim a task from the command line +# - coord-worktree — claim a task + provision an isolated git worktree # - coord-status — set your status from the command line # - coord-whoami — print your current peer ID @@ -118,6 +119,74 @@ else: " 2>/dev/null || echo " ✗ Failed (adapter not running?)" } +# Claim a task AND provision an isolated git worktree for it. +# +# coord-worktree refactor/dispatcher-rewrite +# +# Creates ../-worktrees/ on a branch named +# agent//. The current directory must be a git +# repository — the worktree is created as a sibling directory so the +# main checkout is untouched. If the branch already exists it is reused +# (idempotent for resuming a claim). +coord-worktree() { + local task="${1:?Usage: coord-worktree }" + _coord_env + local peer="${BOJ_COORD_PEER_ID:-}" + if [ -z "$peer" ]; then + echo " ✗ Not registered. Run: coord-tui --id --kind claude" >&2 + return 1 + fi + if ! git rev-parse --show-toplevel >/dev/null 2>&1; then + echo " ✗ Not inside a git repository." >&2 + return 1 + fi + local toplevel; toplevel="$(git rev-parse --show-toplevel)" + local reponame; reponame="$(basename "$toplevel")" + # Sanitise task for use in path/branch: keep alnum/_-/, collapse rest. + local safe; safe="$(printf '%s' "$task" | tr -c 'A-Za-z0-9._/-' '-' \ + | sed 's|/$||;s|^/||')" + local wt_dir="${toplevel}/../${reponame}-worktrees/${safe}" + local branch="agent/${peer}/${safe}" + + # Claim first — if the backend says no, don't touch the working tree. + local tok; tok="$(_coord_token)" + if [ -n "$tok" ]; then + local claim + claim=$(_coord_post coord_claim_task \ + "{\"token\":\"$tok\",\"task\":\"$task\"}" 2>/dev/null) + local ok + ok=$(echo "$claim" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print('yes' if d.get('success') else 'no') +except Exception: + print('no') +" 2>/dev/null) + if [ "$ok" != "yes" ]; then + echo " ✗ Claim refused — not provisioning worktree." >&2 + echo "$claim" >&2 + return 1 + fi + else + echo " ! No coord token — provisioning worktree without claim." >&2 + fi + + mkdir -p "$(dirname "$wt_dir")" + if [ -d "$wt_dir" ]; then + echo " → Worktree already exists: $wt_dir" + elif git -C "$toplevel" show-ref --verify --quiet "refs/heads/${branch}"; then + git -C "$toplevel" worktree add "$wt_dir" "$branch" >/dev/null \ + && echo " ✓ Worktree (existing branch): $wt_dir" \ + || { echo " ✗ git worktree add failed" >&2; return 1; } + else + git -C "$toplevel" worktree add -b "$branch" "$wt_dir" >/dev/null \ + && echo " ✓ Worktree + new branch ${branch}: $wt_dir" \ + || { echo " ✗ git worktree add failed" >&2; return 1; } + fi + echo " → cd $wt_dir" +} + # Set your status: coord-status "doing the thing" coord-status() { local status="${1:?Usage: coord-status }" diff --git a/mcp-bridge/lib/dispatcher.js b/mcp-bridge/lib/dispatcher.js index bd452cdf..741158a8 100644 --- a/mcp-bridge/lib/dispatcher.js +++ b/mcp-bridge/lib/dispatcher.js @@ -34,6 +34,7 @@ import { tryParseEnvelope, validateEnvelope, } from "./nickel-validator.js"; +import * as pathClaims from "./path-claims.js"; import { info, warn, error as logError, setLevel as setLogLevel } from "./logger.js"; import * as otel from "./otel.js"; @@ -218,17 +219,31 @@ async function dispatchLocalCoord(toolName, args) { } } } + + // Advisory path-claims are bridge-layer only — strip from the + // payload forwarded to the verified Zig backend so its schema stays + // unchanged. Backend stays the source of truth for ownership. + let declaredPaths; + let forwarded = args || {}; + if (toolName === "coord_claim_task" && args && Array.isArray(args.paths)) { + declaredPaths = args.paths; + const { paths, ...rest } = args; + forwarded = rest; + } + try { const res = await fetch(`${LOCAL_COORD_URL}/tools/${toolName}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(args || {}), + body: JSON.stringify(forwarded), }); + let data; try { - return await res.json(); + data = await res.json(); } catch { return { success: false, error: "local-coord-mcp backend returned non-JSON" }; } + return annotatePathClaims(toolName, args, data, declaredPaths); } catch (e) { return { success: false, @@ -238,6 +253,32 @@ async function dispatchLocalCoord(toolName, args) { } } +function annotatePathClaims(toolName, args, data, declaredPaths) { + if (!data || typeof data !== "object") return data; + const task = args?.task; + switch (toolName) { + case "coord_claim_task": { + if (!declaredPaths || !task || data.success === false) return data; + const holder = data.holder || "(unknown)"; + const ttl_s = typeof data.ttl_s === "number" ? data.ttl_s : undefined; + const { paths, overlaps } = pathClaims.register({ + task, holder, paths: declaredPaths, ttl_s, + }); + return { ...data, declared_paths: paths, path_overlap: overlaps }; + } + case "coord_progress": { + if (task) pathClaims.refresh(task, data.ttl_s); + return data; + } + case "coord_report_outcome": { + if (task) pathClaims.release(task); + return data; + } + default: + return data; + } +} + /** * Dispatch a parsed JSON-RPC message. Returns a response object or * null for notifications. Transport-agnostic — the caller is responsible diff --git a/mcp-bridge/lib/path-claims.js b/mcp-bridge/lib/path-claims.js new file mode 100644 index 00000000..a7a5769d --- /dev/null +++ b/mcp-bridge/lib/path-claims.js @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Advisory path-claims for local-coord-mcp. +// +// The Zig/Idris backend enforces a task-id mutex; this module layers an +// in-bridge advisory map of `task -> {holder, paths, expires_at}` so +// `coord_claim_task` can return `path_overlap` hints when two active +// claims declare overlapping working-tree paths. Advisory by design: +// the backend is the source of truth for ownership; this module never +// rejects a claim, it only annotates the response. + +const _claims = new Map(); // task -> { holder, paths, expires_at_ms } + +function normalize(p) { + if (typeof p !== "string") return null; + let s = p.trim(); + if (!s) return null; + s = s.replace(/\\/g, "/"); + while (s.includes("//")) s = s.replace(/\/\//g, "/"); + if (s.endsWith("/") && s.length > 1) s = s.slice(0, -1); + if (s.startsWith("./")) s = s.slice(2); + return s; +} + +function segments(p) { + return p.split("/").filter((s) => s !== "" && s !== "."); +} + +// Two paths overlap when one is a segment-prefix of the other (or equal). +// `src/a` overlaps `src/a/b` and `src/a`, but NOT `src/abc`. +export function pathsOverlap(a, b) { + const A = segments(a); + const B = segments(b); + const n = Math.min(A.length, B.length); + for (let i = 0; i < n; i++) if (A[i] !== B[i]) return false; + return true; +} + +function sweep(nowMs = Date.now()) { + for (const [task, entry] of _claims) { + if (entry.expires_at_ms && entry.expires_at_ms <= nowMs) _claims.delete(task); + } +} + +/** + * Register an advisory path-claim and return overlap warnings with + * other *active* claims (excluding the same task by the same holder). + * + * @param {object} args + * @param {string} args.task — task identifier (matches backend) + * @param {string} args.holder — peer-id from backend response (or "?") + * @param {string[]} args.paths — working-tree paths claimed + * @param {number} [args.ttl_s] — bridge-side TTL hint from backend + * @returns {{paths: string[], overlaps: Array<{task,holder,paths:string[],with:string[]}>}} + */ +export function register({ task, holder, paths, ttl_s }) { + sweep(); + const norm = Array.isArray(paths) + ? paths.map(normalize).filter(Boolean) + : []; + const overlaps = []; + for (const [otherTask, other] of _claims) { + if (otherTask === task && other.holder === holder) continue; + const hits = []; + for (const a of norm) { + for (const b of other.paths) { + if (pathsOverlap(a, b)) hits.push(a); + } + } + if (hits.length) { + overlaps.push({ + task: otherTask, + holder: other.holder, + paths: other.paths.slice(), + with: Array.from(new Set(hits)), + }); + } + } + const ttl = typeof ttl_s === "number" && ttl_s > 0 ? ttl_s : 300; + _claims.set(task, { + holder, + paths: norm, + expires_at_ms: Date.now() + ttl * 1000, + }); + return { paths: norm, overlaps }; +} + +/** Refresh the TTL for a task's path-claim (called by coord_progress). */ +export function refresh(task, ttl_s) { + const entry = _claims.get(task); + if (!entry) return false; + const ttl = typeof ttl_s === "number" && ttl_s > 0 ? ttl_s : 300; + entry.expires_at_ms = Date.now() + ttl * 1000; + return true; +} + +/** Release a task's path-claim (called by coord_report_outcome). */ +export function release(task) { + return _claims.delete(task); +} + +/** List active path-claims (for tests/observability). */ +export function list() { + sweep(); + return Array.from(_claims.entries()).map(([task, e]) => ({ + task, + holder: e.holder, + paths: e.paths.slice(), + expires_at_ms: e.expires_at_ms, + })); +} + +/** Test-only: wipe state. */ +export function _reset() { + _claims.clear(); +} diff --git a/mcp-bridge/lib/tools.js b/mcp-bridge/lib/tools.js index 40fe67eb..02e36197 100644 --- a/mcp-bridge/lib/tools.js +++ b/mcp-bridge/lib/tools.js @@ -685,7 +685,7 @@ function buildToolList(scope) { tools.push({ name: "coord_claim_task", - description: "Attempt mutex-style ownership of a named task. Side-effectful; sets this peer as the holder and applies a watchdog TTL (apprentice 30s / journeyman 5m / master none). Returns `{holder, ttl_s}` or `{error:\"already claimed\"}`. Task difficulty and confidence feed the routing engine. Essential for preventing duplicate work in multi-agent environments.", + description: "Attempt mutex-style ownership of a named task. Side-effectful; sets this peer as the holder and applies a watchdog TTL (apprentice 30s / journeyman 5m / master none). Returns `{holder, ttl_s}` or `{error:\"already claimed\"}`. Task difficulty and confidence feed the routing engine. Optional `paths` declares working-tree files this claim expects to touch; the bridge returns advisory `path_overlap` warnings when any other active claim declared overlapping paths (segment-aware prefix match). The backend remains the source of truth for ownership — path warnings never block a claim. Essential for preventing duplicate work in multi-agent environments.", inputSchema: { type: "object", properties: { @@ -694,6 +694,12 @@ function buildToolList(scope) { confidence: { type: "number", minimum: 0, maximum: 1, description: "Self-assessed fit 0.0-1.0. Feeds the overclaim detector (DD-28)." }, dispatch_preference: { type: "string", enum: ["deliberate", "broadcast", "auto"], description: "Routing hint (DD-30). `auto` derives from `task_difficulty`." }, task_difficulty: { type: "string", enum: ["trivial", "routine", "challenging", "novel"], description: "Difficulty label (DD-30)." }, + paths: { + type: "array", + description: "Optional advisory list of working-tree paths this claim expects to touch (e.g. `[\"src/foo\", \"docs/bar.adoc\"]`). Bridge-layer hint only — not enforced by the backend. Overlaps with other active claims surface as `path_overlap` in the response. Cleared on `coord_report_outcome` or when the bridge-side TTL expires.", + items: { type: "string", minLength: 1, maxLength: 256 }, + maxItems: 64, + }, }, required: ["token", "task"], additionalProperties: false, @@ -701,11 +707,25 @@ function buildToolList(scope) { annotations: { title: "Claim Coord Task", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, outputSchema: { type: "object", - description: "Claim result, or `{error:\"already claimed\"}` when another peer holds the task.", + description: "Claim result, or `{error:\"already claimed\"}` when another peer holds the task. When `paths` was supplied, the response also carries `declared_paths` (normalised) and `path_overlap` (advisory warnings only).", properties: { holder: { type: "string", description: "Peer ID now holding the task." }, ttl_s: { type: "number", description: "Watchdog TTL in seconds for this claim." }, error: { type: "string", description: "`\"already claimed\"` on contention." }, + declared_paths: { type: "array", items: { type: "string" }, description: "Echoed normalised paths from the input." }, + path_overlap: { + type: "array", + description: "Other active claims whose declared paths overlap this one. Advisory — claim is granted regardless.", + items: { + type: "object", + properties: { + task: { type: "string", description: "The other claim's task-id." }, + holder: { type: "string", description: "Peer-id of the other claim's holder." }, + paths: { type: "array", items: { type: "string" }, description: "The other claim's full declared path list." }, + with: { type: "array", items: { type: "string" }, description: "Paths from THIS claim that overlap the other." }, + }, + }, + }, }, additionalProperties: true, }, diff --git a/mcp-bridge/tests/path_claims_test.js b/mcp-bridge/tests/path_claims_test.js new file mode 100644 index 00000000..b57fa81a --- /dev/null +++ b/mcp-bridge/tests/path_claims_test.js @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Advisory path-claims — overlap detection, normalisation, TTL sweep. +// Run: node --test mcp-bridge/tests/path_claims_test.js + +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + register, + refresh, + release, + list, + pathsOverlap, + _reset, +} from "../lib/path-claims.js"; + +beforeEach(() => _reset()); + +test("segment-prefix overlap, not character prefix", () => { + assert.equal(pathsOverlap("src/a", "src/a/b"), true); + assert.equal(pathsOverlap("src/a/b", "src/a"), true); + assert.equal(pathsOverlap("src/a", "src/a"), true); + assert.equal(pathsOverlap("src/a", "src/abc"), false); + assert.equal(pathsOverlap("src/a", "lib/a"), false); +}); + +test("first claim sees no overlaps", () => { + const r = register({ task: "t1", holder: "peer-a", paths: ["src/foo"] }); + assert.deepEqual(r.overlaps, []); + assert.deepEqual(r.paths, ["src/foo"]); +}); + +test("overlapping second claim from a different peer surfaces a warning", () => { + register({ task: "t1", holder: "peer-a", paths: ["src/foo"] }); + const r = register({ + task: "t2", holder: "peer-b", paths: ["src/foo/bar.js", "docs/x.adoc"], + }); + assert.equal(r.overlaps.length, 1); + assert.equal(r.overlaps[0].task, "t1"); + assert.equal(r.overlaps[0].holder, "peer-a"); + assert.deepEqual(r.overlaps[0].with, ["src/foo/bar.js"]); +}); + +test("non-overlapping concurrent claims are silent", () => { + register({ task: "t1", holder: "peer-a", paths: ["src/foo"] }); + const r = register({ task: "t2", holder: "peer-b", paths: ["src/bar"] }); + assert.deepEqual(r.overlaps, []); +}); + +test("re-claim by same holder is not flagged as overlap", () => { + register({ task: "t1", holder: "peer-a", paths: ["src/foo"] }); + const r = register({ task: "t1", holder: "peer-a", paths: ["src/foo"] }); + assert.deepEqual(r.overlaps, []); +}); + +test("paths are normalised (trim, backslashes, trailing slash, ./)", () => { + const r = register({ + task: "t1", holder: "p", paths: [" src\\foo\\", "./docs/x.md", "//a//b/"], + }); + assert.deepEqual(r.paths, ["src/foo", "docs/x.md", "/a/b"]); +}); + +test("non-string / empty paths are dropped", () => { + const r = register({ + task: "t1", holder: "p", paths: ["src/a", "", null, 42, " "], + }); + assert.deepEqual(r.paths, ["src/a"]); +}); + +test("TTL sweep removes expired claims on next register", () => { + register({ task: "t1", holder: "peer-a", paths: ["src/foo"], ttl_s: 0.001 }); + const wait = new Promise((r) => setTimeout(r, 10)); + return wait.then(() => { + const r = register({ task: "t2", holder: "peer-b", paths: ["src/foo"] }); + assert.deepEqual(r.overlaps, [], "expired t1 should not overlap"); + assert.equal(list().find((c) => c.task === "t1"), undefined); + }); +}); + +test("refresh extends TTL for an existing claim", () => { + register({ task: "t1", holder: "peer-a", paths: ["src/foo"], ttl_s: 1 }); + assert.equal(refresh("t1", 600), true); + assert.equal(refresh("nonexistent", 600), false); +}); + +test("release removes the claim", () => { + register({ task: "t1", holder: "peer-a", paths: ["src/foo"] }); + assert.equal(release("t1"), true); + assert.equal(list().length, 0); + assert.equal(release("t1"), false); +}); + +test("multiple active claims overlap the new one — all reported", () => { + // t1 owns the umbrella `src/foo`, t2 owns `lib/qux`. A new t3 that + // touches a file under each must surface both as overlaps. + register({ task: "t1", holder: "peer-a", paths: ["src/foo"] }); + register({ task: "t2", holder: "peer-b", paths: ["lib/qux"] }); + const r = register({ + task: "t3", holder: "peer-c", paths: ["src/foo/baz.js", "lib/qux/zot.js"], + }); + assert.equal(r.overlaps.length, 2); + const tasks = r.overlaps.map((o) => o.task).sort(); + assert.deepEqual(tasks, ["t1", "t2"]); +});