agent-coord-mcp 0.26.16 → 0.26.18
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/hooks/replay.mjs +107 -0
- package/hooks/tier.mjs +11 -0
- package/hooks/tmux-pusher.mjs +27 -0
- package/package.json +1 -1
- package/scripts/coord-pusher.mjs +16 -32
- package/src/server.ts +40 -20
- package/src/tools/admin.ts +8 -1
- package/src/tools/away.ts +48 -4
- package/src/tools/board-ref.ts +47 -2
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +381 -25
- package/src/tools/registry.ts +5 -2
- package/src/tools/rooms.ts +2 -1
- package/src/tools/shared.ts +28 -0
- package/src/tools/stall.ts +29 -1
- package/src/tools/transport.ts +79 -10
- package/src/tools/work.ts +70 -4
- package/src/tools/worktrees.ts +64 -2
- package/src/work.ts +4 -0
- package/dist/build.js +0 -113
- package/dist/build.js.map +0 -1
- package/dist/capabilities.js +0 -158
- package/dist/capabilities.js.map +0 -1
- package/dist/prefix.js +0 -64
- package/dist/prefix.js.map +0 -1
- package/dist/roles.js +0 -132
- package/dist/roles.js.map +0 -1
- package/dist/server-identity.js +0 -82
- package/dist/server-identity.js.map +0 -1
- package/dist/server.js +0 -625
- package/dist/server.js.map +0 -1
- package/dist/store.js +0 -553
- package/dist/store.js.map +0 -1
- package/dist/tools/admin.js +0 -317
- package/dist/tools/admin.js.map +0 -1
- package/dist/tools/attention.js +0 -73
- package/dist/tools/attention.js.map +0 -1
- package/dist/tools/away.js +0 -243
- package/dist/tools/away.js.map +0 -1
- package/dist/tools/board-ref.js +0 -164
- package/dist/tools/board-ref.js.map +0 -1
- package/dist/tools/event-kinds.js +0 -39
- package/dist/tools/event-kinds.js.map +0 -1
- package/dist/tools/events.js +0 -234
- package/dist/tools/events.js.map +0 -1
- package/dist/tools/index.js +0 -14
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/logwatch.js +0 -85
- package/dist/tools/logwatch.js.map +0 -1
- package/dist/tools/messaging.js +0 -667
- package/dist/tools/messaging.js.map +0 -1
- package/dist/tools/record-events.js +0 -380
- package/dist/tools/record-events.js.map +0 -1
- package/dist/tools/records.js +0 -686
- package/dist/tools/records.js.map +0 -1
- package/dist/tools/registry.js +0 -497
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/render.js +0 -2
- package/dist/tools/render.js.map +0 -1
- package/dist/tools/rooms.js +0 -210
- package/dist/tools/rooms.js.map +0 -1
- package/dist/tools/rotate.js +0 -143
- package/dist/tools/rotate.js.map +0 -1
- package/dist/tools/scopes.js +0 -126
- package/dist/tools/scopes.js.map +0 -1
- package/dist/tools/shared.js +0 -86
- package/dist/tools/shared.js.map +0 -1
- package/dist/tools/stall.js +0 -387
- package/dist/tools/stall.js.map +0 -1
- package/dist/tools/transport.js +0 -1810
- package/dist/tools/transport.js.map +0 -1
- package/dist/tools/work.js +0 -319
- package/dist/tools/work.js.map +0 -1
- package/dist/tools/worktrees.js +0 -339
- package/dist/tools/worktrees.js.map +0 -1
- package/dist/typed-records.js +0 -174
- package/dist/typed-records.js.map +0 -1
- package/dist/work.js +0 -2
- package/dist/work.js.map +0 -1
package/src/tools/shared.ts
CHANGED
|
@@ -182,6 +182,16 @@ export type Message = {
|
|
|
182
182
|
// (or any other message) carries the parent message id so consumers can
|
|
183
183
|
// mark the packet answered. Absent on every v1/v2 message — those stay valid.
|
|
184
184
|
inReplyTo?: string;
|
|
185
|
+
// PROVENANCE (q-314e0187): the pid of the process that wrote this entry,
|
|
186
|
+
// and its tmux pane when known. `from` names an IDENTITY, not a PROCESS —
|
|
187
|
+
// two live sessions bound to the same agent id write byte-identical `from`
|
|
188
|
+
// values, and that is exactly the condition that let worker-3 truthfully
|
|
189
|
+
// deny sending a message that exists under its name on 2026-08-31: both
|
|
190
|
+
// sessions' accounts were true, and nothing on the message could say which
|
|
191
|
+
// wrote it. Cross-reference against sessions/*.json (SessionBinding) or
|
|
192
|
+
// `ps -p <pid>` to attribute a disputed entry. Absent on every message
|
|
193
|
+
// written before this field existed — that is UNKNOWN, not "same process".
|
|
194
|
+
provenance?: { pid: number; tmuxPane?: string };
|
|
185
195
|
};
|
|
186
196
|
|
|
187
197
|
// THE retention predicate — one definition, three call sites (prune,
|
|
@@ -219,6 +229,24 @@ export type Cursor = {
|
|
|
219
229
|
|
|
220
230
|
export type Source = "inbox" | "room" | "status";
|
|
221
231
|
|
|
232
|
+
// q-40449919 (regression from 0.26.8): a `cursors/*.json` filename has TWO
|
|
233
|
+
// shapes since the push/read cursor split (hooks/push-cursor.mjs) —
|
|
234
|
+
// `<agent>.json` (read) and `<agent>.push.json` (push) — and every reader of
|
|
235
|
+
// `listCursorFiles()` was stripping only the outer `.json`, so a push
|
|
236
|
+
// cursor for `worker-3` parsed as an agent literally named `worker-3.push`.
|
|
237
|
+
// That id is unregistered by construction (false `orphan-inboxes-cursors`),
|
|
238
|
+
// and looking up ITS inbox resolves to a file that never existed, so its
|
|
239
|
+
// real, correct offset compares as past-EOF against an empty file — the
|
|
240
|
+
// bus's only error-level finding, driving `healthy:false` for a healthy
|
|
241
|
+
// fleet. ONE parser, used everywhere a cursor filename becomes an agent id,
|
|
242
|
+
// so the assumption cannot re-drift into some other reader the way the
|
|
243
|
+
// filename shape itself drifted out from under the callers that predate it.
|
|
244
|
+
export function agentIdFromCursorFilename(fname: string): { id: string; kind: "read" | "push" } {
|
|
245
|
+
const push = /^(.*)\.push\.json$/.exec(fname);
|
|
246
|
+
if (push) return { id: push[1], kind: "push" };
|
|
247
|
+
return { id: fname.replace(/\.json$/, ""), kind: "read" };
|
|
248
|
+
}
|
|
249
|
+
|
|
222
250
|
// Resolve the physical file for a (source, agent, channel) tuple.
|
|
223
251
|
export function sourceFile(source: Source, agentId: string, room?: string): string {
|
|
224
252
|
if (source === "inbox") return inboxFile(agentId);
|
package/src/tools/stall.ts
CHANGED
|
@@ -218,6 +218,34 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
|
218
218
|
|
|
219
219
|
export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.number().optional() };
|
|
220
220
|
|
|
221
|
+
/**
|
|
222
|
+
* A row somebody is WORKING — the population the stall clock watches.
|
|
223
|
+
*
|
|
224
|
+
* It was `/🚧/` alone, and that read a fleet living in review as an EMPTY
|
|
225
|
+
* fleet (q-507e80c4). Measured on a consumer fleet 2026-09-02: a board of 3× `🔍 In
|
|
226
|
+
* Review` and 0× `🚧` returned `checked: 0, measurable: 0`, and `coord_away`
|
|
227
|
+
* armed at 0/0 — correct and vacuous at once. A clock watching nothing and a
|
|
228
|
+
* clock watching a healthy fleet return identical green.
|
|
229
|
+
*
|
|
230
|
+
* `🔍 In Review` is a lane with an owner and a branch that can stop moving
|
|
231
|
+
* exactly as an authoring lane can; the review that never gets re-gated is
|
|
232
|
+
* THE stall shape of a QA-gated fleet. It is read here, on the row as written
|
|
233
|
+
* — relabelling `🔍`→`🚧` to satisfy the old predicate was the item's
|
|
234
|
+
* forbidden move, because it satisfies a guard by editing what it reads and
|
|
235
|
+
* reports authoring lanes that do not exist.
|
|
236
|
+
*
|
|
237
|
+
* NOT widened further, deliberately: `⏸ Parked`, `⏳ Queued`, `⛔ Blocked`,
|
|
238
|
+
* `🚫 Unstaffable`, `✅ Done` have no lane to stall. Widening to everything
|
|
239
|
+
* would be the 0/0 defect inverted.
|
|
240
|
+
*
|
|
241
|
+
* One predicate, exported: `coord_away` measures coverage by calling
|
|
242
|
+
* `stall_check` and reading `checked`/`measurable`, so it inherits this
|
|
243
|
+
* population without a change of its own. Anything else that asks "which rows
|
|
244
|
+
* are in flight" should ask here rather than re-derive it from a glyph.
|
|
245
|
+
*/
|
|
246
|
+
export const IN_FLIGHT_STATUS = /🚧|🔍/;
|
|
247
|
+
export const isInFlightStatus = (status: string): boolean => IN_FLIGHT_STATUS.test(status);
|
|
248
|
+
|
|
221
249
|
export async function stallCheckTool(args: { repo?: string; stallMinutes?: number }) {
|
|
222
250
|
const repo = args.repo ?? process.cwd();
|
|
223
251
|
const limit = (args.stallMinutes ?? 30) * 60 * 1000;
|
|
@@ -226,7 +254,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
226
254
|
|
|
227
255
|
const liveTransports = await loadLiveTransports();
|
|
228
256
|
const rows = workstreamsV1RowsOf(parseWorkDoc(readFileSync(board, "utf8")));
|
|
229
|
-
const inFlight = rows.filter((r) =>
|
|
257
|
+
const inFlight = rows.filter((r) => isInFlightStatus(r.status));
|
|
230
258
|
const reg = await readJson<Record<string, { lastHeartbeat: number }>>(AGENTS_FILE, {});
|
|
231
259
|
const now = Date.now();
|
|
232
260
|
const hits: StallHit[] = [];
|
package/src/tools/transport.ts
CHANGED
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
setOffset,
|
|
80
80
|
sysMsg,
|
|
81
81
|
moveFile,
|
|
82
|
+
agentIdFromCursorFilename,
|
|
82
83
|
STALE_MS,
|
|
83
84
|
EVICT_MS,
|
|
84
85
|
MAX_WAIT_MS,
|
|
@@ -927,8 +928,11 @@ export const joinSchema = {
|
|
|
927
928
|
attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
|
|
928
929
|
readInbox: z.boolean().optional(),
|
|
929
930
|
// First-claim guard overrides (server.ts guardFirstClaim): claiming an id
|
|
930
|
-
// that is LIVE on the bus refuses unless the call presents that
|
|
931
|
-
// token (tokens.json / coord-token)
|
|
931
|
+
// that is PROVABLY LIVE on the bus refuses unless the call presents that
|
|
932
|
+
// agent's token (tokens.json / coord-token) — `force` alone no longer
|
|
933
|
+
// overrides a provably live incumbent (q-314e0187). `force` still bypasses
|
|
934
|
+
// the guard when liveness cannot be verified, or the id is verified absent.
|
|
935
|
+
// Both ignored once bound.
|
|
932
936
|
token: z.string().optional(),
|
|
933
937
|
force: z.boolean().optional(),
|
|
934
938
|
// Prose-only exemption from the typed-record rule — see registerSchema.
|
|
@@ -1620,7 +1624,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1620
1624
|
}
|
|
1621
1625
|
const orphanCursor: string[] = [];
|
|
1622
1626
|
for (const fname of await listCursorFiles()) {
|
|
1623
|
-
const id = fname
|
|
1627
|
+
const { id } = agentIdFromCursorFilename(fname);
|
|
1624
1628
|
if (!known.has(id)) {
|
|
1625
1629
|
orphanCursor.push(id);
|
|
1626
1630
|
if (fix) {
|
|
@@ -1649,10 +1653,17 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1649
1653
|
};
|
|
1650
1654
|
|
|
1651
1655
|
// 4. Cursor offsets past end-of-file (would return [] forever).
|
|
1656
|
+
//
|
|
1657
|
+
// q-40449919: `id` must be the AGENT's id, not the cursor filename minus
|
|
1658
|
+
// one extension — a push cursor's filename is `<agent>.push.json`, and
|
|
1659
|
+
// `inboxFile(id)` on the unfixed `<agent>.push` resolves to a file that
|
|
1660
|
+
// never existed, so `inboxMax` reads 0 and the cursor's real, correct
|
|
1661
|
+
// offset compares as past-EOF against an empty file that was never the
|
|
1662
|
+
// agent's inbox in the first place.
|
|
1652
1663
|
{
|
|
1653
1664
|
const broken: string[] = [];
|
|
1654
1665
|
for (const fname of await listCursorFiles()) {
|
|
1655
|
-
const id = fname
|
|
1666
|
+
const { id, kind } = agentIdFromCursorFilename(fname);
|
|
1656
1667
|
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
1657
1668
|
const cursor = await readJson<Cursor>(cursorPath, {});
|
|
1658
1669
|
const overflow: string[] = [];
|
|
@@ -1667,7 +1678,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1667
1678
|
if (off > max) overflow.push(`roomOffsets[${chan}] ${off}>${max}`);
|
|
1668
1679
|
}
|
|
1669
1680
|
if (overflow.length) {
|
|
1670
|
-
broken.push(`${id}: ${overflow.join(", ")}`);
|
|
1681
|
+
broken.push(`${id}${kind === "push" ? " (push)" : ""}: ${overflow.join(", ")}`);
|
|
1671
1682
|
if (fix) {
|
|
1672
1683
|
await updateJson<Cursor>(cursorPath, {}, (c) => {
|
|
1673
1684
|
if ((c.inboxOffset ?? 0) > inboxMax) c.inboxOffset = inboxMax;
|
|
@@ -1681,7 +1692,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1681
1692
|
}
|
|
1682
1693
|
return c;
|
|
1683
1694
|
});
|
|
1684
|
-
fixed.push(`clamped cursor offsets for ${id}`);
|
|
1695
|
+
fixed.push(`clamped cursor offsets for ${id}${kind === "push" ? " (push)" : ""}`);
|
|
1685
1696
|
}
|
|
1686
1697
|
}
|
|
1687
1698
|
}
|
|
@@ -1724,27 +1735,85 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1724
1735
|
}
|
|
1725
1736
|
|
|
1726
1737
|
// 6. Stale agents (registered, no live transport, heartbeat past EVICT_MS). Report only.
|
|
1738
|
+
//
|
|
1739
|
+
// Task 5.3/22.1: "27 stale" collapsed two different populations that look
|
|
1740
|
+
// identical in a flat heartbeat-age list — 14 agents whose PANE was still
|
|
1741
|
+
// genuinely current (their marker's pid had died, e.g. a `/mcp` reconnect
|
|
1742
|
+
// spawned a new process in the SAME pane, but the pane itself never went
|
|
1743
|
+
// anywhere) and 13 agents that were ORPHANS with no pane behind them at
|
|
1744
|
+
// all, aged 19–45h. An orphan is stale by construction and can NEVER
|
|
1745
|
+
// clear — reporting it beside a merely-slow-heartbeat agent manufactures a
|
|
1746
|
+
// permanent false alarm in the same bucket as a real one.
|
|
1747
|
+
//
|
|
1748
|
+
// THE DISCRIMINATOR: a marker file that still exists on disk (even though
|
|
1749
|
+
// its own pid/heartbeat check already failed `isMarkerLive`) and names a
|
|
1750
|
+
// `tmuxTarget` `tmux has-session` still confirms. `has-session` checks the
|
|
1751
|
+
// PANE, not the recorded pid — so it survives exactly the reconnect shape
|
|
1752
|
+
// above, the same probe `wedged-local-pushers` already uses one direction
|
|
1753
|
+
// over (there: a live pid whose pane died; here: a dead/expired marker
|
|
1754
|
+
// whose pane did not). No marker at all, or a marker whose pane is
|
|
1755
|
+
// confirmed gone, is the only shape left — and that is a genuine orphan.
|
|
1727
1756
|
{
|
|
1728
1757
|
// Compute liveness WITHOUT deleting dead markers — loadLiveTransports
|
|
1729
1758
|
// prunes as a side effect, which would make this read-only check mutate
|
|
1730
1759
|
// state (and pre-empt the orphan-marker fix in check 1).
|
|
1731
1760
|
const live = new Set<string>();
|
|
1761
|
+
const markerByAgent = new Map<string, TransportMarker>();
|
|
1732
1762
|
for (const fname of await listTransportFiles()) {
|
|
1733
1763
|
const marker = await readJson<TransportMarker | null>(path.join(TRANSPORT_DIR, fname), null);
|
|
1734
|
-
if (marker
|
|
1764
|
+
if (!marker) continue;
|
|
1765
|
+
markerByAgent.set(marker.agentId, marker);
|
|
1766
|
+
if (isMarkerLive(marker, reg, now)) live.add(marker.agentId);
|
|
1735
1767
|
}
|
|
1736
|
-
|
|
1768
|
+
// Without a tmux binary we cannot probe a pane at all — every stale
|
|
1769
|
+
// agent is reported as an orphan candidate rather than silently split,
|
|
1770
|
+
// same posture `wedged-local-pushers` takes.
|
|
1771
|
+
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
|
1772
|
+
const paneAlive = (target: string): boolean =>
|
|
1773
|
+
tmuxAvailable && spawnSync("tmux", ["has-session", "-t", target]).status === 0;
|
|
1774
|
+
|
|
1775
|
+
const paneConfirmed: string[] = [];
|
|
1776
|
+
const orphans: string[] = [];
|
|
1737
1777
|
for (const [id, a] of Object.entries(reg)) {
|
|
1738
1778
|
if (live.has(id)) continue;
|
|
1739
|
-
if (now - a.lastHeartbeat
|
|
1779
|
+
if (now - a.lastHeartbeat <= EVICT_MS) continue;
|
|
1780
|
+
const age = `${Math.floor((now - a.lastHeartbeat) / 3600000)}h`;
|
|
1781
|
+
const marker = markerByAgent.get(id);
|
|
1782
|
+
if (marker?.transport === "tmux-push" && marker.tmuxTarget && paneAlive(marker.tmuxTarget)) {
|
|
1783
|
+
paneConfirmed.push(`${id} (${age}, pane '${marker.tmuxTarget}' still current)`);
|
|
1784
|
+
} else {
|
|
1785
|
+
orphans.push(`${id} (${age})`);
|
|
1786
|
+
}
|
|
1740
1787
|
}
|
|
1788
|
+
const stale = [...paneConfirmed, ...orphans];
|
|
1741
1789
|
findings.push({
|
|
1742
1790
|
check: "stale-agents",
|
|
1743
1791
|
level: stale.length ? "warn" : "ok",
|
|
1744
|
-
detail: stale.length
|
|
1792
|
+
detail: stale.length
|
|
1793
|
+
? `${stale.length} agent(s) past the eviction window — next list_agents will drop them ` +
|
|
1794
|
+
`(${paneConfirmed.length} pane-confirmed current, ${orphans.length} genuine orphan(s) with no live pane behind them)`
|
|
1795
|
+
: "no stale agents",
|
|
1745
1796
|
fixable: false,
|
|
1746
1797
|
items: stale.length ? stale : undefined,
|
|
1747
1798
|
});
|
|
1799
|
+
// A SEPARATE finding, not a sub-line: "stale-agents" answers "how many
|
|
1800
|
+
// will list_agents drop", which is true of both buckets equally — an
|
|
1801
|
+
// orphan and a pane-confirmed agent are dropped from the SAME list the
|
|
1802
|
+
// same way. "orphan-agents" answers the different question this task is
|
|
1803
|
+
// actually about — which of those, if any, can never clear on their
|
|
1804
|
+
// own — and reports it even when it is empty, so a checker that only
|
|
1805
|
+
// speaks when it fires cannot be told from one that never ran.
|
|
1806
|
+
findings.push({
|
|
1807
|
+
check: "orphan-agents",
|
|
1808
|
+
level: orphans.length ? "warn" : "ok",
|
|
1809
|
+
detail: orphans.length
|
|
1810
|
+
? `${orphans.length} stale agent(s) have no live tmux pane behind them — permanent, will not clear on their own (unregister or let eviction drop them)`
|
|
1811
|
+
: tmuxAvailable
|
|
1812
|
+
? `no orphans among ${stale.length} stale agent(s)${stale.length ? " — all pane-confirmed current" : ""}`
|
|
1813
|
+
: "tmux not available — could not distinguish orphans from pane-confirmed stale agents",
|
|
1814
|
+
fixable: false,
|
|
1815
|
+
items: orphans.length ? orphans : undefined,
|
|
1816
|
+
});
|
|
1748
1817
|
}
|
|
1749
1818
|
|
|
1750
1819
|
// 6b. STALENESS HAS TWO CAUSES AND THEY LOOK IDENTICAL IN A FLAT LIST.
|
package/src/tools/work.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
projectV1ToLanes,
|
|
14
14
|
queueItemsOf,
|
|
15
15
|
renderWorkDoc,
|
|
16
|
+
renderWorkDocForWrite,
|
|
17
|
+
zeroIsUnparsed,
|
|
16
18
|
workDocIssues,
|
|
17
19
|
workDocLegacyWriteIssues,
|
|
18
20
|
LANES_V0_WRITE_ISSUE,
|
|
@@ -55,6 +57,13 @@ export type StoredWorkDoc = {
|
|
|
55
57
|
kind: "queue" | "done" | "board" | "legacy";
|
|
56
58
|
path: string;
|
|
57
59
|
doc: WorkDoc;
|
|
60
|
+
/**
|
|
61
|
+
* The bytes as read. Kept because a COUNT OF ZERO cannot be interpreted
|
|
62
|
+
* without them: `zeroIsUnparsed` needs to know whether the document had
|
|
63
|
+
* content the parser did not understand, and the parsed doc has already
|
|
64
|
+
* discarded that distinction (q-3e7c81a5).
|
|
65
|
+
*/
|
|
66
|
+
source: string;
|
|
58
67
|
};
|
|
59
68
|
|
|
60
69
|
export type StoredFactsDoc = {
|
|
@@ -77,7 +86,7 @@ async function loadDoc(repo: string, f: { kind: WorkFileKind; path: string }): P
|
|
|
77
86
|
const parsed = parseFactsDoc(source);
|
|
78
87
|
return { kind: "facts", path: f.path, source, entries: parsed.entries, issues: parsed.issues };
|
|
79
88
|
}
|
|
80
|
-
return { kind: f.kind, path: f.path, doc: parseWorkDoc(source) };
|
|
89
|
+
return { kind: f.kind, path: f.path, doc: parseWorkDoc(source), source };
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
function docIssues(d: StoredDoc): string[] {
|
|
@@ -97,12 +106,56 @@ function importedSummary(d: StoredDoc) {
|
|
|
97
106
|
};
|
|
98
107
|
}
|
|
99
108
|
const issues = [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)];
|
|
100
|
-
|
|
101
|
-
path: d.path,
|
|
102
|
-
kind: d.kind,
|
|
109
|
+
const counts = {
|
|
103
110
|
queue: queueItemsOf(d.doc).length,
|
|
104
111
|
done: doneEntriesOf(d.doc).length,
|
|
105
112
|
board: listWorkBoardOf(d.doc).length,
|
|
113
|
+
};
|
|
114
|
+
// A ZERO THAT CANNOT BE TRUSTED IS NAMED, NOT RETURNED BARE.
|
|
115
|
+
//
|
|
116
|
+
// These three were plain `.length`, so a document the parser did not
|
|
117
|
+
// understand reported the same zero as an empty one. A consumer's dashboard
|
|
118
|
+
// read that and showed a fleet as IDLE on a night it merged 27 PRs; they
|
|
119
|
+
// declined to enable these verbs rather than ship a display they could not
|
|
120
|
+
// trust (q-3e7c81a5).
|
|
121
|
+
//
|
|
122
|
+
// The counts keep their shape and meaning — a reader that only wants numbers
|
|
123
|
+
// is unaffected — and `unparsed` says which of them are unanswered questions
|
|
124
|
+
// rather than answers. The predicate is the SEAM's, shared with `doctor`'s
|
|
125
|
+
// queue-done-loop rather than restated here.
|
|
126
|
+
// ONLY THE AXES THIS DOCUMENT CLAIMS TO CARRY.
|
|
127
|
+
//
|
|
128
|
+
// Caught by the test's own output rather than by review: qualifying all three
|
|
129
|
+
// for every file reported `docs/QUEUE.md` as having unparsed `done` and `board`
|
|
130
|
+
// axes, which is nonsense — a queue document is not expected to hold done
|
|
131
|
+
// entries, so its zero there is not an unanswered question, it is a category
|
|
132
|
+
// error on my part. Scoping the qualification to the document's KIND is the
|
|
133
|
+
// same population discipline the count itself needed: a claim about an axis a
|
|
134
|
+
// file never carried is noise, and noise is what teaches a reader to skip the
|
|
135
|
+
// real one. `legacy` is the single-file BACKLOG.md, which carries both regions.
|
|
136
|
+
const AXES_BY_KIND = {
|
|
137
|
+
queue: ["queue"],
|
|
138
|
+
done: ["done"],
|
|
139
|
+
board: ["board"],
|
|
140
|
+
legacy: ["queue", "done"],
|
|
141
|
+
} as const;
|
|
142
|
+
const unparsed = (AXES_BY_KIND[d.kind] as readonly (keyof typeof counts)[]).filter((axis) =>
|
|
143
|
+
zeroIsUnparsed(counts[axis], d.source),
|
|
144
|
+
);
|
|
145
|
+
return {
|
|
146
|
+
path: d.path,
|
|
147
|
+
kind: d.kind,
|
|
148
|
+
...counts,
|
|
149
|
+
...(unparsed.length
|
|
150
|
+
? {
|
|
151
|
+
unparsed,
|
|
152
|
+
unparsedNote:
|
|
153
|
+
`${d.path} has content the parser did not understand: ${unparsed.join(", ")} parsed to ZERO. ` +
|
|
154
|
+
`That is an unanswered question, not an empty document — do not render it as idle, empty or done. ` +
|
|
155
|
+
`Either the file uses a shape this grammar does not accept, or it is malformed; both need a human, and ` +
|
|
156
|
+
`neither is "nothing there".`,
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
106
159
|
...(issues.length ? { issues } : {}),
|
|
107
160
|
};
|
|
108
161
|
}
|
|
@@ -359,6 +412,19 @@ export async function exportWorkTool(args: {
|
|
|
359
412
|
});
|
|
360
413
|
continue;
|
|
361
414
|
}
|
|
415
|
+
// DELIBERATELY THE PURE RENDER, NOT THE STAMPING ONE (q-c50e9b83).
|
|
416
|
+
//
|
|
417
|
+
// I routed this through `renderWorkDocForWrite` first and it broke three
|
|
418
|
+
// pre-existing tests that assert `import → export` writes the real documents
|
|
419
|
+
// back BYTE-IDENTICALLY. They were right and the change was wrong: this is a
|
|
420
|
+
// TRANSPORT, not an authoring path. Its contract is to reproduce a document,
|
|
421
|
+
// and a transport that silently edits content the caller never asked it to
|
|
422
|
+
// touch is a worse defect than the one being fixed — it would also mean
|
|
423
|
+
// exporting into ANOTHER project's documents mutates them.
|
|
424
|
+
//
|
|
425
|
+
// So the primary is scoped to the AUTHORING writer (`land`, via records.ts),
|
|
426
|
+
// and an unstamped item arriving through this path is the backstop's
|
|
427
|
+
// population: it cannot reach `main` without passing a push.
|
|
362
428
|
const rendered = renderWorkDoc(d.doc);
|
|
363
429
|
const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null;
|
|
364
430
|
const declared = scopes.documents.find((s) => s.path === d.path);
|
package/src/tools/worktrees.ts
CHANGED
|
@@ -264,6 +264,45 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
264
264
|
return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` };
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Are the commits HEAD has beyond `tip` ALREADY LANDED there?
|
|
269
|
+
*
|
|
270
|
+
* `rev-list --count <tip>..HEAD` is pure ANCESTRY, and under squash merge a landed
|
|
271
|
+
* branch's own shas never enter the base's history — so `ahead` stays > 0 FOREVER
|
|
272
|
+
* for work that shipped (q-5c40db91; 20 of the last 20 merges on this repo's main
|
|
273
|
+
* are single-parent). Reading `ahead > 0` as "this tree holds unlanded work" makes
|
|
274
|
+
* the verb whose job is fast-forwarding idle trees classify a FINISHED tree as
|
|
275
|
+
* mid-slice, permanently.
|
|
276
|
+
*
|
|
277
|
+
* MEASURED, and it cost a lane cycle: a `claim` was refused because a worker's tree
|
|
278
|
+
* "reads as 1 commit not on origin/main" while that branch had been squash-merged an
|
|
279
|
+
* hour earlier. Same inverted alarm as the item's headline — landed work reported as
|
|
280
|
+
* unmerged, inviting preservation of work already shipped — inside our own tooling.
|
|
281
|
+
*
|
|
282
|
+
* `git cherry` compares PATCH IDS, so every line marked `-` means every commit's patch
|
|
283
|
+
* is already upstream. It is NOT complete: several commits squashed into one carry a
|
|
284
|
+
* different combined patch id, so a negative is INCONCLUSIVE and is reported as
|
|
285
|
+
* mid-slice exactly as before. Only a positive changes the verdict, which keeps the
|
|
286
|
+
* failure direction the same as today's for everything this cannot prove.
|
|
287
|
+
*/
|
|
288
|
+
function aheadIsLanded(at: string, tip: string): boolean {
|
|
289
|
+
// `git` here THROWS rather than returning null, so the catch is what keeps an
|
|
290
|
+
// UNREADABLE answer out of the landed bucket: not measured is not landed, and
|
|
291
|
+
// the tree stays mid-slice, which is the direction that refuses rather than
|
|
292
|
+
// the direction that tells someone their work is safe to discard.
|
|
293
|
+
let cherry: string;
|
|
294
|
+
try {
|
|
295
|
+
cherry = git(at, ["cherry", tip, "HEAD"]);
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
if (cherry.trim().length === 0) return false;
|
|
300
|
+
return cherry
|
|
301
|
+
.trim()
|
|
302
|
+
.split("\n")
|
|
303
|
+
.every((l) => l.trim().startsWith("-"));
|
|
304
|
+
}
|
|
305
|
+
|
|
267
306
|
const results = [];
|
|
268
307
|
for (const w of listWorktrees(repo)) {
|
|
269
308
|
const at = w.path;
|
|
@@ -308,7 +347,9 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
308
347
|
(dirty
|
|
309
348
|
? " and DIRTY — not fast-forwarded; uncommitted work is what a diff cannot show"
|
|
310
349
|
: ahead !== "0"
|
|
311
|
-
?
|
|
350
|
+
? aheadIsLanded(at, tip)
|
|
351
|
+
? ` and ${ahead} ahead whose patches are ALREADY UPSTREAM — squash-merged, so ancestry will never agree; not fast-forwarded, but nothing here needs preserving`
|
|
352
|
+
: ` and ${ahead} ahead — not fast-forwarded; it has commits ${ref} does not`
|
|
312
353
|
: args.apply
|
|
313
354
|
? ""
|
|
314
355
|
: " — pass apply:true to fast-forward it"),
|
|
@@ -337,7 +378,28 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
337
378
|
/* treated as diverged below */
|
|
338
379
|
}
|
|
339
380
|
if (ahead !== "0") {
|
|
340
|
-
|
|
381
|
+
// LANDED, NOT MID-SLICE — the ancestry count cannot tell these apart.
|
|
382
|
+
if (aheadIsLanded(at, tip)) {
|
|
383
|
+
results.push({
|
|
384
|
+
path: w.path,
|
|
385
|
+
action: "landed",
|
|
386
|
+
why:
|
|
387
|
+
`${ahead} commit(s) are not ancestors of ${ref}, but every one of their PATCHES is already upstream — ` +
|
|
388
|
+
`this branch was SQUASH-MERGED and its work has shipped. Ancestry can never say so: a squash writes a new ` +
|
|
389
|
+
`commit, so these shas will read as "not on ${ref}" forever. Nothing here needs preserving; detach to ${ref} ` +
|
|
390
|
+
`(or delete the branch) and this tree is reusable. NOT fast-forwarded automatically — moving a tree off ` +
|
|
391
|
+
`committed work is a decision, not a refresh.`,
|
|
392
|
+
});
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
results.push({
|
|
396
|
+
path: w.path,
|
|
397
|
+
action: "refused",
|
|
398
|
+
why:
|
|
399
|
+
`MID-SLICE: ${ahead} commit(s) not on ${ref}, and their patches are NOT upstream. Refusing rather than --force. ` +
|
|
400
|
+
`(A squash of SEVERAL commits into one changes the combined patch id, so this cannot prove the negative — ` +
|
|
401
|
+
`it reports mid-slice, which is the same direction it always failed in.)`,
|
|
402
|
+
});
|
|
341
403
|
continue;
|
|
342
404
|
}
|
|
343
405
|
if (!args.apply) {
|
package/src/work.ts
CHANGED
package/dist/build.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
// Build identity of the RUNNING server process.
|
|
2
|
-
//
|
|
3
|
-
// The principle is the #28 pusher-freshness fix carried one layer up: stamp
|
|
4
|
-
// what you LOADED at init, compare against what's on disk NOW, and resolve the
|
|
5
|
-
// measured artifact from the code that is actually executing
|
|
6
|
-
// (import.meta.url), never from configuration — the thing that measures must
|
|
7
|
-
// be the thing that ran. A server process outlives `npm run build`; without a
|
|
8
|
-
// load-time sample there is nothing truthful to compare the on-disk build to,
|
|
9
|
-
// and a server running pre-rebuild code stamps transport markers with logic
|
|
10
|
-
// the rebuild replaced (observed live 2026-07-29: post-#28 attach spawned a
|
|
11
|
-
// pusher with no `--agent` argv and a single-file freshness stamp, agreeing
|
|
12
|
-
// with the new on-disk check only by coincidence).
|
|
13
|
-
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { fileURLToPath } from "node:url";
|
|
16
|
-
// Newest mtime (epoch ms) across every file under `dir` (recursive) whose
|
|
17
|
-
// name ends with one of `exts`. Returns undefined when the dir is missing or
|
|
18
|
-
// unreadable — callers skip their check rather than guess. Pure over its
|
|
19
|
-
// arguments so tests exercise it on temp trees instead of touching real
|
|
20
|
-
// sources (a utimes on a shared checkout flips every live pusher's freshness
|
|
21
|
-
// while the suite runs files in parallel).
|
|
22
|
-
export function newestMtimeUnder(dir, exts) {
|
|
23
|
-
try {
|
|
24
|
-
let newest;
|
|
25
|
-
for (const rel of readdirSync(dir, { recursive: true })) {
|
|
26
|
-
const name = String(rel);
|
|
27
|
-
if (!exts.some((e) => name.endsWith(e)))
|
|
28
|
-
continue;
|
|
29
|
-
let m;
|
|
30
|
-
try {
|
|
31
|
-
m = statSync(path.join(dir, name)).mtimeMs;
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
continue; // deleted mid-scan
|
|
35
|
-
}
|
|
36
|
-
if (newest === undefined || m > newest)
|
|
37
|
-
newest = m;
|
|
38
|
-
}
|
|
39
|
-
return newest;
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
// The dir this module was loaded FROM: dist/ in production, src/ under tsx.
|
|
46
|
-
// Either way it is the code actually running, which is the point.
|
|
47
|
-
export const BUILD_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
-
// .js for the compiled build, .ts for dev-mode (tsx src/server.ts) — both
|
|
49
|
-
// sides of every comparison use the same list, so the two modes are each
|
|
50
|
-
// self-consistent and can never be compared across.
|
|
51
|
-
const BUILD_EXTS = [".js", ".ts"];
|
|
52
|
-
// Sampled ONCE at module load: the newest mtime across the build this server
|
|
53
|
-
// process actually imported. A later `npm run build` rewrites dist/ under a
|
|
54
|
-
// still-running server; this value stays behind, which is exactly what
|
|
55
|
-
// doctor's server-build-drift check compares against.
|
|
56
|
-
export const SERVER_BUILD_MTIME = newestMtimeUnder(BUILD_DIR, BUILD_EXTS);
|
|
57
|
-
// The on-disk side of the comparison, statted fresh per call.
|
|
58
|
-
// AGENT_COORD_DIST_DIR is a test seam only: it redirects what doctor
|
|
59
|
-
// MEASURES so tests can stage a newer/older build in a temp dir — it never
|
|
60
|
-
// changes what the server loads.
|
|
61
|
-
export function onDiskBuildMtime() {
|
|
62
|
-
const dir = process.env.AGENT_COORD_DIST_DIR ?? BUILD_DIR;
|
|
63
|
-
return newestMtimeUnder(dir, BUILD_EXTS);
|
|
64
|
-
}
|
|
65
|
-
// The uncompiled side of the dist-behind-source comparison: newest mtime
|
|
66
|
-
// across src/**/*.ts, resolved as BUILD_DIR's sibling. undefined on a
|
|
67
|
-
// packaged install with no src/ (callers report "nothing to compare", never
|
|
68
|
-
// warn). Under tsx dev-mode BUILD_DIR *is* src/, so the comparison degrades
|
|
69
|
-
// to src-vs-src and reads ok — dev-mode has no build to fall behind.
|
|
70
|
-
// AGENT_COORD_SRC_DIR is the same test seam as AGENT_COORD_DIST_DIR:
|
|
71
|
-
// it redirects measurement only.
|
|
72
|
-
export function onDiskSourceMtime() {
|
|
73
|
-
const dir = process.env.AGENT_COORD_SRC_DIR ?? path.resolve(BUILD_DIR, "..", "src");
|
|
74
|
-
return newestMtimeUnder(dir, [".ts"]);
|
|
75
|
-
}
|
|
76
|
-
// Best-effort checkout identity, report-only: lets doctor NAME the build
|
|
77
|
-
// (`branch@sha` would be nicer, but HEAD's sha alone already makes
|
|
78
|
-
// mutable-checkout drift visible, which is all this claims). undefined when
|
|
79
|
-
// not a git checkout (npm install) — never an error.
|
|
80
|
-
export const SERVER_BUILD_SHA = (() => {
|
|
81
|
-
try {
|
|
82
|
-
let gitDir = path.resolve(BUILD_DIR, "..", ".git");
|
|
83
|
-
const st = statSync(gitDir);
|
|
84
|
-
if (st.isFile()) {
|
|
85
|
-
// A worktree's .git is a pointer file: "gitdir: <real dir>".
|
|
86
|
-
const ptr = readFileSync(gitDir, "utf8").trim();
|
|
87
|
-
if (!ptr.startsWith("gitdir:"))
|
|
88
|
-
return undefined;
|
|
89
|
-
gitDir = ptr.slice("gitdir:".length).trim();
|
|
90
|
-
}
|
|
91
|
-
const head = readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
|
|
92
|
-
if (!head.startsWith("ref:"))
|
|
93
|
-
return head.slice(0, 12); // detached
|
|
94
|
-
const ref = head.slice(4).trim();
|
|
95
|
-
try {
|
|
96
|
-
return readFileSync(path.join(gitDir, ref), "utf8").trim().slice(0, 12);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// Ref may be packed. commondir handling is deliberately out of scope —
|
|
100
|
-
// best-effort means undefined beats wrong.
|
|
101
|
-
const packed = readFileSync(path.join(gitDir, "packed-refs"), "utf8");
|
|
102
|
-
for (const line of packed.split("\n")) {
|
|
103
|
-
if (line.endsWith(` ${ref}`))
|
|
104
|
-
return line.slice(0, 12);
|
|
105
|
-
}
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
})();
|
|
113
|
-
//# sourceMappingURL=build.js.map
|
package/dist/build.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"build.js","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"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"}
|