codebay 0.14.0 → 0.16.0
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/.mochi/manifest.json +778 -646
- package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
- package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-0vqvn81s.js} +1 -1
- package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
- package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
- package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
- package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
- package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
- package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
- package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
- package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
- package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
- package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-sxcr9gzp.js} +1 -1
- package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-tss2473w.js} +1 -1
- package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-8mpgrxza.server.js} +470 -271
- package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-y5nf6q25.server.js} +111 -110
- package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
- package/.mochi/svelte-compile/Instance-3sbzyp0g.server.js +695 -0
- package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-ysvh5vhh.server.js} +838 -607
- package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
- package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
- package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
- package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
- package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-jq7jj8jd.js} +18 -2
- package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
- package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
- package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-zk4a9zms.js} +28 -31
- package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
- package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
- package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
- package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
- package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
- package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
- package/README.md +43 -3
- package/migrations/0009_agent_runs.sql +24 -0
- package/migrations/0010_agent_run_model.sql +2 -0
- package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
- package/package.json +13 -9
- package/src/__fixtures__/IdeBarStrip.svelte +41 -0
- package/src/avatars/dog.ts +1 -1
- package/src/avatars/lightning.ts +8 -9
- package/src/avatars/rocket.ts +4 -5
- package/src/components/AgentLogBox.svelte +458 -0
- package/src/components/AppShell.svelte +112 -1
- package/src/components/DropZone.svelte +77 -0
- package/src/components/FolderBrowser.svelte +13 -5
- package/src/components/HealthBox.svelte +6 -5
- package/src/components/IdeBar.svelte +222 -76
- package/src/components/InstanceCard.svelte +38 -0
- package/src/components/InstanceView.svelte +83 -2
- package/src/components/SettingsView.svelte +273 -0
- package/src/components/TerminalPane.svelte +54 -0
- package/src/components/UiShowcase.svelte +23 -3
- package/src/container-injections/claude-code-install.ts +1 -1
- package/src/container-injections/claude-history.ts +52 -0
- package/src/container-injections/claude-statusline.ts +4 -1
- package/src/container-injections/github-credentials.ts +12 -1
- package/src/container-injections/tmux.ts +6 -2
- package/src/index.ts +35 -7
- package/src/lib/agent-run-stream.ts +286 -0
- package/src/lib/agent-runs.server.ts +735 -0
- package/src/lib/auth.server.ts +11 -2
- package/src/lib/build-freshness.server.ts +71 -0
- package/src/lib/config.server.ts +36 -7
- package/src/lib/container-files.server.ts +1 -1
- package/src/lib/data-dir-lock.server.ts +78 -0
- package/src/lib/db.server.ts +124 -1
- package/src/lib/devcontainer.server.ts +93 -16
- package/src/lib/docker-client.server.ts +11 -5
- package/src/lib/drives.ts +11 -0
- package/src/lib/exec.server.ts +66 -5
- package/src/lib/injections.server.ts +5 -1
- package/src/lib/instances.server.ts +66 -7
- package/src/lib/log-capture.server.ts +63 -23
- package/src/lib/mcp-auth.server.ts +52 -0
- package/src/lib/osc52.ts +18 -0
- package/src/lib/picker.server.ts +23 -2
- package/src/lib/sandbox-ops.server.ts +263 -0
- package/src/lib/upload.ts +32 -0
- package/src/lib/uploads.server.ts +164 -0
- package/src/mcp/routes.server.ts +25 -0
- package/src/mcp/server.server.ts +55 -0
- package/src/mcp/tools.server.ts +660 -0
- package/src/pages/Instance.svelte +6 -2
- package/src/pages/Settings.svelte +15 -0
- package/src/routes.ts +79 -3
- package/src/types.ts +17 -0
- package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
- package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
- package/.mochi/svelte-client/chunk-phf7v5jj.js +0 -4
- package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
- package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
- package/.mochi/svelte-client/chunk-x0m3k0rn.js +0 -1
- package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
- package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
- package/.mochi/svelte-compile/chunk-ed46m58e.js +0 -22
- package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
- package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
- package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { LOGS_DIR } from './config.server.ts';
|
|
4
|
+
import {
|
|
5
|
+
getInstance,
|
|
6
|
+
getRun,
|
|
7
|
+
insertRun,
|
|
8
|
+
openRunFor,
|
|
9
|
+
openRuns,
|
|
10
|
+
updateRun,
|
|
11
|
+
type AgentRunRow,
|
|
12
|
+
type InstanceRow
|
|
13
|
+
} from './db.server.ts';
|
|
14
|
+
import {
|
|
15
|
+
EXEC_STDIN_MAX_BYTES,
|
|
16
|
+
execInContainer,
|
|
17
|
+
execTargetFor as targetFor,
|
|
18
|
+
markerLine
|
|
19
|
+
} from './exec.server.ts';
|
|
20
|
+
import {
|
|
21
|
+
HOME_PRELUDE,
|
|
22
|
+
shellSingleQuote as quote,
|
|
23
|
+
writeContainerFile
|
|
24
|
+
} from './container-files.server.ts';
|
|
25
|
+
import {
|
|
26
|
+
FETCH_MARKER,
|
|
27
|
+
parseFetchBlocks,
|
|
28
|
+
sizeOnDisk,
|
|
29
|
+
tailBlockScript
|
|
30
|
+
} from './log-capture.server.ts';
|
|
31
|
+
import {
|
|
32
|
+
AGENT_RUN_MARKER,
|
|
33
|
+
CLAUDE_BINARY_WAIT_SECONDS,
|
|
34
|
+
SOURCE_INJECTED_ENV,
|
|
35
|
+
WAIT_FOR_INJECTIONS
|
|
36
|
+
} from './devcontainer.server.ts';
|
|
37
|
+
import {
|
|
38
|
+
claudePermissionFlags,
|
|
39
|
+
type AgentRunSummary,
|
|
40
|
+
type ClaudePermissionMode
|
|
41
|
+
} from '../types.ts';
|
|
42
|
+
import {
|
|
43
|
+
STRUCTURED_OUTPUT_FAILED_ERROR,
|
|
44
|
+
emptyRunState,
|
|
45
|
+
parseRunTimeline,
|
|
46
|
+
readRunChunk,
|
|
47
|
+
readRunFile,
|
|
48
|
+
type RunStreamState,
|
|
49
|
+
type RunTimelineEntry
|
|
50
|
+
} from './agent-run-stream.ts';
|
|
51
|
+
|
|
52
|
+
/** Fast enough that a caller polling `get_run` sees movement, cheap enough to leave always-on. */
|
|
53
|
+
const POLL_MS = 3000;
|
|
54
|
+
|
|
55
|
+
const STATE_MARKER = '__CODEBAY_RUNSTATE__';
|
|
56
|
+
const ERR_MARKER = '__CODEBAY_RUNERR__';
|
|
57
|
+
|
|
58
|
+
/** A run whose launcher process vanished without an exit file is dead; don't wait forever. */
|
|
59
|
+
const MISSING_PROCESS_STRIKES = 5;
|
|
60
|
+
|
|
61
|
+
/** A run nobody stops would otherwise hold its sandbox's single slot forever. */
|
|
62
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Per-pass ceiling on stream bytes. Unbounded, a mirror far behind (manager down, a tool that
|
|
66
|
+
* dumped megabytes) makes every pass time out at the same offset until the strikes kill a live run.
|
|
67
|
+
*/
|
|
68
|
+
const POLL_CHUNK_BYTES = 2 * 1024 * 1024;
|
|
69
|
+
|
|
70
|
+
/** The prompt is staged through the exec env carrier, so it inherits that carrier's ceiling. */
|
|
71
|
+
export const PROMPT_MAX_BYTES = EXEC_STDIN_MAX_BYTES;
|
|
72
|
+
|
|
73
|
+
/** How long a cancel waits for claude to flush the result it records on SIGINT before the last tail. */
|
|
74
|
+
const STOP_DRAIN_SECONDS = 5;
|
|
75
|
+
|
|
76
|
+
export interface StartRunOptions {
|
|
77
|
+
resumeSessionId?: string;
|
|
78
|
+
model?: string;
|
|
79
|
+
maxTurns?: number;
|
|
80
|
+
jsonSchema?: string;
|
|
81
|
+
/** Defaults to full autonomy — an MCP run is unattended, and the container is the sandbox. */
|
|
82
|
+
permissionMode?: ClaudePermissionMode;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const runDirExpr = (runId: string) => `$h/.codebay/runs/${runId}`;
|
|
87
|
+
|
|
88
|
+
export function runMirrorPath(runId: string): string {
|
|
89
|
+
return join(LOGS_DIR, `run-${runId}.jsonl`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const mirrorSize = (runId: string) => sizeOnDisk(runMirrorPath(runId));
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The script the detached process actually runs. It writes its own PGID first so `stopRun` can
|
|
96
|
+
* signal the whole group, and writes the exit file last so the poller can treat that file's
|
|
97
|
+
* appearance as proof the stream is complete.
|
|
98
|
+
*/
|
|
99
|
+
function runScript(runId: string, row: InstanceRow, opts: StartRunOptions): string {
|
|
100
|
+
const flags = [
|
|
101
|
+
'--output-format stream-json',
|
|
102
|
+
'--verbose',
|
|
103
|
+
claudePermissionFlags(opts.permissionMode ?? 'default')
|
|
104
|
+
];
|
|
105
|
+
if (opts.resumeSessionId) flags.push(`--resume ${quote(opts.resumeSessionId)}`);
|
|
106
|
+
if (opts.model) flags.push(`--model ${quote(opts.model)}`);
|
|
107
|
+
if (opts.maxTurns) flags.push(`--max-turns ${opts.maxTurns}`);
|
|
108
|
+
if (opts.jsonSchema) flags.push('--json-schema "$(cat "$d/schema.json")"');
|
|
109
|
+
|
|
110
|
+
const cd = row.remote_workspace_folder ? `cd ${quote(row.remote_workspace_folder)}` : 'cd "$h"';
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
`#!/usr/bin/env bash\n` +
|
|
114
|
+
`${HOME_PRELUDE}[ -n "$HOME" ] || export HOME="$h"\n` +
|
|
115
|
+
`d="${runDirExpr(runId)}"\n` +
|
|
116
|
+
// tmp+mv so the poller can never read a half-written exit file and take it for a real code.
|
|
117
|
+
`w(){ printf '%s' "$2" > "$d/$1.tmp"; mv -f "$d/$1.tmp" "$d/$1"; }\n` +
|
|
118
|
+
`die(){ printf 'codebay: %s\\n' "$2" >> "$d/stderr.log"; w exit "$1"; exit "$1"; }\n` +
|
|
119
|
+
// setsid made this the session leader, so $$ is also the PGID stopRun signals.
|
|
120
|
+
`w pgid "$$"\n` +
|
|
121
|
+
// Tells the IDE/terminal launchers not to start a second Claude beside this one. The KILL
|
|
122
|
+
// step of stopScript removes it itself.
|
|
123
|
+
`m="$HOME/${AGENT_RUN_MARKER}"; : > "$m"; trap 'rm -f "$m"' EXIT\n` +
|
|
124
|
+
// A trapped signal only runs its handler and lets bash carry on, so without the explicit
|
|
125
|
+
// exit a cancel that lands during the wait loops below would still go on to launch claude.
|
|
126
|
+
`trap 'w exit 130; exit 130' INT; trap 'w exit 143; exit 143' TERM\n` +
|
|
127
|
+
// Claude Code refuses --dangerously-skip-permissions under uid 0, which is exactly what an
|
|
128
|
+
// instance with no resolved remote_user execs as.
|
|
129
|
+
`[ "$(id -u)" = 0 ] && export IS_SANDBOX=1\n` +
|
|
130
|
+
// A run queued while the instance was still booting must not race the injections that
|
|
131
|
+
// install and authenticate Claude Code.
|
|
132
|
+
`${WAIT_FOR_INJECTIONS}\n` +
|
|
133
|
+
`${cd} || die 127 "workspace folder is missing"\n` +
|
|
134
|
+
`${SOURCE_INJECTED_ENV}\n` +
|
|
135
|
+
`i=0; until command -v claude >/dev/null 2>&1 || [ "$i" -ge ${CLAUDE_BINARY_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done\n` +
|
|
136
|
+
`command -v claude >/dev/null 2>&1 || die 127 "claude is not installed in this container"\n` +
|
|
137
|
+
`claude -p "$(cat "$d/prompt.txt")" ${flags.join(' ')} </dev/null > "$d/stream.jsonl" 2> "$d/stderr.log"\n` +
|
|
138
|
+
`w exit "$?"\n`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `execInContainer` resolves on the exec stream's `end`, which only fires once every attached fd is
|
|
144
|
+
* closed — so the child must redirect all three or the launch call would hang for the whole run.
|
|
145
|
+
* `setsid` gives it its own process group to signal; a minimal image without it still detaches.
|
|
146
|
+
*/
|
|
147
|
+
function launchScript(runId: string): string {
|
|
148
|
+
const d = runDirExpr(runId);
|
|
149
|
+
return (
|
|
150
|
+
`${HOME_PRELUDE}d="${d}"; ` +
|
|
151
|
+
`[ -f "$d/run.sh" ] || { echo "codebay: run script missing" >&2; exit 1; }; ` +
|
|
152
|
+
`rm -f "$d/exit" "$d/stream.jsonl" "$d/stderr.log" "$d/pgid"; ` +
|
|
153
|
+
`if command -v setsid >/dev/null 2>&1; then setsid nohup bash "$d/run.sh" >/dev/null 2>&1 </dev/null & ` +
|
|
154
|
+
`else nohup bash "$d/run.sh" >/dev/null 2>&1 </dev/null & fi`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reads the exit file *before* the stream tail. An exit code observed first proves claude was
|
|
160
|
+
* already dead, so the bytes read after it are final; the reverse order would let a run finish
|
|
161
|
+
* between the two reads and report a complete exit alongside a truncated stream.
|
|
162
|
+
*/
|
|
163
|
+
function pollScript(runId: string, offset: number): string {
|
|
164
|
+
const d = runDirExpr(runId);
|
|
165
|
+
return (
|
|
166
|
+
`${HOME_PRELUDE}d="${d}"; ` +
|
|
167
|
+
`ex=$(tr -dc '0-9' < "$d/exit" 2>/dev/null); ` +
|
|
168
|
+
`${ALIVE_CHECK(runId)}` +
|
|
169
|
+
`printf '${STATE_MARKER}%s\\t%s\\n' "$ex" "$alive"; ` +
|
|
170
|
+
// run.sh's own trap normally does this; this covers a run that was hard-killed.
|
|
171
|
+
`if [ -n "$ex" ]; then rm -f "$HOME/${AGENT_RUN_MARKER}"; fi; ` +
|
|
172
|
+
// Only worth a read once the run is over; tailing it every pass is pure waste.
|
|
173
|
+
`if [ -n "$ex" ] && [ -s "$d/stderr.log" ]; then printf '${ERR_MARKER}%s\\n' "$(tail -c 2000 "$d/stderr.log" | tr '\\n' ' ')"; fi; ` +
|
|
174
|
+
`printf '%s\\n' '${FETCH_MARKER}'; ` +
|
|
175
|
+
`${tailBlockScript('$d/stream.jsonl', String(offset + 1), POLL_CHUNK_BYTES)}; ` +
|
|
176
|
+
`true`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sets `$pg` and `$alive` from `$d/pgid`. Identity, not just `kill -0`: a restarted container
|
|
182
|
+
* reuses low PIDs, so a bare liveness probe would happily report an unrelated process as this run.
|
|
183
|
+
*/
|
|
184
|
+
const ALIVE_CHECK = (runId: string): string =>
|
|
185
|
+
`pg=$(tr -dc '0-9' < "$d/pgid" 2>/dev/null); ` +
|
|
186
|
+
`alive=0; if [ -n "$pg" ]; then c=$(tr '\\0' ' ' < "/proc/$pg/cmdline" 2>/dev/null); ` +
|
|
187
|
+
`case "$c" in *${runId}*) alive=1 ;; esac; fi; `;
|
|
188
|
+
|
|
189
|
+
function stopScript(runId: string, signal: 'INT' | 'TERM' | 'KILL'): string {
|
|
190
|
+
const d = runDirExpr(runId);
|
|
191
|
+
return (
|
|
192
|
+
`${HOME_PRELUDE}d="${d}"; ${ALIVE_CHECK(runId)}` +
|
|
193
|
+
// `kill -- -0` would signal every process this user owns, so refuse anything but a live pgid.
|
|
194
|
+
`[ "$alive" = 1 ] && [ "$pg" -gt 1 ] || exit 0; ` +
|
|
195
|
+
// The group kill reaches claude's own children; the bare pid is the setsid-less fallback.
|
|
196
|
+
`kill -${signal} -- "-$pg" 2>/dev/null || kill -${signal} "$pg" 2>/dev/null; ` +
|
|
197
|
+
// KILL skips run.sh's trap, and a cancelled run is never polled again, so nothing else owns it.
|
|
198
|
+
(signal === 'KILL' ? `rm -f "$HOME/${AGENT_RUN_MARKER}" "$d/pgid"; ` : '') +
|
|
199
|
+
`true`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Waits for the exit file so a tail that follows a signal sees the result claude flushes on its way out. */
|
|
204
|
+
function waitForExitScript(runId: string, seconds: number): string {
|
|
205
|
+
return (
|
|
206
|
+
`${HOME_PRELUDE}d="${runDirExpr(runId)}"; ` +
|
|
207
|
+
`i=0; while [ ! -f "$d/exit" ] && [ "$i" -lt ${seconds} ]; do sleep 1; i=$((i + 1)); done; `
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** In-memory per-run cursor: the incomplete trailing line the next chunk has to be prefixed with. */
|
|
212
|
+
interface RunCursor {
|
|
213
|
+
carry: string;
|
|
214
|
+
state: RunStreamState;
|
|
215
|
+
missingStrikes: number;
|
|
216
|
+
/** Streaming, because the byte tail can cut a multi-byte character across two passes. */
|
|
217
|
+
decoder: TextDecoder;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface RunRegistry {
|
|
221
|
+
timer?: ReturnType<typeof setInterval>;
|
|
222
|
+
cursors: Map<string, RunCursor>;
|
|
223
|
+
/** The in-flight pass per run, so an overlapping caller joins it instead of seeing stale state. */
|
|
224
|
+
inFlight: Map<string, Promise<void>>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Pinned like every other long-lived map here, so dev-mode hot reload doesn't orphan the timer.
|
|
228
|
+
const globalForRuns = globalThis as unknown as { __codebayRuns?: RunRegistry };
|
|
229
|
+
const registry: RunRegistry = (globalForRuns.__codebayRuns ??= {
|
|
230
|
+
cursors: new Map(),
|
|
231
|
+
inFlight: new Map()
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Collapses concurrent passes for one run onto a single promise. Joining rather than skipping is
|
|
236
|
+
* what lets `get_run`, called straight after `run_agent`, observe the launch it triggered.
|
|
237
|
+
*/
|
|
238
|
+
function once(runId: string, work: () => Promise<void>): Promise<void> {
|
|
239
|
+
const existing = registry.inFlight.get(runId);
|
|
240
|
+
if (existing) return existing;
|
|
241
|
+
const promise = work().finally(() => registry.inFlight.delete(runId));
|
|
242
|
+
registry.inFlight.set(runId, promise);
|
|
243
|
+
return promise;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Like `once`, but for work that must run itself rather than join a pass already in flight. */
|
|
247
|
+
async function afterInFlight(runId: string, work: () => Promise<void>): Promise<void> {
|
|
248
|
+
let pending: Promise<void> | undefined;
|
|
249
|
+
while ((pending = registry.inFlight.get(runId))) await pending;
|
|
250
|
+
return once(runId, work);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Appends a pass's fetched bytes to the mirror; null when the pass carried nothing new. */
|
|
254
|
+
function mirrorChunk(runId: string, stdout: string): Buffer | null {
|
|
255
|
+
const [block] = parseFetchBlocks(stdout);
|
|
256
|
+
if (!block?.base64) return null;
|
|
257
|
+
const bytes = Buffer.from(block.base64, 'base64');
|
|
258
|
+
if (!bytes.length) return null;
|
|
259
|
+
mkdirSync(LOGS_DIR, { recursive: true });
|
|
260
|
+
appendFileSync(runMirrorPath(runId), bytes);
|
|
261
|
+
return bytes;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The mirror is the record; the cursor is only a cache of it, and can lag it. */
|
|
265
|
+
function stateFromMirror(runId: string, fallback: RunStreamState): RunStreamState {
|
|
266
|
+
try {
|
|
267
|
+
return readRunFile(readFileSync(runMirrorPath(runId), 'utf8'));
|
|
268
|
+
} catch {
|
|
269
|
+
return fallback;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Rebuilt from the host mirror, so a manager restart mid-run picks up exactly where it left off. */
|
|
274
|
+
function cursorFor(runId: string): RunCursor {
|
|
275
|
+
let cursor = registry.cursors.get(runId);
|
|
276
|
+
if (!cursor) {
|
|
277
|
+
let state = emptyRunState();
|
|
278
|
+
let carry = '';
|
|
279
|
+
try {
|
|
280
|
+
({ state, carry } = readRunChunk(state, '', readFileSync(runMirrorPath(runId), 'utf8')));
|
|
281
|
+
} catch {
|
|
282
|
+
// No mirror yet — a run that hasn't produced output.
|
|
283
|
+
}
|
|
284
|
+
cursor = { carry, state, missingStrikes: 0, decoder: new TextDecoder('utf-8') };
|
|
285
|
+
registry.cursors.set(runId, cursor);
|
|
286
|
+
}
|
|
287
|
+
return cursor;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Set by `instances.server.ts` at import time. A callback rather than a direct import because the
|
|
292
|
+
* broadcast must not go through `triggerReconcile` — that runs a `docker inspect` per instance, and
|
|
293
|
+
* this fires every few seconds per active run.
|
|
294
|
+
*/
|
|
295
|
+
let onRunChanged: ((runId: string) => void) | undefined;
|
|
296
|
+
export function setRunChangeHook(hook: (runId: string) => void): void {
|
|
297
|
+
onRunChanged = hook;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The one projection of a run row every reader shares, so the Agent log and MCP can't drift apart. */
|
|
301
|
+
export function runDetail(row: AgentRunRow) {
|
|
302
|
+
return {
|
|
303
|
+
id: row.id,
|
|
304
|
+
instance_id: row.instance_id,
|
|
305
|
+
status: row.status,
|
|
306
|
+
prompt: row.prompt,
|
|
307
|
+
session_id: row.session_id,
|
|
308
|
+
resume_session_id: row.resume_session_id,
|
|
309
|
+
model: row.model,
|
|
310
|
+
requested_model: requestedModel(row),
|
|
311
|
+
last_activity: row.last_activity,
|
|
312
|
+
result: row.result,
|
|
313
|
+
structured_output: row.structured_output
|
|
314
|
+
? (JSON.parse(row.structured_output) as unknown)
|
|
315
|
+
: null,
|
|
316
|
+
rejected_structured_output: row.rejected_structured_output
|
|
317
|
+
? (JSON.parse(row.rejected_structured_output) as unknown)
|
|
318
|
+
: null,
|
|
319
|
+
is_error: row.is_error === 1,
|
|
320
|
+
error: row.error,
|
|
321
|
+
exit_code: row.exit_code,
|
|
322
|
+
num_turns: row.num_turns,
|
|
323
|
+
cost_usd: row.cost_usd,
|
|
324
|
+
duration_ms: row.duration_ms,
|
|
325
|
+
created_at: row.created_at,
|
|
326
|
+
started_at: row.started_at,
|
|
327
|
+
finished_at: row.finished_at
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function runSummary(row: AgentRunRow): AgentRunSummary {
|
|
332
|
+
const { id, instance_id, status, last_activity, started_at, finished_at, is_error } =
|
|
333
|
+
runDetail(row);
|
|
334
|
+
return { id, instance_id, status, last_activity, started_at, finished_at, is_error };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Seeds a freshly connected socket, the way `currentHealthSnapshots` does for health. */
|
|
338
|
+
export function currentRunSummaries(): AgentRunSummary[] {
|
|
339
|
+
return openRuns().map(runSummary);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function finish(runId: string, patch: Parameters<typeof updateRun>[1]): void {
|
|
343
|
+
updateRun(runId, { ...patch, finished_at: Date.now() });
|
|
344
|
+
registry.cursors.delete(runId);
|
|
345
|
+
onRunChanged?.(runId);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function startRunTimer(): void {
|
|
349
|
+
if (registry.timer) return;
|
|
350
|
+
registry.timer = setInterval(() => {
|
|
351
|
+
void tick();
|
|
352
|
+
}, POLL_MS);
|
|
353
|
+
// A pending run must never be the reason the process stays alive.
|
|
354
|
+
registry.timer.unref?.();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function stopRunTimer(): void {
|
|
358
|
+
if (!registry.timer) return;
|
|
359
|
+
clearInterval(registry.timer);
|
|
360
|
+
registry.timer = undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function tick(): Promise<void> {
|
|
364
|
+
const rows = openRuns();
|
|
365
|
+
if (!rows.length) {
|
|
366
|
+
stopRunTimer();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
await Promise.all(rows.map((row) => advance(row)));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Re-attaches pollers to runs that were in flight when the manager went down. */
|
|
373
|
+
export function resumeRuns(): void {
|
|
374
|
+
if (openRuns().length) startRunTimer();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function advance(row: AgentRunRow): Promise<void> {
|
|
378
|
+
const instance = getInstance(row.instance_id);
|
|
379
|
+
if (!instance) {
|
|
380
|
+
finish(row.id, { status: 'error', error: 'sandbox no longer exists', is_error: 1 });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (row.status === 'queued') {
|
|
384
|
+
if (instance.status === 'creating') return; // Still booting; the launcher waits it out.
|
|
385
|
+
if (instance.status !== 'running' || !instance.container_id) {
|
|
386
|
+
finish(row.id, {
|
|
387
|
+
status: 'error',
|
|
388
|
+
error: `sandbox is ${instance.status}, not running`,
|
|
389
|
+
is_error: 1
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
await launch(row, instance);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (!instance.container_id) {
|
|
397
|
+
finish(row.id, { status: 'error', error: 'sandbox has no container', is_error: 1 });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
await poll(row, instance);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function optionsOf(row: AgentRunRow): StartRunOptions {
|
|
404
|
+
try {
|
|
405
|
+
return row.options ? (JSON.parse(row.options) as StartRunOptions) : {};
|
|
406
|
+
} catch {
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The alias or id the caller asked for; null when the run took the sandbox default. */
|
|
412
|
+
export function requestedModel(row: AgentRunRow): string | null {
|
|
413
|
+
return optionsOf(row).model ?? null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Staging happens here rather than in `startRun` because a run may be queued against an instance
|
|
418
|
+
* that is still booting and has no container to write into yet.
|
|
419
|
+
*/
|
|
420
|
+
async function stage(row: AgentRunRow, instance: InstanceRow): Promise<string | null> {
|
|
421
|
+
const target = targetFor(instance);
|
|
422
|
+
const dir = runDirExpr(row.id);
|
|
423
|
+
const opts = optionsOf(row);
|
|
424
|
+
const wrote = await writeContainerFile(
|
|
425
|
+
target,
|
|
426
|
+
{ dir, name: 'prompt.txt', mode: '600' },
|
|
427
|
+
row.prompt
|
|
428
|
+
);
|
|
429
|
+
if (!wrote.ok) return `could not stage the prompt: ${wrote.error}`;
|
|
430
|
+
if (opts.jsonSchema) {
|
|
431
|
+
const schema = await writeContainerFile(
|
|
432
|
+
target,
|
|
433
|
+
{ dir, name: 'schema.json', mode: '600' },
|
|
434
|
+
opts.jsonSchema
|
|
435
|
+
);
|
|
436
|
+
if (!schema.ok) return `could not stage the schema: ${schema.error}`;
|
|
437
|
+
}
|
|
438
|
+
const script = await writeContainerFile(
|
|
439
|
+
target,
|
|
440
|
+
{ dir, name: 'run.sh', mode: '700' },
|
|
441
|
+
runScript(row.id, instance, { ...opts, resumeSessionId: row.resume_session_id ?? undefined })
|
|
442
|
+
);
|
|
443
|
+
return script.ok ? null : `could not stage the run script: ${script.error}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function launch(row: AgentRunRow, instance: InstanceRow): Promise<void> {
|
|
447
|
+
return once(row.id, async () => {
|
|
448
|
+
const staged = await stage(row, instance);
|
|
449
|
+
if (staged) {
|
|
450
|
+
finish(row.id, { status: 'error', error: staged, is_error: 1 });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// stopRun doesn't go through once(), so a cancel can land while staging is still in flight.
|
|
454
|
+
if (getRun(row.id)?.status !== 'queued') return;
|
|
455
|
+
// The script detaches and returns at once, so anything slower is a wedged stream — and this
|
|
456
|
+
// runs under once(), where a hung exec would also hang every get_run for the row.
|
|
457
|
+
const res = await execInContainer(targetFor(instance), {
|
|
458
|
+
script: launchScript(row.id),
|
|
459
|
+
timeoutMs: 30_000
|
|
460
|
+
});
|
|
461
|
+
if (!res.ok) {
|
|
462
|
+
finish(row.id, {
|
|
463
|
+
status: 'error',
|
|
464
|
+
error: `could not start run: ${res.error ?? 'unknown error'}`,
|
|
465
|
+
is_error: 1
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (getRun(row.id)?.status !== 'queued') {
|
|
470
|
+
// Cancelled during the launch exec: the process is real now, so put it down.
|
|
471
|
+
void execInContainer(targetFor(instance), {
|
|
472
|
+
script: stopScript(row.id, 'TERM'),
|
|
473
|
+
timeoutMs: 15_000
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
updateRun(row.id, { status: 'running', started_at: Date.now() });
|
|
478
|
+
onRunChanged?.(row.id);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function poll(row: AgentRunRow, instance: InstanceRow): Promise<void> {
|
|
483
|
+
return once(row.id, async () => {
|
|
484
|
+
const cursor = cursorFor(row.id);
|
|
485
|
+
const res = await execInContainer(targetFor(instance), {
|
|
486
|
+
script: pollScript(row.id, mirrorSize(row.id)),
|
|
487
|
+
capture: true,
|
|
488
|
+
timeoutMs: 30_000
|
|
489
|
+
});
|
|
490
|
+
if (!res.ok) {
|
|
491
|
+
// A container that went away mid-run can never produce an exit file.
|
|
492
|
+
if (++cursor.missingStrikes >= MISSING_PROCESS_STRIKES) {
|
|
493
|
+
finish(row.id, {
|
|
494
|
+
status: 'error',
|
|
495
|
+
error: `lost contact with the sandbox: ${res.error ?? 'unknown error'}`,
|
|
496
|
+
is_error: 1
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Read in the script's own order: the state line was captured before these bytes, so an
|
|
503
|
+
// exit code here guarantees the stream below it is complete.
|
|
504
|
+
const [exitRaw = '', aliveRaw = ''] = (markerLine(res.stdout, STATE_MARKER) ?? '')
|
|
505
|
+
.split('\t')
|
|
506
|
+
.map((f) => f.trim());
|
|
507
|
+
|
|
508
|
+
const bytes = mirrorChunk(row.id, res.stdout);
|
|
509
|
+
const changed = bytes !== null;
|
|
510
|
+
if (bytes) {
|
|
511
|
+
const text = cursor.decoder.decode(bytes, { stream: true });
|
|
512
|
+
const next = readRunChunk(cursor.state, cursor.carry, text);
|
|
513
|
+
cursor.state = next.state;
|
|
514
|
+
cursor.carry = next.carry;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The row may have been cancelled while the exec was out; the mirror still got its bytes.
|
|
518
|
+
if (getRun(row.id)?.status !== 'running') return;
|
|
519
|
+
|
|
520
|
+
// The terminal fold reads the whole mirror rather than trusting the cursor: a chunk this
|
|
521
|
+
// process never fetched (a second manager on the same DATA_DIR did) is still on disk.
|
|
522
|
+
const state = exitRaw ? stateFromMirror(row.id, cursor.state) : cursor.state;
|
|
523
|
+
if (changed) {
|
|
524
|
+
updateRun(row.id, {
|
|
525
|
+
session_id: state.sessionId,
|
|
526
|
+
model: state.model,
|
|
527
|
+
last_activity: state.lastActivity,
|
|
528
|
+
num_turns: state.numTurns,
|
|
529
|
+
cost_usd: state.costUsd
|
|
530
|
+
});
|
|
531
|
+
onRunChanged?.(row.id);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (exitRaw) {
|
|
535
|
+
const exitCode = Number.parseInt(exitRaw, 10);
|
|
536
|
+
const failed = state.isError || exitCode !== 0;
|
|
537
|
+
const stderr = markerLine(res.stdout, ERR_MARKER)?.trim();
|
|
538
|
+
finish(row.id, {
|
|
539
|
+
status: failed ? 'error' : 'done',
|
|
540
|
+
exit_code: exitCode,
|
|
541
|
+
is_error: failed ? 1 : 0,
|
|
542
|
+
result: state.result,
|
|
543
|
+
structured_output: state.structuredOutput,
|
|
544
|
+
rejected_structured_output: state.rejectedStructuredOutput,
|
|
545
|
+
duration_ms: state.durationMs ?? (row.started_at ? Date.now() - row.started_at : null),
|
|
546
|
+
num_turns: state.numTurns,
|
|
547
|
+
cost_usd: state.costUsd,
|
|
548
|
+
session_id: state.sessionId,
|
|
549
|
+
model: state.model,
|
|
550
|
+
last_activity: state.lastActivity,
|
|
551
|
+
// A schema failure gets its own marker so a caller can tell "answered but the payload
|
|
552
|
+
// never validated" (result holds that answer) from "claude crashed"; otherwise the
|
|
553
|
+
// stream's own result text beats stderr, which only fills in on an outright crash.
|
|
554
|
+
error: failed
|
|
555
|
+
? state.structuredOutputFailed
|
|
556
|
+
? STRUCTURED_OUTPUT_FAILED_ERROR
|
|
557
|
+
: stderr || state.result || `claude exited ${exitCode}`
|
|
558
|
+
: null
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const timeoutMs = optionsOf(row).timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
564
|
+
if (row.started_at && Date.now() - row.started_at > timeoutMs) {
|
|
565
|
+
await stopRun(row.id, `timed out after ${Math.round(timeoutMs / 60_000)} minutes`, {
|
|
566
|
+
failed: true
|
|
567
|
+
});
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// `alive` covers both a dead process and a missing pgid file: either way, nothing is going
|
|
572
|
+
// to write the exit file, so the container must have restarted under the run.
|
|
573
|
+
if (aliveRaw !== '1') {
|
|
574
|
+
if (++cursor.missingStrikes >= MISSING_PROCESS_STRIKES) {
|
|
575
|
+
finish(row.id, {
|
|
576
|
+
status: 'error',
|
|
577
|
+
error: 'the run process is gone — the sandbox was probably restarted',
|
|
578
|
+
is_error: 1,
|
|
579
|
+
result: state.result
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
cursor.missingStrikes = 0;
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Forces one pass now, so a caller polling `get_run` isn't held to the background cadence. */
|
|
589
|
+
export async function pollRunNow(runId: string): Promise<AgentRunRow | null> {
|
|
590
|
+
const row = getRun(runId);
|
|
591
|
+
if (!row || (row.status !== 'running' && row.status !== 'queued')) return row;
|
|
592
|
+
await advance(row);
|
|
593
|
+
return getRun(runId);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function startRun(
|
|
597
|
+
instance: InstanceRow,
|
|
598
|
+
prompt: string,
|
|
599
|
+
opts: StartRunOptions = {}
|
|
600
|
+
): AgentRunRow {
|
|
601
|
+
if (!prompt.trim()) throw new Error('prompt is required');
|
|
602
|
+
const promptBytes = Buffer.byteLength(prompt, 'utf8');
|
|
603
|
+
if (promptBytes > PROMPT_MAX_BYTES) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`prompt is ${promptBytes} bytes; a run carries at most ${PROMPT_MAX_BYTES} — put the bulk in a ` +
|
|
606
|
+
'workspace file and refer to it'
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (instance.status === 'error') throw new Error('sandbox failed to build');
|
|
610
|
+
// Claude Code keeps one session directory per project, so two concurrent runs would interleave.
|
|
611
|
+
const open = openRunFor(instance.id);
|
|
612
|
+
if (open) throw new Error(`sandbox already has an active run (${open.id})`);
|
|
613
|
+
|
|
614
|
+
const id = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
|
615
|
+
|
|
616
|
+
const row: AgentRunRow = {
|
|
617
|
+
id,
|
|
618
|
+
instance_id: instance.id,
|
|
619
|
+
prompt,
|
|
620
|
+
status: 'queued',
|
|
621
|
+
session_id: null,
|
|
622
|
+
model: null,
|
|
623
|
+
resume_session_id: opts.resumeSessionId ?? null,
|
|
624
|
+
options: JSON.stringify(opts),
|
|
625
|
+
result: null,
|
|
626
|
+
structured_output: null,
|
|
627
|
+
rejected_structured_output: null,
|
|
628
|
+
last_activity: null,
|
|
629
|
+
is_error: 0,
|
|
630
|
+
exit_code: null,
|
|
631
|
+
cost_usd: null,
|
|
632
|
+
duration_ms: null,
|
|
633
|
+
num_turns: null,
|
|
634
|
+
error: null,
|
|
635
|
+
created_at: Date.now(),
|
|
636
|
+
started_at: null,
|
|
637
|
+
finished_at: null
|
|
638
|
+
};
|
|
639
|
+
insertRun(row);
|
|
640
|
+
startRunTimer();
|
|
641
|
+
onRunChanged?.(id);
|
|
642
|
+
// A sandbox that is already up shouldn't wait a poll interval to get going.
|
|
643
|
+
if (instance.status === 'running') void advance(row);
|
|
644
|
+
return row;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* SIGINT first, because Claude Code ends the turn cleanly and records a result on it where SIGTERM
|
|
649
|
+
* leaves the turn unfinished; `failed` marks a stop the caller didn't ask for (a timeout) as an error.
|
|
650
|
+
*/
|
|
651
|
+
export async function stopRun(
|
|
652
|
+
runId: string,
|
|
653
|
+
reason = 'cancelled by the caller',
|
|
654
|
+
{ failed = false } = {}
|
|
655
|
+
): Promise<AgentRunRow> {
|
|
656
|
+
const row = getRun(runId);
|
|
657
|
+
if (!row) throw new Error('Run not found');
|
|
658
|
+
if (row.status !== 'running' && row.status !== 'queued') return row;
|
|
659
|
+
// The mirror, not the cursor: after a manager restart no cursor exists until the first pass.
|
|
660
|
+
const state = stateFromMirror(runId, registry.cursors.get(runId)?.state ?? emptyRunState());
|
|
661
|
+
// Settled before the signal goes out, so a caller that doesn't await (rebuild) still sees the
|
|
662
|
+
// slot free and the poller stops exec'ing into a container that is on its way out.
|
|
663
|
+
finish(runId, {
|
|
664
|
+
status: failed ? 'error' : 'cancelled',
|
|
665
|
+
is_error: failed ? 1 : 0,
|
|
666
|
+
error: reason,
|
|
667
|
+
result: state.result ?? null,
|
|
668
|
+
last_activity: state.lastActivity ?? row.last_activity,
|
|
669
|
+
duration_ms: row.started_at ? Date.now() - row.started_at : null
|
|
670
|
+
});
|
|
671
|
+
const instance = getInstance(row.instance_id);
|
|
672
|
+
if (instance?.container_id) {
|
|
673
|
+
const target = targetFor(instance);
|
|
674
|
+
// SIGINT lets claude flush its result event and exit cleanly; the rest is for a wedged process.
|
|
675
|
+
await execInContainer(target, { script: stopScript(runId, 'INT'), timeoutMs: 15_000 });
|
|
676
|
+
for (const [delay, signal] of [
|
|
677
|
+
[STOP_DRAIN_SECONDS * 1000, 'TERM'],
|
|
678
|
+
[STOP_DRAIN_SECONDS * 2000, 'KILL']
|
|
679
|
+
] as const) {
|
|
680
|
+
// stopScript itself checks the process is still this run, so a clean exit makes these no-ops.
|
|
681
|
+
setTimeout(() => {
|
|
682
|
+
void execInContainer(target, { script: stopScript(runId, signal), timeoutMs: 15_000 });
|
|
683
|
+
}, delay).unref?.();
|
|
684
|
+
}
|
|
685
|
+
await drainAfterStop(runId, instance);
|
|
686
|
+
}
|
|
687
|
+
return getRun(runId)!;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The poller never visits a cancelled row again, so without this last tail the result claude flushed
|
|
692
|
+
* on SIGINT would sit in the container and never reach the row or the mirror.
|
|
693
|
+
*/
|
|
694
|
+
async function drainAfterStop(runId: string, instance: InstanceRow): Promise<void> {
|
|
695
|
+
await afterInFlight(runId, async () => {
|
|
696
|
+
const res = await execInContainer(targetFor(instance), {
|
|
697
|
+
script: waitForExitScript(runId, STOP_DRAIN_SECONDS) + pollScript(runId, mirrorSize(runId)),
|
|
698
|
+
capture: true,
|
|
699
|
+
timeoutMs: (STOP_DRAIN_SECONDS + 25) * 1000
|
|
700
|
+
});
|
|
701
|
+
if (!res.ok || !mirrorChunk(runId, res.stdout)) return;
|
|
702
|
+
const state = stateFromMirror(runId, emptyRunState());
|
|
703
|
+
updateRun(runId, {
|
|
704
|
+
session_id: state.sessionId,
|
|
705
|
+
model: state.model,
|
|
706
|
+
last_activity: state.lastActivity,
|
|
707
|
+
num_turns: state.numTurns,
|
|
708
|
+
cost_usd: state.costUsd,
|
|
709
|
+
result: state.result
|
|
710
|
+
});
|
|
711
|
+
onRunChanged?.(runId);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The run's steps, rendered by the instance page's Agent log. Read from the host mirror rather than
|
|
717
|
+
* the container, so it still works after the sandbox is gone.
|
|
718
|
+
*/
|
|
719
|
+
export function runTimeline(runId: string, limit = 500): RunTimelineEntry[] {
|
|
720
|
+
try {
|
|
721
|
+
return parseRunTimeline(readFileSync(runMirrorPath(runId), 'utf8')).slice(-limit);
|
|
722
|
+
} catch {
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** The trailing lines of a run's mirrored transcript, for `get_logs`. */
|
|
728
|
+
export function readRunLog(runId: string, tailLines = 200): string {
|
|
729
|
+
try {
|
|
730
|
+
const lines = readFileSync(runMirrorPath(runId), 'utf8').split('\n').filter(Boolean);
|
|
731
|
+
return lines.slice(-tailLines).join('\n');
|
|
732
|
+
} catch {
|
|
733
|
+
return '';
|
|
734
|
+
}
|
|
735
|
+
}
|