viewable-one 0.0.0-stage → 0.12.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +767 -0
  20. package/lib/codexApps.mjs +344 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +629 -0
  28. package/lib/devserver.mjs +670 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,406 @@
1
+ // Finalize an exited child, over rows (brief 30 Move 2, 2026-09-11).
2
+ //
3
+ // Same job the file-based `completeRequest` had: log the child, decide what
4
+ // happens to the worktree, tell the board what happened, and settle the run
5
+ // - just none of it through `.glytch/inbox/spawn/` any more. The ONE thing
6
+ // this version does NOT do that the old one did is write anything itself:
7
+ // `result` is write-once server-side, so every field that used to be a
8
+ // separate file write (`<id>.log`'s final authoritative write,
9
+ // `<id>.session.json`, `<id>.server`, `<id>.result.txt`) is instead handed
10
+ // back to the caller as ONE `patch` object, and `runner.mjs` makes exactly
11
+ // one `runnerApi.update({runId, patch})` call with it - a run settles once,
12
+ // not in four separate writes racing whatever else might be reading the row.
13
+ import { cleanWorktree, isGitCheckout, worktreeSummary } from "./worktree.mjs";
14
+ import { readWorktreeRecord } from "./devserver.mjs";
15
+ import { reportOutcomeOnBoard, formatVerdict } from "./boardside.mjs";
16
+ import { promoteLateRows } from "./rowsAnswers.mjs";
17
+ import { housekeeping } from "./runnerMaintenance.mjs";
18
+ import { classifyExit } from "./exitClass.mjs";
19
+ import { readUsageLimit } from "./usageLimit.mjs";
20
+
21
+ const defaultServices = {
22
+ cleanWorktree, worktreeSummary, readWorktreeRecord,
23
+ reportOutcomeOnBoard, promoteLateRows, housekeeping, isGitCheckout,
24
+ };
25
+
26
+ // The tools that write a file, by the names the app's run log already
27
+ // sorts on (src/components/run/runWords.ts): Claude's Write/Edit, Codex's
28
+ // normalised Edit, an ACP title such as "Edit index.html".
29
+ const EDITING_TOOL = /^(write|create|edit|multiedit|notebookedit|patch|apply_patch|update)/i;
30
+ // A shell can write anything, so any shell call counts as a change, failed
31
+ // or not: Claude's Bash, Codex's commandExecution (named Bash), ACP shells.
32
+ const SHELL_TOOL = /^(bash|shell|local_shell|exec|run|terminal|command)/i;
33
+
34
+ /** Might the run have changed files in its folder? It warns that Throw away
35
+ * cannot undo them (#483), so a doubt reads as yes (#735 review): any shell
36
+ * call, an edit with no error result (denied or failed edits change
37
+ * nothing; one with no result yet may have), and a log whose middle was
38
+ * cut (`dropped` characters), since the cut events cannot be checked.
39
+ * A file the ACP client wrote for the agent (fs/write_text_file) logs its
40
+ * own `file_write` line, which counts too. */
41
+ export function mayHaveChangedFiles(log, { dropped = 0 } = {}) {
42
+ if (dropped > 0) return true;
43
+ const edits = [];
44
+ const failed = new Set();
45
+ for (const line of String(log ?? "").split("\n")) {
46
+ if (!line.includes('"tool_use"') && !line.includes('"tool_result"') && !line.includes('"file_write"')) continue;
47
+ let event;
48
+ try { event = JSON.parse(line); } catch { continue; }
49
+ if (event?.type === "system" && event.subtype === "file_write") return true;
50
+ const content = event?.message?.content;
51
+ if (!Array.isArray(content)) continue;
52
+ for (const block of content) {
53
+ if (event.type === "assistant" && block?.type === "tool_use") {
54
+ const name = String(block.name ?? "");
55
+ if (SHELL_TOOL.test(name)) return true;
56
+ if (EDITING_TOOL.test(name)) edits.push(block.id);
57
+ } else if (event.type === "user" && block?.type === "tool_result" && block.is_error === true) {
58
+ failed.add(block.tool_use_id);
59
+ }
60
+ }
61
+ }
62
+ return edits.some((id) => id == null || !failed.has(id));
63
+ }
64
+
65
+
66
+ /** A run's result without its Before and After pictures. */
67
+ const withoutPictures = ({ before, after, changed, ...rest }) => rest;
68
+
69
+ /** Called only after the child and its live log timer have stopped.
70
+ *
71
+ * `context`: `{root, id, outcome, made, isPersistent, isChat, sessionIdSource,
72
+ * from, session, maxMs, readStopRequested, resumeTreeGone,
73
+ * resumeNotFound, request, log, rowsPoll, hostedCallTool, hostedEnv,
74
+ * resumeFn}`. `resumeFn`
75
+ * is `runnerApi.resume` (or a test stub) - the row equivalent of
76
+ * `promoteReplyToRequest`, passed as `promoteLateRows`'s `promote` override
77
+ * so a late answer/message becomes a new APPROVED child run row instead of
78
+ * a new request FILE.
79
+ *
80
+ * Returns `{id, state, patch, detail}` - `patch` is what the caller sends
81
+ * through `runnerApi.update`; nothing here calls it. */
82
+ export async function completeRequest(context, services = defaultServices) {
83
+ const {
84
+ root, id, outcome, made, isPersistent, isChat, sessionIdSource,
85
+ from, session, maxMs, readStopRequested, resumeTreeGone,
86
+ resumeNotFound, request, log, rowsPoll, hostedCallTool, hostedEnv, resumeFn,
87
+ } = context;
88
+ const {
89
+ cleanWorktree, worktreeSummary, readWorktreeRecord,
90
+ reportOutcomeOnBoard, promoteLateRows, housekeeping, isGitCheckout: inGit,
91
+ } = services;
92
+
93
+ // THE DEV SERVER THE SESSION LEFT RUNNING ON PURPOSE (2026-09-06, his
94
+ // ruling A), mirrored into the run row's own `server` column instead of
95
+ // `<id>.server` - read-only from the session's own worktree artifact
96
+ // (`.glytch-server.json`, unaffected by Move 2: the SESSION writes it, not
97
+ // the mailbox), before `cleanWorktree` runs below and can remove the tree
98
+ // it lives in.
99
+ let server = null;
100
+ try {
101
+ const record = await readWorktreeRecord(made.path ?? made.cwd);
102
+ if (record) {
103
+ server = { ...record, worktreePath: made.path ?? made.cwd, mirroredAt: new Date().toISOString() };
104
+ log(`[${id}] a detached dev server was left running: pid ${server.pid}, port ${server.port ?? "unknown"}`);
105
+ }
106
+ } catch (error) {
107
+ log(`[${id}] could not record the session's dev server: ${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+
110
+ const claudeSessionId = extractSessionIdFromLog(sessionIdSource ?? outcome.log ?? "");
111
+
112
+ // A CHILD THAT NEVER RAN KEEPS NOTHING "FOR SESSION RESUME" (2026-09-20).
113
+ // `outcome.spawnError` means `child.on("error")` fired in child.mjs -
114
+ // this machine could not even start the binary (spawn ENOENT, most often)
115
+ // - so the worktree `made` names was never touched: no commits, no
116
+ // uncommitted changes, nothing a resume could possibly continue. Before
117
+ // this, `isPersistent` alone decided the branch below, and a request that
118
+ // carries a sessionId/resumeSessionId (every board-spawned session does)
119
+ // took the "KEPT for session resume" literal unconditionally - so a run
120
+ // that failed to start still held its worktree forever, and the very next
121
+ // request on the same prompt was refused as "already has an active
122
+ // session" against a tree with nothing in it. `cleanWorktree` is safe to
123
+ // call here even though it is NOT normally called for a persistent
124
+ // request: it inspects the tree's own git status before removing anything
125
+ // (worktree.mjs's `cleanWorktreeImpl`), so a request that also happened to
126
+ // fail AFTER doing real work is still `spawnError`-free by definition
127
+ // (the child ran, so this branch is not reached) and never hits this path.
128
+ const neverStarted = Boolean(outcome.spawnError);
129
+ const cleaned = neverStarted
130
+ ? await cleanWorktree(root, made, log, [])
131
+ : isPersistent
132
+ ? { removed: false, detail: `${made.detail}\nKEPT for session resume (persistent session).` }
133
+ : await cleanWorktree(root, made, log, []);
134
+
135
+ const treeKept = !cleaned.removed && made.isolated && made.path;
136
+ let sidecar;
137
+ // The branch and the count of files it touched, for the reply's figures
138
+ // line and its links. Null for a chat turn and for a tree nobody kept.
139
+ let summary = null;
140
+ // THE SIDECAR IS WRITTEN FOR EVERY CHAT TURN, NOT ONLY A PERSISTENT ONE.
141
+ // A first message carries no session id to resume, so `isPersistent` is
142
+ // false for it and `treeKept` is false too (a chat turn's `made` is never
143
+ // `isolated`) - without `isChat` here that first message would write no
144
+ // sidecar at all, and the room would have no way to learn which
145
+ // conversation it just opened (`claudeSessionId`) or where it lives
146
+ // (`worktreePath`, equal to the cwd). No sidecar, no second turn.
147
+ if (isPersistent || treeKept || isChat) {
148
+ // Only a copy has a branch to summarise. A chat turn ran in the room's
149
+ // own directory; a run in place ran in the person's own checkout, whose
150
+ // branch and uncommitted files are theirs, not the run's (#735: a run
151
+ // told to change nothing read "branch main, 1 file").
152
+ if (made.isolated && !isChat) {
153
+ try {
154
+ summary = await worktreeSummary(made.path ?? made.cwd, root);
155
+ } catch { /* non-fatal */ }
156
+ }
157
+ sidecar = {
158
+ ...(claudeSessionId ? { claudeSessionId } : {}),
159
+ ...(outcome.codexThreadId ? { codexThreadId: outcome.codexThreadId } : {}),
160
+ // NEVER A PATH THAT WAS JUST DELETED (2026-09-20). `cleaned.removed`
161
+ // is only ever true when `cleanWorktree` actually ran and the tree is
162
+ // gone - the app reads `sidecar.worktreePath` to decide whether to
163
+ // show Merge/Delete on this run's card, and a path it cannot act on
164
+ // is worse than no path at all.
165
+ //
166
+ // AND NEVER THE LINKED FOLDER ITSELF (2026-09-26, live run
167
+ // spawn-muipo5wp-7706dc in a folder that is not a git repo): a
168
+ // persistent session that ran IN PLACE (`made.isolated` false - no
169
+ // worktree could be made, or the idea turned it off) has no tree of
170
+ // its own, yet `made.cwd` is the checkout root, and recording it here
171
+ // put Keep changes and Throw away over the person's own folder.
172
+ // A chat turn is the one non-isolated case that still needs the path:
173
+ // it IS the room's cwd, and the next turn is sent back there.
174
+ ...(cleaned.removed || (!made.isolated && !isChat) ? {} : { worktreePath: made.path ?? made.cwd }),
175
+ ...(from ? { fromId: from } : {}),
176
+ expired: isPersistent ? (!claudeSessionId || outcome.ok !== true) : undefined,
177
+ ...(summary?.line ? { worktreeSummary: summary.line } : {}),
178
+ ...(outcome.envelope?.total_cost_usd !== undefined ? { costUsd: outcome.envelope.total_cost_usd } : {}),
179
+ ...(outcome.envelope?.cost_source !== undefined ? { costSource: outcome.envelope.cost_source } : {}),
180
+ ...(outcome.envelope?.num_turns !== undefined ? { numTurns: outcome.envelope.num_turns } : {}),
181
+ ...(outcome.contextFill !== null && outcome.contextFill !== undefined ? { contextFill: outcome.contextFill } : {}),
182
+ };
183
+ log(`[${id}] sidecar: claudeSessionId=${claudeSessionId ?? "none"}, worktree=${treeKept ? "kept" : "persistent"}${summary?.line ? `, summary=${summary.line}` : ""}`);
184
+ }
185
+
186
+ // A COMMENT ON THE RUN'S OWN IDEA, BEFORE THE ROW SETTLES - the fix for
187
+ // "done with nothing said about how it went" carries over unchanged, only
188
+ // the door moved: `propose` under the run's session key instead of an
189
+ // `addComment` ops file. `frameId` is simply `from` now (fromIdOf(request))
190
+ // since there is no separate run-record id to have diverged from it.
191
+ const wasStopped = outcome.stoppedByUser === true || readStopRequested();
192
+ // No copy, so no Throw away; the reply says why (#483), but only for a
193
+ // folder that is not a git checkout and only when the run may have
194
+ // changed files there (#735). A chat turn and a read-only plan change nothing.
195
+ const changedNonGitFolder = async () => {
196
+ if (made.isolated || isChat || made.detail?.startsWith("Read-only planning")) return false;
197
+ if (!mayHaveChangedFiles(outcome.log, { dropped: outcome.dropped })) return false;
198
+ try { return !(await inGit(made.cwd)); } catch { return false; }
199
+ };
200
+ try {
201
+ await reportOutcomeOnBoard(session, outcome, from, {
202
+ callTool: hostedCallTool,
203
+ env: hostedEnv,
204
+ request,
205
+ stopped: wasStopped,
206
+ worktree: summary ? { branch: summary.branch, files: summary.files } : undefined,
207
+ inPlace: await changedNonGitFolder(),
208
+ preview: Number.isFinite(Number(server?.port)) ? `http://localhost:${Number(server.port)}` : undefined,
209
+ });
210
+ } catch (error) {
211
+ log(`[${id}] could not comment the outcome onto the board: ${error instanceof Error ? error.message : String(error)}`);
212
+ }
213
+
214
+ const resumeNote = resumeTreeGone
215
+ ? `THE EARLIER CONVERSATION COULD NOT BE RESUMED: ITS WORKTREE IS GONE.\n` +
216
+ `${request.worktreePath} no longer exists (merged and deleted, swept, or pruned), ` +
217
+ `and Claude Code keeps conversations per working directory - so session ` +
218
+ `${String(request.resumeSessionId).trim()} could not be reached from anywhere this ` +
219
+ `session could run. This ran as a FRESH session in a new worktree instead, and was told so ` +
220
+ `in one line. The sidecar records the NEW session id, so the next run of this idea resumes ` +
221
+ `THIS session rather than the one that is gone.\n\n`
222
+ : resumeNotFound
223
+ ? `THE EARLIER CONVERSATION COULD NOT BE RESUMED: CLAUDE CODE HAD NO RECORD OF IT.\n` +
224
+ `The worktree was still here and was reused, but ` +
225
+ `--resume ${String(request.resumeSessionId).trim()} came back with ` +
226
+ `"No conversation found" at 0 turns and $0.00. Nothing was spent and nothing was done, so ` +
227
+ `this ran ONCE more as a fresh session in that same worktree - the one retry the ` +
228
+ `"exactly once" rule allows, because the first attempt did not happen. The sidecar records ` +
229
+ `the NEW session id.\n\n`
230
+ : "";
231
+ const detail = `${resumeNote}${describeOutcome(outcome, maxMs, from, { stopped: wasStopped })}\n\n${cleaned.detail}`;
232
+ // STOPPED OUTRANKS ok. The row's own `status` check constraint carries a
233
+ // distinct 'stopped' value the file-based runner had no room for (it only
234
+ // ever wrote `stopped: true` as an annotation beside a two-state "done"),
235
+ // so a run the human clicked Stop on settles as 'stopped' whether or not
236
+ // its exit happened to read as ok - "you stopped it" is worth keeping
237
+ // separate from "it finished" even when the two exits look alike.
238
+ const status = wasStopped ? "stopped" : outcome.ok === true ? "done" : "error";
239
+ const patch = {
240
+ status,
241
+ result: `${outcome.ok === true ? "OK" : "ERROR"}\n\n${detail}\n`,
242
+ finished_at: new Date().toISOString(),
243
+ // Completion owns the final metadata, including clearing an early path
244
+ // when cleanup removed the worktree. Failures before completion have no
245
+ // final sidecar and may retain their early workspace receipt instead.
246
+ // The pictures ride on the closing reply only: two of them on every run
247
+ // row would weigh down each list of runs (#2194).
248
+ sidecar: { ...(sidecar ?? {}), ...(outcome.result ? { result: withoutPictures(outcome.result) } : {}), ...(status === "error" ? agentErrorOf(outcome) : {}) },
249
+ ...(server ? { server } : {}),
250
+ };
251
+
252
+ // THE ROW EQUIVALENT OF THE LATE-REPLY SWEEP, ONE MORE `collect_session`
253
+ // CALL (brief 30, W2b, carried into Move 2 unchanged): only when the run
254
+ // ever had a bearer to send in the first place. `resumeFn` (runnerApi's
255
+ // `resume`) replaces `promoteReplyToRequest`'s file write with one op the
256
+ // function does server-side.
257
+ if (rowsPoll) {
258
+ try {
259
+ const { filed: filedRows, dropped } = await promoteLateRows(
260
+ root, id,
261
+ {
262
+ poller: rowsPoll, callTool: hostedCallTool, sessionKey: session.key, env: hostedEnv,
263
+ promote: async (_root, targetId, reply, _log, promotionId) => {
264
+ // IMAGES, item 6: `reply.images` is the late row's own images
265
+ // (rowsAnswers.mjs's `promoteLateRows`), carried into `resume`
266
+ // exactly as a reply file's `images` used to ride
267
+ // `promoteReplyToRequest`.
268
+ const out = await resumeFn({ runId: targetId, message: reply.attributed ?? reply.text, images: reply.images }, { env: hostedEnv });
269
+ if (!out.ok) return null;
270
+ void promotionId; // resume's own idempotency is server-side (Move 2 contract)
271
+ return out.id ?? null;
272
+ },
273
+ },
274
+ log,
275
+ );
276
+ if (filedRows.length > 0) log(`[${id}] ${filedRows.length} late row(s) became run(s): ${filedRows.join(", ")}`);
277
+ // WHAT NEVER REACHED THE SESSION (round 4 item 75) rides the final
278
+ // sidecar; the app marks the matching note "did not reach the session".
279
+ if (dropped.length > 0) patch.sidecar = { ...patch.sidecar, undelivered: dropped };
280
+ } catch (error) {
281
+ log(`[${id}] late-row sweep failed: ${error instanceof Error ? error.message : String(error)}`);
282
+ }
283
+ }
284
+
285
+ try { await housekeeping(root, log); }
286
+ catch (error) { log(`[${id}] post-session housekeeping failed: ${error?.message ?? error}`); }
287
+
288
+ return { id, state: status === "done" ? "done" : "failed", patch, detail };
289
+ }
290
+
291
+ /** WHAT THE AGENT ITSELF SAID WENT WRONG (#482), for the app's "Claude
292
+ * sent back an error: <error>." `agentError` is its first line, short;
293
+ * `usageLimit` is set when that was the plan running out (usageLimit.mjs),
294
+ * with the time it comes back when the agent said one. A stall, a timeout
295
+ * or a binary that never started has no words of the agent's to keep. */
296
+ export function agentErrorOf(outcome) {
297
+ if (outcome.spawnError || outcome.stalled || outcome.timedOut) return {};
298
+ const env = outcome.envelope ?? {};
299
+ const said = [
300
+ env.is_error === true && typeof env.result === "string" ? env.result : "",
301
+ typeof env.error === "string" ? env.error : "",
302
+ ...(Array.isArray(env.errors) ? env.errors.filter((e) => typeof e === "string") : []),
303
+ typeof outcome.stderr === "string" ? outcome.stderr.slice(-2000) : "",
304
+ ].map((t) => t.trim()).filter(Boolean);
305
+ if (said.length === 0) return {};
306
+ const first = said[0].split("\n").map((l) => l.trim()).find(Boolean) ?? "";
307
+ const limit = said.map((t) => readUsageLimit(t)).find((r) => r !== null);
308
+ return {
309
+ agentError: first.length > 200 ? `${first.slice(0, 199).trimEnd()}…` : first,
310
+ ...(limit ? { usageLimit: limit } : {}),
311
+ };
312
+ }
313
+
314
+ function describeOutcome(outcome, maxMs, frameId, { stopped } = {}) {
315
+ const verdict = formatVerdict(outcome);
316
+ const verdictLine = verdict ? `${verdict}\n\n` : "";
317
+ const retriedNote = outcome.retried ? `\nRetried once after ${outcome.firstCrashClass} before any output.` : "";
318
+ const where = `${frameId ? `The run's record is on the board (${frameId}). ` : "This run has no idea on the board. "}` +
319
+ `Its row is the record now - there is nothing left for them to accept or reject.\n` +
320
+ `The child's output is this run's events.${retriedNote}`;
321
+ if (outcome.spawnError) {
322
+ return (
323
+ `Could not start the session: ${outcome.spawnError}\n\n` +
324
+ `That is your computer failing to run the binary, not the session failing.\n${where}`
325
+ );
326
+ }
327
+ if (outcome.finished && !outcome.finishedWithError) {
328
+ const envelope = `The session finished its work: its final result envelope is at the end of the events.`;
329
+ if (stopped || outcome.timedOut || outcome.code !== 0) {
330
+ const closedBy = stopped ? "a stop" : `the wall clock of ${Math.round(maxMs / 60_000)} minutes`;
331
+ return `${verdictLine}${envelope} The child was closed by ${closedBy} after finishing; nothing was cut short.\n${where}`;
332
+ }
333
+ return (
334
+ `${verdictLine}${envelope} The session finished and exited on its own once the envelope landed. ` +
335
+ `The worktree stays, and the session can be resumed from the board (Continue).\n${where}`
336
+ );
337
+ }
338
+ if (outcome.finishedWithError) {
339
+ return (
340
+ `${verdictLine}The session ended its last turn with an ERROR result envelope: the engine ` +
341
+ `itself said the run went wrong (a refusal, an internal error). The envelope ` +
342
+ `is at the end of the events.\n${where}`
343
+ );
344
+ }
345
+ if (outcome.stalled) {
346
+ return (
347
+ `${verdictLine}Stopped responding.\n\n` +
348
+ `No output for ${Math.round((outcome.stallMs || 0) / 60_000)}m with a turn open. ` +
349
+ `The session was sent SIGTERM. Whatever it had not finished is not finished.\n${where}`
350
+ );
351
+ }
352
+ if (stopped) {
353
+ return (
354
+ `${verdictLine}Stopped by user request.\n\n` +
355
+ `The session was sent SIGTERM because the human clicked Stop. ` +
356
+ `Whatever it had not finished is not finished.\n${where}`
357
+ );
358
+ }
359
+ if (outcome.timedOut) {
360
+ return (
361
+ `${verdictLine}Killed at its wall clock of ${Math.round(maxMs / 60_000)} minutes.\n\n` +
362
+ `The board says "done", because the board carries two states and this is an ` +
363
+ `ending like any other. What actually happened is here: it ran out of time, so ` +
364
+ `whatever it had not finished is not finished.\n${where}`
365
+ );
366
+ }
367
+ if (outcome.code === 0) return `${verdictLine}The session finished cleanly (exit 0).\n\n${where}`;
368
+ const reason = outcome.stderr?.trim();
369
+ const why = reason ? `\n\nIt printed this on stderr before exiting:\n${reason.slice(-800)}` : "";
370
+ const cut =
371
+ outcome.dropped > 0
372
+ ? `\nThe live log was capped: ${outcome.dropped} characters dropped. The full transcript is this run's events.`
373
+ : "";
374
+ const cls = classifyExit(outcome.code, outcome.signal);
375
+ const ended = cls.isCrash
376
+ ? `The session crashed: ${cls.description}.${outcome.retried ? "" : " Not retried: it had already produced output, and a retry would duplicate it."}`
377
+ : `The session exited ${outcome.code}${outcome.signal ? ` on ${outcome.signal}` : ""}.`;
378
+ return (
379
+ `${verdictLine}${ended}${why}\n\n` +
380
+ `Nothing retries it. Whatever it managed to put on the board is there and is theirs to ` +
381
+ `keep or reject.${cut}\n${where}`
382
+ );
383
+ }
384
+
385
+ /** Extract the Claude session id from a stream-json log. Every event carries
386
+ * a `sessionID` field (verified on CLI 2.1.34); the first one found is the
387
+ * session's own id. Unchanged from the file-based runner. */
388
+ function extractSessionIdFromLog(text) {
389
+ if (!text || text.trim() === "") return null;
390
+ for (const raw of text.split("\n")) {
391
+ const line = raw.trim();
392
+ if (line === "" || !line.startsWith("{")) continue;
393
+ try {
394
+ const parsed = JSON.parse(line);
395
+ if (parsed !== null && typeof parsed === "object") {
396
+ const id = typeof parsed.sessionID === "string" && parsed.sessionID.trim() !== ""
397
+ ? parsed.sessionID
398
+ : typeof parsed.session_id === "string" && parsed.session_id.trim() !== ""
399
+ ? parsed.session_id
400
+ : null;
401
+ if (id !== null) return id;
402
+ }
403
+ } catch { continue; }
404
+ }
405
+ return null;
406
+ }
@@ -0,0 +1,37 @@
1
+ /** Lease renewal never restarts work. Losing ownership requests the same
2
+ * local stop as the human Stop action, including during run preparation. */
3
+ export function startRunLease(project, run, settings, { api, log = () => {}, now = () => performance.now(), every = setInterval, clear = clearInterval } = {}) {
4
+ if (!run.lease_expires_at || !settings.runnerInstanceId) return { ready: Promise.resolve(), stop() {} };
5
+ settings.stopped ??= new Set();
6
+ let deadline = now() + 240_000;
7
+ let closed = false;
8
+ let pending = false;
9
+ const lose = () => {
10
+ if (!settings.stopped.has(run.id)) log(`[${run.id}] runner lease lost; stopping local execution`);
11
+ settings.stopped.add(run.id);
12
+ (settings.leaseLost ??= new Set()).add(run.id);
13
+ };
14
+ const tick = async (initial = false) => {
15
+ if (closed) return;
16
+ if (!Number.isFinite(deadline) || now() >= deadline) lose();
17
+ if (pending || settings.stopped.has(run.id)) return;
18
+ pending = true;
19
+ const sentAt = now();
20
+ try {
21
+ const response = await api.heartbeat({ projectIds: [project.id], runnerInstanceId: settings.runnerInstanceId, activeRunIds: [run.id] }, { env: settings.env, fetchImpl: settings.fetchImpl });
22
+ if (closed) return;
23
+ for (const id of response.stops ?? []) settings.stopped.add(id);
24
+ if (response.ok && Array.isArray(response.renewedRunIds)) {
25
+ if (!response.renewedRunIds.includes(run.id)) lose();
26
+ else if (Number.isFinite(response.leaseDurationMs) && response.leaseDurationMs > 60_000) deadline = sentAt + response.leaseDurationMs - 60_000;
27
+ } else if (initial) lose();
28
+ } catch { if (initial) lose(); }
29
+ finally { pending = false; }
30
+ };
31
+ // Check expiry independently of a potentially pending network request.
32
+ const expiry = every(() => { if (!closed && now() >= deadline) lose(); }, 1000);
33
+ const renewal = every(() => { void tick(); }, 30_000);
34
+ expiry.unref?.(); renewal.unref?.();
35
+ const ready = tick(true);
36
+ return { ready, stop() { closed = true; clear(expiry); clear(renewal); } };
37
+ }
@@ -0,0 +1,27 @@
1
+ // Lifecycle maintenance is called by runner.mjs at its existing boundaries:
2
+ // housekeeping at start/spawn/teardown. This module owns no timers, session
3
+ // pool, or child processes.
4
+ //
5
+ // THE SIDECAR BACKFILL is retired too, for a different reason: it fixed up
6
+ // a FILE sidecar (`<id>.session.json`) a crashed session left incomplete,
7
+ // and a run's sidecar is a row column now (`runs.sidecar`, written whole by
8
+ // one `runnerApi.update` at the end of `runRequest` - there is no partial
9
+ // write over rows for a backfill to complete). `housekeeping`'s only
10
+ // production caller (`runnerCompletion.mjs`) already called it with
11
+ // `backfill: false` before this pass for exactly that reason - the option
12
+ // is dropped rather than kept as a dead flag nobody could ever set to true
13
+ // usefully again.
14
+ import { reapOrphanedWorktreeProcesses } from "./worktree.mjs";
15
+
16
+ /** The orphan-process sweep, run at the moments it can be useful and never
17
+ * on a timer (SPAWN.md, ruling q-mt9as5a0 - no reaper, no heartbeat,
18
+ * nothing inferred from silence on its own timer). Never throws: a failed
19
+ * sweep must not take a session down. */
20
+ export async function housekeeping(root, log = () => {}, { reap = true } = {}) {
21
+ if (!reap) return;
22
+ try {
23
+ await reapOrphanedWorktreeProcesses(root, log);
24
+ } catch (error) {
25
+ log(`orphan sweep failed: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ }