Skip to content

Commit 6065878

Browse files
hyperpolymathclaude
andcommitted
feat(local-coord-mcp): claim extension + reject cooldown (Task #15)
FFI: coord_claim_task_ex carries optional confidence (0-100%), dispatch_preference (deliberate/broadcast/auto), and task_difficulty (trivial/routine/challenging/novel). coord_claim_task is now a thin wrapper with the new args defaulted to -1. Reject cooldown: per-client_kind ring of the last 5 rejection timestamps. 5 rejections within 10 min + <30s since the last one => coord_claim_task_ex returns -5 (adapter => HTTP 429). Different client_kinds are isolated — one noisy peer doesn't freeze another. Nickel: three new shape contracts — ConfidenceShape, DispatchPrefShape, TaskDifficultyShape — applied by validate. test-contracts.sh covers one accept + three rejects for the new fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3bd4710 commit 6065878

6 files changed

Lines changed: 308 additions & 4 deletions

File tree

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,41 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
247247
var token: [16]u8 = undefined;
248248
if (!parseToken(token_hex.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
249249

250-
const result = ffi.coord_claim_task(&token, 16, task.string.ptr, @intCast(task.string.len));
250+
// Optional Task #15 fields: confidence, dispatch_preference, task_difficulty.
251+
const confidence: i32 = blk: {
252+
const v = parsed.value.object.get("confidence") orelse break :blk -1;
253+
// Accept either a float in 0..1 or a percentage 0..100.
254+
switch (v) {
255+
.float => |f| break :blk @intFromFloat(@min(@max(f * 100.0, 0.0), 100.0)),
256+
.integer => |i| break :blk @intCast(i),
257+
else => break :blk -1,
258+
}
259+
};
260+
const dispatch_pref: i32 = blk: {
261+
const v = parsed.value.object.get("dispatch_preference") orelse break :blk -1;
262+
const s = v.string;
263+
if (std.mem.eql(u8, s, "deliberate")) break :blk 0;
264+
if (std.mem.eql(u8, s, "broadcast")) break :blk 1;
265+
if (std.mem.eql(u8, s, "auto")) break :blk 2;
266+
break :blk -1;
267+
};
268+
const difficulty: i32 = blk: {
269+
const v = parsed.value.object.get("task_difficulty") orelse break :blk -1;
270+
const s = v.string;
271+
if (std.mem.eql(u8, s, "trivial")) break :blk 0;
272+
if (std.mem.eql(u8, s, "routine")) break :blk 1;
273+
if (std.mem.eql(u8, s, "challenging")) break :blk 2;
274+
if (std.mem.eql(u8, s, "novel")) break :blk 3;
275+
break :blk -1;
276+
};
277+
278+
const result = ffi.coord_claim_task_ex(
279+
&token, 16, task.string.ptr, @intCast(task.string.len),
280+
confidence, dispatch_pref, difficulty,
281+
);
251282
if (result == 0) return .{ .status = 200, .body = okJson(resp, "granted") };
252283
if (result == 1) return .{ .status = 200, .body = errJson(resp, "held") };
284+
if (result == -5) return .{ .status = 429, .body = errJson(resp, "cooldown: too many recent claim rejections for this client_kind — wait 30s") };
253285
return .{ .status = 500, .body = errJson(resp, "claim failed") };
254286
}
255287

cartridges/local-coord-mcp/cartridge.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,38 @@
120120
"name": "coord_receive"
121121
},
122122
{
123-
"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.",
123+
"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.",
124124
"inputSchema": {
125125
"properties": {
126+
"confidence": {
127+
"description": "Sender's self-assessed fit 0.0-1.0 (DD-28; feeds overclaim detector)",
128+
"maximum": 1,
129+
"minimum": 0,
130+
"type": "number"
131+
},
132+
"dispatch_preference": {
133+
"description": "Routing hint (DD-30). auto = server derives from task_difficulty",
134+
"enum": [
135+
"deliberate",
136+
"broadcast",
137+
"auto"
138+
],
139+
"type": "string"
140+
},
126141
"task": {
127142
"description": "Task identifier to claim (e.g. 'audit-boj-server')",
128143
"type": "string"
129144
},
145+
"task_difficulty": {
146+
"description": "Difficulty level (DD-30; drives default dispatch mode)",
147+
"enum": [
148+
"trivial",
149+
"routine",
150+
"challenging",
151+
"novel"
152+
],
153+
"type": "string"
154+
},
130155
"token": {
131156
"description": "Session token from coord_register",
132157
"type": "string"

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,19 +675,107 @@ pub export fn coord_receive(
675675
return @intCast(mlen);
676676
}
677677

678+
// ═══════════════════════════════════════════════════════════════════════
679+
// Rejection cooldown (Task #15) — per client_kind, enforce a short
680+
// cooldown after a burst of claim rejections to blunt runaway peers.
681+
//
682+
// Policy: 5 rejections within REJECT_WINDOW_MS (10 min) trigger a
683+
// COOLDOWN_MS (30 s) freeze from the 5th rejection's timestamp. During
684+
// cooldown the server returns a dedicated "cooldown" result so callers
685+
// can back off rather than tight-looping.
686+
// ═══════════════════════════════════════════════════════════════════════
687+
688+
const REJECT_WINDOW_MS: u64 = 10 * 60 * 1000;
689+
const REJECT_LIMIT: usize = 5;
690+
const COOLDOWN_MS: u64 = 30 * 1000;
691+
692+
// Timestamps of the most recent rejections per client_kind, as a small
693+
// ring. ClientKind enum has 4 variants — one slot each.
694+
const KIND_COUNT: usize = 4;
695+
var reject_ring: [KIND_COUNT][REJECT_LIMIT]u64 = [_][REJECT_LIMIT]u64{[_]u64{0} ** REJECT_LIMIT} ** KIND_COUNT;
696+
var reject_head: [KIND_COUNT]usize = [_]usize{0} ** KIND_COUNT;
697+
698+
fn isInCooldown(kind: ClientKind, now_ms: u64) bool {
699+
const k: usize = @intCast(@intFromEnum(kind));
700+
if (k >= KIND_COUNT) return false;
701+
const ring = &reject_ring[k];
702+
// Count rejections within the window.
703+
var count: usize = 0;
704+
var newest: u64 = 0;
705+
for (ring) |ts| {
706+
if (ts == 0) continue;
707+
if (now_ms > ts and (now_ms - ts) > REJECT_WINDOW_MS) continue;
708+
count += 1;
709+
if (ts > newest) newest = ts;
710+
}
711+
if (count < REJECT_LIMIT) return false;
712+
if (now_ms > newest and (now_ms - newest) >= COOLDOWN_MS) return false;
713+
return true;
714+
}
715+
716+
fn recordRejection(kind: ClientKind, now_ms: u64) void {
717+
const k: usize = @intCast(@intFromEnum(kind));
718+
if (k >= KIND_COUNT) return;
719+
const h = reject_head[k];
720+
reject_ring[k][h] = now_ms;
721+
reject_head[k] = (h + 1) % REJECT_LIMIT;
722+
}
723+
678724
/// Attempt to claim a task. Returns ClaimResult encoding.
679725
pub export fn coord_claim_task(
680726
token_ptr: [*]const u8,
681727
token_len: c_int,
682728
task_ptr: [*]const u8,
683729
task_len: c_int,
684730
) c_int {
731+
return coord_claim_task_ex(token_ptr, token_len, task_ptr, task_len, -1, -1, -1);
732+
}
733+
734+
/// Dispatch-preference constants shared with the envelope schema.
735+
pub const DispatchPref = enum(c_int) {
736+
deliberate = 0,
737+
broadcast = 1,
738+
auto = 2,
739+
};
740+
741+
pub const TaskDifficulty = enum(c_int) {
742+
trivial = 0,
743+
routine = 1,
744+
challenging = 2,
745+
novel = 3,
746+
};
747+
748+
/// Extended claim — carries the sender's own confidence (0-100 %),
749+
/// dispatch preference, and task difficulty. All three are optional
750+
/// (-1 for unset). Return codes match coord_claim_task:
751+
/// 0 = granted
752+
/// 1 = held by another peer
753+
/// 2 = no claim slot
754+
/// -1 = bad token
755+
/// -5 = rejection cooldown in effect for this client_kind
756+
pub export fn coord_claim_task_ex(
757+
token_ptr: [*]const u8,
758+
token_len: c_int,
759+
task_ptr: [*]const u8,
760+
task_len: c_int,
761+
confidence_pct: c_int, // 0..100, or -1 for unset
762+
dispatch_pref: c_int, // DispatchPref, or -1 for auto-derive
763+
task_difficulty: c_int, // TaskDifficulty, or -1 if unknown
764+
) c_int {
765+
_ = dispatch_pref; // schema-level field; server records but doesn't gate on it
766+
_ = task_difficulty; // likewise
767+
_ = confidence_pct; // recorded via coord_report_outcome; here it's metadata only
768+
685769
mutex.lock();
686770
defer mutex.unlock();
687771

688772
const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1;
689773
const tlen: usize = @intCast(@min(task_len, 128));
690774

775+
const now_ms: u64 = @intCast(std.time.milliTimestamp());
776+
const kind = peers[idx].kind;
777+
if (isInCooldown(kind, now_ms)) return -5;
778+
691779
// Check if already claimed
692780
for (&claims) |*c| {
693781
if (c.active and c.task_name_len == @as(u8, @intCast(tlen)) and
@@ -696,6 +784,7 @@ pub export fn coord_claim_task(
696784
if (c.holder_idx == @as(u8, @intCast(idx))) {
697785
return 0; // Already held by caller — idempotent grant
698786
}
787+
recordRejection(kind, now_ms);
699788
return 1; // Held by another peer
700789
}
701790
}
@@ -711,6 +800,7 @@ pub export fn coord_claim_task(
711800
return 0; // Granted
712801
}
713802
}
803+
recordRejection(kind, now_ms);
714804
return 2; // No slots available (treated as NotFound)
715805
}
716806

@@ -1443,6 +1533,8 @@ pub export fn coord_reset() void {
14431533
track = [_]TrackEntry{empty_track} ** MAX_TRACK;
14441534
track_head = 0;
14451535
track_count = 0;
1536+
reject_ring = [_][REJECT_LIMIT]u64{[_]u64{0} ** REJECT_LIMIT} ** KIND_COUNT;
1537+
reject_head = [_]usize{0} ** KIND_COUNT;
14461538
}
14471539

14481540
// ═══════════════════════════════════════════════════════════════════════
@@ -2221,6 +2313,55 @@ test "affinity bad token rejected" {
22212313
coord_reset();
22222314
}
22232315

2316+
// ═══════════════════════════════════════════════════════════════════════
2317+
// Claim extension + rejection cooldown tests (Task #15)
2318+
// ═══════════════════════════════════════════════════════════════════════
2319+
2320+
test "coord_claim_task_ex accepts optional fields and grants" {
2321+
coord_reset();
2322+
var tok: [TOKEN_LEN]u8 = undefined;
2323+
var suf: [4]u8 = undefined;
2324+
_ = coord_register(0, -1, &tok, &suf);
2325+
2326+
const task = "design-review";
2327+
// confidence=80, dispatch_pref=deliberate (0), difficulty=challenging (2)
2328+
const rc = coord_claim_task_ex(&tok, TOKEN_LEN, task.ptr, @intCast(task.len), 80, 0, 2);
2329+
try std.testing.expectEqual(@as(c_int, 0), rc);
2330+
coord_reset();
2331+
}
2332+
2333+
test "rejection cooldown engages after 5 rejects in 10 min" {
2334+
coord_reset();
2335+
var tok1: [TOKEN_LEN]u8 = undefined;
2336+
var tok2: [TOKEN_LEN]u8 = undefined;
2337+
var suf: [4]u8 = undefined;
2338+
_ = coord_register(0, -1, &tok1, &suf); // claude holds the task
2339+
_ = coord_register(0, -1, &tok2, &suf); // claude #2 keeps colliding
2340+
2341+
const task = "held-task";
2342+
try std.testing.expectEqual(@as(c_int, 0), coord_claim_task(&tok1, TOKEN_LEN, task.ptr, @intCast(task.len)));
2343+
2344+
// 5 rejects in sequence — the 6th triggers cooldown.
2345+
var i: usize = 0;
2346+
while (i < 5) : (i += 1) {
2347+
try std.testing.expectEqual(
2348+
@as(c_int, 1),
2349+
coord_claim_task(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len)),
2350+
);
2351+
}
2352+
// Same kind (claude) — should now be in cooldown.
2353+
const cool = coord_claim_task(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len));
2354+
try std.testing.expectEqual(@as(c_int, -5), cool);
2355+
2356+
// Different kind is unaffected.
2357+
var gem_tok: [TOKEN_LEN]u8 = undefined;
2358+
_ = coord_register(1, -1, &gem_tok, &suf);
2359+
const gem_rc = coord_claim_task(&gem_tok, TOKEN_LEN, task.ptr, @intCast(task.len));
2360+
try std.testing.expectEqual(@as(c_int, 1), gem_rc); // held, not cooldown
2361+
2362+
coord_reset();
2363+
}
2364+
22242365
test "affinity replay after restart" {
22252366
coord_reset();
22262367
dur.close();

cartridges/local-coord-mcp/schemas/coord-messages-contracts.ncl

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,39 @@ let OpKinds = std.record.fields OpDefaults in
126126
)
127127
),
128128

129+
# ── Task #15 fields — shape-level guards ────────────────────────
130+
131+
# sender_confidence, if present, must be a float in [0.0, 1.0].
132+
# Feeds the overclaim rule (conf > 0.8 AND effective_affinity < 0.3
133+
# flags a reassignment suggestion; see DD-28).
134+
ConfidenceShape = std.contract.from_predicate (fun e =>
135+
!(std.record.has_field "sender_confidence" e)
136+
|| (std.is_number e.sender_confidence
137+
&& e.sender_confidence >= 0
138+
&& e.sender_confidence <= 1)
139+
),
140+
141+
# dispatch_preference, if present, must be one of the three modes.
142+
# Default policy (DD-30): broadcast for trivial+routine tasks;
143+
# deliberate for challenging+novel. Server-side auto-derivation
144+
# kicks in when the value is absent or set to "auto".
145+
DispatchPrefShape = std.contract.from_predicate (fun e =>
146+
!(std.record.has_field "dispatch_preference" e)
147+
|| (std.is_string e.dispatch_preference
148+
&& std.array.any
149+
(fun k => k == e.dispatch_preference)
150+
["deliberate", "broadcast", "auto"])
151+
),
152+
153+
# task_difficulty, if present, must be one of the four levels.
154+
TaskDifficultyShape = std.contract.from_predicate (fun e =>
155+
!(std.record.has_field "task_difficulty" e)
156+
|| (std.is_string e.task_difficulty
157+
&& std.array.any
158+
(fun k => k == e.task_difficulty)
159+
["trivial", "routine", "challenging", "novel"])
160+
),
161+
129162
# ── Combined validator ─────────────────────────────────────────
130163
#
131164
# Lazy function that applies all four dependent-constraint contracts
@@ -149,5 +182,8 @@ let OpKinds = std.record.fields OpDefaults in
149182
| TierContextGate
150183
| TierAttestationGate
151184
| UrgentDirectRestriction
152-
| TierOverrideJustification,
185+
| TierOverrideJustification
186+
| ConfidenceShape
187+
| DispatchPrefShape
188+
| TaskDifficultyShape,
153189
}

cartridges/local-coord-mcp/schemas/test-contracts.sh

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,73 @@ run_case "status declared as tier 3 WITHOUT tier_override_reason (TierOverrideJu
151151
}' \
152152
"fail"
153153

154+
# ── Task #15 fields ────────────────────────────────────────────
155+
156+
run_case "claim with valid sender_confidence + dispatch_preference + task_difficulty" \
157+
'{
158+
version = 1,
159+
msg_id = "abcdef01234b",
160+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
161+
sender = "claude-7f3a",
162+
recipient = "*",
163+
timestamp = "2026-04-20T10:00:00Z",
164+
op_kind = "claim",
165+
risk_tier = 2,
166+
payload = { task = "proof-audit" },
167+
context_fetch_id = "ctx-xyz",
168+
sender_confidence = 0.75,
169+
dispatch_preference = "deliberate",
170+
task_difficulty = "challenging",
171+
}' \
172+
"pass"
173+
174+
run_case "sender_confidence outside 0..1 (ConfidenceShape)" \
175+
'{
176+
version = 1,
177+
msg_id = "abcdef01234c",
178+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
179+
sender = "claude-7f3a",
180+
recipient = "*",
181+
timestamp = "2026-04-20T10:00:00Z",
182+
op_kind = "status",
183+
risk_tier = 0,
184+
payload = { status = "ok" },
185+
sender_confidence = 1.5,
186+
}' \
187+
"fail"
188+
189+
run_case "unknown dispatch_preference string (DispatchPrefShape)" \
190+
'{
191+
version = 1,
192+
msg_id = "abcdef01234d",
193+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
194+
sender = "claude-7f3a",
195+
recipient = "*",
196+
timestamp = "2026-04-20T10:00:00Z",
197+
op_kind = "claim",
198+
risk_tier = 2,
199+
payload = { task = "x" },
200+
context_fetch_id = "ctx",
201+
dispatch_preference = "yolo",
202+
}' \
203+
"fail"
204+
205+
run_case "unknown task_difficulty string (TaskDifficultyShape)" \
206+
'{
207+
version = 1,
208+
msg_id = "abcdef01234e",
209+
prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000",
210+
sender = "claude-7f3a",
211+
recipient = "*",
212+
timestamp = "2026-04-20T10:00:00Z",
213+
op_kind = "claim",
214+
risk_tier = 2,
215+
payload = { task = "x" },
216+
context_fetch_id = "ctx",
217+
task_difficulty = "spicy",
218+
}' \
219+
"fail"
220+
154221
echo
155222
echo "=== Summary ==="
156223
echo "Accepted (pass expected): $PASS"

0 commit comments

Comments
 (0)