@mnemom/mnemom 0.17.32-next.2 → 0.17.32-next.3
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/commands/agent.js +147 -65
- package/dist/durable/adopt-api.d.ts +37 -0
- package/dist/durable/adopt-api.js +106 -0
- package/dist/durable/adopt-live.d.ts +17 -0
- package/dist/durable/adopt-live.js +152 -0
- package/dist/durable/adopt.d.ts +305 -0
- package/dist/durable/adopt.js +679 -0
- package/dist/durable/cwd-rule.d.ts +65 -0
- package/dist/durable/cwd-rule.js +111 -0
- package/dist/durable/discover.d.ts +68 -0
- package/dist/durable/discover.js +123 -0
- package/dist/durable/gate.d.ts +8 -0
- package/dist/durable/gate.js +13 -0
- package/dist/durable/launch-spec.d.ts +137 -0
- package/dist/durable/launch-spec.js +277 -0
- package/dist/durable/proc.d.ts +86 -0
- package/dist/durable/proc.js +279 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +52 -1
- package/dist/lib/agent.d.ts +7 -0
- package/dist/lib/agent.js +26 -0
- package/dist/lib/api.d.ts +32 -0
- package/dist/lib/api.js +16 -0
- package/dist/lib/experimental.d.ts +1 -1
- package/dist/lib/experimental.js +13 -0
- package/dist/lib/session-key.d.ts +68 -0
- package/dist/lib/session-key.js +101 -0
- package/dist/lib/statusline.d.ts +13 -0
- package/dist/lib/statusline.js +27 -3
- package/dist/lib/subscription-login.d.ts +1 -1
- package/dist/lib/subscription-login.js +4 -1
- package/dist/vendor/durable-contract/index.js +5663 -0
- package/package.json +5 -4
|
@@ -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));
|