@mnemom/mnemom 0.17.32-next.2 → 0.17.32-next.4

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.
@@ -0,0 +1,679 @@
1
+ /**
2
+ * `mnemom agent sessions adopt` (BACKBONE C4; C14-R12..R25, R20, R21; C18-R10).
3
+ *
4
+ * Plan (no side effects; `--dry-run` stops here): the live tracked claudes on this machine (the
5
+ * step-2 discovery), grouped one table per agent. For each session: its row (A2 GET), the C4 cwd
6
+ * rule with the transcript-slug check, the spec built from the original's exact argv by the one
7
+ * builder, and the Note ('approvals with you; flags dropped: …', S1 grammar). A group holding a
8
+ * session that fails the cwd rule or is otherwise unadoptable is skipped whole (R15), unless
9
+ * --own-agent moves its adoptable sessions to their own agent. The group checks (R17 sharing,
10
+ * R18 lock preconditions) run through the non-mutating lock check before anything is stopped;
11
+ * a refusal prints its one-line fix and touches nothing. The check fails closed: a check route the
12
+ * API does not serve (404/405 with no durable code), a timeout or a dropped response refuses the
13
+ * group as durable_precondition_unavailable, so an original whose group checks did not run is
14
+ * never stopped.
15
+ *
16
+ * A live original whose row is already locked (C14-R11 re-run): its spec is built from its exact
17
+ * argv and compared with the committed charter's launch_spec by the S1 specHash. Equal is a no-op
18
+ * (no check, no lock request, no signal). Different is refused 422-style on stderr
19
+ * (durable_unsupported {feature: launch_spec_change} and the S1 launch_spec_change fix line) and
20
+ * the command exits 1, again with no request and no signal. A committed spec that cannot be read
21
+ * is refused the same way as durable_precondition_unavailable.
22
+ *
23
+ * Run (after one [Y/n] per adoptable group): per session the U01 handover order (R25): the
24
+ * pinned-binary check ran in the plan (wired only when a pinned path is known: S1 freezes no host
25
+ * state field for it, see adopt-live.ts), SIGSTOP of the original claude pid only (an original
26
+ * that exited meanwhile is reported and skipped), the lock request (retried under one
27
+ * Idempotency-Key until an answer is decisive, see lockAnswerKind), SIGCONT only on a 4xx
28
+ * refusal, then the seat's hello (the row reaches running) or ATTACH_WAIT_S, then SIGKILL of the
29
+ * original's whole tree minus this process. Every signal re-checks its pid first (the original's
30
+ * identity; each tree pid's start time against the snapshot) and a pid that no longer matches is
31
+ * skipped and named, never signalled. The handover fails closed: when no attempt is
32
+ * decisive a request may still be in flight, so a re-read that shows the row locked proceeds and
33
+ * anything else leaves the original stopped (never two running copies of one conversation);
34
+ * adopt then names the pid and the way out.
35
+ *
36
+ * Every code adopt prints is an S1 code and every fix line an S1 catalogue entry rendered once
37
+ * (renderFixLine); a line whose parameters are unknown is not printed (C11-R27).
38
+ *
39
+ * On a host home (an executor state file present) the summary prints the current S1 risk line
40
+ * verbatim; adopt writes no seen record and never reads the stored risk-line version (R20).
41
+ */
42
+ import { randomUUID } from "node:crypto";
43
+ import { attachWaitSeconds, ATTACH_WAIT_S, FIX_LINES, lockRefusalFixKey, renderFixLine, RISK_LINE_TEXT, parseLaunchSpec, placeholdersOf, specHash, } from "../vendor/durable-contract/index.js";
44
+ import { classifyCwd, cwdFixLine } from "./cwd-rule.js";
45
+ import { agentSlugOf } from "./discover.js";
46
+ import { adoptNote, buildDurableLaunchSpec, launchSpecChangeFixLines, runningSourceInput, specUserArgv, } from "./launch-spec.js";
47
+ import { processTree } from "./proc.js";
48
+ const GOAL = {
49
+ on: "sealed",
50
+ implicit: "implicit",
51
+ off: "none",
52
+ };
53
+ const UNADOPTABLE_TEXT = {
54
+ no_ledger: "no local launch record for this conversation",
55
+ no_row: "the session is not registered",
56
+ row_unreadable: "the session row could not be read",
57
+ server_shape: "a server-shape session (no local transcript)",
58
+ no_cc_session_id: "no Claude Code session id is registered, so a seat cannot resume it",
59
+ no_transcript: "no local transcript for its Claude Code session",
60
+ no_cwd: "its working directory cannot be read",
61
+ };
62
+ /** The cwd the rule checks: the claude's PWD when it resolves to the process cwd (a symlink kept), else the process cwd. */
63
+ export function ruleCwd(live, realpath) {
64
+ const pwd = live.env.PWD;
65
+ if (pwd && live.cwd && realpath(pwd) === live.cwd)
66
+ return pwd;
67
+ return live.cwd;
68
+ }
69
+ /** The session row of a GET answer: the body itself, or its `session`; agent_id also from `agent.id`. */
70
+ export function rowOf(a) {
71
+ if (a.kind !== "ok" || a.body === null || typeof a.body !== "object")
72
+ return null;
73
+ const b = a.body;
74
+ const inner = (b.session && typeof b.session === "object" ? b.session : b);
75
+ if (inner.agent_id === undefined && typeof inner.agent?.id === "string")
76
+ return { ...inner, agent_id: inner.agent.id };
77
+ return inner;
78
+ }
79
+ async function planSession(live, deps, launcherKey) {
80
+ const facts = deps.ledger(live.conversationId);
81
+ const base = {
82
+ name: live.sessionName,
83
+ conversation_id: live.conversationId,
84
+ cc_session_id: null,
85
+ pid: live.pid,
86
+ cwd: live.cwd,
87
+ cwd_kind: null,
88
+ verdict: "unadoptable",
89
+ reason: null,
90
+ goal: facts ? GOAL[facts.goalMode] : "none",
91
+ rules: facts?.requirements != null ? String(facts.requirements) : "-",
92
+ note: "",
93
+ spec: null,
94
+ spec_hash: null,
95
+ dropped: [],
96
+ fix: null,
97
+ _live: live,
98
+ };
99
+ const unadoptable = (reason) => {
100
+ base.verdict = "unadoptable";
101
+ base.reason = reason;
102
+ base.note = `unadoptable: ${UNADOPTABLE_TEXT[reason]}`;
103
+ return {
104
+ s: base,
105
+ agentId: facts?.agentId ?? null,
106
+ agentLabel: facts?.agentLabel ?? agentSlugOf(live.env) ?? "unknown",
107
+ };
108
+ };
109
+ if (!facts)
110
+ return unadoptable("no_ledger");
111
+ if (facts.launchShape === "server")
112
+ return unadoptable("server_shape");
113
+ const got = await settle(() => deps.api.getSession(live.conversationId, facts));
114
+ if (got.kind === "error" && got.status === 404)
115
+ return unadoptable("no_row");
116
+ const row = rowOf(got);
117
+ if (!row)
118
+ return unadoptable("row_unreadable");
119
+ Object.assign(base, { _row: row });
120
+ const agentId = row.agent_id ?? facts.agentId;
121
+ if (row.durable_mode === "locked") {
122
+ await compareLocked(base, live, row, deps, launcherKey);
123
+ return { s: base, agentId, agentLabel: facts.agentLabel };
124
+ }
125
+ const cc = typeof row.cc_session_id === "string" && row.cc_session_id ? row.cc_session_id : null;
126
+ if (cc === null)
127
+ return unadoptable("no_cc_session_id");
128
+ base.cc_session_id = cc;
129
+ const cwd = ruleCwd(live, deps.realpath);
130
+ if (cwd === null)
131
+ return unadoptable("no_cwd");
132
+ const verdict = classifyCwd(cwd, deps.cwd(live), cc);
133
+ if (!verdict.ok && verdict.cwd_kind === null)
134
+ return unadoptable(verdict.unadoptable);
135
+ if (!verdict.ok) {
136
+ base.verdict = "skip";
137
+ base.cwd_kind = verdict.cwd_kind;
138
+ base.reason = `cwd_${verdict.cwd_kind}`;
139
+ // A tracked row holds the name, so the fix carries the done clause (S1 fix-line rule).
140
+ base.fix = cwdFixLine(verdict, { name: live.sessionName, launcherKey, nameHeld: true });
141
+ base.note = cwdNote(verdict.cwd_kind, base.fix);
142
+ return { s: base, agentId, agentLabel: facts.agentLabel };
143
+ }
144
+ base.cwd = verdict.root;
145
+ base.cwd_kind = "root";
146
+ const built = await buildDurableLaunchSpec(runningSourceInput(live.argv.slice(1), verdict.root, {
147
+ passthroughHint: live.passthroughHint,
148
+ remoteControl: live.remoteControl,
149
+ }));
150
+ base.verdict = "adopt";
151
+ base.spec = built.spec;
152
+ base.spec_hash = built.hash;
153
+ base.dropped = built.dropped;
154
+ base.note = adoptNote(built.dropped);
155
+ return { s: base, agentId, agentLabel: facts.agentLabel };
156
+ }
157
+ const cwdNote = (kind, fix) => (fix ? `${kind}: ${fix}` : kind);
158
+ /**
159
+ * C14-R11: a live original of an already-locked session. Build its spec from the exact argv (at
160
+ * the realpath of its cwd, as the committed spec holds it) and compare by the S1 specHash with the
161
+ * committed charter's launch_spec. Sets the session's verdict; makes no request and no signal.
162
+ */
163
+ async function compareLocked(base, live, row, deps, launcherKey) {
164
+ const committed = parseLaunchSpec(row.charter?.launch_spec ?? null);
165
+ const cwd = ruleCwd(live, deps.realpath);
166
+ const real = cwd === null ? null : deps.realpath(cwd);
167
+ if (committed === null || real === null) {
168
+ base.verdict = "spec_change";
169
+ base.reason = "spec_unverified";
170
+ base.note = "locked: the committed launch spec could not be compared";
171
+ return;
172
+ }
173
+ let built;
174
+ try {
175
+ built = await buildDurableLaunchSpec(runningSourceInput(live.argv.slice(1), real, {
176
+ passthroughHint: live.passthroughHint,
177
+ remoteControl: live.remoteControl,
178
+ }));
179
+ }
180
+ catch {
181
+ base.verdict = "spec_change";
182
+ base.reason = "spec_unverified";
183
+ base.note = "locked: the committed launch spec could not be compared";
184
+ return;
185
+ }
186
+ if ((await specHash(built.spec)) === (await specHash(committed))) {
187
+ base.verdict = "durable";
188
+ base.reason = "already_durable";
189
+ base.note = "already durable: not adopted again";
190
+ return;
191
+ }
192
+ base.verdict = "spec_change";
193
+ base.reason = "launch_spec_change";
194
+ base.spec = built.spec;
195
+ base.spec_hash = built.hash;
196
+ base.dropped = built.dropped;
197
+ base.note = "locked: the launch spec differs from the committed one";
198
+ // The sole-unlock line needs "the agent's sole locked session", which no S1 route lets adopt
199
+ // read (gaps/c4-relock-sole-unlock.md): it is never offered from here.
200
+ // launchSpecChangeFixLines' tuple type guarantees the launch_spec_change line is first.
201
+ base.fix = launchSpecChangeFixLines({
202
+ name: live.sessionName,
203
+ flags: specUserArgv(built.spec).join(" "),
204
+ launcherKey,
205
+ soleLockedSession: false,
206
+ })[0];
207
+ }
208
+ /**
209
+ * The S1 fix line for a lock refusal answered by the check or the lock route, or null when the
210
+ * code has none or a parameter it needs is unknown (no placeholder is ever printed).
211
+ */
212
+ export function refusalFix(code, details, ctx) {
213
+ const d = (details && typeof details === "object" ? details : {});
214
+ let key;
215
+ if (code === "durable_agent_contained") {
216
+ key = d.status === "killed" ? "agent_killed" : "agent_contained";
217
+ }
218
+ else if (code !== null && code in LOCK_REFUSAL_FIX_CODES) {
219
+ key = lockRefusalFixKey({ code: code });
220
+ }
221
+ else
222
+ key = null;
223
+ if (key === null)
224
+ return null;
225
+ const params = { name: ctx.name };
226
+ const agent = typeof d.agent === "string" ? d.agent : ctx.agent;
227
+ if (agent)
228
+ params.agent = agent;
229
+ const refusals = Array.isArray(d.refusals) ? d.refusals : [];
230
+ const repos = refusals
231
+ .map((r) => r.repo)
232
+ .filter((r) => typeof r === "string" && r !== "");
233
+ if (repos.length > 0)
234
+ params.repo = repos.join(", ");
235
+ if (placeholdersOf(key).some((p) => params[p] === undefined))
236
+ return null;
237
+ return renderFixLine(key, { launcherKey: ctx.launcherKey, nameHeld: true, params });
238
+ }
239
+ const LOCK_REFUSAL_FIX_CODES = Object.freeze({
240
+ durable_lock_not_entitled: true,
241
+ durable_lock_mfa_required: true,
242
+ durable_lock_branch_unprotected: true,
243
+ durable_lock_agent_shared: true,
244
+ durable_no_executor: true,
245
+ // No durable_cwd_not_root: S1 makes it a CLI-only code (codes.ts:135, status null, surfaces
246
+ // ["cli"]), so neither route can answer it, and its one field, cwd_kind, names no path for a fix
247
+ // line. The cwd rule is adopt's own (classifyCwd), with its own fix lines (cwdFixLine).
248
+ durable_precondition_unavailable: true,
249
+ });
250
+ /**
251
+ * The precheck route answered 404/405 with no durable code: the API does not serve it (see
252
+ * PRECHECK note in adopt-api.ts; gaps/c4-adopt-precheck-route.md). Adopt fails closed on it.
253
+ */
254
+ export function isPrecheckMissing(a) {
255
+ return a.kind === "error" && (a.status === 404 || a.status === 405) && a.code === null;
256
+ }
257
+ /** The lock body for a planned session (S1 LOCK_BODY_KEYS: spec, executor_id, own_agent). */
258
+ export function lockBodyFor(s, executorId, ownAgent) {
259
+ if (!s.spec)
260
+ throw new RangeError(`no spec for ${s.name}`);
261
+ return ownAgent
262
+ ? { spec: s.spec, executor_id: executorId, own_agent: true }
263
+ : { spec: s.spec, executor_id: executorId };
264
+ }
265
+ /** Build the plan: discovery, per-session checks, grouping and the group checks. No side effects. */
266
+ export async function planAdopt(deps, opts = {}) {
267
+ const launcherKey = deps.launcherKey();
268
+ const exec = deps.executorState();
269
+ const groups = new Map();
270
+ for (const live of deps.discover()) {
271
+ const { s, agentId, agentLabel } = await planSession(live, deps, launcherKey);
272
+ const key = agentId ?? `?${agentLabel}`;
273
+ let g = groups.get(key);
274
+ if (!g) {
275
+ g = {
276
+ agent_id: agentId,
277
+ agent: agentLabel,
278
+ verdict: "adopt",
279
+ reason: null,
280
+ code: null,
281
+ fix: null,
282
+ own_agent: false,
283
+ sessions: [],
284
+ };
285
+ groups.set(key, g);
286
+ }
287
+ g.sessions.push(s);
288
+ }
289
+ let ownCheckout;
290
+ for (const g of groups.values()) {
291
+ g.sessions.sort((a, b) => a.name.localeCompare(b.name));
292
+ // not_git: offer a checkout this agent already runs in, else this command's own (C11-R27).
293
+ for (const s of g.sessions) {
294
+ if (s.cwd_kind !== "not_git")
295
+ continue;
296
+ const root = g.sessions.find((x) => x.cwd_kind === "root")?.cwd ?? null;
297
+ if (root === null && ownCheckout === undefined)
298
+ ownCheckout = deps.ownCheckout();
299
+ const checkout = root ?? ownCheckout ?? null;
300
+ if (checkout === null)
301
+ continue;
302
+ s.fix = cwdFixLine({ ok: false, cwd_kind: "not_git", params: { checkout } }, { name: s.name, launcherKey, nameHeld: true });
303
+ s.note = cwdNote("not_git", s.fix);
304
+ }
305
+ const adoptable = g.sessions.filter((s) => s.verdict === "adopt");
306
+ const blocking = g.sessions.filter((s) => s.verdict === "skip" || s.verdict === "unadoptable");
307
+ if (adoptable.length === 0) {
308
+ g.verdict = blocking.length > 0 ? "skip" : "none";
309
+ g.reason = blocking.length > 0 ? blocking[0].reason : null;
310
+ continue;
311
+ }
312
+ if (blocking.length > 0 && !opts.ownAgent) {
313
+ // R15: the whole group is skipped; the header names the blocking session.
314
+ g.verdict = "skip";
315
+ const first = blocking[0];
316
+ g.reason = `${first.reason} (${first.name})`;
317
+ continue;
318
+ }
319
+ g.own_agent = blocking.length > 0 || opts.ownAgent === true;
320
+ if (!exec) {
321
+ g.verdict = "refuse";
322
+ g.code = "durable_no_executor";
323
+ g.fix = renderFixLine("no_executor", { launcherKey, nameHeld: true, params: {} });
324
+ continue;
325
+ }
326
+ const pinned = deps.pinnedBinary();
327
+ if (pinned !== null && !deps.binaryRunnable(pinned)) {
328
+ // No S1 code or fix entry names a missing pinned binary (gaps/c4-pinned-binary-state.md).
329
+ g.verdict = "refuse";
330
+ g.code = "durable_precondition_unavailable";
331
+ g.reason = `the pinned claude binary ${pinned} is missing`;
332
+ continue;
333
+ }
334
+ for (const s of adoptable) {
335
+ const a = await settle(() => deps.api.lockCheck(s.conversation_id, lockBodyFor(s, exec.executor_id, g.own_agent)));
336
+ if (a.kind === "ok")
337
+ continue;
338
+ g.verdict = "refuse";
339
+ if (isPrecheckMissing(a)) {
340
+ g.code = "durable_precondition_unavailable";
341
+ g.reason = "this API does not serve the lock check, so nothing was stopped";
342
+ }
343
+ else if (a.kind === "error") {
344
+ g.code = a.code ?? `http_${a.status}`;
345
+ g.reason = a.message;
346
+ g.fix = refusalFix(a.code, a.details, { name: s.name, launcherKey, agent: g.agent });
347
+ }
348
+ else {
349
+ g.code = "durable_precondition_unavailable";
350
+ g.reason = a.kind === "timeout" ? "the lock check timed out" : a.message;
351
+ }
352
+ break;
353
+ }
354
+ }
355
+ return { groups: [...groups.values()], risk_line: exec ? RISK_LINE_TEXT : null };
356
+ }
357
+ // ---- rendering ---------------------------------------------------------------------------------
358
+ /** The --json document (internal fields removed). */
359
+ export function planJson(plan) {
360
+ return {
361
+ groups: plan.groups.map((g) => ({
362
+ ...g,
363
+ sessions: g.sessions.map(({ _live, _row, ...s }) => s),
364
+ })),
365
+ risk_line: plan.risk_line,
366
+ };
367
+ }
368
+ const COLS = ["Session", "Goal", "Rules", "Note"];
369
+ /** One table per agent group (R12): Session, Goal, Rules, Note; Note last and unpadded. */
370
+ export function renderGroup(g) {
371
+ const head = g.verdict === "adopt"
372
+ ? `Agent ${g.agent}${g.own_agent ? " (moving to its own agent)" : ""}`
373
+ : g.verdict === "refuse"
374
+ ? `Agent ${g.agent}: refused (${g.code})${g.reason ? `: ${g.reason}` : ""}`
375
+ : g.verdict === "skip"
376
+ ? `Agent ${g.agent}: skipped (${g.reason})`
377
+ : `Agent ${g.agent}: nothing to adopt`;
378
+ const rows = g.sessions.map((s) => [s.name, s.goal, s.rules, s.note]);
379
+ const width = [0, 1, 2].map((c) => Math.max(COLS[c].length, ...rows.map((r) => r[c].length)));
380
+ const line = (r) => ` ${r
381
+ .slice(0, 3)
382
+ .map((v, c) => v.padEnd(width[c]))
383
+ .join(" ")} ${r[3]}`;
384
+ const out = [head, line(COLS), ...rows.map(line)];
385
+ if (g.fix)
386
+ out.push(` fix: ${g.fix}`);
387
+ return out;
388
+ }
389
+ export function renderPlan(plan) {
390
+ const out = [];
391
+ if (plan.groups.length === 0)
392
+ out.push("No running tracked sessions on this machine.");
393
+ for (const g of plan.groups)
394
+ out.push(...renderGroup(g), "");
395
+ if (plan.risk_line)
396
+ out.push(plan.risk_line);
397
+ return out;
398
+ }
399
+ /**
400
+ * The pid still names the process discovery saw: the same start time and argv[0]. A pid is reused
401
+ * once its process exits, so a bare pid is never signalled on its own (pid reuse guard).
402
+ */
403
+ export function sameProcess(live, now) {
404
+ return (now !== null && live.start !== null && now.start === live.start && now.argv0 === live.argv[0]);
405
+ }
406
+ /** A lock outcome that stayed unknown: S1 has no timeout code. */
407
+ const LOCK_UNAVAILABLE_CODE = "durable_precondition_unavailable";
408
+ /** Re-reads of the row after the lock attempts ran out undecided, REREAD_GAP_MS apart. */
409
+ export const REREAD_ATTEMPTS = 3;
410
+ export const REREAD_GAP_MS = 1000;
411
+ /**
412
+ * What the server says about a lock whose answer never came. Only a row that carries its
413
+ * durable_mode counts as an answer; a failed read, or a row without the field, is no answer.
414
+ */
415
+ export async function commitOutcome(cid, deps, facts) {
416
+ for (let i = 0; i < REREAD_ATTEMPTS; i++) {
417
+ if (i > 0)
418
+ await deps.sleep(REREAD_GAP_MS);
419
+ const mode = rowOf(await settle(() => deps.api.getSession(cid, facts)))?.durable_mode;
420
+ if (typeof mode === "string")
421
+ return mode === "locked" ? "committed" : "not_committed";
422
+ }
423
+ return "unknown";
424
+ }
425
+ /** Lock attempts per handover (the first plus same-key retries) and the gaps between them. */
426
+ export const LOCK_RETRY_GAPS_MS = [2000, 5000, 10000];
427
+ export const LOCK_ATTEMPTS = LOCK_RETRY_GAPS_MS.length + 1;
428
+ /** One lock answer, read: see lockAnswerKind. */
429
+ export function lockDecision(a, first) {
430
+ const kind = lockAnswerKind(a, first);
431
+ return kind === "refused" ? { kind, a: a } : { kind, a };
432
+ }
433
+ export function lockAnswerKind(a, first) {
434
+ if (a.kind === "ok")
435
+ return "committed";
436
+ if (a.kind !== "error")
437
+ return "ambiguous";
438
+ if (a.code === LOCK_UNAVAILABLE_CODE)
439
+ return first ? "refused" : "ambiguous";
440
+ if (a.status >= 500 || a.code === "durable_retry")
441
+ return "ambiguous";
442
+ if (a.code === "idempotency_key_in_flight")
443
+ return "pending";
444
+ return "refused";
445
+ }
446
+ /**
447
+ * The lock, retried under one Idempotency-Key until an answer is decisive (committed or refused)
448
+ * or LOCK_ATTEMPTS run out; the last answer is returned either way.
449
+ */
450
+ export async function lockDecided(cid, body, deps) {
451
+ const key = randomUUID();
452
+ let last = { a: { kind: "timeout" }, kind: "ambiguous" };
453
+ for (const gap of [0, ...LOCK_RETRY_GAPS_MS]) {
454
+ if (gap > 0)
455
+ await deps.sleep(gap);
456
+ const a = await settle(() => deps.api.lock(cid, body, key));
457
+ last = lockDecision(a, gap === 0);
458
+ if (last.kind === "committed" || last.kind === "refused")
459
+ return last;
460
+ }
461
+ return last;
462
+ }
463
+ /** An API call that never throws (a throw reads as a dropped response). */
464
+ export async function settle(f) {
465
+ try {
466
+ return await f();
467
+ }
468
+ catch (e) {
469
+ return { kind: "network", message: e instanceof Error ? e.message : String(e) };
470
+ }
471
+ }
472
+ /** One session's U01 handover (R25 steps 2-5); step 1 ran in the plan. */
473
+ export async function handover(s, g, exec, deps, facts, launcherKey) {
474
+ const live = s._live;
475
+ const same = () => sameProcess(live, deps.identity(live.pid));
476
+ const ended = { ok: false, name: s.name, committed: false, ended: true };
477
+ // The confirm prompt has no deadline: re-read the identity right before each signal.
478
+ if (!same())
479
+ return ended;
480
+ try {
481
+ deps.signal(live.pid, "SIGSTOP");
482
+ }
483
+ catch {
484
+ return { ok: false, name: s.name, committed: false, gone: true };
485
+ }
486
+ // It changed between the check and the stop: the stop hit another process, so resume it.
487
+ if (!same()) {
488
+ try {
489
+ deps.signal(live.pid, "SIGCONT");
490
+ }
491
+ catch {
492
+ // gone too: nothing to resume
493
+ }
494
+ return ended;
495
+ }
496
+ const d = await lockDecided(s.conversation_id, lockBodyFor(s, exec.executor_id, g.own_agent), deps);
497
+ // Only a 4xx refusal proves the lock did not commit: resume the original and report it. The
498
+ // lock attempts can take seconds, so the identity is re-read: a reused pid is never signalled.
499
+ if (d.kind === "refused") {
500
+ const a = d.a;
501
+ if (same()) {
502
+ try {
503
+ deps.signal(live.pid, "SIGCONT");
504
+ }
505
+ catch {
506
+ // it exited while stopped: nothing to resume, the refusal still stands
507
+ }
508
+ }
509
+ else
510
+ deps.err(`${s.name}: pid ${live.pid} is no longer the original; not resumed`);
511
+ return {
512
+ ok: false,
513
+ name: s.name,
514
+ committed: false,
515
+ code: a.code ?? `http_${a.status}`,
516
+ fix: refusalFix(a.code, a.details, { name: s.name, launcherKey, agent: g.agent }),
517
+ };
518
+ }
519
+ // No attempt was decisive and a request may still be in flight: only a re-read that shows the
520
+ // lock decides. A row that is not locked proves nothing yet, so the original stays stopped.
521
+ if (d.kind !== "committed" &&
522
+ (await commitOutcome(s.conversation_id, deps, facts)) !== "committed")
523
+ return {
524
+ ok: false,
525
+ name: s.name,
526
+ committed: null,
527
+ unconfirmed: true,
528
+ pid: live.pid,
529
+ code: LOCK_UNAVAILABLE_CODE,
530
+ };
531
+ // After the commit the original is never resumed: wait for the seat's hello, then end it.
532
+ const lease = s._row?.lease_seconds;
533
+ let waitS = ATTACH_WAIT_S;
534
+ try {
535
+ if (typeof lease === "number")
536
+ waitS = attachWaitSeconds(lease);
537
+ }
538
+ catch {
539
+ waitS = ATTACH_WAIT_S;
540
+ }
541
+ const deadline = deps.now() + waitS * 1000;
542
+ let hello = false;
543
+ while (deps.now() < deadline) {
544
+ const r = rowOf(await settle(() => deps.api.getSession(s.conversation_id, facts)));
545
+ if (r && (r.effective_state ?? r.state) === "running") {
546
+ hello = true;
547
+ break;
548
+ }
549
+ await deps.sleep(500);
550
+ }
551
+ // Stopped by us, the original cannot exit on its own; if its pid names another process now, it
552
+ // was ended from outside and that pid is not ours to kill.
553
+ if (!same())
554
+ return { ok: true, name: s.name, hello, killed: false };
555
+ const table = deps.processTable();
556
+ const tree = processTree(live.pid, table, new Set([deps.selfPid]));
557
+ const startAt = new Map(table.map((row) => [row.pid, row.start]));
558
+ for (const pid of tree) {
559
+ // Re-check each pid against the snapshot right before its kill: a pid that exited and was
560
+ // reused (or whose start the snapshot could not read) is skipped, never killed.
561
+ const snap = startAt.get(pid);
562
+ if (snap === undefined || deps.startOf(pid) !== snap) {
563
+ deps.err(`${s.name}: pid ${pid} no longer matches the process tree read before the kill; skipped`);
564
+ continue;
565
+ }
566
+ try {
567
+ deps.signal(pid, "SIGKILL");
568
+ }
569
+ catch {
570
+ // already gone
571
+ }
572
+ }
573
+ return { ok: true, name: s.name, hello, killed: true };
574
+ }
575
+ /**
576
+ * Ruling 19: the plan already holds a refusal, a refused group (lock check, executor state, pinned
577
+ * binary) or a locked session's changed spec (C14-R11). A group skipped by the cwd rule, or with
578
+ * nothing to adopt, is not a refusal.
579
+ */
580
+ export function planRefused(plan) {
581
+ return plan.groups.some((g) => g.verdict === "refuse" || g.sessions.some((s) => s.verdict === "spec_change"));
582
+ }
583
+ /** The whole verb. Returns the exit status: 1 when anything was refused (ruling 19), else 0. */
584
+ export async function adoptCommand(deps, opts = {}) {
585
+ const plan = await planAdopt(deps, opts);
586
+ if (opts.dryRun) {
587
+ if (opts.json)
588
+ deps.out(JSON.stringify(planJson(plan), null, 2));
589
+ else
590
+ for (const l of renderPlan(plan))
591
+ deps.out(l);
592
+ return planRefused(plan) ? 1 : 0;
593
+ }
594
+ if (opts.json)
595
+ deps.out(JSON.stringify(planJson(plan), null, 2));
596
+ else
597
+ for (const l of renderPlan(plan))
598
+ deps.out(l);
599
+ const exec = deps.executorState();
600
+ const launcherKey = deps.launcherKey();
601
+ let failed = planRefused(plan);
602
+ // C14-R11: a locked session's changed (or uncomparable) spec is refused on stderr; no request.
603
+ for (const g of plan.groups)
604
+ for (const s of g.sessions) {
605
+ if (s.verdict !== "spec_change")
606
+ continue;
607
+ failed = true;
608
+ deps.err(s.reason === "launch_spec_change"
609
+ ? `${s.name}: not adopted (durable_unsupported {feature: launch_spec_change}); it keeps running as before`
610
+ : `${s.name}: not adopted (durable_precondition_unavailable): the committed launch spec could not be compared; it keeps running as before`);
611
+ if (s.fix)
612
+ deps.err(` fix: ${s.fix}`);
613
+ }
614
+ for (const g of plan.groups) {
615
+ if (g.verdict !== "adopt")
616
+ continue;
617
+ if (!exec) {
618
+ // The plan saw the executor state; it is gone now. Refuse loudly, never skip in silence.
619
+ failed = true;
620
+ deps.err(`${g.agent}: not adopted (durable_no_executor); it keeps running as before`);
621
+ deps.err(` fix: ${renderFixLine("no_executor", { launcherKey, nameHeld: true, params: {} })}`);
622
+ continue;
623
+ }
624
+ const adoptable = g.sessions.filter((s) => s.verdict === "adopt");
625
+ const ok = await deps.confirm(`Adopt ${adoptable.length} session${adoptable.length === 1 ? "" : "s"} of agent ${g.agent}${g.own_agent ? " onto their own agent" : ""}? [Y/n] `);
626
+ if (!ok) {
627
+ deps.out(`${g.agent}: not adopted`);
628
+ continue;
629
+ }
630
+ for (const s of adoptable) {
631
+ // Read again at run time: the session record can go between the plan and now (a
632
+ // concurrent `mnemom agent done`), and nothing is stopped without its facts.
633
+ const facts = deps.ledger(s.conversation_id);
634
+ if (facts === null) {
635
+ failed = true;
636
+ deps.err(`${s.name}: not adopted (${LOCK_UNAVAILABLE_CODE}): its session record is gone since the plan; it keeps running as before`);
637
+ continue;
638
+ }
639
+ const r = await handover(s, g, exec, deps, facts, launcherKey);
640
+ if (r.ok) {
641
+ deps.out(`\`${s.name}\` is locked; the host daemon runs it. Attach: \`mnemom agent ${s.name}\` (${s.note})`);
642
+ if (!r.hello)
643
+ deps.out(`${s.name}: the seat has not said hello yet: \`mnemom agent host status\``);
644
+ if (!r.killed)
645
+ deps.out(`${s.name}: the original process ended while waiting; nothing was killed`);
646
+ }
647
+ else if ("ended" in r) {
648
+ failed = true;
649
+ deps.out(`${s.name}: not adopted: the session ended while waiting for confirmation; no lock was requested. Re-run \`mnemom agent sessions adopt\``);
650
+ }
651
+ else if ("unconfirmed" in r) {
652
+ failed = true;
653
+ deps.err(`${s.name}: not confirmed (${r.code}): the lock request got no clear answer and the session does not show it locked, so the original (pid ${r.pid}) is left stopped`);
654
+ deps.err(` next: run \`mnemom agent sessions adopt --dry-run\`; if it shows ${s.name} already durable, the host daemon runs it and \`kill -KILL ${r.pid}\` ends the stopped original; otherwise \`kill -CONT ${r.pid}\` resumes it`);
655
+ }
656
+ else if ("gone" in r) {
657
+ failed = true;
658
+ deps.out(`${s.name}: not adopted: it exited before it could be stopped`);
659
+ }
660
+ else {
661
+ failed = true;
662
+ deps.out(`${s.name}: not adopted (${r.code}); it keeps running as before`);
663
+ if (r.fix)
664
+ deps.out(` fix: ${r.fix}`);
665
+ }
666
+ }
667
+ }
668
+ return failed ? 1 : 0;
669
+ }
670
+ /** The fix catalogue entries adopt can print (exported for the catalogue lint test). */
671
+ export const ADOPT_FIX_KEYS = Object.freeze([
672
+ "agent_killed",
673
+ "agent_contained",
674
+ "no_executor",
675
+ "launch_spec_change",
676
+ "cwd_subdirectory",
677
+ "cwd_not_git",
678
+ "cwd_symlink_transcript",
679
+ ].filter((k) => k in FIX_LINES));