@dynamicagents/plugins 0.13.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.
@@ -50,4 +50,6 @@ export declare function thinkingCard(durationMs: number | undefined, messageId:
50
50
  export declare function initCard(event: InitEvent): Card;
51
51
  /** A session's end, with what its `result` line says about it. */
52
52
  export declare function resultCard(result: ClaudeCodeResult): Card;
53
+ /** A session its run gave up on before reading its end, and why. */
54
+ export declare function lostCard(why: string): Card;
53
55
  export {};
@@ -261,6 +261,17 @@ export function resultCard(result) {
261
261
  }
262
262
  };
263
263
  }
264
+ /** A session its run gave up on before reading its end, and why. */
265
+ export function lostCard(why) {
266
+ return {
267
+ text: "Session ended before its run read it to the end",
268
+ detail: {
269
+ title: "Session",
270
+ status: "error",
271
+ sections: [section("Why", why, "text")]
272
+ }
273
+ };
274
+ }
264
275
  function duration(ms) {
265
276
  const s = Math.round(ms / 1000);
266
277
  if (s < 60)
@@ -51,8 +51,9 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
53
  import { isPlatformTransientError } from "agents";
54
+ import { lostCard } from "./cards.js";
54
55
  import { claudeCodeSession, requireCredentials } from "./session.js";
55
- import { execIdFor, followUpExecIdFor } from "./run.js";
56
+ import { AttachGivenUpError, execIdFor, followUpExecIdFor } from "./run.js";
56
57
  /** Zeroed: the session's tokens are the CLI's context, not this conversation's. */
57
58
  const USAGE = {
58
59
  inputTokens: {
@@ -138,6 +139,25 @@ export function claudeCodeModel(options) {
138
139
  },
139
140
  ...(signal ? { signal } : {})
140
141
  };
142
+ /**
143
+ * The session's end card, filed where the run gives up on a session it will
144
+ * never read to its end: a re-attach given up on, or a cancel that stops it.
145
+ * Without it the transcript shows a session that never ended, and a retry's
146
+ * start reads as more of it.
147
+ *
148
+ * Not on any other throw: a transient one is retried by re-entering this
149
+ * turn, which reads on from the stored cursor. Keyed on the exec, so a card
150
+ * filed twice is filed once.
151
+ */
152
+ const fileLost = async (execId, reason) => {
153
+ const card = lostCard(reason instanceof Error ? reason.message : String(reason));
154
+ await options
155
+ .note(`${execId}:lost`, card.text, card.detail)
156
+ .catch((err) => console.warn("[claude-code] a lost session's end was not filed", {
157
+ execId,
158
+ err: String(err)
159
+ }));
160
+ };
141
161
  /**
142
162
  * Drain one exec to its end, starting it or re-attaching from the cursor.
143
163
  *
@@ -164,6 +184,8 @@ export function claudeCodeModel(options) {
164
184
  const after = await storage.get(KEYS.cursor);
165
185
  const moved = after?.execId === execId &&
166
186
  (cursor?.execId !== execId || after.seq > cursor.seq);
187
+ if (err instanceof AttachGivenUpError)
188
+ await fileLost(execId, err);
167
189
  if (!isStreamCut(err) || signal?.aborted || (stalled && !moved))
168
190
  throw err;
169
191
  stalled = !moved;
@@ -184,7 +206,9 @@ export function claudeCodeModel(options) {
184
206
  runId,
185
207
  err: String(err)
186
208
  }));
187
- throw signal?.reason ?? new Error("the session was stopped");
209
+ const reason = signal?.reason ?? new Error("the session was stopped");
210
+ await fileLost(execId, reason);
211
+ throw reason;
188
212
  }
189
213
  return {
190
214
  exitCode: outcome.exitCode,
@@ -403,6 +403,14 @@ export declare function isExecBusy(err: unknown): boolean;
403
403
  * nothing to recover and retrying only discovers that again.
404
404
  */
405
405
  export declare function isExecLost(err: unknown): boolean;
406
+ /**
407
+ * {@link attachRun} giving up on a subscriber that was never released. Unlike
408
+ * a transient failure, nothing retries it: the run fails, and its host stops
409
+ * the session.
410
+ */
411
+ export declare class AttachGivenUpError extends Error {
412
+ name: string;
413
+ }
406
414
  /**
407
415
  * Start a session, detached.
408
416
  *
@@ -426,8 +434,18 @@ export declare function startRun(runtime: SessionRuntime, options: LaunchOptions
426
434
  /** Stops the fallback's wait — see {@link AttachOptions.signal}. */
427
435
  signal?: AbortSignal;
428
436
  }): Promise<WorkspaceRuntimeExecHandle<"utf8">>;
429
- /** The clock and the wait, injectable so a spec can drive the bound. */
430
437
  export interface AttachOptions {
438
+ /**
439
+ * How long to wait out a subscriber that has not been released: the
440
+ * session's own `timeoutMs`.
441
+ *
442
+ * Giving up fails the run, and a failed run is stopped by its host — so a
443
+ * shorter bound kills a healthy session, and past this one the session is
444
+ * over anyway. How long a release actually took is logged when the attach
445
+ * lands.
446
+ */
447
+ maxWaitMs: number;
448
+ /** The clock and the wait, injectable so a spec can drive the bound. */
431
449
  now?: () => number;
432
450
  wait?: (ms: number) => Promise<void>;
433
451
  /** Stops the wait — see `DrainOptions.signal`. */
@@ -447,10 +465,9 @@ export interface AttachOptions {
447
465
  *
448
466
  * Everything else is rethrown on the first look, `EEXEC_LOST` above all: it is
449
467
  * the runtime saying the container was replaced, `resume` turns it into a report
450
- * for the model, and a retry loop would sit on it for {@link ATTACH_MAX_MS}
451
- * learning nothing.
468
+ * for the model, and a retry loop would sit on it learning nothing.
452
469
  */
453
- export declare function attachRun(runtime: SessionRuntime, cursor: DrainCursor, options?: AttachOptions): Promise<WorkspaceRuntimeExecHandle<"utf8">>;
470
+ export declare function attachRun(runtime: SessionRuntime, cursor: DrainCursor, options: AttachOptions): Promise<WorkspaceRuntimeExecHandle<"utf8">>;
454
471
  /**
455
472
  * Drain a session to its end.
456
473
  *
@@ -334,6 +334,10 @@ export function isExecLost(err) {
334
334
  * caller can await to know the attachment is gone. {@link attachRun} answers by
335
335
  * waiting rather than by failing.
336
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
+ *
337
341
  * **Matched on the message, not on a code, and that is not laziness.** This one
338
342
  * is raised inside the container image rather than by the runtime client: the
339
343
  * string appears nowhere in the Worker bundle, and it arrives here as a plain
@@ -350,29 +354,18 @@ function isExecSubscribed(err) {
350
354
  e.message.includes("already has a live subscriber"));
351
355
  }
352
356
  /**
353
- * How long {@link attachRun} waits out a subscriber that has not been released.
354
- *
355
- * Bounded, because what it is waiting on may not be a teardown at all: an
356
- * isolate that is genuinely wedged holding the stream releases it when it dies,
357
- * and nothing here can tell that apart from one that is a moment from
358
- * finishing. Exceeding this rethrows, so the turn fails and Think's recovery
359
- * retries it on a fresh isolate — which is the right escalation, and the one
360
- * this whole mechanism exists to stop being the *first* resort.
361
- *
362
- * Sized well above what the race costs in practice. The race normally clears
363
- * on the first or second look, and a deployment where it took fifteen seconds is
364
- * 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.
365
360
  */
366
- const ATTACH_MAX_MS = 30_000;
361
+ export class AttachGivenUpError extends Error {
362
+ name = "AttachGivenUpError";
363
+ }
367
364
  /** The first gap between attach attempts, doubled up to {@link ATTACH_CAP_MS}. */
368
365
  const ATTACH_BACKOFF_MS = 250;
369
366
  /**
370
- * The ceiling on that doubling.
371
- *
372
- * Without it the last gap before {@link ATTACH_MAX_MS} would be most of the
373
- * budget, so a subscriber released early in it would still be waited out to the
374
- * end. What this buys is that the wait ends soon after the release, rather than
375
- * 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.
376
369
  */
377
370
  const ATTACH_CAP_MS = 2_000;
378
371
  /**
@@ -408,7 +401,8 @@ export async function startRun(runtime, options) {
408
401
  throw err;
409
402
  console.info("[claude-code] a session is already running under this id — attaching", { execId: options.execId });
410
403
  return await attachRun(runtime, freshCursor(options.execId), {
411
- signal: options.signal
404
+ signal: options.signal,
405
+ maxWaitMs: options.timeoutMs
412
406
  });
413
407
  }
414
408
  }
@@ -426,17 +420,19 @@ export async function startRun(runtime, options) {
426
420
  *
427
421
  * Everything else is rethrown on the first look, `EEXEC_LOST` above all: it is
428
422
  * the runtime saying the container was replaced, `resume` turns it into a report
429
- * for the model, and a retry loop would sit on it for {@link ATTACH_MAX_MS}
430
- * learning nothing.
423
+ * for the model, and a retry loop would sit on it learning nothing.
431
424
  */
432
- export async function attachRun(runtime, cursor, options = {}) {
425
+ export async function attachRun(runtime, cursor, options) {
433
426
  const now = options.now ?? Date.now;
434
427
  const wait = options.wait ?? sleep;
435
- const deadline = now() + ATTACH_MAX_MS;
428
+ const started = now();
429
+ const deadline = started + options.maxWaitMs;
436
430
  let backoff = ATTACH_BACKOFF_MS;
431
+ let waited = false;
437
432
  for (;;) {
433
+ let handle;
438
434
  try {
439
- return await runtime.getExec(cursor.execId, {
435
+ handle = await runtime.getExec(cursor.execId, {
440
436
  encoding: "utf8",
441
437
  // `0` is a legal seq and also the beginning, so a fresh cursor resumes
442
438
  // from the start either way. A stored cursor names its own place.
@@ -444,24 +440,40 @@ export async function attachRun(runtime, cursor, options = {}) {
444
440
  });
445
441
  }
446
442
  catch (err) {
447
- // Rethrown rather than retried once the budget is gone, so the step still
448
- // fails on a subscriber that is never coming back — just not first.
449
- if (!isExecSubscribed(err) ||
450
- now() >= deadline ||
451
- options.signal?.aborted)
443
+ if (!isExecSubscribed(err) || options.signal?.aborted)
452
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
+ }
453
464
  // Clamped to what is left, so the last gap lands *on* the deadline rather
454
- // than past it. Uncapped, a schedule whose final doubling straddles the
455
- // bound would take one more look on the far side of it — and the bound
456
- // would be a number in a comment rather than one the code keeps.
457
- const waitMs = Math.min(backoff, deadline - now());
458
- // One line per wait, not per attempt: this is the condition whose
459
- // frequency is worth watching, and it went unnamed in the logs for as
460
- // long as retries were absorbing it one at a time.
461
- console.info("[claude-code] the previous drain is still attached — waiting", { execId: cursor.execId, waitMs });
462
- await wait(waitMs);
465
+ // than past it, and the bound is one the code keeps.
466
+ await wait(Math.min(backoff, deadline - now()));
463
467
  backoff = Math.min(backoff * 2, ATTACH_CAP_MS);
468
+ continue;
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
+ });
464
475
  }
476
+ return handle;
465
477
  }
466
478
  }
467
479
  /**
@@ -186,7 +186,10 @@ export function claudeCodeSession(config) {
186
186
  try {
187
187
  let handle;
188
188
  try {
189
- handle = await attachRun(runtime, cursor, { signal: sinks.signal });
189
+ handle = await attachRun(runtime, cursor, {
190
+ signal: sinks.signal,
191
+ maxWaitMs: timeoutMs
192
+ });
190
193
  }
191
194
  catch (err) {
192
195
  if (!isExecLost(err))
@@ -12,4 +12,4 @@
12
12
  * and the capture move together. AGENTS.md's "Updating Claude Code" is the
13
13
  * procedure.
14
14
  */
15
- export declare const VERIFIED_CLAUDE_CODE_VERSION = "2.1.289";
15
+ export declare const VERIFIED_CLAUDE_CODE_VERSION = "2.1.296";
@@ -12,4 +12,4 @@
12
12
  * and the capture move together. AGENTS.md's "Updating Claude Code" is the
13
13
  * procedure.
14
14
  */
15
- export const VERIFIED_CLAUDE_CODE_VERSION = "2.1.289";
15
+ export const VERIFIED_CLAUDE_CODE_VERSION = "2.1.296";
@@ -1,6 +1,10 @@
1
1
  import { type Workspace } from "@cloudflare/computer";
2
2
  /** Written into the tree by a successful host install; see `./install-job.ts`. */
3
3
  export declare const INSTALLED_MARKER = ".da-installed";
4
+ /** Every package root with a tree on this container's disk, one per line. */
5
+ export declare const DEPS_ROOTS_COMMAND = "for f in /var/lib/workspace-deps/*.root; do [ -f \"$f\" ] && { cat \"$f\"; echo; }; done; true";
6
+ /** {@link DEPS_ROOTS_COMMAND}'s output as paths, each once. */
7
+ export declare function parseDepsRoots(stdout: string): string[];
4
8
  /**
5
9
  * The per-container setup, and a read of the marker under `dir`.
6
10
  *
@@ -65,6 +65,11 @@ import { deploymentFault } from "./container-fault.js";
65
65
  * this installs ahead of the real binaries on `PATH`. Mounting earlier cannot
66
66
  * work: a bootstrap that initialises submodules and installs each of them in one
67
67
  * command creates the directories the mounts belong in.
68
+ *
69
+ * A container restored from a snapshot (see `./deps-snapshot.ts`) has the trees
70
+ * on its disk and none of the mounts, which are kernel state. So each mount
71
+ * records its package root beside its tree, and the setup mounts every recorded
72
+ * root again before it reads the marker through one.
68
73
  */
69
74
  /** Where the trees live on the container's disk. */
70
75
  const DEPS_DISK = "/var/lib/workspace-deps";
@@ -86,6 +91,23 @@ const WRAPPED = [
86
91
  ];
87
92
  /** Written into the tree by a successful host install; see `./install-job.ts`. */
88
93
  export const INSTALLED_MARKER = ".da-installed";
94
+ /**
95
+ * The package root a tree belongs to, written beside it as `<tree>.root`.
96
+ *
97
+ * Beside rather than inside: `npm ci` empties the tree it installs into, and the
98
+ * hash a tree is named by cannot be turned back into the path.
99
+ */
100
+ const ROOT_SUFFIX = ".root";
101
+ /** Every package root with a tree on this container's disk, one per line. */
102
+ export const DEPS_ROOTS_COMMAND = `for f in ${DEPS_DISK}/*${ROOT_SUFFIX}; do [ -f "$f" ] && { cat "$f"; echo; }; done; true`;
103
+ /** {@link DEPS_ROOTS_COMMAND}'s output as paths, each once. */
104
+ export function parseDepsRoots(stdout) {
105
+ const roots = stdout
106
+ .split("\n")
107
+ .map((line) => line.trim())
108
+ .filter((line) => line.startsWith("/"));
109
+ return [...new Set(roots)];
110
+ }
89
111
  /**
90
112
  * The per-container setup, and a read of the marker under `dir`.
91
113
  *
@@ -118,7 +140,7 @@ export function depsSetupCommand(workspaceDir, dir) {
118
140
  'nm="$dir/node_modules"',
119
141
  'mountpoint -q "$nm" && exit 0',
120
142
  `disk="${DEPS_DISK}/$(printf %s "$dir" | sha256sum | cut -c1-16)"`,
121
- 'mkdir -p "$disk" "$nm" && mount --bind "$disk" "$nm" && exit 0',
143
+ `mkdir -p "$disk" "$nm" && mount --bind "$disk" "$nm" && { printf %s "$dir" > "$disk${ROOT_SUFFIX}"; exit 0; }`,
122
144
  `echo "workspace-deps: could not put $nm on the container's disk" >&2`,
123
145
  "exit 1"
124
146
  ].join("\n");
@@ -139,6 +161,9 @@ export function depsSetupCommand(workspaceDir, dir) {
139
161
  `install -m 755 /tmp/workspace-deps-mount ${MOUNT_HELPER} || exit 1`,
140
162
  `printf '%s\\n' ${shellQuote(wrapper)} > /tmp/workspace-deps-wrap`,
141
163
  `for tool in ${WRAPPED.join(" ")}; do install -m 755 /tmp/workspace-deps-wrap "${WRAPPER_DIR}/$tool" || exit 1; done`,
164
+ // A root that is not in this checkout is left alone by the helper, and one
165
+ // that cannot be mounted reads as no marker below — an install, not a fault.
166
+ `for f in ${DEPS_DISK}/*${ROOT_SUFFIX}; do [ -f "$f" ] && ${MOUNT_HELPER} "$(cat "$f")"; done`,
142
167
  ...(dir
143
168
  ? [
144
169
  `echo "MARKER $(cat ${shellQuote(`${dir}/node_modules/${INSTALLED_MARKER}`)} 2>/dev/null || echo -)"`
@@ -0,0 +1,110 @@
1
+ import type { Workspace } from "@cloudflare/computer";
2
+ export interface DepsSnapshotRecord {
3
+ /** The platform's snapshot id. */
4
+ id: string;
5
+ /** In bytes, as the platform reported it. */
6
+ size: number;
7
+ /** The image reference it was taken on — see the file comment. */
8
+ image: string;
9
+ /** Where the install ran. */
10
+ dir: string;
11
+ /** The install fingerprint its marker holds. */
12
+ fingerprint: string;
13
+ /** Every package root with a tree on the disk, and its inputs' digest then. */
14
+ roots: {
15
+ dir: string;
16
+ fingerprint: string | null;
17
+ }[];
18
+ /** {@link packageRootSet} of the checkout then. */
19
+ rootSet: string;
20
+ at: number;
21
+ }
22
+ /** What a restore is checked against: the install a cold start would run. */
23
+ export interface ExpectedTree {
24
+ dir: string;
25
+ fingerprint: string;
26
+ }
27
+ /** A tree an install finished, as its record names it — `at` tells two apart. */
28
+ export interface InstalledTree extends ExpectedTree {
29
+ at: number;
30
+ }
31
+ export interface DepsSnapshotDeps {
32
+ storage: DurableObjectStorage;
33
+ container: () => Container | undefined;
34
+ /** The image a start from no snapshot would boot. */
35
+ image: () => string | undefined;
36
+ workspace: () => Workspace;
37
+ rootFingerprint: (dir: string) => Promise<string | null>;
38
+ /** The tree the install record says this container holds. */
39
+ tree: () => Promise<{
40
+ dir: string;
41
+ fingerprint: string | null;
42
+ at: number;
43
+ } | undefined>;
44
+ tag: () => string;
45
+ id: () => string;
46
+ }
47
+ /**
48
+ * Which package roots a checkout has, as one digest: every directory under
49
+ * `dir` with a `package.json`, and the submodules `.gitmodules` declares — one a
50
+ * bootstrap clones in the container has no `package.json` here until then.
51
+ */
52
+ export declare function packageRootSet(fs: Workspace["fs"], dir: string): Promise<string>;
53
+ export declare class DepsSnapshot {
54
+ #private;
55
+ private readonly deps;
56
+ readonly launch: {
57
+ readonly containerSnapshot: ContainerSnapshotRestoreParams | undefined;
58
+ };
59
+ constructor(deps: DepsSnapshotDeps);
60
+ /**
61
+ * Restore with the next start if the record fits it, and say which record.
62
+ *
63
+ * `expected` is a thunk because it reads the checkout, and a workspace with no
64
+ * snapshot or one from another image should not pay for that.
65
+ */
66
+ arm(expected: () => Promise<ExpectedTree | undefined>): Promise<DepsSnapshotRecord | undefined>;
67
+ /** Forget an armed restore, taken or not. */
68
+ disarm(): void;
69
+ /**
70
+ * Whether the container that came up holds `record`'s tree, judged by the
71
+ * marker the setup read in it.
72
+ *
73
+ * Drops the record when a start took it and the tree is not there; leaves it
74
+ * when no start did, since nothing was learned about it.
75
+ */
76
+ restored(record: DepsSnapshotRecord, marker: string | null | undefined): Promise<boolean>;
77
+ /**
78
+ * Snapshot the running container, which has just installed `tree`, after any
79
+ * snapshot already in flight — a later install's request is never dropped for
80
+ * an earlier one's.
81
+ *
82
+ * Never throws: a container that cannot be snapshotted installs again next
83
+ * time, which is today's cost and no worse.
84
+ */
85
+ take(tree: InstalledTree): Promise<void>;
86
+ /** Snapshots in flight, finished — for whatever is about to stop the container. */
87
+ settled(): Promise<void>;
88
+ /**
89
+ * The entries that ask for `tree`'s snapshot, for the install to write with
90
+ * its verdict — see the file comment, and {@link takeDue}.
91
+ */
92
+ dueEntries(tree: InstalledTree): Record<string, unknown>;
93
+ /** Take the snapshot an install asked for, if one is waiting. */
94
+ takeDue(): Promise<void>;
95
+ /** The container went, and the disk an asked-for snapshot was of with it. */
96
+ forgetDue(): Promise<void>;
97
+ get(): Promise<DepsSnapshotRecord | undefined>;
98
+ /**
99
+ * Start from another workspace's snapshot — one taken for the same checkout
100
+ * path on the same image, as a worktree shares with the checkout it was cut
101
+ * from.
102
+ *
103
+ * Kept out when this workspace's own snapshot already fits, and refused when
104
+ * the offered one does not.
105
+ */
106
+ seed(record: DepsSnapshotRecord, expected: () => Promise<ExpectedTree | undefined>): Promise<{
107
+ seeded: boolean;
108
+ reason?: string;
109
+ }>;
110
+ }