agent-coord-mcp 0.26.5 → 0.26.7
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/prefix.js +64 -0
- package/dist/prefix.js.map +1 -0
- package/dist/server.js +32 -2
- 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/away.js +122 -0
- package/dist/tools/away.js.map +1 -0
- package/dist/tools/events.js +171 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/records.js +651 -0
- package/dist/tools/records.js.map +1 -0
- package/dist/tools/registry.js +45 -5
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/rotate.js +143 -0
- package/dist/tools/rotate.js.map +1 -0
- package/dist/tools/shared.js +9 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +294 -0
- package/dist/tools/stall.js.map +1 -0
- package/dist/tools/transport.js +92 -16
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +294 -0
- package/dist/tools/worktrees.js.map +1 -0
- 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/prefix.ts +72 -0
- package/src/server.ts +138 -3
- package/src/tools/attention.ts +91 -0
- package/src/tools/away.ts +121 -0
- package/src/tools/events.ts +199 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/records.ts +747 -0
- package/src/tools/registry.ts +45 -5
- package/src/tools/rotate.ts +180 -0
- package/src/tools/shared.ts +24 -0
- package/src/tools/stall.ts +311 -0
- package/src/tools/transport.ts +99 -3
- package/src/tools/worktrees.ts +311 -0
package/src/tools/transport.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
|
|
2
2
|
import { newestMtimeUnder, onDiskBuildMtime, onDiskSourceMtime, SERVER_BUILD_MTIME, SERVER_BUILD_SHA, BUILD_DIR } from "../build.js";
|
|
3
|
+
import { prefixOf, prefixVerdict } from "../prefix.js";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
3
5
|
import { registerTool } from "./registry.js";
|
|
4
6
|
import { roleInputSchema, type RoleArg } from "../roles.js";
|
|
5
7
|
import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
|
|
@@ -307,13 +309,69 @@ function defaultReminderText(agentId: string): string {
|
|
|
307
309
|
);
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
/*
|
|
313
|
+
* REMINDERS ARE OBSERVABLE, because "scheduled" is not "delivered".
|
|
314
|
+
*
|
|
315
|
+
* `send_command` returns `reminderScheduled` the instant the timer is set, and
|
|
316
|
+
* the delivery is an async lockfile-protected append whose only failure path
|
|
317
|
+
* was a line on stderr. So a reminder could be LATE or LOST and the caller had
|
|
318
|
+
* already been told it was handled — the same claim-without-a-completion-signal
|
|
319
|
+
* shape as an empty check rollup reading green.
|
|
320
|
+
*
|
|
321
|
+
* MEASURED: `withLock` is configured `retries: 10, minTimeout: 20,
|
|
322
|
+
* maxTimeout: 200`, so the append is PERMITTED well over a second of backoff.
|
|
323
|
+
* Under sustained inbox contention the reminder lands at ~420ms. Any assertion
|
|
324
|
+
* with a wall-clock budget below the lock's own policy is asserting something
|
|
325
|
+
* the system never promised — which is what made tools.test.mjs:156 fail under
|
|
326
|
+
* full-suite load and pass everywhere else.
|
|
327
|
+
*
|
|
328
|
+
* A longer sleep would convert that race into a slower race. This gives the
|
|
329
|
+
* work a real completion signal instead, so callers and tests can await the
|
|
330
|
+
* thing itself rather than guess at a duration.
|
|
331
|
+
*/
|
|
332
|
+
type PendingReminder = { done: Promise<void>; timer: NodeJS.Timeout };
|
|
333
|
+
const pendingReminders = new Set<PendingReminder>();
|
|
334
|
+
|
|
335
|
+
/** Reminders whose delivery FAILED, kept so a drop is observable, not stderr-only. */
|
|
336
|
+
export const reminderFailures: { to: string; error: string; at: number }[] = [];
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resolves once every scheduled reminder has been delivered or has failed.
|
|
340
|
+
*
|
|
341
|
+
* Re-`ref`s the pending timers for the duration of the wait. The reminder timer
|
|
342
|
+
* is deliberately `unref`d so it never holds the MCP server open on its own —
|
|
343
|
+
* but that also means the event loop will not wait for it, and awaiting an
|
|
344
|
+
* unref'd timer's promise hangs until the loop drains. A caller that has
|
|
345
|
+
* explicitly asked to wait is stating the opposite intent, so the ref is
|
|
346
|
+
* restored for exactly that window and dropped again afterwards.
|
|
347
|
+
*
|
|
348
|
+
* (The unref has a production consequence worth naming: if the server's
|
|
349
|
+
* transport closes before the timer fires, the reminder is dropped. That is a
|
|
350
|
+
* separate defect from the one this function fixes — reported, not silently
|
|
351
|
+
* papered over.)
|
|
352
|
+
*/
|
|
353
|
+
export function remindersSettled(): Promise<void> {
|
|
354
|
+
const pending = [...pendingReminders];
|
|
355
|
+
for (const p of pending) p.timer.ref();
|
|
356
|
+
return Promise.all(pending.map((p) => p.done)).then(() => {
|
|
357
|
+
for (const p of pending) p.timer.unref();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
310
361
|
function scheduleReminders(
|
|
311
362
|
from: string,
|
|
312
363
|
recipients: string[],
|
|
313
364
|
delayMs: number,
|
|
314
365
|
override: string | undefined,
|
|
315
366
|
): void {
|
|
367
|
+
// Registered BEFORE the timer fires, so awaiting settlement covers the delay
|
|
368
|
+
// as well as the write — otherwise a caller could observe "nothing pending"
|
|
369
|
+
// during the window between scheduling and firing.
|
|
370
|
+
let markDone: () => void = () => {};
|
|
371
|
+
const done = new Promise<void>((resolve) => { markDone = resolve; });
|
|
372
|
+
let entry: PendingReminder;
|
|
316
373
|
const t = setTimeout(async () => {
|
|
374
|
+
try {
|
|
317
375
|
for (const r of recipients) {
|
|
318
376
|
try {
|
|
319
377
|
const reminder: Message = {
|
|
@@ -328,12 +386,20 @@ function scheduleReminders(
|
|
|
328
386
|
};
|
|
329
387
|
await appendJsonl(inboxFile(r), reminder);
|
|
330
388
|
} catch (e) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
389
|
+
const error = (e as Error)?.message ?? String(e);
|
|
390
|
+
// Recorded, not just printed: a dropped reminder leaves a just-cleared
|
|
391
|
+
// agent contextless, and stderr is not somewhere anyone looks for that.
|
|
392
|
+
reminderFailures.push({ to: r, error, at: Date.now() });
|
|
393
|
+
process.stderr.write(`[send_command] post-/clear reminder to '${r}' failed: ${error}\n`);
|
|
334
394
|
}
|
|
335
395
|
}
|
|
396
|
+
} finally {
|
|
397
|
+
pendingReminders.delete(entry);
|
|
398
|
+
markDone();
|
|
399
|
+
}
|
|
336
400
|
}, delayMs);
|
|
401
|
+
entry = { done, timer: t };
|
|
402
|
+
pendingReminders.add(entry);
|
|
337
403
|
// Don't keep the event loop alive solely for the reminder — the MCP server's
|
|
338
404
|
// transport already holds it open as long as it's connected.
|
|
339
405
|
if (typeof t.unref === "function") t.unref();
|
|
@@ -639,6 +705,11 @@ export async function attachAgentTool(args: {
|
|
|
639
705
|
// code, and after an in-place rebuild the two differ (that difference is
|
|
640
706
|
// exactly what doctor's provenance check exists to surface).
|
|
641
707
|
serverBuildMtime: SERVER_BUILD_MTIME,
|
|
708
|
+
// WHAT this transport carries, recorded by the code that decides it.
|
|
709
|
+
// `includeRoom` is what the pusher is actually spawned with a few lines
|
|
710
|
+
// above, so the marker cannot claim a capability the process was not
|
|
711
|
+
// given — the marker and the spawn come from one value, not two.
|
|
712
|
+
rooms: includeRoom,
|
|
642
713
|
};
|
|
643
714
|
// Use updateJson so it lockfile-protects and creates the file atomically.
|
|
644
715
|
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
@@ -941,6 +1012,10 @@ export const reportTransportSchema = {
|
|
|
941
1012
|
// counterpart has been upgraded since. The remote pusher passes
|
|
942
1013
|
// `(await fsp.stat(__filename)).mtimeMs`; absent → doctor skips the check.
|
|
943
1014
|
scriptMtime: z.number().optional(),
|
|
1015
|
+
// Whether this pusher carries room traffic as well as DMs. Absent → the
|
|
1016
|
+
// marker cannot say, and every reader reports UNKNOWN rather than assuming
|
|
1017
|
+
// a full transport (the worker-2 shape).
|
|
1018
|
+
rooms: z.boolean().optional(),
|
|
944
1019
|
};
|
|
945
1020
|
|
|
946
1021
|
// Called by an external push daemon (typically scripts/coord-pusher.mjs on a
|
|
@@ -954,6 +1029,7 @@ export async function reportTransportTool(args: {
|
|
|
954
1029
|
host?: string;
|
|
955
1030
|
since?: number;
|
|
956
1031
|
scriptMtime?: number;
|
|
1032
|
+
rooms?: boolean;
|
|
957
1033
|
}) {
|
|
958
1034
|
const marker: TransportMarker = {
|
|
959
1035
|
agentId: args.agentId,
|
|
@@ -963,6 +1039,7 @@ export async function reportTransportTool(args: {
|
|
|
963
1039
|
host: args.host,
|
|
964
1040
|
since: args.since ?? Date.now(),
|
|
965
1041
|
scriptMtime: args.scriptMtime,
|
|
1042
|
+
rooms: args.rooms,
|
|
966
1043
|
};
|
|
967
1044
|
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
968
1045
|
return { ok: true, marker };
|
|
@@ -1212,6 +1289,25 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1212
1289
|
});
|
|
1213
1290
|
}
|
|
1214
1291
|
|
|
1292
|
+
// 1b³. WHERE A NEW GLOBAL INSTALL WOULD LAND, which is a different question
|
|
1293
|
+
// from which copy is running (1b² above) and neither substitutes. On
|
|
1294
|
+
// this box `npm prefix -g` and the fleet's actual load path pointed at
|
|
1295
|
+
// two different nvm versions, so an install that printed success would
|
|
1296
|
+
// have updated a copy nothing loads.
|
|
1297
|
+
{
|
|
1298
|
+
const loadPrefix = prefixOf(BUILD_DIR);
|
|
1299
|
+
let installPrefix: string | null = null;
|
|
1300
|
+
let npmPath: string | undefined;
|
|
1301
|
+
try {
|
|
1302
|
+
installPrefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
1303
|
+
npmPath = execFileSync("which", ["npm"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
1304
|
+
} catch {
|
|
1305
|
+
installPrefix = null;
|
|
1306
|
+
}
|
|
1307
|
+
const v = prefixVerdict(loadPrefix, installPrefix, npmPath);
|
|
1308
|
+
findings.push({ check: "install-prefix", level: v.level, detail: v.detail, fixable: false });
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1215
1311
|
// 1b²ᵇ. The affirmative catch for merged-but-never-rebuilt: src/ newer than
|
|
1216
1312
|
// the compiled build means no restart can help — the artifact every
|
|
1217
1313
|
// future session will load is already behind the code. Distinct from
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `ensure_worktree` — the worktree invariant as a verb.
|
|
3
|
+
*
|
|
4
|
+
* `git worktree add` lives on five cards as a RECIPE, which means it is followed
|
|
5
|
+
* from memory and skipped under pressure. The failure it prevents is not
|
|
6
|
+
* theoretical: a shared checkout is how two agents' edits land in one tree, and
|
|
7
|
+
* the primary checkout is the one everybody reaches for because it is the path
|
|
8
|
+
* they already have.
|
|
9
|
+
*
|
|
10
|
+
* CUT FROM `origin/<base>`, NEVER A LOCAL BRANCH. A local `main` is whatever the
|
|
11
|
+
* last person left there; `origin/<base>` is what everyone else will merge into.
|
|
12
|
+
* This is the same rule as `land`'s target-tip check, one step earlier: the
|
|
13
|
+
* question is always "what does the thing I am merging into have?".
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compare paths by their REAL path, never by string.
|
|
22
|
+
*
|
|
23
|
+
* git reports `/private/var/...` where a caller passes `/var/...` — macOS's
|
|
24
|
+
* symlink — so `path.resolve` comparison silently MISSES the primary and the
|
|
25
|
+
* guard that refuses it never fires. The same symlink cost a carrier check its
|
|
26
|
+
* main-module guard earlier today: it exited 0 having done nothing.
|
|
27
|
+
*/
|
|
28
|
+
const samePath = (a: string, b: string): boolean => {
|
|
29
|
+
const real = (x: string) => {
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(x);
|
|
32
|
+
} catch {
|
|
33
|
+
return path.resolve(x);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return real(a) === real(b);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const git = (repo: string, args: string[]): string =>
|
|
40
|
+
execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
41
|
+
|
|
42
|
+
/** Every worktree git knows about: `{path, branch, bare}` in list order. The
|
|
43
|
+
* FIRST entry is the primary checkout — that is what `--porcelain` guarantees. */
|
|
44
|
+
export function listWorktrees(repo: string): { path: string; branch: string | null }[] {
|
|
45
|
+
const out: { path: string; branch: string | null }[] = [];
|
|
46
|
+
let cur: { path: string; branch: string | null } | null = null;
|
|
47
|
+
for (const line of git(repo, ["worktree", "list", "--porcelain"]).split("\n")) {
|
|
48
|
+
if (line.startsWith("worktree ")) {
|
|
49
|
+
if (cur) out.push(cur);
|
|
50
|
+
cur = { path: line.slice(9), branch: null };
|
|
51
|
+
} else if (line.startsWith("branch ") && cur) {
|
|
52
|
+
cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (cur) out.push(cur);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The primary checkout — the tree nobody may be given for a slice. */
|
|
60
|
+
export function primaryOf(repo: string): string | null {
|
|
61
|
+
const all = listWorktrees(repo);
|
|
62
|
+
return all.length ? (all[0] as { path: string }).path : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isDirty = (repo: string): boolean => {
|
|
66
|
+
try {
|
|
67
|
+
return git(repo, ["status", "--porcelain"]).length > 0;
|
|
68
|
+
} catch {
|
|
69
|
+
return true; // unreadable is not clean
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const ensureWorktreeSchema = {
|
|
74
|
+
agentId: z.string().min(1),
|
|
75
|
+
repo: z.string().min(1),
|
|
76
|
+
base: z.string().min(1),
|
|
77
|
+
task: z.string().optional(),
|
|
78
|
+
ephemeral: z.boolean().optional(),
|
|
79
|
+
parent: z.string().optional(),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export async function ensureWorktreeTool(args: {
|
|
83
|
+
agentId: string;
|
|
84
|
+
repo: string;
|
|
85
|
+
base: string;
|
|
86
|
+
task?: string;
|
|
87
|
+
ephemeral?: boolean;
|
|
88
|
+
parent?: string;
|
|
89
|
+
}) {
|
|
90
|
+
const { agentId, repo, base } = args;
|
|
91
|
+
if (!path.isAbsolute(repo)) return { ok: false as const, error: `repo must be an absolute path, got '${repo}'` };
|
|
92
|
+
if (!existsSync(repo)) return { ok: false as const, error: `no such repo: ${repo}` };
|
|
93
|
+
|
|
94
|
+
let primary: string | null;
|
|
95
|
+
try {
|
|
96
|
+
primary = primaryOf(repo);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return { ok: false as const, error: `not a git repository (${String((e as Error).message).split("\n")[0]})` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `repo` IS the primary checkout in normal use — that is how you address the
|
|
102
|
+
// repository. What must never happen is HANDING AN AGENT THE PRIMARY as its
|
|
103
|
+
// slice tree, so the refusal is on the computed TARGET, not on the input.
|
|
104
|
+
//
|
|
105
|
+
// Reading spec 1.4 literally ("calling with the primary path exits non-zero")
|
|
106
|
+
// would refuse every call and make the verb unusable, since `repo` is always
|
|
107
|
+
// the primary. The invariant it protects is the one implemented here: two
|
|
108
|
+
// agents editing one tree. Flagged in the PR rather than silently chosen.
|
|
109
|
+
return await create({ ...args, primary: primary ?? repo });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function create(a: {
|
|
113
|
+
agentId: string;
|
|
114
|
+
repo: string;
|
|
115
|
+
base: string;
|
|
116
|
+
task?: string;
|
|
117
|
+
ephemeral?: boolean;
|
|
118
|
+
parent?: string;
|
|
119
|
+
primary: string;
|
|
120
|
+
}) {
|
|
121
|
+
const { repo, base, agentId, primary } = a;
|
|
122
|
+
|
|
123
|
+
// The BASE must exist as a REMOTE ref. A missing `origin/<base>` is refused
|
|
124
|
+
// rather than silently falling back to a local branch of the same name — the
|
|
125
|
+
// local one is whatever the last person left there.
|
|
126
|
+
const ref = `origin/${base}`;
|
|
127
|
+
let sha: string;
|
|
128
|
+
try {
|
|
129
|
+
git(repo, ["fetch", "--quiet", "origin", base]);
|
|
130
|
+
} catch {
|
|
131
|
+
/* offline or no remote: fall through to the rev-parse, which is the real test */
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
sha = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
135
|
+
} catch {
|
|
136
|
+
return {
|
|
137
|
+
ok: false as const,
|
|
138
|
+
error: `${ref} does not resolve — refusing to cut a tree from a local '${base}', which is whatever the last person left there. Fetch the remote, or name a base that exists on origin.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ONE PROJECT PARENT: trees live beside the primary, named for the agent, so a
|
|
143
|
+
// stray tree is identifiable without opening it.
|
|
144
|
+
const parent = a.parent ?? path.dirname(primary);
|
|
145
|
+
const leaf = `${path.basename(primary)}-${agentId}${a.ephemeral ? "-ephemeral" : ""}`;
|
|
146
|
+
const target = path.join(parent, leaf);
|
|
147
|
+
const branch = a.task ? `${agentId}/${a.task}` : `${agentId}/work`;
|
|
148
|
+
|
|
149
|
+
// DEFENCE IN DEPTH, AND IT IS CURRENTLY UNREACHABLE — stated because a guard
|
|
150
|
+
// that cannot fire is worth nothing until someone knows it cannot.
|
|
151
|
+
//
|
|
152
|
+
// The leaf is always `<primary-basename>-<agentId>`, so the target can never
|
|
153
|
+
// equal the primary while that naming holds. I tried to write a test that
|
|
154
|
+
// reaches this branch and could not without symlink contrivance; the honest
|
|
155
|
+
// conclusion is that the NAMING is the real invariant and this is a backstop
|
|
156
|
+
// for the day someone changes it. It is kept rather than deleted for exactly
|
|
157
|
+
// that day, and labelled rather than left looking load-bearing.
|
|
158
|
+
//
|
|
159
|
+
// What actually protects the invariant is tested instead: the target always
|
|
160
|
+
// differs from the primary, for any agent id.
|
|
161
|
+
if (samePath(target, primary)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false as const,
|
|
164
|
+
error:
|
|
165
|
+
`refusing to hand back the PRIMARY checkout (${primary}) as a slice tree — that is the path everyone already has, ` +
|
|
166
|
+
`so it is the one two agents end up editing at once. Pass a different parent or agentId.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const existing = listWorktrees(repo).find((w) => samePath(w.path, target));
|
|
171
|
+
if (existing) {
|
|
172
|
+
// IDEMPOTENT, and it reports what it FOUND rather than what it would have
|
|
173
|
+
// made: a verb that silently returns a tree on a different branch than asked
|
|
174
|
+
// for is the adjacent-answer shape.
|
|
175
|
+
const head = git(existing.path, ["rev-parse", "HEAD"]);
|
|
176
|
+
// IS THE REUSED TREE ACTUALLY AT THE BASE IT CLAIMS?
|
|
177
|
+
//
|
|
178
|
+
// The verb warned when an existing tree was on a different BRANCH and said
|
|
179
|
+
// nothing about it being behind the BASE — so a tree left on last week's
|
|
180
|
+
// main was handed back as ready. Starting a new task there produces the
|
|
181
|
+
// "stale main -> confidently-wrong inventories" failure the worker card
|
|
182
|
+
// already warns about, and nothing about the result looks stale.
|
|
183
|
+
//
|
|
184
|
+
// Reported here, not refused: an existing tree legitimately holds
|
|
185
|
+
// in-progress work mid-slice (1.3). The refusal belongs to `claim`, which
|
|
186
|
+
// is the verb that means "start something new".
|
|
187
|
+
let atBase = false;
|
|
188
|
+
let behindBy: number | null = null;
|
|
189
|
+
try {
|
|
190
|
+
const baseSha = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
191
|
+
atBase = head === baseSha;
|
|
192
|
+
if (!atBase) behindBy = Number(git(existing.path, ["rev-list", "--count", `HEAD..${ref}`])) || 0;
|
|
193
|
+
} catch {
|
|
194
|
+
// NOT MEASURED IS NOT AT-BASE. Leaving `atBase` false is the safe
|
|
195
|
+
// direction: it makes `claim` ask rather than assume.
|
|
196
|
+
atBase = false;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
ok: true as const,
|
|
200
|
+
path: existing.path,
|
|
201
|
+
sha: head,
|
|
202
|
+
branch: existing.branch,
|
|
203
|
+
created: false,
|
|
204
|
+
base: ref,
|
|
205
|
+
atBase,
|
|
206
|
+
behindBy: behindBy ?? 0,
|
|
207
|
+
...(existing.branch !== branch
|
|
208
|
+
? { warning: `existing tree is on '${existing.branch}', not the '${branch}' this call would have created — reusing it, NOT re-pointing it` }
|
|
209
|
+
: {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
git(repo, ["worktree", "add", "-q", "-b", branch, target, sha]);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return { ok: false as const, error: `git worktree add failed: ${String((e as Error).message).split("\n")[0]}` };
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: true as const,
|
|
220
|
+
path: target,
|
|
221
|
+
sha,
|
|
222
|
+
branch,
|
|
223
|
+
created: true,
|
|
224
|
+
base: ref,
|
|
225
|
+
// Cut from `sha`, which IS origin/<base> — true by construction here, and
|
|
226
|
+
// stated so callers need not special-case "created" to know it.
|
|
227
|
+
atBase: true,
|
|
228
|
+
behindBy: 0,
|
|
229
|
+
...(a.ephemeral
|
|
230
|
+
? { ephemeral: true, removeWith: `git -C ${repo} worktree remove --force ${target} && git -C ${repo} branch -D ${branch}` }
|
|
231
|
+
: {}),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ----------
|
|
236
|
+
|
|
237
|
+
export const refreshWorktreesSchema = {
|
|
238
|
+
repo: z.string().min(1),
|
|
239
|
+
base: z.string().min(1),
|
|
240
|
+
apply: z.boolean().optional(),
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Fast-forward IDLE trees onto `origin/<base>`. Never `--force`, never a
|
|
245
|
+
* mid-slice tree.
|
|
246
|
+
*
|
|
247
|
+
* A tree is MID-SLICE when it is dirty or holds commits the base does not.
|
|
248
|
+
* Pulling under someone's feet is worse than staleness: staleness is visible in
|
|
249
|
+
* a diff, a clobbered work-in-progress is not. So dirty or diverged is REFUSED
|
|
250
|
+
* per tree and named, and the run continues for the others.
|
|
251
|
+
*/
|
|
252
|
+
export async function refreshWorktreesTool(args: { repo: string; base: string; apply?: boolean }) {
|
|
253
|
+
const { repo, base } = args;
|
|
254
|
+
const ref = `origin/${base}`;
|
|
255
|
+
try {
|
|
256
|
+
git(repo, ["fetch", "--quiet", "origin", base]);
|
|
257
|
+
} catch {
|
|
258
|
+
/* reported per tree below */
|
|
259
|
+
}
|
|
260
|
+
let tip: string;
|
|
261
|
+
try {
|
|
262
|
+
tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
263
|
+
} catch {
|
|
264
|
+
return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const results = [];
|
|
268
|
+
for (const w of listWorktrees(repo)) {
|
|
269
|
+
const at = w.path;
|
|
270
|
+
if (samePath(at, primaryOf(repo) ?? "")) {
|
|
271
|
+
results.push({ path: w.path, action: "skipped", why: "primary checkout — not a slice tree" });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (isDirty(at)) {
|
|
275
|
+
results.push({ path: w.path, action: "refused", why: "MID-SLICE: uncommitted changes. Pulling here would clobber work a diff cannot show." });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
let head: string;
|
|
279
|
+
try {
|
|
280
|
+
head = git(at, ["rev-parse", "HEAD"]);
|
|
281
|
+
} catch {
|
|
282
|
+
results.push({ path: w.path, action: "refused", why: "unreadable HEAD" });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (head === tip) {
|
|
286
|
+
results.push({ path: w.path, action: "current", why: `already at ${ref}` });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
let ahead = "0";
|
|
290
|
+
try {
|
|
291
|
+
ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
|
|
292
|
+
} catch {
|
|
293
|
+
/* treated as diverged below */
|
|
294
|
+
}
|
|
295
|
+
if (ahead !== "0") {
|
|
296
|
+
results.push({ path: w.path, action: "refused", why: `MID-SLICE: ${ahead} commit(s) not on ${ref}. Refusing rather than --force.` });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (!args.apply) {
|
|
300
|
+
results.push({ path: w.path, action: "would-fast-forward", why: `behind ${ref}` });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
git(at, ["merge", "--ff-only", tip]);
|
|
305
|
+
results.push({ path: w.path, action: "fast-forwarded", why: `to ${tip.slice(0, 8)}` });
|
|
306
|
+
} catch (e) {
|
|
307
|
+
results.push({ path: w.path, action: "refused", why: `ff-only failed: ${String((e as Error).message).split("\n")[0]}` });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { ok: true as const, base: ref, tip: tip.slice(0, 8), applied: args.apply === true, trees: results };
|
|
311
|
+
}
|