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 Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ../<repo>-worktrees/<task> on branch agent/<peer-id>/<task>.
# 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:
Expand Down
43 changes: 43 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
<task-id>` claims the task and provisions an isolated
`git worktree` at `../<repo>-worktrees/<task>` on branch
`agent/<peer-id>/<task>`, 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
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-id>` claims the task and provisions an isolated `git worktree` at `../<repo>-worktrees/<task>` on branch `agent/<peer-id>/<task>`, 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:
Expand Down
12 changes: 11 additions & 1 deletion cartridges/local-coord-mcp/cartridge.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
Expand Down
69 changes: 69 additions & 0 deletions coord-tui/shell/coord-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ../<repo>-worktrees/<sanitised-task> on a branch named
# agent/<peer-id>/<sanitised-task>. 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 <task-name>}"
_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 <status text>}"
Expand Down
45 changes: 43 additions & 2 deletions mcp-bridge/lib/dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
117 changes: 117 additions & 0 deletions mcp-bridge/lib/path-claims.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// 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();
}
Loading
Loading