@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 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 found = false;
431
+ let transcript;
432
432
  try {
433
- found = readdirSync(projects).some((dir) => existsSync(join(projects, dir, resume.id + ".jsonl")));
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 (!found)
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.id);
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 durable = entries.filter((e) => e.id === id);
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 && e.runtime === entry.runtime && e.id === patch.id))
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
- if (exact.length > 1)
1183
- throw new Error(`Ambiguous durable id: ${exact.map((e) => e.runtime + ":" + e.id).join(", ")}`);
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {