agent-coord-mcp 0.26.6 → 0.26.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +4 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/attention.js +73 -0
- package/dist/tools/attention.js.map +1 -0
- package/dist/tools/events.js +171 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/logwatch.js +85 -0
- package/dist/tools/logwatch.js.map +1 -0
- package/dist/tools/messaging.js +30 -0
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +107 -1
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +23 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +126 -11
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +29 -5
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +30 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/hooks/push-cursor.mjs +78 -0
- package/hooks/tmux-pusher.mjs +35 -9
- package/package.json +2 -2
- package/scripts/check-test-count.mjs +1 -1
- package/scripts/coord-attention-clock.mjs +122 -0
- package/scripts/coord-stall-clock.mjs +125 -0
- package/src/server.ts +22 -0
- package/src/tools/attention.ts +91 -0
- package/src/tools/events.ts +199 -0
- package/src/tools/logwatch.ts +107 -0
- package/src/tools/messaging.ts +29 -0
- package/src/tools/records.ts +110 -2
- package/src/tools/registry.ts +23 -4
- package/src/tools/stall.ts +130 -13
- package/src/tools/transport.ts +31 -5
- package/src/tools/worktrees.ts +28 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Event subscriptions — stop relying on someone CHOOSING to tell you.
|
|
3
|
+
*
|
|
4
|
+
* The bus is almost entirely direct messaging: an agent learns something
|
|
5
|
+
* happened because another agent decided to say so. Every miss this week was a
|
|
6
|
+
* missing NOTIFICATION rather than a missing capability — two mergeable PRs sat
|
|
7
|
+
* 17 hours because nobody told the coordinator to gate, the console's trigger
|
|
8
|
+
* ran zero times because nothing woke it, and `stall_check` runs only when a
|
|
9
|
+
* human types it.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { ROOT } from "../store.js";
|
|
16
|
+
|
|
17
|
+
const subsFile = () => path.join(ROOT, "subscriptions.json");
|
|
18
|
+
|
|
19
|
+
export type SubKind = "task" | "phase" | "item" | "pr";
|
|
20
|
+
export type Subscription = {
|
|
21
|
+
id: string;
|
|
22
|
+
agentId: string;
|
|
23
|
+
kind: SubKind;
|
|
24
|
+
target: string;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
/** null until this subscription has been EVALUATED at least once. */
|
|
27
|
+
lastEvaluatedAt: number | null;
|
|
28
|
+
lastEventAt: number | null;
|
|
29
|
+
/** Idempotency keys already delivered, for 6.4. */
|
|
30
|
+
delivered: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function readSubs(): Subscription[] {
|
|
34
|
+
const f = subsFile();
|
|
35
|
+
if (!existsSync(f)) return [];
|
|
36
|
+
try {
|
|
37
|
+
return (JSON.parse(readFileSync(f, "utf8")).subscriptions ?? []) as Subscription[];
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeSubs(subs: Subscription[]): void {
|
|
44
|
+
mkdirSync(ROOT, { recursive: true });
|
|
45
|
+
writeFileSync(subsFile(), `${JSON.stringify({ subscriptions: subs }, null, 2)}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 6.3 — A SUBSCRIPTION THAT NEVER FIRES MUST BE DISTINGUISHABLE FROM ONE NEVER
|
|
50
|
+
* REGISTERED, so never-evaluated is an ERROR rather than a quiet zero.
|
|
51
|
+
*
|
|
52
|
+
* We hit the absence of this rule twice in two days: the console's standing
|
|
53
|
+
* trigger and `stall_check`'s clock both looked healthy while never running. A
|
|
54
|
+
* subscription with no last-evaluated mark has produced no evidence of
|
|
55
|
+
* anything, and "no events" is the same output a broken subscription gives.
|
|
56
|
+
*/
|
|
57
|
+
export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; detail: string } {
|
|
58
|
+
if (s.lastEvaluatedAt === null)
|
|
59
|
+
return {
|
|
60
|
+
level: "error",
|
|
61
|
+
detail: `never evaluated — this subscription has produced no evidence it is wired to anything. "No events yet" and "never ran" are the same output, and only one of them is healthy.`,
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
level: "ok",
|
|
65
|
+
detail: s.lastEventAt
|
|
66
|
+
? `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, last event ${new Date(s.lastEventAt).toISOString()}`
|
|
67
|
+
: `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, no events yet — evaluated and quiet, which is different from never run`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 6.4 — DELIVERY IS AT-LEAST-ONCE BY DESIGN. Safe for a reader, DOUBLE
|
|
73
|
+
* EXECUTION for an executor: a callback that triggers work must carry a key, or
|
|
74
|
+
* the same merge lands twice. The key is derived from the EVENT, never from the
|
|
75
|
+
* delivery attempt, so a retry produces the same key.
|
|
76
|
+
*/
|
|
77
|
+
export const eventKey = (kind: SubKind, target: string, ref: string): string =>
|
|
78
|
+
createHash("sha256").update(`${kind}:${target}:${ref}`).digest("hex").slice(0, 16);
|
|
79
|
+
|
|
80
|
+
export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 6.2 — EVENTS ARE DERIVED FROM THE RECORD, NEVER PARALLEL TO IT.
|
|
84
|
+
*
|
|
85
|
+
* Enforced here rather than promised in a comment: the event's `ref` must
|
|
86
|
+
* already be present in the record document before anything is emitted. An
|
|
87
|
+
* event stream that can say "task X complete" while DONE.md does not is a
|
|
88
|
+
* second source of truth, and record-vs-state divergence is the defect this
|
|
89
|
+
* fleet hit most this week. ADR-003 keeps markdown authoritative, and this must
|
|
90
|
+
* not quietly reopen it.
|
|
91
|
+
*
|
|
92
|
+
* So the ordering is not a convention: emission READS the record, and an event
|
|
93
|
+
* whose cause is not in the record cannot be emitted at all.
|
|
94
|
+
*/
|
|
95
|
+
export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true } | { ok: false; error: string } {
|
|
96
|
+
if (!ev.ref) return { ok: false, error: `event for ${ev.kind} ${ev.target} carries no ref — nothing ties it to a record entry` };
|
|
97
|
+
if (!String(recordText).includes(ev.ref))
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error:
|
|
101
|
+
`refusing to emit ${ev.kind} ${ev.target}: its ref ${ev.ref} is NOT in the record. ` +
|
|
102
|
+
`An event that exists without the record change that caused it is a second source of truth — ` +
|
|
103
|
+
`the stream would claim something the authoritative document does not.`,
|
|
104
|
+
};
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Subscriptions matching an event. Exact target match; no wildcards yet. */
|
|
109
|
+
export const matching = (subs: Subscription[], ev: RecordEvent): Subscription[] =>
|
|
110
|
+
subs.filter((s) => s.kind === ev.kind && s.target === ev.target);
|
|
111
|
+
|
|
112
|
+
export type Delivery = { subscriptionId: string; agentId: string; key: string; status: "delivered" | "duplicate-suppressed" };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Evaluate every subscription against one event and return what to deliver.
|
|
116
|
+
*
|
|
117
|
+
* EVALUATION IS RECORDED EVEN WHEN NOTHING MATCHES — that is 6.3's whole point.
|
|
118
|
+
* A subscription only learns it is alive by being evaluated, so the mark is
|
|
119
|
+
* written for every subscription of that kind, not only the ones that fired.
|
|
120
|
+
*/
|
|
121
|
+
export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): { subs: Subscription[]; deliveries: Delivery[] } {
|
|
122
|
+
const key = eventKey(ev.kind, ev.target, ev.ref);
|
|
123
|
+
const deliveries: Delivery[] = [];
|
|
124
|
+
const next = subs.map((s) => {
|
|
125
|
+
if (s.kind !== ev.kind) return s;
|
|
126
|
+
const evaluated = { ...s, lastEvaluatedAt: now };
|
|
127
|
+
if (s.target !== ev.target) return evaluated;
|
|
128
|
+
if (s.delivered.includes(key)) {
|
|
129
|
+
deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "duplicate-suppressed" });
|
|
130
|
+
return evaluated;
|
|
131
|
+
}
|
|
132
|
+
deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "delivered" });
|
|
133
|
+
return { ...evaluated, lastEventAt: now, delivered: [...s.delivered, key].slice(-200) };
|
|
134
|
+
});
|
|
135
|
+
return { subs: next, deliveries };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ── verbs ─────────────────────────────────────────────────────────────────── */
|
|
139
|
+
|
|
140
|
+
export const subscribeSchema = {
|
|
141
|
+
agentId: z.string().min(1),
|
|
142
|
+
kind: z.enum(["task", "phase", "item", "pr"]),
|
|
143
|
+
target: z.string().min(1),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string }) {
|
|
147
|
+
const subs = readSubs();
|
|
148
|
+
const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target);
|
|
149
|
+
if (dupe) return { ok: true as const, subscription: dupe, note: "already subscribed — returning the existing registration rather than a second one" };
|
|
150
|
+
const sub: Subscription = {
|
|
151
|
+
id: randomUUID(),
|
|
152
|
+
agentId: args.agentId,
|
|
153
|
+
kind: args.kind,
|
|
154
|
+
target: args.target,
|
|
155
|
+
createdAt: Date.now(),
|
|
156
|
+
lastEvaluatedAt: null,
|
|
157
|
+
lastEventAt: null,
|
|
158
|
+
delivered: [],
|
|
159
|
+
};
|
|
160
|
+
writeSubs([...subs, sub]);
|
|
161
|
+
return { ok: true as const, subscription: sub, health: subscriptionHealth(sub) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const unsubscribeSchema = { agentId: z.string().min(1), id: z.string().min(1) };
|
|
165
|
+
|
|
166
|
+
export async function unsubscribeTool(args: { agentId: string; id: string }) {
|
|
167
|
+
const subs = readSubs();
|
|
168
|
+
const sub = subs.find((s) => s.id === args.id);
|
|
169
|
+
if (!sub) return { ok: false as const, error: `no subscription '${args.id}'` };
|
|
170
|
+
// Another agent's subscription is not yours to remove: silently dropping
|
|
171
|
+
// someone else's notification is how a miss is manufactured.
|
|
172
|
+
if (sub.agentId !== args.agentId) return { ok: false as const, error: `subscription '${args.id}' belongs to '${sub.agentId}', not '${args.agentId}'` };
|
|
173
|
+
writeSubs(subs.filter((s) => s.id !== args.id));
|
|
174
|
+
return { ok: true as const, removed: sub };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const listSubscriptionsSchema = { agentId: z.string().optional() };
|
|
178
|
+
|
|
179
|
+
export async function listSubscriptionsTool(args: { agentId?: string }) {
|
|
180
|
+
const all = readSubs();
|
|
181
|
+
const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
|
|
182
|
+
const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
|
|
183
|
+
const neverEvaluated = rows.filter((r) => r.health.level === "error");
|
|
184
|
+
return {
|
|
185
|
+
ok: neverEvaluated.length === 0,
|
|
186
|
+
// Population beside the verdict, always: "no subscriptions" and "none
|
|
187
|
+
// listed for you" are different claims.
|
|
188
|
+
population: { listed: rows.length, total: all.length },
|
|
189
|
+
subscriptions: rows,
|
|
190
|
+
...(neverEvaluated.length
|
|
191
|
+
? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER been evaluated — they have produced no evidence of being wired to anything.` }
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Persist an evaluation. Callers do this after a record write, never before. */
|
|
197
|
+
export function commitEvaluation(next: Subscription[]): void {
|
|
198
|
+
writeSubs(next);
|
|
199
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Task 8 — a watch over an append-only log, keyed on FILE OFFSET.
|
|
3
|
+
*
|
|
4
|
+
* A WATCH MUST NOT BE ABLE TO CONSUME. This is Task 7.1's rule at its second
|
|
5
|
+
* caller, not a second fix: the read cursor records what the AGENT has taken
|
|
6
|
+
* delivery of, and only the agent reading may advance it. A pusher typing into
|
|
7
|
+
* a pane is a reader; a watch waiting for traffic is a reader; neither may
|
|
8
|
+
* consume on the agent's behalf.
|
|
9
|
+
*
|
|
10
|
+
* So this keeps its OWN position — `lastLineSeen` — and never touches
|
|
11
|
+
* `cursors/<id>.json`. A watch that advanced the read cursor would reproduce
|
|
12
|
+
* exactly the defect Task 7 removed, one component over: the message would be
|
|
13
|
+
* marked consumed by something that only looked at it.
|
|
14
|
+
*
|
|
15
|
+
* WHY THIS DOES NOT VIOLATE 6.2 (8.5). "Events are derived from the record,
|
|
16
|
+
* never parallel to it." The room and inbox JSONL logs ARE the record — they
|
|
17
|
+
* are the authoritative store, not a projection of one. `land`'s events are
|
|
18
|
+
* derived from a document write; these are derived from the log itself. Both
|
|
19
|
+
* read the authoritative artifact and neither maintains a second source of
|
|
20
|
+
* truth. The test for a violation is whether the stream could assert something
|
|
21
|
+
* the record does not, and it cannot: every event here IS a line in the log,
|
|
22
|
+
* identified by its offset in that log.
|
|
23
|
+
*
|
|
24
|
+
* Keyed on OFFSET rather than timestamp or id, because an offset is a property
|
|
25
|
+
* of the log itself. Two lines can share a `ts`, an id can be rewritten, and a
|
|
26
|
+
* clock can disagree with another clock — a line's position cannot.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { ROOT } from "../store.js";
|
|
32
|
+
|
|
33
|
+
export type LogKind = "room" | "inbox";
|
|
34
|
+
|
|
35
|
+
export type LogWatch = {
|
|
36
|
+
id: string;
|
|
37
|
+
agentId: string;
|
|
38
|
+
kind: LogKind;
|
|
39
|
+
target: string;
|
|
40
|
+
createdAt: number;
|
|
41
|
+
/** null until evaluated at least once — never-evaluated is an ERROR (8.3). */
|
|
42
|
+
lastEvaluatedAt: number | null;
|
|
43
|
+
/** The offset this watch has REPORTED up to. Never the read cursor. */
|
|
44
|
+
lastLineSeen: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const logFileFor = (kind: LogKind, target: string): string =>
|
|
48
|
+
kind === "room" ? path.join(ROOT, "rooms", `${target}.jsonl`) : path.join(ROOT, "inbox", `${target}.jsonl`);
|
|
49
|
+
|
|
50
|
+
/** Parsed lines of a log. Malformed lines are SKIPPED but still counted. */
|
|
51
|
+
export function readLog(file: string): { entries: unknown[]; total: number } {
|
|
52
|
+
if (!existsSync(file)) return { entries: [], total: 0 };
|
|
53
|
+
const lines = readFileSync(file, "utf8").split("\n").filter((l) => l.trim());
|
|
54
|
+
const entries: unknown[] = [];
|
|
55
|
+
for (const l of lines) {
|
|
56
|
+
try {
|
|
57
|
+
entries.push(JSON.parse(l));
|
|
58
|
+
} catch {
|
|
59
|
+
// A malformed line still OCCUPIES an offset. Skipping it without counting
|
|
60
|
+
// would shift every subsequent line's position by one and silently
|
|
61
|
+
// re-report the whole tail.
|
|
62
|
+
entries.push(null);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { entries, total: lines.length };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type LogEvent = { kind: LogKind; target: string; offset: number; line: unknown };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lines appended since `lastLineSeen`.
|
|
72
|
+
*
|
|
73
|
+
* A TRUNCATED LOG IS NOT A QUIET ONE. If the file is SHORTER than the watch's
|
|
74
|
+
* position, something rewrote or pruned it — reporting "no new lines" would be
|
|
75
|
+
* indistinguishable from a healthy quiet period. It is surfaced instead.
|
|
76
|
+
*/
|
|
77
|
+
export function newLines(
|
|
78
|
+
watch: Pick<LogWatch, "kind" | "target" | "lastLineSeen">,
|
|
79
|
+
log: { entries: unknown[]; total: number },
|
|
80
|
+
): { events: LogEvent[]; truncated: boolean; total: number } {
|
|
81
|
+
if (log.total < watch.lastLineSeen)
|
|
82
|
+
return { events: [], truncated: true, total: log.total };
|
|
83
|
+
const events: LogEvent[] = [];
|
|
84
|
+
for (let i = watch.lastLineSeen; i < log.total; i++) {
|
|
85
|
+
events.push({ kind: watch.kind, target: watch.target, offset: i, line: log.entries[i] ?? null });
|
|
86
|
+
}
|
|
87
|
+
return { events, truncated: false, total: log.total };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 8.3 — health, identical in shape to the record subscriptions, plus lastLineSeen. */
|
|
91
|
+
export function watchHealth(w: LogWatch): { level: "ok" | "error"; detail: string } {
|
|
92
|
+
if (w.lastEvaluatedAt === null)
|
|
93
|
+
return {
|
|
94
|
+
level: "error",
|
|
95
|
+
detail: `never evaluated — this watch has produced no evidence it is attached to anything. "No lines yet" and "never ran" are the same output, and only one of them is healthy.`,
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
level: "ok",
|
|
99
|
+
detail: `last evaluated ${new Date(w.lastEvaluatedAt).toISOString()}, reported up to line ${w.lastLineSeen}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const logWatchSchema = {
|
|
104
|
+
agentId: z.string().min(1),
|
|
105
|
+
kind: z.enum(["room", "inbox"]),
|
|
106
|
+
target: z.string().min(1),
|
|
107
|
+
};
|
package/src/tools/messaging.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { promises as fsp } from "node:fs";
|
|
|
7
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { readLog } from "./logwatch.js";
|
|
10
11
|
import { renderRecord } from "./render.js";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import {
|
|
@@ -660,6 +661,30 @@ export async function waitForMessageTool(args: {
|
|
|
660
661
|
// readMessagesTool now filters out for room/status) doesn't return an empty
|
|
661
662
|
// result — keep waiting until we have something to deliver or time out.
|
|
662
663
|
while (Date.now() < deadline) {
|
|
664
|
+
// ALREADY-UNREAD CONTENT WAKES THIS IMMEDIATELY.
|
|
665
|
+
//
|
|
666
|
+
// The loop below waits for the file to GROW from the size captured at entry,
|
|
667
|
+
// so anything that arrived before the wait started could not wake it: the
|
|
668
|
+
// agent sat through the full timeout with messages readable the whole time.
|
|
669
|
+
// Seven real posts landed across consecutive 60s waits that all returned
|
|
670
|
+
// `timedOut`, while `read_messages` returned every one.
|
|
671
|
+
//
|
|
672
|
+
// Growth is the wrong question on its own — "is there anything for me" is
|
|
673
|
+
// the right one, and it is also the cheaper check.
|
|
674
|
+
const pending = await readMessagesTool({ agentId: args.agentId, source: args.source, room: args.room });
|
|
675
|
+
if (pending.messages.length > 0) return { ...pending, waited: true, timedOut: false };
|
|
676
|
+
|
|
677
|
+
// 8.4 — THE SAME POSITION THE WATCH USES, not a second mechanism beside it.
|
|
678
|
+
//
|
|
679
|
+
// This compared byte SIZE, which answers "did the file grow" — a proxy for
|
|
680
|
+
// "is there a new line". The watch keys on LINE OFFSET, which is the thing
|
|
681
|
+
// itself, and reusing it means the wait and the watch cannot disagree about
|
|
682
|
+
// what "new" means. A second mechanism beside the first is how two answers
|
|
683
|
+
// to one question start drifting.
|
|
684
|
+
//
|
|
685
|
+
// It still consumes nothing by itself: the offset is read, never written
|
|
686
|
+
// back to the cursor (8.2).
|
|
687
|
+
const startLines = readLog(file).total;
|
|
663
688
|
const startSize = await fileSize(file);
|
|
664
689
|
const remaining = deadline - Date.now();
|
|
665
690
|
if (remaining <= 0) break;
|
|
@@ -680,6 +705,10 @@ export async function waitForMessageTool(args: {
|
|
|
680
705
|
};
|
|
681
706
|
|
|
682
707
|
const check = async () => {
|
|
708
|
+
// Line count first — a write that changes bytes without adding a line
|
|
709
|
+
// (a rewrite, a truncation) is not new traffic, and size alone reports
|
|
710
|
+
// it as such.
|
|
711
|
+
if (readLog(file).total > startLines) return finish(true);
|
|
683
712
|
const sz = await fileSize(file);
|
|
684
713
|
if (sz > startSize) finish(true);
|
|
685
714
|
};
|
package/src/tools/records.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "@davidbalzan/groundwork-seam";
|
|
28
28
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
29
29
|
import { haltState } from "./stall.js";
|
|
30
|
+
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
30
31
|
|
|
31
32
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
32
33
|
const DONE_DOC = "docs/DONE.md";
|
|
@@ -198,6 +199,44 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
198
199
|
|
|
199
200
|
// ---------- claim ----------
|
|
200
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Insert or replace ONE row in the workstreams table, as TEXT.
|
|
204
|
+
*
|
|
205
|
+
* 2.4b: "a verb only fails closed if it is the ONLY path — nothing currently
|
|
206
|
+
* stops a coordinator editing the board directly instead of calling `claim`."
|
|
207
|
+
* `claim` used to RETURN a `boardHunk` for someone to paste by hand, which is
|
|
208
|
+
* the same discipline with a nicer API. Every legacy row on the board today is
|
|
209
|
+
* hand-written, and that is why `stall_check`'s vcs half has nothing to
|
|
210
|
+
* resolve: a pasted row carries a path, a `claim`-written row carries a real
|
|
211
|
+
* branch ref.
|
|
212
|
+
*
|
|
213
|
+
* Text insertion rather than a re-render: the rest of the file stays
|
|
214
|
+
* byte-identical, the same reason `land` appends its DONE line as text. A board
|
|
215
|
+
* this verb rewrote wholesale would be a diff nobody could review.
|
|
216
|
+
*/
|
|
217
|
+
export function upsertBoardRow(text: string, agentId: string, row: string): { text: string; action: "inserted" | "replaced" | "unchanged" } {
|
|
218
|
+
const lines = text.split("\n");
|
|
219
|
+
const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
|
|
220
|
+
if (header === -1) return { text, action: "unchanged" };
|
|
221
|
+
// The table ends at the first line that is not a row.
|
|
222
|
+
let end = header + 1;
|
|
223
|
+
while (end < lines.length && /^\s*\|/.test(lines[end])) end++;
|
|
224
|
+
|
|
225
|
+
// OWNER MATCHED ON THE CELL, NOT ON THE WHOLE LINE. An agent id appearing in
|
|
226
|
+
// a "Last note" cell is not that agent's row — the occurrence-vs-position
|
|
227
|
+
// defect this repo has re-derived at four granularities.
|
|
228
|
+
const ownerOf = (l: string) => (l.split("|")[2] ?? "").replace(/[`*\s]/g, "");
|
|
229
|
+
const existing = lines.findIndex((l, i) => i > header + 1 && i < end && ownerOf(l) === agentId);
|
|
230
|
+
|
|
231
|
+
if (existing !== -1) {
|
|
232
|
+
if (lines[existing] === row) return { text, action: "unchanged" };
|
|
233
|
+
lines[existing] = row;
|
|
234
|
+
return { text: lines.join("\n"), action: "replaced" };
|
|
235
|
+
}
|
|
236
|
+
lines.splice(end, 0, row);
|
|
237
|
+
return { text: lines.join("\n"), action: "inserted" };
|
|
238
|
+
}
|
|
239
|
+
|
|
201
240
|
export const claimSchema = {
|
|
202
241
|
project: z.string().min(1),
|
|
203
242
|
agentId: z.string().min(1),
|
|
@@ -205,9 +244,10 @@ export const claimSchema = {
|
|
|
205
244
|
repo: z.string().optional(),
|
|
206
245
|
base: z.string().optional(),
|
|
207
246
|
task: z.string().optional(),
|
|
247
|
+
write: z.boolean().optional(),
|
|
208
248
|
};
|
|
209
249
|
|
|
210
|
-
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string }) {
|
|
250
|
+
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
|
|
211
251
|
const halt = haltState();
|
|
212
252
|
if (halt.halted) {
|
|
213
253
|
return {
|
|
@@ -255,12 +295,52 @@ export async function claimTool(args: { project: string; agentId: string; itemId
|
|
|
255
295
|
};
|
|
256
296
|
}
|
|
257
297
|
|
|
298
|
+
// A NEW TASK DOES NOT START FROM A STALE TREE.
|
|
299
|
+
//
|
|
300
|
+
// `ensure_worktree` is idempotent and reuses the agent's existing tree, which
|
|
301
|
+
// is right mid-slice (1.3) and wrong here: `claim` MEANS "start something
|
|
302
|
+
// new", and a tree left on last week's base produces the confidently-wrong
|
|
303
|
+
// inventories the worker card warns about, with nothing about the result
|
|
304
|
+
// looking stale.
|
|
305
|
+
//
|
|
306
|
+
// A freshly CREATED tree is cut from origin/<base> and needs no check — this
|
|
307
|
+
// only ever fires on reuse.
|
|
308
|
+
if (!wt.created && wt.atBase === false) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false as const,
|
|
311
|
+
error:
|
|
312
|
+
`cannot claim: the worktree at ${wt.path} is NOT at ${wt.base}` +
|
|
313
|
+
`${wt.behindBy ? ` (${wt.behindBy} commit(s) behind)` : ""} — it is on '${wt.branch}' at ${String(wt.sha).slice(0, 8)}. ` +
|
|
314
|
+
`A new task started here would be based on stale content, and nothing about the result would look stale. ` +
|
|
315
|
+
`Run \`refresh_worktrees\` to fast-forward idle trees, or finish and land the work already in it.`,
|
|
316
|
+
item: { id: item.id, priority: item.priority },
|
|
317
|
+
worktree: { path: wt.path, branch: wt.branch, sha: wt.sha, base: wt.base, atBase: false },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
|
|
322
|
+
|
|
323
|
+
// 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
|
|
324
|
+
// write:true, matching `land`. A returned hunk that a human pastes is the
|
|
325
|
+
// same discipline with a nicer API, and the pasted rows are why the board
|
|
326
|
+
// carries paths where a claim would have carried a resolvable branch ref.
|
|
327
|
+
let board: { action: string; path?: string } = { action: "reported" };
|
|
328
|
+
const b = readDoc(repo, BOARD_DOC);
|
|
329
|
+
if (!b) {
|
|
330
|
+
board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
|
|
331
|
+
} else if (args.write) {
|
|
332
|
+
const next = upsertBoardRow(b.text, args.agentId, boardHunk);
|
|
333
|
+
if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
|
|
334
|
+
board = { action: next.action, path: BOARD_DOC };
|
|
335
|
+
}
|
|
336
|
+
|
|
258
337
|
return {
|
|
259
338
|
ok: true as const,
|
|
260
339
|
project: args.project,
|
|
261
340
|
agentId: args.agentId,
|
|
262
341
|
item: { id: item.id, priority: item.priority, text: item.text },
|
|
263
|
-
boardHunk
|
|
342
|
+
boardHunk,
|
|
343
|
+
board,
|
|
264
344
|
worktreeEnsured: true,
|
|
265
345
|
worktree: { path: wt.path, sha: wt.sha, branch: wt.branch, created: wt.created, base: wt.base },
|
|
266
346
|
};
|
|
@@ -404,6 +484,33 @@ export async function landTool(args: {
|
|
|
404
484
|
}
|
|
405
485
|
}
|
|
406
486
|
|
|
487
|
+
// 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
|
|
488
|
+
//
|
|
489
|
+
// Read back from disk, AFTER the write, and refuse to emit anything whose ref
|
|
490
|
+
// is not there. The ordering is the guarantee: an event cannot exist without
|
|
491
|
+
// the record entry that caused it, because the record is what is consulted to
|
|
492
|
+
// decide whether to emit. An event stream that can say "task X complete"
|
|
493
|
+
// while DONE.md does not is a second source of truth, and record-vs-state
|
|
494
|
+
// divergence is the defect this fleet hit most this week.
|
|
495
|
+
//
|
|
496
|
+
// Reported, never thrown: a delivery failure must not undo a merge that has
|
|
497
|
+
// already happened. `land` is a RECORDER.
|
|
498
|
+
let events: { emitted: RecordEvent[]; deliveries: unknown[]; refused: string[] } = { emitted: [], deliveries: [], refused: [] };
|
|
499
|
+
if (args.write && target) {
|
|
500
|
+
const after = readDoc(repo, DONE_DOC);
|
|
501
|
+
const recordText = after?.text ?? "";
|
|
502
|
+
const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(target.text) };
|
|
503
|
+
const derived = eventIsDerived(recordText, ev);
|
|
504
|
+
if (!derived.ok) {
|
|
505
|
+
events.refused.push(derived.error);
|
|
506
|
+
} else {
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
const { subs, deliveries } = evaluate(readSubs(), ev, now);
|
|
509
|
+
commitEvaluation(subs);
|
|
510
|
+
events = { emitted: [ev], deliveries, refused: [] };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
407
514
|
return {
|
|
408
515
|
ok: true as const,
|
|
409
516
|
project: args.project,
|
|
@@ -411,6 +518,7 @@ export async function landTool(args: {
|
|
|
411
518
|
comparedAgainst: ref,
|
|
412
519
|
landedIn: landedIn.slice(0, 8),
|
|
413
520
|
queueItem: target ? { id: target.id, closed: true, textUnchanged: true } : null,
|
|
521
|
+
events,
|
|
414
522
|
candidates: target
|
|
415
523
|
? undefined
|
|
416
524
|
: candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
|
package/src/tools/registry.ts
CHANGED
|
@@ -259,10 +259,29 @@ export async function listAgentsTool() {
|
|
|
259
259
|
|
|
260
260
|
const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
261
261
|
for (const [id, entry] of Object.entries(current)) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
// A LIVE TRANSPORT PROTECTS AN AGENT FROM EVICTION. IT DOES NOT STAMP IT.
|
|
263
|
+
//
|
|
264
|
+
// This loop used to write `entry.lastHeartbeat = now` here, and
|
|
265
|
+
// `agents.json` is ONE store shared by every fleet on the machine while
|
|
266
|
+
// `list_agents` takes no project argument — so a call from one fleet
|
|
267
|
+
// rewrote every other fleet's timestamps. Another fleet did not ask for
|
|
268
|
+
// that, cannot see it, and it is not ours to write.
|
|
269
|
+
//
|
|
270
|
+
// kit#105 removed the fabricated value from the RESPONSE; the WRITE
|
|
271
|
+
// stayed, so every consumer reading the file directly still saw
|
|
272
|
+
// freshness this call had invented.
|
|
273
|
+
//
|
|
274
|
+
// AND IT MASKED STALL DETECTION. `stall_check` reads `lastHeartbeat` to
|
|
275
|
+
// find agents that have gone quiet. Because any `list_agents` call
|
|
276
|
+
// refreshed every live-transport agent, that clock could never age past
|
|
277
|
+
// the threshold, so no-heartbeat could not fire for exactly the agents
|
|
278
|
+
// most likely to be stuck — alive, attached, and doing nothing.
|
|
279
|
+
//
|
|
280
|
+
// Nothing is lost: the pusher calls `heartbeat` every 60s
|
|
281
|
+
// (scripts/coord-pusher.mjs:181), so an attached agent has a REAL
|
|
282
|
+
// heartbeat. This stamp only ever overwrote a true value with a
|
|
283
|
+
// simultaneous one.
|
|
284
|
+
if (liveTransports.has(id)) continue;
|
|
266
285
|
if (now - entry.lastHeartbeat > EVICT_MS) {
|
|
267
286
|
evicted.push(id);
|
|
268
287
|
delete current[id];
|