@brutalsystems/muster 3.3.0 → 3.3.2
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/guard.js +50 -5
- package/dist/reap.js +3 -1
- package/dist/registry.js +20 -5
- package/dist/run.js +23 -4
- package/package.json +1 -1
package/dist/guard.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, } from "node:fs";
|
|
4
4
|
import { realpath } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -425,16 +425,24 @@ export function resolveResume(req) {
|
|
|
425
425
|
* tincan and the ledger could not say which one a message reaches. A fork
|
|
426
426
|
* only reads the original, so it may resume a live one.
|
|
427
427
|
*/
|
|
428
|
-
export function assertResumable(resume, env, alive = pidAlive) {
|
|
428
|
+
export function assertResumable(resume, env, cwd, alive = pidAlive) {
|
|
429
429
|
const configDir = claudeConfigDir(env);
|
|
430
430
|
const projects = join(configDir, "projects");
|
|
431
|
-
let
|
|
431
|
+
let transcript;
|
|
432
432
|
try {
|
|
433
|
-
|
|
433
|
+
transcript = readdirSync(projects)
|
|
434
|
+
.map((dir) => join(projects, dir, resume.id + ".jsonl"))
|
|
435
|
+
.find((file) => existsSync(file));
|
|
434
436
|
}
|
|
435
437
|
catch { }
|
|
436
|
-
if (!
|
|
438
|
+
if (!transcript)
|
|
437
439
|
throw new Error(`No Claude conversation ${resume.id} in ${configDir}; --resume reopens a session recorded in the profile this launch runs in`);
|
|
440
|
+
// Claude 2.1.302 resumes a conversation from any directory, so this is
|
|
441
|
+
// muster's to refuse (#10): the history would describe one tree while the
|
|
442
|
+
// agent works in another. Unknown when the transcript names no cwd.
|
|
443
|
+
const recorded = transcriptCwd(transcript);
|
|
444
|
+
if (recorded !== undefined && realPath(recorded) !== realPath(cwd))
|
|
445
|
+
throw new Error(`Claude session ${resume.id} was recorded in ${recorded}, not ${cwd}; resume it with --cwd ${recorded}`);
|
|
438
446
|
if (resume.fork)
|
|
439
447
|
return;
|
|
440
448
|
const sessions = claudeSessionsDir(env);
|
|
@@ -458,6 +466,43 @@ export function assertResumable(resume, env, alive = pidAlive) {
|
|
|
458
466
|
throw new Error(`Claude session ${resume.id} is running (pid ${pid}); stop it before resuming it, or pass --fork-session to continue it under a new id`);
|
|
459
467
|
}
|
|
460
468
|
}
|
|
469
|
+
/** The first `cwd` a transcript records, read from its head: a transcript can
|
|
470
|
+
* run to many megabytes, and the cwd is on its first message. */
|
|
471
|
+
function transcriptCwd(file) {
|
|
472
|
+
let head = "";
|
|
473
|
+
try {
|
|
474
|
+
const fd = openSync(file, "r");
|
|
475
|
+
try {
|
|
476
|
+
const buf = Buffer.alloc(1 << 20);
|
|
477
|
+
head = buf.toString("utf8", 0, readSync(fd, buf, 0, buf.length, 0));
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
closeSync(fd);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
for (const line of head.split("\n")) {
|
|
487
|
+
if (!line.includes('"cwd"'))
|
|
488
|
+
continue;
|
|
489
|
+
try {
|
|
490
|
+
const { cwd } = JSON.parse(line);
|
|
491
|
+
if (typeof cwd === "string" && cwd)
|
|
492
|
+
return cwd;
|
|
493
|
+
}
|
|
494
|
+
catch { }
|
|
495
|
+
}
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
function realPath(path) {
|
|
499
|
+
try {
|
|
500
|
+
return realpathSync(path);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return resolve(path);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
461
506
|
function pidAlive(pid) {
|
|
462
507
|
try {
|
|
463
508
|
process.kill(pid, 0);
|
package/dist/reap.js
CHANGED
|
@@ -125,8 +125,10 @@ async function stopSession(home, socket, launchId) {
|
|
|
125
125
|
});
|
|
126
126
|
try {
|
|
127
127
|
const entry = (await new Registry(home).all()).find((e) => e.launchId === launchId);
|
|
128
|
+
// By launch id: since --resume, the durable id may name a newer launch of
|
|
129
|
+
// the same session, which this check was not armed for.
|
|
128
130
|
if (entry)
|
|
129
|
-
await muster.stop(entry.
|
|
131
|
+
await muster.stop(entry.launchId);
|
|
130
132
|
}
|
|
131
133
|
finally {
|
|
132
134
|
await muster.close();
|
package/dist/registry.js
CHANGED
|
@@ -45,6 +45,21 @@ export class IdempotencyKeyReuse extends Error {
|
|
|
45
45
|
*/
|
|
46
46
|
const UNOWNED_LOCK_MS = 60_000;
|
|
47
47
|
let self;
|
|
48
|
+
/** Statuses whose process is over: such an entry no longer holds its id. */
|
|
49
|
+
const FINISHED = ["exited", "failed", "stopped"];
|
|
50
|
+
/**
|
|
51
|
+
* The entry a durable id names. One id can have several since `--resume`: the
|
|
52
|
+
* launches that ran a session before, now finished, and the one running it
|
|
53
|
+
* now. The live one is meant, else the newest; two live ones cannot arise,
|
|
54
|
+
* because `update` refuses a second live holder of an id.
|
|
55
|
+
*/
|
|
56
|
+
export function currentEntry(entries) {
|
|
57
|
+
const live = entries.filter((e) => !FINISHED.includes(e.status));
|
|
58
|
+
if (live.length > 1)
|
|
59
|
+
throw new Error("Ambiguous durable id; update by launchId");
|
|
60
|
+
return (live[0] ??
|
|
61
|
+
[...entries].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]);
|
|
62
|
+
}
|
|
48
63
|
export class Registry {
|
|
49
64
|
home;
|
|
50
65
|
lockWait;
|
|
@@ -300,16 +315,16 @@ export class Registry {
|
|
|
300
315
|
async update(id, patch) {
|
|
301
316
|
return this.transaction(async (entries) => {
|
|
302
317
|
const byLaunch = entries.find((e) => e.launchId === id);
|
|
303
|
-
const
|
|
304
|
-
if (!byLaunch && durable.length > 1)
|
|
305
|
-
throw new Error("Ambiguous durable id; update by launchId");
|
|
306
|
-
const entry = byLaunch ?? durable[0];
|
|
318
|
+
const entry = byLaunch ?? currentEntry(entries.filter((e) => e.id === id));
|
|
307
319
|
if (!entry)
|
|
308
320
|
throw new Error(`Unknown launch ${id}`);
|
|
309
321
|
if ("requester" in patch)
|
|
310
322
|
throw new Error("requester is recorded once and cannot be updated");
|
|
311
323
|
if (patch.id &&
|
|
312
|
-
entries.some((e) => e !== entry &&
|
|
324
|
+
entries.some((e) => e !== entry &&
|
|
325
|
+
e.runtime === entry.runtime &&
|
|
326
|
+
e.id === patch.id &&
|
|
327
|
+
!FINISHED.includes(e.status)))
|
|
313
328
|
throw new Error("Durable identity already registered");
|
|
314
329
|
Object.assign(entry, patch);
|
|
315
330
|
return structuredClone(entry);
|
package/dist/run.js
CHANGED
|
@@ -11,7 +11,7 @@ import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct,
|
|
|
11
11
|
import { IdentityError, readIdentity, } from "./identity-store.js";
|
|
12
12
|
import { resolveCredential } from "./identity-token.js";
|
|
13
13
|
import { copyIdentity, identityEnv } from "./identity-copy.js";
|
|
14
|
-
import { Registry } from "./registry.js";
|
|
14
|
+
import { currentEntry, Registry } from "./registry.js";
|
|
15
15
|
import { LaunchLog } from "./log.js";
|
|
16
16
|
import { hosts, selectHost } from "./hosts/index.js";
|
|
17
17
|
import { AGENTS } from "./agents.js";
|
|
@@ -802,6 +802,15 @@ export class Muster {
|
|
|
802
802
|
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline, req.prompt)
|
|
803
803
|
: await resolveClaude(root.pid, resolveEnv, deadline);
|
|
804
804
|
if (identity) {
|
|
805
|
+
// The answer #10 asks for: a resume that registered some other
|
|
806
|
+
// session is a launch that failed, never a new session reported as
|
|
807
|
+
// the old one. Not fatal at once, like every other diagnosis here,
|
|
808
|
+
// in case the registry is read mid-write.
|
|
809
|
+
const resume = req.runtime === "claude" ? resolveResume(req) : null;
|
|
810
|
+
if (resume && (identity.id === resume.id) === resume.fork)
|
|
811
|
+
throw new Error(resume.fork
|
|
812
|
+
? `--fork-session registered the original session ${resume.id} instead of a new one`
|
|
813
|
+
: `Claude registered session ${identity.id} instead of resuming ${resume.id}`);
|
|
805
814
|
diagnostic =
|
|
806
815
|
req.runtime === "codex"
|
|
807
816
|
? "thread id reserved but no rollout"
|
|
@@ -923,7 +932,7 @@ export class Muster {
|
|
|
923
932
|
throw new Error("--resume is available to a local requester only");
|
|
924
933
|
if (identityName)
|
|
925
934
|
throw new Error("--resume cannot be combined with an identity: the identity's config dir holds none of this profile's conversations");
|
|
926
|
-
assertResumable(resume, agentBase);
|
|
935
|
+
assertResumable(resume, agentBase, effective.cwd);
|
|
927
936
|
}
|
|
928
937
|
let argv = plannedArgv;
|
|
929
938
|
let codexPolicy = [];
|
|
@@ -1176,11 +1185,21 @@ export class Muster {
|
|
|
1176
1185
|
}
|
|
1177
1186
|
async find(id) {
|
|
1178
1187
|
const all = await this.registry.all();
|
|
1188
|
+
// A launch id names exactly one launch, so a caller that holds one (the
|
|
1189
|
+
// reaper) can never reach a later launch of the same resumed session.
|
|
1190
|
+
const launch = all.find((e) => e.launchId === id);
|
|
1191
|
+
if (launch)
|
|
1192
|
+
return launch;
|
|
1179
1193
|
const exact = all.filter((e) => e.id === id);
|
|
1180
1194
|
if (exact.length === 1)
|
|
1181
1195
|
return exact[0];
|
|
1182
|
-
|
|
1183
|
-
|
|
1196
|
+
// The same session under two runtimes is ambiguous. Under one, it was
|
|
1197
|
+
// resumed: the launch running it now, else the latest, is the one meant.
|
|
1198
|
+
if (exact.length > 1) {
|
|
1199
|
+
if (new Set(exact.map((e) => e.runtime)).size > 1)
|
|
1200
|
+
throw new Error(`Ambiguous durable id: ${exact.map((e) => e.runtime + ":" + e.id).join(", ")}`);
|
|
1201
|
+
return currentEntry(exact);
|
|
1202
|
+
}
|
|
1184
1203
|
const peers = all.filter((e) => e.kind === "session" && e.status === "running" && e.peer);
|
|
1185
1204
|
const named = peers.map((e) => ({
|
|
1186
1205
|
runtime: runtimeName(e.runtime),
|