agent-coord-mcp 0.26.14 → 0.26.16
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/capabilities.js +158 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/server-identity.js +82 -0
- package/dist/server-identity.js.map +1 -0
- package/dist/server.js +61 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/board-ref.js +164 -0
- package/dist/tools/board-ref.js.map +1 -0
- package/dist/tools/events.js +64 -7
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/record-events.js +9 -2
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +15 -1
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +3 -0
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +25 -24
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +26 -56
- package/dist/tools/transport.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +0 -0
- package/package.json +14 -11
- package/scripts/check-test-count.mjs +152 -91
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/capabilities.ts +188 -0
- package/src/server-identity.ts +83 -0
- package/src/server.ts +70 -2
- package/src/tools/board-ref.ts +194 -0
- package/src/tools/events.ts +89 -9
- package/src/tools/record-events.ts +9 -2
- package/src/tools/records.ts +15 -1
- package/src/tools/registry.ts +17 -0
- package/src/tools/stall.ts +38 -24
- package/src/tools/transport.ts +26 -57
|
File without changes
|
package/scripts/coord-token.mjs
CHANGED
|
File without changes
|
|
File without changes
|
package/scripts/spawn-agent.sh
CHANGED
|
File without changes
|
package/scripts/stop-agent.sh
CHANGED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Phase 5.1 Task 11 — CLOSE THE LOOP: answer a capability question by
|
|
3
|
+
* EXERCISING the capability, in the process that would serve it.
|
|
4
|
+
*
|
|
5
|
+
* 11.1: NO TIMESTAMP ON THIS BOX CAN ANSWER A CAPABILITY QUESTION. Every
|
|
6
|
+
* artefact people reach for is a proxy that fails differently:
|
|
7
|
+
*
|
|
8
|
+
* an mtime tracks WRITES. A reinstall of byte-identical code moves
|
|
9
|
+
* it, and an edit that never got built does not.
|
|
10
|
+
* serverBuildMtime is stamped at ATTACH, so it tells you when a transport
|
|
11
|
+
* started, not what the running process contains.
|
|
12
|
+
* a version string is a LABEL someone typed into package.json. Measured
|
|
13
|
+
* today: a tarball published as 0.5.23 contained a change
|
|
14
|
+
* the version numbering said it could not, and the number
|
|
15
|
+
* was believed over the artefact for an hour.
|
|
16
|
+
* `npm view` answers from a cache, and told this fleet the wrong
|
|
17
|
+
* published version twice in one morning.
|
|
18
|
+
*
|
|
19
|
+
* Each is honest about something and dishonest about capability, and the
|
|
20
|
+
* failure is always the same shape: an ANSWER ABOUT A LABEL read as an answer
|
|
21
|
+
* about behaviour.
|
|
22
|
+
*
|
|
23
|
+
* 11.2: A RELEASE IS NOT DELIVERED UNTIL A SERVER THAT RESTARTED ANSWERS.
|
|
24
|
+
* `merged · published · installed · restarted · observed` — five states, and
|
|
25
|
+
* the last two are the ones that keep being skipped. A probe run INSIDE the
|
|
26
|
+
* server process is the only artefact that speaks for the loaded code: it
|
|
27
|
+
* cannot be satisfied by a file that exists, a version that matches, or a
|
|
28
|
+
* package that installed, because it calls the code and reports what happened.
|
|
29
|
+
*
|
|
30
|
+
* WHY A PROBE MAY NEVER READ A VERSION: if a probe branched on a version
|
|
31
|
+
* string it would inherit that string's dishonesty, and a fleet would then
|
|
32
|
+
* have a capability check that passes on a restarted-but-not-upgraded server.
|
|
33
|
+
* Probes call behaviour. The version travels beside the answer as CONTEXT and
|
|
34
|
+
* is labelled as such.
|
|
35
|
+
*/
|
|
36
|
+
import { prRefsIn } from "./tools/record-events.js";
|
|
37
|
+
import { EVENT_KIND_IDS } from "./tools/event-kinds.js";
|
|
38
|
+
import { suggestRecordType, typedRecordMode } from "./typed-records.js";
|
|
39
|
+
import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
|
|
40
|
+
import { recordAuthorityFor } from "./roles.js";
|
|
41
|
+
import { subscriptionHealth } from "./tools/events.js";
|
|
42
|
+
|
|
43
|
+
export type ProbeResult = {
|
|
44
|
+
id: string;
|
|
45
|
+
/** The release the behaviour arrived in — reported, never TESTED against. */
|
|
46
|
+
since: string;
|
|
47
|
+
present: boolean;
|
|
48
|
+
/** What was actually called and what came back. The evidence, not a claim. */
|
|
49
|
+
evidence: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type Probe = { id: string; since: string; run: () => { present: boolean; evidence: string } };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Each probe calls a behaviour that did not exist before its release and
|
|
56
|
+
* reports what it observed. A probe that could pass without the code being
|
|
57
|
+
* loaded is not a probe — it is a restatement of the version.
|
|
58
|
+
*/
|
|
59
|
+
const PROBES: Probe[] = [
|
|
60
|
+
{
|
|
61
|
+
id: "typed-records-obligatory",
|
|
62
|
+
since: "0.26.10",
|
|
63
|
+
run: () => {
|
|
64
|
+
const s = suggestRecordType("DONE: shipped it", []);
|
|
65
|
+
const mode = typedRecordMode();
|
|
66
|
+
return {
|
|
67
|
+
present: s.type === "done" && (mode === "warn" || mode === "refuse"),
|
|
68
|
+
evidence: `suggestRecordType("DONE: …") -> '${s.type}', policy mode '${mode}'`,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "record-events-all-kinds",
|
|
74
|
+
since: "0.26.12",
|
|
75
|
+
run: () => {
|
|
76
|
+
const kinds = [...EVENT_KIND_IDS].sort();
|
|
77
|
+
const present = ["item", "phase", "pr", "task"].every((k) => kinds.includes(k as never));
|
|
78
|
+
return { present, evidence: `subscribable kinds: ${kinds.join(", ")}` };
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "multi-pr-citation",
|
|
83
|
+
since: "0.26.14",
|
|
84
|
+
run: () => {
|
|
85
|
+
const refs = prRefsIn("owner/repo#170, #173");
|
|
86
|
+
return {
|
|
87
|
+
present: refs.length === 2 && refs[1] === "owner/repo#173",
|
|
88
|
+
evidence: `prRefsIn("owner/repo#170, #173") -> [${refs.join(", ")}]`,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "away-means-david-away",
|
|
94
|
+
since: "0.26.13",
|
|
95
|
+
run: () => {
|
|
96
|
+
const present = "merge" in LEAD_REFUSED && PARKED_CATEGORIES.includes("licence" as never);
|
|
97
|
+
return {
|
|
98
|
+
present,
|
|
99
|
+
evidence: `coord_away refuses [${Object.keys(LEAD_REFUSED).join(", ")}] for the lead; parks ${PARKED_CATEGORIES.length} categories`,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "subscription-scanned-vs-evaluated",
|
|
105
|
+
since: "0.26.15",
|
|
106
|
+
run: () => {
|
|
107
|
+
// The capability is that "the machinery ran" and "your kind fired" are
|
|
108
|
+
// separable. Probed by asking for the state that used to be reported as
|
|
109
|
+
// broken: scanned, never evaluated — a healthy idle watch.
|
|
110
|
+
const base = { id: "p", agentId: "p", kind: "item", target: "t", createdAt: 0, lastEvaluatedAt: null, lastEventAt: null, delivered: [] } as never;
|
|
111
|
+
const quiet = subscriptionHealth({ ...(base as object), lastScannedAt: Date.now() } as never);
|
|
112
|
+
const unscanned = subscriptionHealth({ ...(base as object), lastScannedAt: null } as never);
|
|
113
|
+
return {
|
|
114
|
+
present: quiet.level === "ok" && unscanned.level === "error",
|
|
115
|
+
evidence: `scanned+quiet -> '${quiet.level}', never-scanned -> '${unscanned.level}'`,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "record-authority",
|
|
121
|
+
since: "0.24.0",
|
|
122
|
+
run: () => {
|
|
123
|
+
const worker = recordAuthorityFor({ roleId: "worker" });
|
|
124
|
+
return {
|
|
125
|
+
present: worker.mayNotEmit.includes("verdict"),
|
|
126
|
+
evidence: `a worker mayNotEmit: [${worker.mayNotEmit.join(", ")}]`,
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
export type CapabilityReport = {
|
|
133
|
+
/**
|
|
134
|
+
* WHICH PROCESS ANSWERED. Not evidence of anything — context, so a reader can
|
|
135
|
+
* tell two servers apart when their answers disagree. Deliberately beside the
|
|
136
|
+
* probe results rather than above them: the temptation this whole verb exists
|
|
137
|
+
* to remove is reading the identity INSTEAD of the answers.
|
|
138
|
+
*/
|
|
139
|
+
answeredBy: { pid: number; startedAtIso: string; module: string; versionLabel: string };
|
|
140
|
+
probes: ProbeResult[];
|
|
141
|
+
missing: string[];
|
|
142
|
+
ok: boolean;
|
|
143
|
+
note: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export function probeCapabilities(context: { module: string; versionLabel: string }): CapabilityReport {
|
|
147
|
+
const probes: ProbeResult[] = PROBES.map((p) => {
|
|
148
|
+
try {
|
|
149
|
+
const r = p.run();
|
|
150
|
+
return { id: p.id, since: p.since, present: r.present, evidence: r.evidence };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// A THROWN PROBE IS AN ABSENT CAPABILITY, NOT A BROKEN CHECK. Older code
|
|
153
|
+
// that lacks the symbol throws exactly here, and reporting that as an
|
|
154
|
+
// error rather than an absence would make the common case look like a
|
|
155
|
+
// malfunction.
|
|
156
|
+
return { id: p.id, since: p.since, present: false, evidence: `probe threw: ${(e as Error).message}` };
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
const missing = probes.filter((p) => !p.present).map((p) => p.id);
|
|
160
|
+
return {
|
|
161
|
+
answeredBy: {
|
|
162
|
+
pid: process.pid,
|
|
163
|
+
startedAtIso: new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString(),
|
|
164
|
+
module: context.module,
|
|
165
|
+
versionLabel: context.versionLabel,
|
|
166
|
+
},
|
|
167
|
+
probes,
|
|
168
|
+
missing,
|
|
169
|
+
ok: missing.length === 0,
|
|
170
|
+
note:
|
|
171
|
+
"Every line above was produced by CALLING the code in this process. `versionLabel` is a label someone typed " +
|
|
172
|
+
"into package.json and is context, never evidence — a published tarball has already been observed carrying a " +
|
|
173
|
+
"change its version said it could not. THIS ANSWER IS ABOUT ONE PROCESS: a release is delivered when every " +
|
|
174
|
+
"live agent's OWN server answers, which is the fifth state (merged · published · installed · restarted · observed). " +
|
|
175
|
+
"A server that has not restarted answers honestly about the old code it is still running.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ── the verb ──────────────────────────────────────────────────────────────── */
|
|
180
|
+
|
|
181
|
+
import { resolveServerIdentity } from "./server-identity.js";
|
|
182
|
+
|
|
183
|
+
export const capabilitiesSchema = {} as const;
|
|
184
|
+
|
|
185
|
+
export async function capabilitiesTool() {
|
|
186
|
+
const id = resolveServerIdentity();
|
|
187
|
+
return probeCapabilities({ module: id.path, versionLabel: id.version });
|
|
188
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHICH SERVER PACKAGE IS RUNNING — a LEAF module, importing nothing from
|
|
3
|
+
* `tools/`.
|
|
4
|
+
*
|
|
5
|
+
* It lived in `transport.ts`, and `capabilities.ts` imported it from there. The
|
|
6
|
+
* moment `status` needs to REPORT capabilities, that becomes
|
|
7
|
+
* transport -> capabilities -> transport: a cycle, and the same one Task 9
|
|
8
|
+
* produced when the kind registry sat inside `record-events.ts`. That one only
|
|
9
|
+
* worked because `tools/index.ts` happened to import in the surviving order —
|
|
10
|
+
* a load-ORDER dependency, invisible until something imported a module
|
|
11
|
+
* directly, which is what a consumer or a test does.
|
|
12
|
+
*
|
|
13
|
+
* A cycle that works by luck is not a working cycle, so the shared fact moves
|
|
14
|
+
* out rather than the edge being added on top of it.
|
|
15
|
+
*
|
|
16
|
+
* NOTE WHAT THIS REPORTS AND WHAT IT DOES NOT. `version` is the label in the
|
|
17
|
+
* package.json this process loaded from — CONTEXT for a reader, never evidence
|
|
18
|
+
* of a capability. A published tarball has already been observed carrying a
|
|
19
|
+
* change its version said it could not. Capability questions are answered by
|
|
20
|
+
* `capabilities`, which CALLS the code.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { isGitRepo } from "./tools/scopes.js";
|
|
27
|
+
|
|
28
|
+
/** Running server package + optional git identity. `fromDir` is a test seam. */
|
|
29
|
+
export function resolveServerIdentity(fromDir?: string): {
|
|
30
|
+
path: string;
|
|
31
|
+
version: string;
|
|
32
|
+
branch?: string;
|
|
33
|
+
sha?: string;
|
|
34
|
+
} {
|
|
35
|
+
const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
let dir = start;
|
|
37
|
+
let pkgFile: string | undefined;
|
|
38
|
+
for (let i = 0; i < 8; i++) {
|
|
39
|
+
const candidate = path.join(dir, "package.json");
|
|
40
|
+
if (existsSync(candidate)) {
|
|
41
|
+
pkgFile = candidate;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const parent = path.dirname(dir);
|
|
45
|
+
if (parent === dir) break;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
|
|
49
|
+
const pkgDir = path.dirname(pkgPath);
|
|
50
|
+
let version = "unknown";
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
|
|
53
|
+
if (typeof raw.version === "string" && raw.version) version = raw.version;
|
|
54
|
+
} catch {
|
|
55
|
+
/* leave unknown — never invent a version */
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const out: { path: string; version: string; branch?: string; sha?: string } = {
|
|
59
|
+
path: pkgDir,
|
|
60
|
+
version,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
dir = pkgDir;
|
|
64
|
+
let gitRoot: string | undefined;
|
|
65
|
+
for (let i = 0; i < 10; i++) {
|
|
66
|
+
if (existsSync(path.join(dir, ".git"))) {
|
|
67
|
+
gitRoot = dir;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
const parent = path.dirname(dir);
|
|
71
|
+
if (parent === dir) break;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
if (gitRoot && isGitRepo(gitRoot)) {
|
|
75
|
+
const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
76
|
+
const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
77
|
+
const b = branch.status === 0 ? String(branch.stdout).trim() : "";
|
|
78
|
+
const s = sha.status === 0 ? String(sha.stdout).trim() : "";
|
|
79
|
+
if (b) out.branch = b;
|
|
80
|
+
if (s) out.sha = s;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -10,11 +10,14 @@ import { coordAwaySchema, coordAwayTool, readAway, awayRefusal, secondCoordinato
|
|
|
10
10
|
import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool } from "./tools/rotate.js";
|
|
11
11
|
import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
|
|
12
12
|
import { scanRecordEventsSchema, scanRecordEventsTool } from "./tools/record-events.js";
|
|
13
|
+
import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
|
|
13
14
|
import {
|
|
14
15
|
ensureDirs,
|
|
15
16
|
getTokenMap,
|
|
16
17
|
reloadTokenMapSync,
|
|
17
18
|
sessionFile,
|
|
19
|
+
listSessionFiles,
|
|
20
|
+
readJson,
|
|
18
21
|
type SessionBinding,
|
|
19
22
|
} from "./store.js";
|
|
20
23
|
import {
|
|
@@ -105,6 +108,7 @@ import {
|
|
|
105
108
|
listWorkTool,
|
|
106
109
|
exportWorkSchema,
|
|
107
110
|
exportWorkTool,
|
|
111
|
+
isPidAlive,
|
|
108
112
|
} from "./tools/index.js";
|
|
109
113
|
|
|
110
114
|
function jsonResult(data: unknown) {
|
|
@@ -145,6 +149,41 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
145
149
|
let sessionMarker: string | undefined;
|
|
146
150
|
let exitHooksInstalled = false;
|
|
147
151
|
|
|
152
|
+
// Task 16.1: the stdio half of the leak `#174` closed for HTTP. `#169`
|
|
153
|
+
// shipped an idle-timeout version of this and was HELD after a live test
|
|
154
|
+
// proved the failure mode it risked: killing a session's process out from
|
|
155
|
+
// under a still-attached client is SILENT, NON-RECOVERABLE loss, and idle
|
|
156
|
+
// is not abandoned. This reaps on the same signal `#174` uses instead — a
|
|
157
|
+
// NEW bind for the SAME agent id — which needs no timer and no guess.
|
|
158
|
+
//
|
|
159
|
+
// Why a new bind is PROOF rather than a guess, mirroring `#174`'s
|
|
160
|
+
// `agentSessions` reasoning one process boundary over: `recordSessionBinding`
|
|
161
|
+
// only runs after `guardFirstClaim` has already authorized this claim —
|
|
162
|
+
// tofu (verified the id was NOT live), token (the holder's own credential),
|
|
163
|
+
// force (an explicit override) or same-pane (the same physical seat
|
|
164
|
+
// restarting). In every one of those four paths, whoever controls this
|
|
165
|
+
// agent id has already moved to THIS session, so any other live marker for
|
|
166
|
+
// the same id left over is provably superseded, not merely idle — the
|
|
167
|
+
// exact distinction `#169` could not make with a timer.
|
|
168
|
+
async function reapSupersededStdioSessions(agentId: string): Promise<void> {
|
|
169
|
+
for (const file of await listSessionFiles()) {
|
|
170
|
+
const s = await readJson<SessionBinding | null>(file, null);
|
|
171
|
+
if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
|
|
172
|
+
if (!isPidAlive(s.pid)) continue; // already gone; nothing to signal
|
|
173
|
+
console.error(
|
|
174
|
+
`[agent-coord-mcp] reaping stdio session pid ${s.pid} for agent '${agentId}' — superseded by this session (pid ${process.pid})`,
|
|
175
|
+
);
|
|
176
|
+
try {
|
|
177
|
+
// SIGTERM, not a kill(9): the superseded process's OWN exit hook
|
|
178
|
+
// (below) unlinks its marker and exits 0 — the same graceful path
|
|
179
|
+
// an operator's `quit` or Ctrl-C would take, not a hard kill.
|
|
180
|
+
process.kill(s.pid, "SIGTERM");
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error(`[agent-coord-mcp] reap FAILED for pid ${s.pid}:`, err);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
148
187
|
// Best-effort: a marker left behind by SIGKILL has a dead pid, which both
|
|
149
188
|
// the guard's evidence read and doctor's duplicate-session-binding check
|
|
150
189
|
// treat as garbage (doctor fix deletes it).
|
|
@@ -176,6 +215,9 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
176
215
|
process.on(sig, () => { cleanup(); process.exit(0); });
|
|
177
216
|
}
|
|
178
217
|
}
|
|
218
|
+
reapSupersededStdioSessions(agentId).catch((err) =>
|
|
219
|
+
console.error("[agent-coord-mcp] stdio supersession reap threw:", err),
|
|
220
|
+
);
|
|
179
221
|
} catch { /* marker is observability, never worth failing the bind */ }
|
|
180
222
|
}
|
|
181
223
|
|
|
@@ -215,9 +257,28 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
215
257
|
}
|
|
216
258
|
if (ev.live) {
|
|
217
259
|
if (ev.samePane && ev.boundElsewhere === 0) return "same-pane";
|
|
260
|
+
// A LEFTOVER SESSION IS NAMED AS ONE, AND force DOES NOT REMOVE IT.
|
|
261
|
+
//
|
|
262
|
+
// The old wording reported the same evidence and then offered `force:true`
|
|
263
|
+
// as the way through, which reads as a procedural step. It is not: forcing
|
|
264
|
+
// past a live session does not stop that process — it adds a second
|
|
265
|
+
// claimant to one identity, which is the condition that had two sessions
|
|
266
|
+
// holding worker-3's yesterday. The refusal papered over the thing it had
|
|
267
|
+
// just detected.
|
|
268
|
+
//
|
|
269
|
+
// A live TRANSPORT marker is a different fact and is not called a leak:
|
|
270
|
+
// that pid is the pusher daemon, doing its job. Only a live SESSION
|
|
271
|
+
// binding that is not this process is a leftover.
|
|
272
|
+
const leaks = ev.leakedSessions ?? [];
|
|
273
|
+
const leakLine = leaks.length
|
|
274
|
+
? ` LEFTOVER PROCESS${leaks.length > 1 ? "ES" : ""} HOLDING THIS IDENTITY: ` +
|
|
275
|
+
leaks.map((l) => `pid ${l.pid} (via ${l.via})`).join(", ") +
|
|
276
|
+
`. Look at ${leaks.length > 1 ? "them" : "it"} before deciding — \`ps -p ${leaks.map((l) => l.pid).join(",")}\` — because force:true does NOT stop ${leaks.length > 1 ? "those processes" : "that process"}; it binds a SECOND session to one identity, and two sessions on one id is the defect this refusal exists to surface, not a step past it.`
|
|
277
|
+
: "";
|
|
218
278
|
throw new Error(
|
|
219
|
-
`agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it
|
|
220
|
-
|
|
279
|
+
`agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it.` +
|
|
280
|
+
leakLine +
|
|
281
|
+
` If you ARE '${claimed}' restarting, re-join from its own tmux pane${leaks.length ? " once nothing else holds it" : ""}, or pass its token. ` +
|
|
221
282
|
`If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
|
|
222
283
|
);
|
|
223
284
|
}
|
|
@@ -635,6 +696,13 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
635
696
|
gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
636
697
|
);
|
|
637
698
|
|
|
699
|
+
addTool(
|
|
700
|
+
"capabilities",
|
|
701
|
+
"What THIS SERVER PROCESS can actually do, answered by CALLING each capability rather than by reading a version, an mtime, or a file. No timestamp on the box can answer a capability question: an mtime tracks writes (a reinstall of identical bytes moves it), `serverBuildMtime` is stamped at attach, and a version is a label someone typed — a published tarball has been observed carrying a change its version said it could not. Each probe reports the call it made and what came back, so an absent capability reads as an absence rather than an error. THE ANSWER IS ABOUT ONE PROCESS: a release is delivered only when every live agent's OWN server answers, which is the fifth state (merged \u00b7 published \u00b7 installed \u00b7 restarted \u00b7 observed). A server that has not restarted answers honestly about the old code it still runs.",
|
|
702
|
+
capabilitiesSchema,
|
|
703
|
+
gate(null, capabilitiesTool as () => Promise<unknown>),
|
|
704
|
+
);
|
|
705
|
+
|
|
638
706
|
addTool(
|
|
639
707
|
"scan_record_events",
|
|
640
708
|
"Turn a repo's COMMITTED record changes into events: new `docs/DONE.md` entries, queue items that left `docs/QUEUE.md`, and newly-ticked phase checkboxes, between the stored watermark and HEAD. The commit is the boundary \u2014 an uncommitted edit is not yet a record. A HAND-EDIT fires exactly like `land` does, which is the point: fleets merge with `gh` and edit DONE.md directly. Reports by default; `write:true` delivers and advances the watermark. Re-scanning a delivered range is safe (the idempotency key comes from the event, so it reports duplicate-suppressed), and a watermark that no longer resolves REFUSES rather than silently narrowing its window.",
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* IS THIS BOARD CELL'S REF A PER-AGENT ACTIVITY SIGNAL?
|
|
3
|
+
*
|
|
4
|
+
* One classifier, used by the CHECKER that reads the cell and the VERB that
|
|
5
|
+
* writes it. A rule enforced at the read and not the write is a rule the write
|
|
6
|
+
* defeats: `claim` writes these rows, so if a bad ref is refusable by
|
|
7
|
+
* `stall_check` and writable by `claim`, the verb the fleet is being pushed
|
|
8
|
+
* toward becomes the thing that manufactures the bad row.
|
|
9
|
+
*
|
|
10
|
+
* FOUR WAYS THIS ONE CELL HAS BEEN WRONG, all measured on this fleet:
|
|
11
|
+
*
|
|
12
|
+
* `docs/*` a PATH — refused today, correctly
|
|
13
|
+
* `main` a SHARED ref — resolved, and reported the
|
|
14
|
+
* whole fleet's activity as one agent's, so the
|
|
15
|
+
* row could never stall
|
|
16
|
+
* `worker-3/task13-…` LOCAL-ONLY — resolved for its author and for
|
|
17
|
+
* nobody else, so the same board read
|
|
18
|
+
* differently from two checkouts
|
|
19
|
+
* `worker-1/task16-3-…` MERGED — resolves everywhere and is FROZEN by
|
|
20
|
+
* construction, so it reports an ever-growing
|
|
21
|
+
* stall on a lane that is finished
|
|
22
|
+
*
|
|
23
|
+
* THREE OF THOSE FOUR RESOLVED CLEANLY. So the test cannot be "does it
|
|
24
|
+
* resolve" — that check passed on every bad case except the path. The property
|
|
25
|
+
* is narrower and it is the one thing all four violate:
|
|
26
|
+
*
|
|
27
|
+
* THE CELL MUST NAME A REF WHOSE MOVEMENT IS THIS AGENT'S WORK.
|
|
28
|
+
*
|
|
29
|
+
* Which decomposes into three questions git can actually answer:
|
|
30
|
+
*
|
|
31
|
+
* scoped does the ref name begin with this agent's id? That is the ONLY
|
|
32
|
+
* per-agent signal this repo carries. Author cannot serve: measured
|
|
33
|
+
* on 60 commits of `origin/main`, every one is authored "David
|
|
34
|
+
* Balzan", so an `--author` filter would match every agent on every
|
|
35
|
+
* ref forever WHILE LOOKING SCOPED.
|
|
36
|
+
* shared is it resolvable from ANY checkout, not just the one that happens
|
|
37
|
+
* to have fetched it? That means the remote-tracking form.
|
|
38
|
+
* live is it still ahead of the base? A merged branch's ref never moves
|
|
39
|
+
* again, so measuring it produces a number that only grows.
|
|
40
|
+
*
|
|
41
|
+
* WHAT THIS DELIBERATELY DOES NOT DO: guess. Every rejection is reported with
|
|
42
|
+
* the reason and the population it judged, and an UNPUSHED branch is called
|
|
43
|
+
* unpushed rather than stalled — a freshly claimed lane has no shared evidence
|
|
44
|
+
* yet, and saying "stalled" there would be the manufactured-narrowing error
|
|
45
|
+
* pointed at a new lane.
|
|
46
|
+
*/
|
|
47
|
+
import { execFileSync } from "node:child_process";
|
|
48
|
+
|
|
49
|
+
export type BoardRefVerdict =
|
|
50
|
+
| { kind: "measurable"; ref: string }
|
|
51
|
+
| { kind: "empty"; why: string }
|
|
52
|
+
| { kind: "path"; why: string }
|
|
53
|
+
| { kind: "shared"; why: string }
|
|
54
|
+
| { kind: "unscoped"; why: string }
|
|
55
|
+
| { kind: "local-only"; why: string }
|
|
56
|
+
| { kind: "unpushed"; why: string }
|
|
57
|
+
| { kind: "merged"; why: string };
|
|
58
|
+
|
|
59
|
+
const git = (repo: string, args: string[]): string | null => {
|
|
60
|
+
try {
|
|
61
|
+
return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const resolves = (repo: string, ref: string): boolean => !!git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
67
|
+
|
|
68
|
+
/** The `\`ref\`` inside a `Branch · Worktree` cell, or "". */
|
|
69
|
+
export function refInCell(cell: string): string {
|
|
70
|
+
return (String(cell ?? "").match(/`([^`]+)`/)?.[1] ?? "").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The remote-tracking form a board cell should name for `branch`. */
|
|
74
|
+
export const boardRefFor = (branch: string): string => (branch.startsWith("origin/") ? branch : `origin/${branch}`);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Classify one cell for one agent.
|
|
78
|
+
*
|
|
79
|
+
* `base` is the ref a merged branch would be contained in — `origin/main` by
|
|
80
|
+
* convention, passed rather than assumed so a consumer fleet on another default
|
|
81
|
+
* branch is not silently misjudged.
|
|
82
|
+
*/
|
|
83
|
+
export function classifyBoardRef(
|
|
84
|
+
repo: string,
|
|
85
|
+
agentId: string,
|
|
86
|
+
cell: string,
|
|
87
|
+
base = "origin/main",
|
|
88
|
+
): BoardRefVerdict {
|
|
89
|
+
const raw = refInCell(cell);
|
|
90
|
+
if (!raw) return { kind: "empty", why: "no ref in the Branch · Worktree cell" };
|
|
91
|
+
|
|
92
|
+
const bare = raw.replace(/^origin\//, "");
|
|
93
|
+
|
|
94
|
+
// A SHARED REF FIRST, because `main` resolves and would otherwise be measured.
|
|
95
|
+
// Named explicitly rather than inferred from scoping, so the reason a reader
|
|
96
|
+
// gets is the real one.
|
|
97
|
+
const shared = new Set(["main", "master", "develop", "trunk", "HEAD"]);
|
|
98
|
+
if (shared.has(bare)) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "shared",
|
|
101
|
+
why:
|
|
102
|
+
`'${raw}' is a SHARED branch — every agent merges into it, so its movement is the fleet's activity and not '${agentId}'s. ` +
|
|
103
|
+
`A row pointing here can never stall, which is a manufactured green rather than a measurement.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// SCOPED BY NAME — but only refusing a ref that belongs to a DIFFERENT agent,
|
|
108
|
+
// never one that is merely unconventional.
|
|
109
|
+
//
|
|
110
|
+
// Authorship cannot carry ownership here (every commit in this repo is
|
|
111
|
+
// authored by the same person, so `--author` would match every agent on every
|
|
112
|
+
// ref while looking scoped), which leaves the branch-naming convention
|
|
113
|
+
// `<agentId>/<slice>`. But REQUIRING that convention would make a repo that
|
|
114
|
+
// does not use it universally blind — and a narrowing that disables the check
|
|
115
|
+
// everywhere is worse than the defect it fixes. So a plain `feature-x` is
|
|
116
|
+
// allowed and `<someone-else>/x` is refused: the second is positively
|
|
117
|
+
// someone else's work, the first is only unlabelled.
|
|
118
|
+
const owner = bare.includes("/") ? bare.slice(0, bare.indexOf("/")) : null;
|
|
119
|
+
if (owner && owner !== agentId && /^[\w.-]+-(worker|aide|qa|ci|coordinator)(-\d+)?$/.test(owner)) {
|
|
120
|
+
return {
|
|
121
|
+
kind: "unscoped",
|
|
122
|
+
why:
|
|
123
|
+
`'${raw}' is scoped to '${owner}', not '${agentId}' — its movement is that agent's work. ` +
|
|
124
|
+
`A per-agent signal needs a ref only this agent writes, and the branch name is the only ownership marker this repo carries.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const remote = `origin/${bare}`;
|
|
129
|
+
const remoteResolves = resolves(repo, remote);
|
|
130
|
+
const localResolves = resolves(repo, bare);
|
|
131
|
+
|
|
132
|
+
// NO SHARED FORM CAN EXIST, so the local ref is the best evidence there is.
|
|
133
|
+
// Without this a repo with no `origin` remote would report every row
|
|
134
|
+
// unmeasurable forever — the check disabled everywhere by a rule meant to
|
|
135
|
+
// make it honest. Preferring the shared form is right; requiring one that
|
|
136
|
+
// cannot exist is not.
|
|
137
|
+
if (!remoteResolves && !resolves(repo, "origin/HEAD") && git(repo, ["remote", "get-url", "origin"]) === null) {
|
|
138
|
+
if (!localResolves) {
|
|
139
|
+
return { kind: "path", why: `'${raw}' does not resolve as a git ref in a repo with no remote — it is a path or glob` };
|
|
140
|
+
}
|
|
141
|
+
return { kind: "measurable", ref: bare };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!remoteResolves) {
|
|
145
|
+
// Local-only vs never-pushed are different facts and only one is a defect
|
|
146
|
+
// in the CELL. Both are unmeasurable, and neither is a stall.
|
|
147
|
+
if (localResolves) {
|
|
148
|
+
return {
|
|
149
|
+
kind: "local-only",
|
|
150
|
+
why:
|
|
151
|
+
`'${raw}' resolves in THIS checkout and not on the remote, so the same board reads differently from another checkout — ` +
|
|
152
|
+
`whether it is measurable depends on who last ran \`git fetch\`, which is a property of the reader rather than of the work.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
kind: "unpushed",
|
|
157
|
+
why:
|
|
158
|
+
`'${remote}' does not exist — the branch has not been pushed, so there is no shared evidence of activity yet. ` +
|
|
159
|
+
`That is an ABSENCE of evidence on a new lane, not a stall.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// MERGED IS FROZEN. Its ref can never move again, so any age computed from it
|
|
164
|
+
// only grows — an ever-worsening stall on a lane that is finished.
|
|
165
|
+
//
|
|
166
|
+
// `--is-ancestor` ALONE CANNOT SEE IT, and this fleet is the case that proves
|
|
167
|
+
// it: we SQUASH-merge, so the branch's commits never become ancestors of the
|
|
168
|
+
// base. Measured on a branch I knew was merged — `--is-ancestor` said NO
|
|
169
|
+
// while the work was plainly on main. A merged-detector that cannot detect
|
|
170
|
+
// this repo's own merge strategy is inert, which is worse than absent because
|
|
171
|
+
// it reads as covered.
|
|
172
|
+
//
|
|
173
|
+
// `git cherry` compares PATCH IDS, so a squashed single-commit branch reports
|
|
174
|
+
// `-` (already upstream). It is not complete either — squashing several
|
|
175
|
+
// commits into one changes the patch id — so BOTH tests run and either one
|
|
176
|
+
// answering yes is enough. Neither is asked to be sufficient alone.
|
|
177
|
+
if (resolves(repo, base)) {
|
|
178
|
+
const ancestor = git(repo, ["merge-base", "--is-ancestor", remote, base]) !== null;
|
|
179
|
+
const cherry = git(repo, ["cherry", base, remote]);
|
|
180
|
+
const allUpstream =
|
|
181
|
+
cherry !== null && cherry.length > 0 && cherry.split("\n").every((l) => l.trim().startsWith("-"));
|
|
182
|
+
if (ancestor || allUpstream) {
|
|
183
|
+
return {
|
|
184
|
+
kind: "merged",
|
|
185
|
+
why:
|
|
186
|
+
`'${remote}' has already landed in ${base} (${ancestor ? "an ancestor" : "every commit's patch is upstream — a squash merge"}) — ` +
|
|
187
|
+
`so this ref is FROZEN and can never move again. ` +
|
|
188
|
+
`Measuring it reports a stall that grows forever on a finished lane. The row needs the agent's current slice, or an idle marker.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { kind: "measurable", ref: remote };
|
|
194
|
+
}
|