Skip to content

Commit c9a291d

Browse files
claudehyperpolymath
authored andcommitted
feat(local-coord-mcp): watchdog TTL enforcement (DD-20)
From P1 in docs/handover/COORD-MCP-TODO.md — closes the DD-20 watchdog item. Bump cartridge version to 0.9.0. Mechanism --------- Each claim now carries `claimed_at_ms`, stamped at grant and refreshed on heartbeat. `sweepExpiredClaims(now_ms)` walks active claims and auto-releases any where `(now − claimed_at_ms) > TTL(holder.role)`. TTLs (DD-20): apprentice → 30 s journeyman → 5 min master → no watchdog (approver, not executor) Sweep runs: - implicitly at the top of every coord_claim_task (contention is the natural moment abandoned work matters — the new caller benefits immediately from the release), and - explicitly via `coord_sweep_watchdog` for ops that want a polling tick independent of claim traffic. New FFI + tools --------------- FFI: coord_progress(token, task) — 0 OK, -1 bad token, -2 no claim, -3 not the holder coord_sweep_watchdog(token) — released-count, -1 on bad token Adapter: coord_progress {token, task} coord_sweep_watchdog {token} → {released, ttl_apprentice_ms, ttl_journeyman_ms} Durability ---------- EventType.claim_progress = 17 — payload: claim_idx:u8 timestamp_ms:u64 Replay sets claimed_at_ms from claim_progress events; claim_add alone restores a fresh TTL (old logs predate the field). Audit ----- Auto-releases emit an audit record (kind=3 AUTO_RELEASE) with the claim index, holder, role, age_ms, and task name. DD-21's warn_drift broadcast via Opus review is a separate P1 item. Tests ----- watchdog: apprentice claim swept past 30s TTL watchdog: progress heartbeat keeps claim alive watchdog: journeyman gets 5min TTL, master never swept watchdog: progress rejected from non-holder watchdog: implicit sweep frees stale slot on contention Tests rewind `claims[i].claimed_at_ms` directly (same-module private access) to simulate elapsed time deterministically — no sleeps, no flakiness. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> https://claude.ai/code/session_01DobLJY3jgoso4M3z7xcZi8
1 parent 100876c commit c9a291d

6 files changed

Lines changed: 361 additions & 4 deletions

File tree

cartridges/local-coord-mcp/adapter/local_coord_adapter.zig

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,39 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
818818
return .{ .status = 200, .body = resp[0..stream.pos] };
819819
}
820820

821+
if (std.mem.eql(u8, tool, "coord_progress")) {
822+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
823+
defer parsed.deinit();
824+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
825+
const task_val = parsed.value.object.get("task") orelse return .{ .status = 400, .body = errJson(resp, "missing task") };
826+
827+
var token: [16]u8 = undefined;
828+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
829+
830+
const task = task_val.string;
831+
const rc = ffi.coord_progress(&token, 16, task.ptr, @intCast(task.len));
832+
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "heartbeat") };
833+
if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
834+
if (rc == -2) return .{ .status = 404, .body = errJson(resp, "no active claim for this task") };
835+
if (rc == -3) return .{ .status = 403, .body = errJson(resp, "caller is not the claim holder") };
836+
return .{ .status = 500, .body = errJson(resp, "progress failed") };
837+
}
838+
839+
if (std.mem.eql(u8, tool, "coord_sweep_watchdog")) {
840+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
841+
defer parsed.deinit();
842+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
843+
var token: [16]u8 = undefined;
844+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
845+
846+
const rc = ffi.coord_sweep_watchdog(&token, 16);
847+
if (rc < 0) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
848+
const body_out = std.fmt.bufPrint(resp,
849+
"{{\"success\":true,\"released\":{d},\"ttl_apprentice_ms\":30000,\"ttl_journeyman_ms\":300000}}",
850+
.{rc}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
851+
return .{ .status = 200, .body = body_out };
852+
}
853+
821854
if (std.mem.eql(u8, tool, "coord_health")) {
822855
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
823856
defer parsed.deinit();

cartridges/local-coord-mcp/cartridge.json

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,44 @@
589589
"type": "object"
590590
},
591591
"name": "coord_health"
592+
},
593+
{
594+
"description": "Heartbeat for a held claim — resets the watchdog TTL (DD-20). Apprentice TTL is 30 s, journeyman 5 min, master no watchdog. Long-running work should ping this periodically so the server doesn't auto-release the claim.",
595+
"inputSchema": {
596+
"properties": {
597+
"task": {
598+
"description": "Task identifier previously claimed via coord_claim_task",
599+
"type": "string"
600+
},
601+
"token": {
602+
"description": "Session token from coord_register",
603+
"type": "string"
604+
}
605+
},
606+
"required": [
607+
"token",
608+
"task"
609+
],
610+
"type": "object"
611+
},
612+
"name": "coord_progress"
613+
},
614+
{
615+
"description": "Explicit watchdog sweep — release any claim whose holder has missed its role-based TTL. The sweep also runs implicitly at the top of every coord_claim_task, so this tool is only needed by ops wanting an external tick.",
616+
"inputSchema": {
617+
"properties": {
618+
"token": {
619+
"description": "Session token from coord_register — any active peer may invoke.",
620+
"type": "string"
621+
}
622+
},
623+
"required": [
624+
"token"
625+
],
626+
"type": "object"
627+
},
628+
"name": "coord_sweep_watchdog"
592629
}
593630
],
594-
"version": "0.8.0"
631+
"version": "0.9.0"
595632
}

cartridges/local-coord-mcp/cartridge.ncl

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
spdx = "PMPL-1.0-or-later",
1212
copyright = "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>",
1313
name = "local-coord-mcp",
14-
version = "0.8.0",
14+
version = "0.9.0",
1515
description = "Localhost multi-instance coordination — peer discovery, message passing, and task claiming for parallel AI sessions on the same machine",
1616
domain = "ai",
1717
tier = "Ayo",
@@ -295,6 +295,29 @@
295295
required = ["token", "peer_id"],
296296
},
297297
},
298+
{
299+
name = "coord_progress",
300+
description = "Heartbeat for a held claim — resets the watchdog TTL (DD-20). Apprentice TTL is 30 s, journeyman 5 min, master no watchdog. Long-running work should ping this periodically so the server doesn't auto-release the claim.",
301+
inputSchema = {
302+
type = "object",
303+
properties = {
304+
token = { type = "string", description = "Session token from coord_register" },
305+
task = { type = "string", description = "Task identifier previously claimed via coord_claim_task" },
306+
},
307+
required = ["token", "task"],
308+
},
309+
},
310+
{
311+
name = "coord_sweep_watchdog",
312+
description = "Explicit watchdog sweep — release any claim whose holder has missed its role-based TTL. The sweep also runs implicitly at the top of every coord_claim_task, so this tool is only needed by ops wanting an external tick.",
313+
inputSchema = {
314+
type = "object",
315+
properties = {
316+
token = { type = "string", description = "Session token from coord_register — any active peer may invoke." },
317+
},
318+
required = ["token"],
319+
},
320+
},
298321
{
299322
name = "coord_health",
300323
description = "Read-only snapshot of coord-mcp state: active peer count (with per-kind / per-role breakdown), pending quarantine depth, active claims, track-record fill, and recent reject counts with cooldown flags. Lets other tooling monitor coord health without walking every export individually.",

cartridges/local-coord-mcp/ffi/coord_durability.zig

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ pub const EventType = enum(u16) {
5656
peer_variant_set = 15,
5757
// Task #34 — capability advertisement (class / tier / prover_strengths).
5858
peer_capabilities_set = 16,
59+
// DD-20 — watchdog heartbeat for a claim.
60+
claim_progress = 17,
5961
_,
6062
};
6163

@@ -313,6 +315,14 @@ pub fn logClaimRel(claim_idx: u8) void {
313315
append(.claim_rel, &[_]u8{claim_idx});
314316
}
315317

318+
/// CLAIM_PROGRESS — claim_idx:u8 timestamp_ms:u64 (9B). DD-20 watchdog.
319+
pub fn logClaimProgress(claim_idx: u8, timestamp_ms: u64) void {
320+
var buf: [9]u8 = undefined;
321+
buf[0] = claim_idx;
322+
std.mem.writeInt(u64, buf[1..9], timestamp_ms, .little);
323+
append(.claim_progress, &buf);
324+
}
325+
316326
/// QUAR_ADD — request_id:u32 sender_idx:u8 target_idx:i8 risk_tier:u8
317327
/// msg_len:u16 msg[msg_len]
318328
pub fn logQuarAdd(request_id: u32, sender_idx: u8, target_idx: i8, risk_tier: u8, msg: []const u8) void {
@@ -478,6 +488,15 @@ pub fn decodeClaimAdd(p: []const u8) ?ClaimAdd {
478488
return .{ .claim_idx = p[0], .holder_idx = p[1], .task = p[3 .. 3 + n] };
479489
}
480490

491+
pub const ClaimProgress = struct { claim_idx: u8, timestamp_ms: u64 };
492+
pub fn decodeClaimProgress(p: []const u8) ?ClaimProgress {
493+
if (p.len < 9) return null;
494+
return .{
495+
.claim_idx = p[0],
496+
.timestamp_ms = std.mem.readInt(u64, p[1..9], .little),
497+
};
498+
}
499+
481500
pub const QuarAdd = struct {
482501
request_id: u32,
483502
sender_idx: u8,

0 commit comments

Comments
 (0)