viewable-one 0.0.0-stage → 0.12.1
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/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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
|
+
}
|