@dynamicagents/plugins 0.12.0 → 0.14.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/README.md +58 -77
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +55 -0
- package/dist/claude-code/cards.js +283 -0
- package/dist/claude-code/config.d.ts +28 -63
- package/dist/claude-code/config.js +0 -11
- package/dist/claude-code/egress.d.ts +8 -1
- package/dist/claude-code/egress.js +42 -4
- package/dist/claude-code/events.d.ts +106 -9
- package/dist/claude-code/events.js +201 -56
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +16 -90
- package/dist/claude-code/index.js +14 -276
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +366 -0
- package/dist/claude-code/run.d.ts +230 -116
- package/dist/claude-code/run.js +380 -167
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +288 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +1 -5
- package/dist/computer/context.js +7 -10
- package/dist/computer/file-lock.js +5 -4
- package/dist/computer/gate.d.ts +4 -4
- package/dist/computer/gate.js +5 -5
- package/dist/computer/index.d.ts +40 -258
- package/dist/computer/index.js +71 -324
- package/dist/computer/paths.d.ts +36 -16
- package/dist/computer/paths.js +42 -29
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +1 -40
- package/dist/computer/render.js +2 -68
- package/dist/computer/tools-exec.js +12 -13
- package/dist/computer/tools-file.d.ts +9 -1
- package/dist/computer/tools-file.js +22 -82
- package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +4 -5
- package/dist/repo/checkout.js +31 -8
- package/dist/repo/context.d.ts +35 -9
- package/dist/repo/context.js +101 -49
- package/dist/repo/index.d.ts +73 -28
- package/dist/repo/index.js +112 -96
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.js +1 -1
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
- package/dist/repo/tools-review.d.ts +9 -0
- package/dist/repo/tools-review.js +67 -47
- package/dist/repo/tools-worktree.js +144 -60
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +4 -4
- package/dist/{computer → workspace}/advisory.js +2 -2
- package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
- package/dist/{computer/host → workspace}/ca-trust.js +12 -3
- package/dist/{computer/host → workspace}/container-deps.d.ts +4 -0
- package/dist/{computer/host → workspace}/container-deps.js +26 -1
- package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
- package/dist/{computer/host → workspace}/container-fault.js +23 -8
- package/dist/workspace/deps-snapshot.d.ts +110 -0
- package/dist/workspace/deps-snapshot.js +424 -0
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
- package/dist/{computer/host → workspace}/git-host.js +72 -2
- package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
- package/dist/{computer/host → workspace}/git-identity.js +1 -1
- package/dist/workspace/index.d.ts +44 -47
- package/dist/workspace/index.js +52 -112
- package/dist/{computer/host → workspace}/install-job.d.ts +53 -4
- package/dist/{computer/host → workspace}/install-job.js +346 -178
- package/dist/{computer → workspace}/install.d.ts +17 -8
- package/dist/{computer → workspace}/install.js +24 -6
- package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +125 -27
- package/dist/{computer/host/workspace.js → workspace/object.js} +341 -58
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/{computer → workspace}/shell.d.ts +5 -5
- package/dist/{computer → workspace}/shell.js +6 -6
- package/dist/{computer/host → workspace}/wake.d.ts +3 -1
- package/package.json +17 -37
- package/dist/arc-agi/analysis.d.ts +0 -322
- package/dist/arc-agi/analysis.js +0 -802
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -24
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -40
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -24
- package/dist/arc-agi/main-agent.js +0 -62
- package/dist/arc-agi/recipe.d.ts +0 -48
- package/dist/arc-agi/recipe.js +0 -97
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -29
- package/dist/arc-agi/soul.js +0 -79
- package/dist/arc-agi/store.d.ts +0 -153
- package/dist/arc-agi/store.js +0 -215
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -574
- package/dist/arc-agi/types.d.ts +0 -211
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -133
- package/dist/computer/read.js +0 -200
- package/dist/computer/tools-find.js +0 -216
- package/dist/recall/index.d.ts +0 -105
- package/dist/recall/index.js +0 -181
- package/dist/repo/tools-forge.d.ts +0 -4
- package/dist/triage/index.d.ts +0 -360
- package/dist/triage/index.js +0 -311
- /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
- /package/dist/{computer/host → workspace}/sync.js +0 -0
- /package/dist/{computer/host → workspace}/wake.js +0 -0
package/dist/claude-code/run.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { shellQuote } from "@cloudflare/computer";
|
|
2
|
+
import { GITHUB_ENV_KEYS, githubEnv } from "./github.js";
|
|
2
3
|
import { parseStream, toProgress, RATE_LIMIT_OK } from "./events.js";
|
|
3
4
|
import { DEFAULT_PERMISSION_MODE } from "./config.js";
|
|
4
5
|
/** Namespace for every session exec id in a workspace. */
|
|
@@ -6,21 +7,27 @@ export const CLAUDE_EXEC_PREFIX = "claude-code-run";
|
|
|
6
7
|
/**
|
|
7
8
|
* The exec id one session occupies.
|
|
8
9
|
*
|
|
9
|
-
* **Per
|
|
10
|
-
* one Durable Object and one container, but
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* would
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* is simply not to share the id.
|
|
10
|
+
* **Per run, not fixed**, and the difference is load-bearing. A workspace is
|
|
11
|
+
* one Durable Object and one container, but runs are concurrent — sessions in a
|
|
12
|
+
* scratchpad share their parent's container — so under a single shared id they would spawn
|
|
13
|
+
* over one another, each drain would attach to whichever exec won, and
|
|
14
|
+
* `killRun` would stop somebody else's session. That is the displacement bug the
|
|
15
|
+
* coder's install guard exists to prevent, in a new place; here the answer is
|
|
16
|
+
* simply not to share the id.
|
|
17
17
|
*
|
|
18
|
-
* Fixed *per
|
|
18
|
+
* Fixed *per run*, though, because the point is still to find it again: an
|
|
19
19
|
* isolate that dies mid-drain leaves the session running in the container, and
|
|
20
|
-
* `getExec` is how the next
|
|
20
|
+
* `getExec` is how the next drain re-attaches instead of starting a second one.
|
|
21
21
|
*/
|
|
22
|
-
export function execIdFor(
|
|
23
|
-
return `${CLAUDE_EXEC_PREFIX}:${
|
|
22
|
+
export function execIdFor(runId) {
|
|
23
|
+
return `${CLAUDE_EXEC_PREFIX}:${runId}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The id a run's follow-up turn runs under — its own, because the session's exec
|
|
27
|
+
* has already finished under {@link execIdFor} and an id names one process.
|
|
28
|
+
*/
|
|
29
|
+
export function followUpExecIdFor(runId) {
|
|
30
|
+
return `${execIdFor(runId)}:follow-up`;
|
|
24
31
|
}
|
|
25
32
|
/**
|
|
26
33
|
* What the container is given instead of a credential.
|
|
@@ -44,6 +51,41 @@ export const CREDENTIAL_PLACEHOLDER = "sk-ant-oat01-" + "0".repeat(24);
|
|
|
44
51
|
* is refused rather than overridden.
|
|
45
52
|
*/
|
|
46
53
|
const RESERVED_ENV_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
54
|
+
/**
|
|
55
|
+
* The workspace mount, `/workspace` in every image this plugin runs in.
|
|
56
|
+
* Whatever a session writes under it is synced into the workspace's Durable
|
|
57
|
+
* Object; whatever it writes anywhere else dies with the container.
|
|
58
|
+
*/
|
|
59
|
+
const WORKSPACE_MOUNT = "/workspace";
|
|
60
|
+
/**
|
|
61
|
+
* Where every session keeps Claude Code's own state — its `CLAUDE_CONFIG_DIR`.
|
|
62
|
+
*
|
|
63
|
+
* The whole config directory moves, created if absent: the transcripts under
|
|
64
|
+
* `projects/<cwd-with-/-as-->/<session-id>.jsonl`, the session index,
|
|
65
|
+
* `.claude.json` and the auto-memory directory. None of it is a credential.
|
|
66
|
+
*
|
|
67
|
+
* This is what decides how long a session stays resumable. The CLI's own
|
|
68
|
+
* `$HOME/.claude` is on the container's disk, so a conversation there dies with
|
|
69
|
+
* the container — which a deploy, a rollout and the workspace's own idle timer
|
|
70
|
+
* all end. Under the workspace mount it is the Durable Object's storage, and the
|
|
71
|
+
* conversation outlives the container that held it; a later run can continue it
|
|
72
|
+
* from that same workspace object, and from no other.
|
|
73
|
+
*
|
|
74
|
+
* **Fixed, and never a host's to choose.** A transcript is found only where it
|
|
75
|
+
* was written, so every second place a session could write one is a resume that
|
|
76
|
+
* cannot find it. `env` cannot move it either: {@link buildLaunch} refuses the
|
|
77
|
+
* key.
|
|
78
|
+
*
|
|
79
|
+
* The directory is named {@link file://../computer/paths.ts SESSION_STATE_DIR},
|
|
80
|
+
* which is the name the file tools' walk steps over: a transcript records every
|
|
81
|
+
* line the session read, so a walk that descended into it would answer the
|
|
82
|
+
* parent's `grep` for a line of source with the session that quoted it. The
|
|
83
|
+
* name is written out here rather than imported, because `/claude-code` does
|
|
84
|
+
* not reach `/computer` — a spec holds the two to the same spelling.
|
|
85
|
+
*/
|
|
86
|
+
export const SESSION_CONFIG_DIR = `${WORKSPACE_MOUNT}/.claude-sessions`;
|
|
87
|
+
/** The environment key {@link SESSION_CONFIG_DIR} travels as. */
|
|
88
|
+
const CONFIG_DIR_ENV_KEY = "CLAUDE_CONFIG_DIR";
|
|
47
89
|
/**
|
|
48
90
|
* Git's identity as an *environment*, not a `git config`.
|
|
49
91
|
*
|
|
@@ -73,17 +115,19 @@ function gitIdentityEnv(author) {
|
|
|
73
115
|
* Build the command and environment for one session.
|
|
74
116
|
*
|
|
75
117
|
* `--output-format stream-json` with `--verbose`, because the stream is the only
|
|
76
|
-
* way to report progress before the run ends and a run
|
|
77
|
-
*
|
|
118
|
+
* way to report progress before the run ends, and a run lasts for minutes.
|
|
119
|
+
* `--verbose` is required: without it Claude Code emits
|
|
78
120
|
* only the final result even in stream mode.
|
|
79
121
|
*
|
|
80
|
-
* Note what is **absent**. No `--bare
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
122
|
+
* Note what is **absent**. No `--bare` and no `--settings` override: a cloned
|
|
123
|
+
* repository's `CLAUDE.md`, skills and hooks are exactly the material that makes
|
|
124
|
+
* the agent good at that repository, and the container is already an
|
|
125
|
+
* arbitrary-code-execution environment by design — the install runs the repo's
|
|
126
|
+
* `postinstall`, the agent runs its test suite. Stripping one door while the
|
|
127
|
+
* others stand open buys nothing and costs the agent its context.
|
|
128
|
+
* `CLAUDE_CONFIG_DIR` is not that kind of flag, and every session gets it: it
|
|
129
|
+
* moves where the *client* keeps its own state, touching nothing a repository
|
|
130
|
+
* ships — see {@link SESSION_CONFIG_DIR}.
|
|
87
131
|
*
|
|
88
132
|
* No `ANTHROPIC_BASE_URL` either: `http-gateway` egress intercepts
|
|
89
133
|
* transparently, so the client talks to the real hostname and the gateway sees
|
|
@@ -132,14 +176,59 @@ export function buildLaunch(options) {
|
|
|
132
176
|
"hand it to every process in the container, including a cloned " +
|
|
133
177
|
"repository's install scripts. Pass it as `credential` instead.");
|
|
134
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Refused for the same reason: a host that set it meant its sessions to keep
|
|
181
|
+
* their state somewhere else, and a transcript written anywhere but the one
|
|
182
|
+
* place is a conversation no later run will find.
|
|
183
|
+
*/
|
|
184
|
+
if (options.env && CONFIG_DIR_ENV_KEY in options.env) {
|
|
185
|
+
throw new Error(`claude-code: ${CONFIG_DIR_ENV_KEY} cannot be set through \`env\`. Every ` +
|
|
186
|
+
`session keeps its state in ${SESSION_CONFIG_DIR}, which is where a ` +
|
|
187
|
+
"later run looks for the conversation it continues.");
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Refused rather than dropped, both of them: dropped, a caller that asked to
|
|
191
|
+
* continue or fork a conversation gets a session that started one of its own,
|
|
192
|
+
* and reports it as the continuation of whatever it was told to continue. An
|
|
193
|
+
* empty id is the same state {@link file://./session.ts followUp} refuses,
|
|
194
|
+
* and refusing it here covers every caller of this option rather than that
|
|
195
|
+
* one path.
|
|
196
|
+
*/
|
|
197
|
+
if (options.resume === "") {
|
|
198
|
+
throw new Error("claude-code: `resume` is the session id of the conversation to " +
|
|
199
|
+
"continue, so it cannot be empty. Launch without it to start one.");
|
|
200
|
+
}
|
|
201
|
+
if (options.fork && !options.resume) {
|
|
202
|
+
throw new Error("claude-code: `fork` forks a resumed conversation, so it needs the " +
|
|
203
|
+
"`resume` session id it is forking.");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Refused beside `github`: a host value would either hand the session a real
|
|
207
|
+
* token or point git's credential at something other than the placeholder.
|
|
208
|
+
*/
|
|
209
|
+
const githubKey = options.github
|
|
210
|
+
? GITHUB_ENV_KEYS.find((key) => options.env && key in options.env)
|
|
211
|
+
: undefined;
|
|
212
|
+
if (githubKey) {
|
|
213
|
+
throw new Error(`claude-code: ${githubKey} cannot be set through \`env\` when \`githubToken\` ` +
|
|
214
|
+
"is configured. The session presents a placeholder and the egress " +
|
|
215
|
+
"gateway swaps in the GitHub token on the way out.");
|
|
216
|
+
}
|
|
135
217
|
const permissionMode = options.permissionMode ?? DEFAULT_PERMISSION_MODE;
|
|
136
218
|
const argv = ["claude", "-p", shellQuote(options.prompt)];
|
|
219
|
+
if (options.resume)
|
|
220
|
+
argv.push("--resume", shellQuote(options.resume));
|
|
221
|
+
if (options.fork)
|
|
222
|
+
argv.push("--fork-session");
|
|
137
223
|
argv.push("--output-format", "stream-json", "--verbose");
|
|
138
224
|
argv.push("--permission-mode", permissionMode);
|
|
139
225
|
if (options.model)
|
|
140
226
|
argv.push("--model", shellQuote(options.model));
|
|
141
227
|
if (options.effort)
|
|
142
228
|
argv.push("--effort", options.effort);
|
|
229
|
+
if (options.jsonSchema) {
|
|
230
|
+
argv.push("--json-schema", shellQuote(JSON.stringify(options.jsonSchema)));
|
|
231
|
+
}
|
|
143
232
|
const env = {
|
|
144
233
|
// Pinned image; an autoupdate would move the wire shape the gateway and the
|
|
145
234
|
// parser are both written against, mid-run and without a deploy.
|
|
@@ -155,9 +244,10 @@ export function buildLaunch(options) {
|
|
|
155
244
|
env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS = String(options.maxConcurrentSubagents);
|
|
156
245
|
return {
|
|
157
246
|
command: argv.join(" "),
|
|
158
|
-
//
|
|
159
|
-
// before it. For the placeholder
|
|
160
|
-
// rather than the only one; for
|
|
247
|
+
// Everything below `options.env` is applied **after** the host's environment
|
|
248
|
+
// rather than before it. For the placeholder and the config directory that
|
|
249
|
+
// makes the guards above a second line rather than the only one; for
|
|
250
|
+
// `IS_SANDBOX` it means a host cannot unset
|
|
161
251
|
// the variable its own permission mode depends on — see the note above.
|
|
162
252
|
//
|
|
163
253
|
// For the identity it is what keeps the four keys one answer. Spread before
|
|
@@ -172,6 +262,8 @@ export function buildLaunch(options) {
|
|
|
172
262
|
? { IS_SANDBOX: "1" }
|
|
173
263
|
: undefined),
|
|
174
264
|
...gitIdentityEnv(options.author),
|
|
265
|
+
...(options.github ? githubEnv() : undefined),
|
|
266
|
+
[CONFIG_DIR_ENV_KEY]: SESSION_CONFIG_DIR,
|
|
175
267
|
[RESERVED_ENV_KEY]: CREDENTIAL_PLACEHOLDER
|
|
176
268
|
}
|
|
177
269
|
};
|
|
@@ -204,8 +296,8 @@ function changedReading(previous, next) {
|
|
|
204
296
|
const after = { ...next };
|
|
205
297
|
return [...new Set([...Object.keys(before), ...Object.keys(after)])].some((key) => before[key] !== after[key]);
|
|
206
298
|
}
|
|
207
|
-
/** Distinguishes "the
|
|
208
|
-
const
|
|
299
|
+
/** Distinguishes "the caller stopped the drain" from a stream event in the race below. */
|
|
300
|
+
const STOPPED = Symbol("stopped");
|
|
209
301
|
/**
|
|
210
302
|
* How rarely a drain offers a cursor to persist.
|
|
211
303
|
*
|
|
@@ -217,7 +309,7 @@ const WINDOW_EXPIRED = Symbol("window-expired");
|
|
|
217
309
|
*/
|
|
218
310
|
const CHECKPOINT_MIN_MS = 30_000;
|
|
219
311
|
/** Whether a thrown value is the runtime refusing to reuse a live exec id. */
|
|
220
|
-
function isExecBusy(err) {
|
|
312
|
+
export function isExecBusy(err) {
|
|
221
313
|
return err?.code === "EEXEC_BUSY";
|
|
222
314
|
}
|
|
223
315
|
/**
|
|
@@ -226,26 +318,30 @@ function isExecBusy(err) {
|
|
|
226
318
|
* `EEXEC_LOST` is the runtime saying the container that held the execution was
|
|
227
319
|
* replaced. The session died with it, and no attachment will ever find it — so
|
|
228
320
|
* unlike `EEXEC_BUSY`, which is a live session reached the wrong way, there is
|
|
229
|
-
* nothing to recover and retrying only
|
|
321
|
+
* nothing to recover and retrying only discovers that again.
|
|
230
322
|
*/
|
|
231
323
|
export function isExecLost(err) {
|
|
232
324
|
return err?.code === "EEXEC_LOST";
|
|
233
325
|
}
|
|
234
326
|
/**
|
|
235
|
-
* Whether a thrown value is the *previous*
|
|
236
|
-
*
|
|
237
|
-
* An exec's event stream admits one subscriber at a time. A
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
327
|
+
* Whether a thrown value is the *previous* drain's attachment, not yet released.
|
|
328
|
+
*
|
|
329
|
+
* An exec's event stream admits one subscriber at a time. A drain resumed after
|
|
330
|
+
* an eviction, or a follow-up asked for the moment a session ends, can ask for
|
|
331
|
+
* the next one while the last is still being released — and the release on the
|
|
332
|
+
* far side is asynchronous and unobservable from here: the workspace client
|
|
333
|
+
* carries `Symbol.dispose`, not `Symbol.asyncDispose`, so there is nothing a
|
|
334
|
+
* caller can await to know the attachment is gone. {@link attachRun} answers by
|
|
243
335
|
* waiting rather than by failing.
|
|
244
336
|
*
|
|
337
|
+
* A workspace object reset by the platform leaves its dead connection's
|
|
338
|
+
* attachment held far longer: past thirty seconds, with the session mid-call
|
|
339
|
+
* and printing nothing.
|
|
340
|
+
*
|
|
245
341
|
* **Matched on the message, not on a code, and that is not laziness.** This one
|
|
246
342
|
* is raised inside the container image rather than by the runtime client: the
|
|
247
|
-
* string appears nowhere in the Worker bundle, and it arrives
|
|
248
|
-
*
|
|
343
|
+
* string appears nowhere in the Worker bundle, and it arrives here as a plain
|
|
344
|
+
* `Error` with no `code` — unlike `EEXEC_BUSY` and `EEXEC_LOST`
|
|
249
345
|
* above, which the client throws and codes. The code is checked first anyway, so
|
|
250
346
|
* the day the container starts sending one this keeps working and the string
|
|
251
347
|
* test becomes the fallback it should have been.
|
|
@@ -258,47 +354,36 @@ function isExecSubscribed(err) {
|
|
|
258
354
|
e.message.includes("already has a live subscriber"));
|
|
259
355
|
}
|
|
260
356
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* isolate that is genuinely wedged holding the stream releases it when it dies,
|
|
265
|
-
* and nothing here can tell that apart from one that is a moment from
|
|
266
|
-
* finishing. Exceeding this rethrows, so the Workflow step retries with a fresh
|
|
267
|
-
* isolate — which is the right escalation, and the one this whole mechanism
|
|
268
|
-
* exists to stop being the *first* resort.
|
|
269
|
-
*
|
|
270
|
-
* Sized well above what the race costs in practice. The boundary normally clears
|
|
271
|
-
* on the first or second look, and a deployment where it took fifteen seconds is
|
|
272
|
-
* what this is sized to outlast.
|
|
357
|
+
* {@link attachRun} giving up on a subscriber that was never released. Unlike
|
|
358
|
+
* a transient failure, nothing retries it: the run fails, and its host stops
|
|
359
|
+
* the session.
|
|
273
360
|
*/
|
|
274
|
-
|
|
361
|
+
export class AttachGivenUpError extends Error {
|
|
362
|
+
name = "AttachGivenUpError";
|
|
363
|
+
}
|
|
275
364
|
/** The first gap between attach attempts, doubled up to {@link ATTACH_CAP_MS}. */
|
|
276
365
|
const ATTACH_BACKOFF_MS = 250;
|
|
277
366
|
/**
|
|
278
|
-
* The ceiling on that doubling
|
|
279
|
-
*
|
|
280
|
-
* Without it the last gap before {@link ATTACH_MAX_MS} would be most of the
|
|
281
|
-
* budget, so a subscriber released early in it would still be waited out to the
|
|
282
|
-
* end. What this buys is that the wait ends soon after the release, rather than
|
|
283
|
-
* at the next power of two.
|
|
367
|
+
* The ceiling on that doubling, so the wait ends soon after the release rather
|
|
368
|
+
* than at the next power of two.
|
|
284
369
|
*/
|
|
285
370
|
const ATTACH_CAP_MS = 2_000;
|
|
286
371
|
/**
|
|
287
372
|
* Start a session, detached.
|
|
288
373
|
*
|
|
289
374
|
* **Falls back to attaching when the id is already live**, which is not a
|
|
290
|
-
* nicety. `start` spawns and then drains for minutes, so a
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
375
|
+
* nicety. `start` spawns and then drains for minutes, so a turn cut anywhere
|
|
376
|
+
* after the spawn is recovered with no cursor stored yet — and
|
|
377
|
+
* `@cloudflare/computer` refuses to reuse an id whose execution is still
|
|
378
|
+
* running (`EEXEC_BUSY`). Without this the recovery throws, every later one
|
|
294
379
|
* throws the same way, and a perfectly healthy session in the container becomes
|
|
295
380
|
* unreachable.
|
|
296
381
|
*
|
|
297
382
|
* **The sync stays on the default, and `defer` is not an option here.** A
|
|
298
383
|
* deferred exec skips the post-command pull entirely and settles the outcome
|
|
299
384
|
* from the cursor instead — and its `cancel` reads the stream to the end first,
|
|
300
|
-
* so
|
|
301
|
-
*
|
|
385
|
+
* so stopping a drain would block until the session exits. Both halves are
|
|
386
|
+
* exactly what this drain is built not to do.
|
|
302
387
|
*/
|
|
303
388
|
export async function startRun(runtime, options) {
|
|
304
389
|
const { command, env } = buildLaunch(options);
|
|
@@ -315,113 +400,159 @@ export async function startRun(runtime, options) {
|
|
|
315
400
|
if (!isExecBusy(err))
|
|
316
401
|
throw err;
|
|
317
402
|
console.info("[claude-code] a session is already running under this id — attaching", { execId: options.execId });
|
|
318
|
-
return await attachRun(runtime, freshCursor(options.execId)
|
|
403
|
+
return await attachRun(runtime, freshCursor(options.execId), {
|
|
404
|
+
signal: options.signal,
|
|
405
|
+
maxWaitMs: options.timeoutMs
|
|
406
|
+
});
|
|
319
407
|
}
|
|
320
408
|
}
|
|
321
409
|
/**
|
|
322
410
|
* Re-attach to a session this isolate did not start.
|
|
323
411
|
*
|
|
324
|
-
* **Waits out a subscriber the previous
|
|
412
|
+
* **Waits out a subscriber the previous drain has not released yet**, and that
|
|
325
413
|
* wait is the point of this function rather than a refinement of it. The
|
|
326
|
-
* alternative is not "fail fast" — it is spending a
|
|
327
|
-
* condition that clears on its own in a second, when
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
* followed had no attempt left to be retried on. See {@link isExecSubscribed}.
|
|
414
|
+
* alternative is not "fail fast" — it is spending a recovery to discover a
|
|
415
|
+
* condition that clears on its own in a second, when recoveries are what a
|
|
416
|
+
* deploy, a severed stub and a network drop have to be covered out of. A
|
|
417
|
+
* deployment lost a twenty-one-minute session exactly that way — consecutive
|
|
418
|
+
* attach races used the retries up, and the redeploy that followed had no
|
|
419
|
+
* attempt left to be retried on. See {@link isExecSubscribed}.
|
|
333
420
|
*
|
|
334
421
|
* Everything else is rethrown on the first look, `EEXEC_LOST` above all: it is
|
|
335
422
|
* the runtime saying the container was replaced, `resume` turns it into a report
|
|
336
|
-
* for the model, and a retry loop would sit on it
|
|
337
|
-
* learning nothing.
|
|
423
|
+
* for the model, and a retry loop would sit on it learning nothing.
|
|
338
424
|
*/
|
|
339
|
-
export async function attachRun(runtime, cursor, options
|
|
425
|
+
export async function attachRun(runtime, cursor, options) {
|
|
340
426
|
const now = options.now ?? Date.now;
|
|
341
427
|
const wait = options.wait ?? sleep;
|
|
342
|
-
const
|
|
428
|
+
const started = now();
|
|
429
|
+
const deadline = started + options.maxWaitMs;
|
|
343
430
|
let backoff = ATTACH_BACKOFF_MS;
|
|
431
|
+
let waited = false;
|
|
344
432
|
for (;;) {
|
|
433
|
+
let handle;
|
|
345
434
|
try {
|
|
346
|
-
|
|
435
|
+
handle = await runtime.getExec(cursor.execId, {
|
|
347
436
|
encoding: "utf8",
|
|
348
437
|
// `0` is a legal seq and also the beginning, so a fresh cursor resumes
|
|
349
|
-
// from the start either way.
|
|
438
|
+
// from the start either way. A stored cursor names its own place.
|
|
350
439
|
resume: cursor.seq
|
|
351
440
|
});
|
|
352
441
|
}
|
|
353
442
|
catch (err) {
|
|
354
|
-
|
|
355
|
-
// fails on a subscriber that is never coming back — just not first.
|
|
356
|
-
if (!isExecSubscribed(err) || now() >= deadline)
|
|
443
|
+
if (!isExecSubscribed(err) || options.signal?.aborted)
|
|
357
444
|
throw err;
|
|
445
|
+
if (now() >= deadline) {
|
|
446
|
+
const waitedMs = now() - started;
|
|
447
|
+
console.warn("[claude-code] gave up waiting out the previous drain", {
|
|
448
|
+
execId: cursor.execId,
|
|
449
|
+
waitedMs
|
|
450
|
+
});
|
|
451
|
+
throw new AttachGivenUpError("claude-code: the session's stream to its container was cut, and the " +
|
|
452
|
+
"container held on to the old connection for the whole of the " +
|
|
453
|
+
"session's time limit, so it could not be re-attached. The cut is " +
|
|
454
|
+
"the platform's, not the session's; what the session did so far is " +
|
|
455
|
+
"in its workspace and on the transcript.", { cause: err });
|
|
456
|
+
}
|
|
457
|
+
// Logged once each way rather than per look: how long a release takes
|
|
458
|
+
// is the number worth reading, and over a session-long bound a line per
|
|
459
|
+
// look would bury it.
|
|
460
|
+
if (!waited) {
|
|
461
|
+
waited = true;
|
|
462
|
+
console.info("[claude-code] the previous drain is still attached — waiting", { execId: cursor.execId, maxWaitMs: options.maxWaitMs });
|
|
463
|
+
}
|
|
358
464
|
// Clamped to what is left, so the last gap lands *on* the deadline rather
|
|
359
|
-
// than past it
|
|
360
|
-
|
|
361
|
-
// would be a number in a comment rather than one the code keeps.
|
|
362
|
-
const waitMs = Math.min(backoff, deadline - now());
|
|
363
|
-
// One line per wait, not per attempt: this is the condition whose
|
|
364
|
-
// frequency is worth watching, and it went unnamed in the logs for as
|
|
365
|
-
// long as the Workflow was absorbing it a retry at a time.
|
|
366
|
-
console.info("[claude-code] the previous window is still attached — waiting", { execId: cursor.execId, waitMs });
|
|
367
|
-
await wait(waitMs);
|
|
465
|
+
// than past it, and the bound is one the code keeps.
|
|
466
|
+
await wait(Math.min(backoff, deadline - now()));
|
|
368
467
|
backoff = Math.min(backoff * 2, ATTACH_CAP_MS);
|
|
468
|
+
continue;
|
|
369
469
|
}
|
|
470
|
+
if (waited) {
|
|
471
|
+
console.info("[claude-code] attached after waiting out the previous drain", {
|
|
472
|
+
execId: cursor.execId,
|
|
473
|
+
waitedMs: now() - started
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return handle;
|
|
370
477
|
}
|
|
371
478
|
}
|
|
372
479
|
/**
|
|
373
|
-
* Drain a session
|
|
480
|
+
* Drain a session to its end.
|
|
374
481
|
*
|
|
375
|
-
* Returns `done:
|
|
376
|
-
*
|
|
377
|
-
* stream has finished.
|
|
482
|
+
* Returns `done: true` once the stream has finished, or `done: false` when the
|
|
483
|
+
* caller's signal stopped the drain with the process still running.
|
|
378
484
|
*/
|
|
379
|
-
export async function drainRun(handle, cursor, options) {
|
|
485
|
+
export async function drainRun(handle, cursor, options = {}) {
|
|
380
486
|
const now = options.now ?? Date.now;
|
|
381
|
-
const deadline = now() + options.windowMs;
|
|
382
487
|
const reader = handle.getReader();
|
|
383
488
|
/**
|
|
384
|
-
* The
|
|
489
|
+
* The drain's notes, in order — built as the stream is parsed rather than
|
|
385
490
|
* from a list of events at the end, so there is one place a note is numbered
|
|
386
491
|
* and the sink below cannot disagree with what is returned.
|
|
387
492
|
*/
|
|
388
493
|
const progress = [];
|
|
389
494
|
let emitted = cursor.emitted;
|
|
390
495
|
let buffer = cursor.carry;
|
|
496
|
+
let thinkingFrom = cursor.thinkingFrom;
|
|
391
497
|
let seq = cursor.seq;
|
|
392
498
|
let result = cursor.result;
|
|
393
|
-
// This
|
|
394
|
-
// means the first reading of each
|
|
499
|
+
// This drain's reading only — see `DrainOutcome`. Starting undefined also
|
|
500
|
+
// means the first reading of each drain reaches the log, which is what keeps
|
|
395
501
|
// a resumed session's bucket visible without carrying a stale one forward.
|
|
396
502
|
let rateLimit;
|
|
397
503
|
let stderr = cursor.stderr ?? "";
|
|
504
|
+
let sessionId = cursor.sessionId;
|
|
505
|
+
/**
|
|
506
|
+
* Set when this drain is the one that learned the session id, and cleared by
|
|
507
|
+
* the checkpoint it lets through. See {@link DrainCursor.sessionId}.
|
|
508
|
+
*/
|
|
509
|
+
let newHandle = false;
|
|
398
510
|
let exitCode;
|
|
399
511
|
let checkpointedAt = now();
|
|
400
512
|
/**
|
|
401
513
|
* The sink's calls, chained rather than awaited.
|
|
402
514
|
*
|
|
403
|
-
* Ordering matters — these are sentences in a conversation — and
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
515
|
+
* Ordering matters — these are sentences in a conversation — and awaiting
|
|
516
|
+
* one inside the read loop would stall the drain for as long as the session
|
|
517
|
+
* keeps talking. A rejected note does not stop the drain — a note is
|
|
518
|
+
* best-effort — but it is logged, and it ends the checkpoints: see
|
|
519
|
+
* `DrainOptions.onCheckpoint`. Settled before the drain returns or throws, so
|
|
520
|
+
* nothing is cut short when it unwinds, and nothing lands behind a drain that
|
|
521
|
+
* re-attaches after it.
|
|
408
522
|
*/
|
|
409
523
|
let sunk = Promise.resolve();
|
|
524
|
+
/** Set once a note is rejected — see `DrainOptions.onCheckpoint`. */
|
|
525
|
+
let rejected = false;
|
|
526
|
+
/**
|
|
527
|
+
* The last position every note behind which reached the sink: where this
|
|
528
|
+
* drain began, then each checkpoint the chain reaches unrejected.
|
|
529
|
+
*/
|
|
530
|
+
let safe = cursor;
|
|
410
531
|
/**
|
|
411
532
|
* Parse whatever complete lines the buffer now holds.
|
|
412
533
|
*
|
|
413
534
|
* Called after **every** stdout event rather than once at the end. Claude
|
|
414
|
-
* Code's stream carries whole tool results, so a
|
|
415
|
-
* way out would hold a whole
|
|
416
|
-
* Object's memory; parsing eagerly keeps only `carry`, which is at
|
|
417
|
-
* incomplete line.
|
|
535
|
+
* Code's stream carries whole tool results, so a drain that only parsed on
|
|
536
|
+
* the way out would hold a whole session's transcript of a noisy build in a
|
|
537
|
+
* Durable Object's memory; parsing eagerly keeps only `carry`, which is at
|
|
538
|
+
* most one incomplete line.
|
|
418
539
|
*/
|
|
419
540
|
const absorb = () => {
|
|
420
|
-
const parsed = parseStream(buffer
|
|
541
|
+
const parsed = parseStream(buffer, {
|
|
542
|
+
now: now(),
|
|
543
|
+
...(thinkingFrom === undefined ? {} : { thinkingFrom })
|
|
544
|
+
});
|
|
421
545
|
buffer = parsed.carry;
|
|
546
|
+
thinkingFrom = parsed.thinkingFrom;
|
|
422
547
|
for (const event of parsed.events) {
|
|
423
548
|
if (event.kind === "result")
|
|
424
549
|
result = event.result;
|
|
550
|
+
// The first one only, and it is the id of whatever this drain is actually
|
|
551
|
+
// draining: a resumed conversation's own, or the new one a fork starts.
|
|
552
|
+
if (event.kind === "init" && !sessionId) {
|
|
553
|
+
sessionId = event.sessionId;
|
|
554
|
+
newHandle = true;
|
|
555
|
+
}
|
|
425
556
|
if (event.kind === "rateLimit") {
|
|
426
557
|
/**
|
|
427
558
|
* Logged when the reading **changes**, not per line — and a change is
|
|
@@ -452,15 +583,24 @@ export async function drainRun(handle, cursor, options) {
|
|
|
452
583
|
}
|
|
453
584
|
}
|
|
454
585
|
// Numbered from the running total, so a note has the same key whether it is
|
|
455
|
-
//
|
|
456
|
-
// a
|
|
457
|
-
// harmless. See `toProgress`.
|
|
586
|
+
// filed from here or returned on the outcome — and the same key again when
|
|
587
|
+
// a resumed drain replays this part of the stream, which is what makes the
|
|
588
|
+
// duplicate harmless. See `toProgress`.
|
|
458
589
|
for (const note of toProgress(parsed.events, emitted)) {
|
|
459
590
|
progress.push(note);
|
|
460
591
|
emitted++;
|
|
461
592
|
const sink = options.onProgress;
|
|
462
593
|
if (sink)
|
|
463
|
-
sunk = sunk
|
|
594
|
+
sunk = sunk
|
|
595
|
+
.then(() => sink(note))
|
|
596
|
+
.catch((err) => {
|
|
597
|
+
rejected = true;
|
|
598
|
+
console.warn("[claude-code] a note did not reach the parent", {
|
|
599
|
+
execId: cursor.execId,
|
|
600
|
+
key: note.key,
|
|
601
|
+
err: String(err)
|
|
602
|
+
});
|
|
603
|
+
});
|
|
464
604
|
}
|
|
465
605
|
if (parsed.skipped > 0) {
|
|
466
606
|
// Not fatal, and deliberately not silent: a systematic schema change
|
|
@@ -485,45 +625,65 @@ export async function drainRun(handle, cursor, options) {
|
|
|
485
625
|
* One object, so `seq`, `carry` and `emitted` are written together — a cursor
|
|
486
626
|
* that advanced its position without its note count would renumber every key
|
|
487
627
|
* after it, and a replay would then post the whole tail again under keys the
|
|
488
|
-
*
|
|
628
|
+
* transcript has never seen.
|
|
489
629
|
*/
|
|
490
630
|
const checkpoint = () => ({
|
|
491
631
|
execId: cursor.execId,
|
|
492
632
|
seq,
|
|
493
633
|
carry: buffer,
|
|
494
634
|
emitted,
|
|
635
|
+
...(thinkingFrom === undefined ? {} : { thinkingFrom }),
|
|
636
|
+
...(sessionId ? { sessionId } : {}),
|
|
495
637
|
...(result ? { result } : {}),
|
|
496
638
|
...(stderr ? { stderr } : {})
|
|
497
639
|
});
|
|
498
640
|
/**
|
|
499
|
-
* Offer the caller a cursor to
|
|
500
|
-
* {@link CHECKPOINT_MIN_MS}.
|
|
641
|
+
* Offer the caller a cursor to store, at most every
|
|
642
|
+
* {@link CHECKPOINT_MIN_MS} — and once more the moment the session id appears.
|
|
501
643
|
*
|
|
502
644
|
* Fire-and-forget onto the same chain the notes ride, so a storage write
|
|
503
645
|
* cannot stall the read loop and cannot land before the notes it claims are
|
|
504
646
|
* already posted.
|
|
647
|
+
*
|
|
648
|
+
* **The id does not wait for the interval.** It is the handle to everything
|
|
649
|
+
* that follows — the one thing that lets a later run continue this
|
|
650
|
+
* conversation — and a session killed inside the first interval would
|
|
651
|
+
* otherwise leave none. One extra write per session buys that.
|
|
505
652
|
*/
|
|
506
653
|
const offerCheckpoint = () => {
|
|
507
654
|
const sink = options.onCheckpoint;
|
|
508
655
|
// **Refused without `onProgress`, not merely discouraged.** A checkpoint is
|
|
509
|
-
// only safe because the notes behind it have been
|
|
510
|
-
// caller that
|
|
511
|
-
// delivered, and a
|
|
656
|
+
// only safe because the notes behind it have been filed; offered to a
|
|
657
|
+
// caller that files nothing, it advances a cursor past notes that were never
|
|
658
|
+
// delivered, and a drain dying after it loses them permanently. The
|
|
512
659
|
// documented unsafe combination is one nothing can reach.
|
|
513
|
-
if (!sink || !options.onProgress)
|
|
660
|
+
if (!sink || !options.onProgress || rejected)
|
|
514
661
|
return;
|
|
515
|
-
if (
|
|
662
|
+
if (newHandle)
|
|
663
|
+
newHandle = false;
|
|
664
|
+
else if (now() - checkpointedAt < CHECKPOINT_MIN_MS)
|
|
516
665
|
return;
|
|
517
666
|
checkpointedAt = now();
|
|
518
667
|
const at = checkpoint();
|
|
519
|
-
|
|
668
|
+
// Checked again on the chain: a note queued ahead of this one may yet be
|
|
669
|
+
// rejected, and this cursor names it.
|
|
670
|
+
sunk = sunk
|
|
671
|
+
.then(() => {
|
|
672
|
+
if (rejected)
|
|
673
|
+
return;
|
|
674
|
+
safe = at;
|
|
675
|
+
return sink(at);
|
|
676
|
+
})
|
|
677
|
+
.catch(() => { });
|
|
520
678
|
};
|
|
521
679
|
const finish = async (code) => {
|
|
522
|
-
const next = checkpoint();
|
|
523
680
|
// Everything the sink was given has been delivered before the outcome
|
|
524
|
-
// naming it is returned, so a caller cannot
|
|
525
|
-
// notes nobody
|
|
681
|
+
// naming it is returned, so a caller cannot store a cursor that claims
|
|
682
|
+
// notes nobody filed. After a rejected note it is the last cursor behind
|
|
683
|
+
// it: the caller stores it, and a drain resumed from there files the note
|
|
684
|
+
// again, while the notes after it replay under keys the transcript dedupes.
|
|
526
685
|
await sunk;
|
|
686
|
+
const next = rejected ? safe : checkpoint();
|
|
527
687
|
return code === undefined
|
|
528
688
|
? {
|
|
529
689
|
done: false,
|
|
@@ -542,7 +702,9 @@ export async function drainRun(handle, cursor, options) {
|
|
|
542
702
|
// said everything worth saying, and its stderr is the CLI's own
|
|
543
703
|
// diagnostic chatter — surfacing that beside a perfectly good report
|
|
544
704
|
// would bury the report.
|
|
545
|
-
...(!result &&
|
|
705
|
+
...(!result && (SIGNALS[code] || stderr)
|
|
706
|
+
? { stderr: explainExit(code, stderr) }
|
|
707
|
+
: {})
|
|
546
708
|
};
|
|
547
709
|
};
|
|
548
710
|
try {
|
|
@@ -566,14 +728,11 @@ export async function drainRun(handle, cursor, options) {
|
|
|
566
728
|
* outstanding pull; see this plugin's README.
|
|
567
729
|
*
|
|
568
730
|
* It is also unbounded, deliberately: the pull carries whatever the
|
|
569
|
-
* session wrote, an install's dependency tree included
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
* committed. A pull that *fails* is the case above, and nothing retries
|
|
575
|
-
* that. See {@link file://./config.ts ClaudeCodeConfig.windowMs} for how
|
|
576
|
-
* the window is sized around an unbounded tail.
|
|
731
|
+
* session wrote, an install's dependency tree included. Cutting it short
|
|
732
|
+
* would trade a late report for edits that never land, and that trade is
|
|
733
|
+
* not close: a drain cut with the pull still in flight is resumed, and
|
|
734
|
+
* the sync resumes from the blocks it has already committed. A pull that
|
|
735
|
+
* *fails* is the case above, and nothing retries that.
|
|
577
736
|
*/
|
|
578
737
|
if (exitCode !== undefined) {
|
|
579
738
|
const next = await reader.read();
|
|
@@ -582,22 +741,30 @@ export async function drainRun(handle, cursor, options) {
|
|
|
582
741
|
consume(next.value);
|
|
583
742
|
continue;
|
|
584
743
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
744
|
+
if (options.signal?.aborted)
|
|
745
|
+
return await stop();
|
|
746
|
+
const stopping = stopped(options.signal);
|
|
747
|
+
const next = await Promise.race([
|
|
748
|
+
reader.read(),
|
|
749
|
+
stopping.promise
|
|
750
|
+
]).finally(stopping.clear);
|
|
751
|
+
if (next === STOPPED)
|
|
752
|
+
return await stop();
|
|
592
753
|
if (next.done) {
|
|
593
754
|
// The stream ended without an `exit` event — the container went away
|
|
594
755
|
// under the run. Report it as a failure rather than as still-running,
|
|
595
|
-
// or the caller
|
|
756
|
+
// or the caller would wait on a dead process.
|
|
757
|
+
stderr = containerLost(stderr);
|
|
596
758
|
return await finish(-1);
|
|
597
759
|
}
|
|
598
760
|
consume(next.value);
|
|
599
761
|
}
|
|
600
762
|
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
// See `sunk`. It never rejects: each link catches its own.
|
|
765
|
+
await sunk;
|
|
766
|
+
throw err;
|
|
767
|
+
}
|
|
601
768
|
finally {
|
|
602
769
|
reader.releaseLock();
|
|
603
770
|
}
|
|
@@ -621,46 +788,92 @@ export async function drainRun(handle, cursor, options) {
|
|
|
621
788
|
exitCode = event.code ?? -1;
|
|
622
789
|
}
|
|
623
790
|
/**
|
|
624
|
-
* End the
|
|
791
|
+
* End the drain with the session still running.
|
|
625
792
|
*
|
|
626
793
|
* **Cancels rather than merely releasing the lock.** Releasing leaves the
|
|
627
|
-
* attachment and its pending read alive on the far side, and
|
|
628
|
-
* would
|
|
629
|
-
* it settles the pending read, resolves the sync outcome as `pending`, and
|
|
630
|
-
* lets the next
|
|
631
|
-
*
|
|
794
|
+
* attachment and its pending read alive on the far side, and the next drain
|
|
795
|
+
* would find it still subscribed. `cancel` is the wrapper's own designed exit
|
|
796
|
+
* — it settles the pending read, resolves the sync outcome as `pending`, and
|
|
797
|
+
* lets the next `getExec` open a fresh attachment that will run the real sync
|
|
798
|
+
* when the session finally ends.
|
|
632
799
|
*
|
|
633
|
-
* **Cancels before settling the sinks, not after
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
* order because `finish` reads only what `consume` already put in local state,
|
|
638
|
-
* and takes nothing off the stream. It does not close the race that
|
|
639
|
-
* {@link attachRun} waits out — the release is still asynchronous on the far
|
|
640
|
-
* side — it just stops this end adding to it.
|
|
800
|
+
* **Cancels before settling the sinks, not after**, so the attachment is not
|
|
801
|
+
* held open across notes still being filed. Safe in this order because
|
|
802
|
+
* `finish` reads only what `consume` already put in local state, and takes
|
|
803
|
+
* nothing off the stream.
|
|
641
804
|
*/
|
|
642
|
-
async function
|
|
805
|
+
async function stop() {
|
|
643
806
|
absorb();
|
|
644
|
-
await reader.cancel("
|
|
807
|
+
await reader.cancel("drain stopped").catch(() => { });
|
|
645
808
|
return await finish();
|
|
646
809
|
}
|
|
647
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* The signal behind each exit code `computerd` maps one to — the ones a process
|
|
813
|
+
* is stopped from outside with.
|
|
814
|
+
*/
|
|
815
|
+
const SIGNALS = { 143: "SIGTERM", 137: "SIGKILL" };
|
|
816
|
+
/** A resultless session's `stderr`, explained when a signal ended it. */
|
|
817
|
+
function explainExit(code, stderr) {
|
|
818
|
+
const signal = SIGNALS[code];
|
|
819
|
+
if (!signal)
|
|
820
|
+
return stderr;
|
|
821
|
+
return endedFromOutside(`the process was stopped by ${signal} before it reported a result — its ` +
|
|
822
|
+
"container stopping, its time limit or a cancel does this.", stderr);
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* What a session reports on `stderr` when its container went away under it:
|
|
826
|
+
* the stream ended with no `exit` event, or a re-attach found the exec gone.
|
|
827
|
+
*/
|
|
828
|
+
export function containerLost(printed = "") {
|
|
829
|
+
return endedFromOutside("the container holding this session was stopped or replaced before the " +
|
|
830
|
+
"session finished — a deploy or a container rollout does this — so the " +
|
|
831
|
+
"process was ended from outside and reported no result.", printed);
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The report for a process that did not end on its own: why, what that leaves,
|
|
835
|
+
* then what it had printed.
|
|
836
|
+
*
|
|
837
|
+
* **The cause comes first.** What the process printed is not why it ended, and
|
|
838
|
+
* without this it is all a report shows — so whatever came last, a startup
|
|
839
|
+
* warning included, reads as the cause.
|
|
840
|
+
*
|
|
841
|
+
* **What it must not say is that starting over is safe.** A session is an
|
|
842
|
+
* agent: by the time it was stopped, it may have written files that reached the
|
|
843
|
+
* workspace, committed, pushed, or called something outside altogether. None of
|
|
844
|
+
* that is visible from here — only that the process is gone — so the honest
|
|
845
|
+
* report is what is known plus where to look, and the decision belongs to
|
|
846
|
+
* whoever can read the durable state.
|
|
847
|
+
*/
|
|
848
|
+
function endedFromOutside(cause, printed) {
|
|
849
|
+
const said = printed.trim();
|
|
850
|
+
return (`${cause} What it had already written to the workspace is still there, ` +
|
|
851
|
+
"and anything it did outside the workspace — a commit, a push, a request — " +
|
|
852
|
+
"has already happened. Check the workspace and the branch before starting " +
|
|
853
|
+
"this work again, since a rerun repeats from the beginning." +
|
|
854
|
+
(said
|
|
855
|
+
? `\n\nWhat it had printed by then, which is not why it ended:\n${said}`
|
|
856
|
+
: ""));
|
|
857
|
+
}
|
|
648
858
|
function sleep(ms) {
|
|
649
859
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
650
860
|
}
|
|
651
861
|
/**
|
|
652
|
-
*
|
|
862
|
+
* The caller's signal, as a race a read can win.
|
|
653
863
|
*
|
|
654
|
-
* Cleared by the caller whichever side wins: one is
|
|
655
|
-
*
|
|
656
|
-
* a session that finishes in seconds would still cost the whole window.
|
|
864
|
+
* Cleared by the caller whichever side wins: one listener is added per read,
|
|
865
|
+
* and a session is many reads.
|
|
657
866
|
*/
|
|
658
|
-
function
|
|
659
|
-
let
|
|
660
|
-
const
|
|
661
|
-
|
|
867
|
+
function stopped(signal) {
|
|
868
|
+
let onAbort = () => { };
|
|
869
|
+
const promise = new Promise((resolve) => {
|
|
870
|
+
onAbort = () => resolve(STOPPED);
|
|
871
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
662
872
|
});
|
|
663
|
-
return {
|
|
873
|
+
return {
|
|
874
|
+
promise,
|
|
875
|
+
clear: () => signal?.removeEventListener("abort", onAbort)
|
|
876
|
+
};
|
|
664
877
|
}
|
|
665
878
|
/** How much stderr is worth carrying, in characters. */
|
|
666
879
|
const STDERR_MAX = 2_000;
|