@dynamicagents/plugins 0.11.1 → 0.13.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.
Files changed (134) hide show
  1. package/README.md +59 -79
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +53 -0
  5. package/dist/claude-code/cards.js +272 -0
  6. package/dist/claude-code/config.d.ts +73 -31
  7. package/dist/claude-code/config.js +0 -2
  8. package/dist/claude-code/credentials.d.ts +21 -9
  9. package/dist/claude-code/credentials.js +43 -9
  10. package/dist/claude-code/egress.d.ts +15 -14
  11. package/dist/claude-code/egress.js +51 -14
  12. package/dist/claude-code/events.d.ts +153 -9
  13. package/dist/claude-code/events.js +289 -58
  14. package/dist/claude-code/github.d.ts +39 -0
  15. package/dist/claude-code/github.js +82 -0
  16. package/dist/claude-code/index.d.ts +24 -80
  17. package/dist/claude-code/index.js +19 -234
  18. package/dist/claude-code/model.d.ts +152 -0
  19. package/dist/claude-code/model.js +342 -0
  20. package/dist/claude-code/run.d.ts +283 -92
  21. package/dist/claude-code/run.js +620 -114
  22. package/dist/claude-code/session.d.ts +131 -0
  23. package/dist/claude-code/session.js +285 -0
  24. package/dist/claude-code/spec.d.ts +46 -0
  25. package/dist/claude-code/spec.js +83 -0
  26. package/dist/claude-code/verified.d.ts +15 -0
  27. package/dist/claude-code/verified.js +15 -0
  28. package/dist/computer/context.d.ts +19 -0
  29. package/dist/computer/context.js +222 -0
  30. package/dist/computer/file-lock.d.ts +5 -0
  31. package/dist/computer/file-lock.js +40 -0
  32. package/dist/computer/gate.d.ts +12 -12
  33. package/dist/computer/gate.js +12 -12
  34. package/dist/computer/index.d.ts +57 -255
  35. package/dist/computer/index.js +98 -1039
  36. package/dist/computer/paths.d.ts +47 -22
  37. package/dist/computer/paths.js +64 -49
  38. package/dist/computer/proxy.d.ts +45 -0
  39. package/dist/computer/proxy.js +234 -0
  40. package/dist/computer/render.d.ts +27 -38
  41. package/dist/computer/render.js +32 -65
  42. package/dist/computer/tools-exec.d.ts +3 -0
  43. package/dist/computer/tools-exec.js +268 -0
  44. package/dist/computer/tools-file.d.ts +12 -0
  45. package/dist/computer/tools-file.js +64 -0
  46. package/dist/computer/tools-grep.d.ts +3 -0
  47. package/dist/computer/tools-grep.js +129 -0
  48. package/dist/repo/checkout.d.ts +35 -6
  49. package/dist/repo/checkout.js +64 -6
  50. package/dist/repo/context.d.ts +135 -0
  51. package/dist/repo/context.js +582 -0
  52. package/dist/repo/index.d.ts +79 -57
  53. package/dist/repo/index.js +118 -1112
  54. package/dist/repo/tools-checks.d.ts +3 -0
  55. package/dist/repo/tools-checks.js +112 -0
  56. package/dist/repo/tools-clone.d.ts +4 -0
  57. package/dist/repo/tools-clone.js +192 -0
  58. package/dist/repo/tools-pr.d.ts +14 -0
  59. package/dist/repo/tools-pr.js +196 -0
  60. package/dist/repo/tools-review.d.ts +12 -0
  61. package/dist/repo/tools-review.js +393 -0
  62. package/dist/repo/tools-worktree.d.ts +4 -0
  63. package/dist/repo/tools-worktree.js +305 -0
  64. package/dist/repo/tools-worktrees.d.ts +12 -0
  65. package/dist/repo/tools-worktrees.js +48 -0
  66. package/dist/repo/url.d.ts +2 -2
  67. package/dist/repo/url.js +7 -7
  68. package/dist/scratch/index.d.ts +4 -4
  69. package/dist/scratch/index.js +12 -11
  70. package/dist/{computer → workspace}/advisory.d.ts +33 -20
  71. package/dist/{computer → workspace}/advisory.js +32 -7
  72. package/dist/workspace/ca-trust.d.ts +95 -0
  73. package/dist/workspace/ca-trust.js +312 -0
  74. package/dist/workspace/container-deps.d.ts +54 -0
  75. package/dist/workspace/container-deps.js +224 -0
  76. package/dist/workspace/container-fault.d.ts +38 -0
  77. package/dist/workspace/container-fault.js +73 -0
  78. package/dist/workspace/exec.d.ts +94 -0
  79. package/dist/workspace/exec.js +153 -0
  80. package/dist/workspace/format.d.ts +36 -0
  81. package/dist/workspace/format.js +57 -0
  82. package/dist/workspace/git-host.d.ts +79 -0
  83. package/dist/workspace/git-host.js +226 -0
  84. package/dist/workspace/git-identity.d.ts +57 -0
  85. package/dist/workspace/git-identity.js +147 -0
  86. package/dist/workspace/index.d.ts +43 -47
  87. package/dist/workspace/index.js +52 -112
  88. package/dist/workspace/install-job.d.ts +121 -0
  89. package/dist/workspace/install-job.js +866 -0
  90. package/dist/{computer → workspace}/install.d.ts +15 -19
  91. package/dist/{computer → workspace}/install.js +8 -11
  92. package/dist/workspace/object.d.ts +502 -0
  93. package/dist/workspace/object.js +1427 -0
  94. package/dist/workspace/open.d.ts +95 -0
  95. package/dist/workspace/open.js +63 -0
  96. package/dist/workspace/read.d.ts +16 -0
  97. package/dist/workspace/read.js +22 -0
  98. package/dist/workspace/shell.d.ts +45 -0
  99. package/dist/workspace/shell.js +80 -0
  100. package/dist/workspace/sync.d.ts +111 -0
  101. package/dist/workspace/sync.js +124 -0
  102. package/dist/workspace/wake.d.ts +19 -0
  103. package/dist/workspace/wake.js +10 -0
  104. package/package.json +28 -42
  105. package/dist/arc-agi/analysis.d.ts +0 -336
  106. package/dist/arc-agi/analysis.js +0 -829
  107. package/dist/arc-agi/client.d.ts +0 -82
  108. package/dist/arc-agi/client.js +0 -113
  109. package/dist/arc-agi/game-tools.d.ts +0 -25
  110. package/dist/arc-agi/game-tools.js +0 -26
  111. package/dist/arc-agi/index.d.ts +0 -42
  112. package/dist/arc-agi/index.js +0 -175
  113. package/dist/arc-agi/main-agent.d.ts +0 -27
  114. package/dist/arc-agi/main-agent.js +0 -65
  115. package/dist/arc-agi/recipe.d.ts +0 -51
  116. package/dist/arc-agi/recipe.js +0 -100
  117. package/dist/arc-agi/scorecard.d.ts +0 -118
  118. package/dist/arc-agi/scorecard.js +0 -163
  119. package/dist/arc-agi/soul.d.ts +0 -40
  120. package/dist/arc-agi/soul.js +0 -90
  121. package/dist/arc-agi/store.d.ts +0 -154
  122. package/dist/arc-agi/store.js +0 -216
  123. package/dist/arc-agi/tools.d.ts +0 -36
  124. package/dist/arc-agi/tools.js +0 -576
  125. package/dist/arc-agi/types.d.ts +0 -212
  126. package/dist/arc-agi/types.js +0 -19
  127. package/dist/claude-code/recipe.d.ts +0 -53
  128. package/dist/claude-code/recipe.js +0 -100
  129. package/dist/computer/read.d.ts +0 -137
  130. package/dist/computer/read.js +0 -203
  131. package/dist/recall/index.d.ts +0 -99
  132. package/dist/recall/index.js +0 -171
  133. package/dist/triage/index.d.ts +0 -355
  134. package/dist/triage/index.js +0 -302
@@ -0,0 +1,1427 @@
1
+ import { DurableObject, tracing } from "cloudflare:workers";
2
+ // One Durable Object has one alarm, and this object wakes for more reasons than
3
+ // that. A `Scheduler` is the multiplexer; what it does *not* own is what this
4
+ // object owes on waking, which is the callbacks registered on it below.
5
+ import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
6
+ import { Workspace } from "@cloudflare/computer";
7
+ import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container";
8
+ import { createGitClient } from "@cloudflare/computer/git";
9
+ import { createCloudflareObserver } from "@cloudflare/computer/observe/cloudflare";
10
+ import { ContainerTrust } from "./ca-trust.js";
11
+ import { ContainerDeps } from "./container-deps.js";
12
+ import { ContainerGitIdentity } from "./git-identity.js";
13
+ import { InstallJob } from "./install-job.js";
14
+ import { WorkspaceGitHost } from "./git-host.js";
15
+ import { SYNC_DRAIN_BUDGET_MS, SYNC_DRAIN_RESUME_MS, WorkspaceSync, syncRetryDelayMs } from "./sync.js";
16
+ // Leaf modules rather than `./index.ts` — see the same import in
17
+ // {@link file://./install-job.ts} for why the barrel is not reachable from here.
18
+ import { deriveAdvisories } from "./advisory.js";
19
+ import { pathExists } from "./read.js";
20
+ /**
21
+ * A workspace: one Durable Object, one container, one repository.
22
+ *
23
+ * **Shared by every agent in a Worker that has a container.** Each subclasses
24
+ * {@link WorkspaceObjectBase}, supplies a {@link WorkspaceObjectConfig}, and
25
+ * inherits everything else. Keep that seam narrow: it is the complete answer to
26
+ * "what is different about this agent's container", and anything that does not
27
+ * fit through it becomes a second copy of this file.
28
+ *
29
+ * A directory inside the plugin rather than a subpath beside it, so that one
30
+ * capability stays one import path and a consumer cannot take the tools without
31
+ * the object they address. The bundle cost of that is nothing: `sideEffects` is
32
+ * false, so an agent that only calls `bash` carries no container backend and
33
+ * no isomorphic-git.
34
+ *
35
+ * What this file must not do is import the plugin barrel. The barrel re-exports
36
+ * this module, and the class below is built at import time — a cycle would
37
+ * evaluate its base as `undefined`. The imports above reach the leaf modules
38
+ * beside it instead.
39
+ *
40
+ * `@cloudflare/computer` pairs a SQLite-backed virtual filesystem in *this*
41
+ * object's storage with a container running `computerd`, which mounts it over
42
+ * FUSE at `/workspace`. Commands run against the same tree the Worker reads over
43
+ * RPC, and the tree outlives the container.
44
+ *
45
+ * **One repository per object**, because `@cloudflare/computer` is strictly 1 DO ↔ 1
46
+ * container: the id derives from caller *and* repository (see `workspaceName`),
47
+ * so two repositories for one caller are two objects and two containers.
48
+ *
49
+ * **It constructs `Workspace` rather than using `withWorkspace`.** The mixin
50
+ * stores the `Workspace` under a module-private symbol the package does not
51
+ * export, so a method on the object cannot reach it — and what the host is
52
+ * required to do needs exactly that: driving an outstanding pull (the library
53
+ * owns no alarm and schedules nothing of its own), and the direct `runtime`
54
+ * access the detached install needs. So this object owns the `Workspace` and
55
+ * implements the one method the mixin otherwise provides,
56
+ * `__getWorkspaceStub`. Callers outside see no difference.
57
+ *
58
+ * ## What is here, and what is a module next door
59
+ *
60
+ * This file is the Durable Object: the wake map, the deadlines, the RPC surface
61
+ * and the wiring between them. The work each of those drives has its own home,
62
+ * and each carries the reasoning for its own decisions:
63
+ *
64
+ * - `./install-job.ts` — when a dependency install runs, and every guard on it.
65
+ * - `./sync.ts` — the host's half of syncing, which nothing else will do.
66
+ * - `./ca-trust.ts` — making a container able to speak TLS.
67
+ * - `./git-identity.ts` — who a container's commits belong to.
68
+ * - `./git-host.ts` — the operations that hold the GitHub credential.
69
+ *
70
+ * Each takes a small explicit seam, and that is the test: a module that needed
71
+ * the whole object back would be a module that did not want extracting.
72
+ */
73
+ /** Where every checkout lives, inside the container and in the VFS. */
74
+ export const WORKSPACE_DIR = "/workspace";
75
+ /**
76
+ * The Durable Object name for one caller's checkout of one repository.
77
+ *
78
+ * Exported because several places must agree on it and none can see the others:
79
+ * the plugin that resolves the stub, the agent that hands it down to sub-agents,
80
+ * and the cancellation path. A pipe rather than a slash, so the caller half
81
+ * cannot forge a repository boundary by containing one.
82
+ *
83
+ * `repo` is undefined only before the first `repo_clone` or `scratch_open` of a
84
+ * session — what a task works in is model-chosen, so there is genuinely nothing
85
+ * to key on until it has chosen. That window resolves to a caller-level
86
+ * workspace, which is never worked in: `beforeCheckout` sets the repository
87
+ * before any git runs, and `scratch_open` sets its sentinel before anything
88
+ * resolves a name.
89
+ *
90
+ * `repo` is not always a repository. A host may pass a sentinel through here as
91
+ * one — a scratchpad is modelled that way, which is what keys it to its own
92
+ * object and its own container. Anything that is not a GitHub name works, as long
93
+ * as no caller could clone something that collides with it.
94
+ */
95
+ export function workspaceName(callerKey, repo) {
96
+ return repo ? `${callerKey}|${repo}` : `${callerKey}|<unassigned>`;
97
+ }
98
+ // --- where the work is ------------------------------------------------------
99
+ /**
100
+ * The key the checkout record lives under.
101
+ *
102
+ * Deliberately **not** the install record's: what is on disk and what was
103
+ * installed into it have different lifetimes, and one record cannot answer both.
104
+ * See {@link WorkspaceObjectBase.noteCheckout}.
105
+ */
106
+ const CHECKOUT_KEY = "checkout";
107
+ // --- what this object wakes for, and when ----------------------------------
108
+ /**
109
+ * Where the idle-reclaim deadline keeps its current schedule id.
110
+ *
111
+ * A schedule is a row the scheduler mints an id for, not a keyed upsert — so
112
+ * "push this deadline back", which `#touch()` does on every single call into
113
+ * this object, is cancel-then-set and needs the id of what stands now. See
114
+ * `namedDeadline`.
115
+ */
116
+ const IDLE_RECLAIM_ID = "idle-reclaim-id";
117
+ /**
118
+ * How long a workspace survives without being used.
119
+ *
120
+ * A Durable Object is **never** reclaimed by the platform, and a namespace
121
+ * cannot be enumerated from a Worker, so nothing else is coming to clean up.
122
+ * Source-only workspaces are small (6.3 MB for slack-gatekeeper), which makes
123
+ * this hygiene rather than cost control — but unbounded hygiene is still
124
+ * unbounded.
125
+ *
126
+ * Exported because a host keeping work in a workspace has to say how long that
127
+ * work survives untouched.
128
+ */
129
+ export const IDLE_RECLAIM_MS = 7 * 24 * 60 * 60 * 1000;
130
+ /** Where the container-idle deadline keeps its current schedule id. */
131
+ const CONTAINER_IDLE_ID = "container-idle-id";
132
+ /** The holds on the container — see `hold` — as key → the time each lapses. */
133
+ const HOLDS_KEY = "container-holds";
134
+ /** When a release was asked for while a hold stood, for the last `unhold`. */
135
+ const RELEASE_ASKED_KEY = "container-release-asked";
136
+ /** Where the container-warm wake keeps its current schedule id. */
137
+ const CONTAINER_WARM_ID = "container-warm-id";
138
+ /**
139
+ * How long a container stays up after the last command **started**.
140
+ *
141
+ * Ours to schedule. The backend replaces a container but never stops one, and
142
+ * there is no `@cloudflare/containers` `Container` here with a `sleepAfter` to
143
+ * lean on: a container nobody is using runs until this deadline stops it.
144
+ *
145
+ * **This must exceed the longest command the agent allows**, and breaking that
146
+ * kills work in flight. Measured from when a command *starts*: `#touch()` arms
147
+ * the clock on the way into this object, and a running command touches nothing
148
+ * again until it finishes — `handle.result()` is one long await and the FUSE
149
+ * traffic under it never surfaces as an RPC. Set equal to `DEFAULT_TIMEOUT_MS`
150
+ * in `./exec.ts`, the two timers race and whichever fires first destroys
151
+ * the container the other depends on. Twenty minutes is double that ceiling;
152
+ * raise it, never lower it, if `bash` is given a longer timeout.
153
+ *
154
+ * **The default, not the policy.** "Longest command" is a fact about the agent,
155
+ * so one whose commands run longer must say so via
156
+ * {@link WorkspaceObjectConfig.containerIdleMs} — `claude-coordinator` holds a
157
+ * `claude -p` session open for its whole 40-minute timeout.
158
+ */
159
+ const CONTAINER_IDLE_MS = 20 * 60_000;
160
+ /**
161
+ * Refuse to grow past this, of the 10 GB a Durable Object may hold.
162
+ *
163
+ * Source-only workspaces run at ~6 MB, so this should never fire — which is why
164
+ * it is worth having: if something does start pulling a large tree in, a
165
+ * sentence naming the number beats a write failing somewhere unrelated.
166
+ */
167
+ const STORAGE_CAP_BYTES = 8 * 1024 * 1024 * 1024;
168
+ /** Where the pending-pull drain keeps its current schedule id. */
169
+ const SYNC_DRAIN_ID = "sync-drain-id";
170
+ /**
171
+ * How long a container may be kept alive past its idle deadline so an
172
+ * outstanding pull can finish.
173
+ *
174
+ * The deadline defers while a drain is still moving blocks, because destroying
175
+ * the container is what makes unpulled work unrecoverable. It cannot defer
176
+ * forever — a pull that never completes would keep a container billing
177
+ * indefinitely — so this is the wall on that, measured from the last use.
178
+ */
179
+ const SYNC_DRAIN_GRACE_MS = 30 * 60_000;
180
+ /**
181
+ * How long a container start may take before the calls waiting on it are refused.
182
+ *
183
+ * Late, because refusing a start that was only slow clears `#readying` while it
184
+ * runs, and the next caller starts a second one beside it — and a start pushing
185
+ * a large tree has taken nine minutes. Not later, because a turn is cut at
186
+ * fifteen: past that the model reads no reason, and its next turn joins the
187
+ * same stuck start.
188
+ */
189
+ const READY_DEADLINE_MS = 9 * 60_000;
190
+ /**
191
+ * `work`, refused once `ms` pass without it settling. The work itself goes on;
192
+ * only the wait for it ends — see {@link READY_DEADLINE_MS}.
193
+ */
194
+ export function readyWithin(work, ms) {
195
+ let timer;
196
+ const expired = new Promise((_resolve, reject) => {
197
+ timer = setTimeout(() => reject(new Error(`the workspace container did not become ready within ${ms / 1000}s; the next call starts it again`)), ms);
198
+ });
199
+ // The loser still settles, and a rejection nobody holds fails the request.
200
+ work.catch(() => { });
201
+ return Promise.race([work, expired]).finally(() => clearTimeout(timer));
202
+ }
203
+ /**
204
+ * Set once the synced dependency trees are gone from this object's storage —
205
+ * see {@link WorkspaceObjectBase.#purgeSyncedTrees}.
206
+ */
207
+ const SYNCED_TREES_PURGED_KEY = "deps:purged";
208
+ /**
209
+ * The `images` key a container starts from: `containers[].images.app` in the
210
+ * consumer's wrangler config. Named to the backend rather than left as its
211
+ * default, because {@link IMAGE_ENV} has to be read under the same key.
212
+ */
213
+ const IMAGE_NAME = "app";
214
+ /**
215
+ * The env name the prepared image's reference rides under — see `backend` for
216
+ * why it rides at all.
217
+ *
218
+ * Nothing reads it, and the spelling keeps it that way. `computerd` hands a
219
+ * command its allowlist plus every `COMPUTER_VAR_` name, and reads its own
220
+ * configuration from `COMPUTERD_`, where a later release could claim this
221
+ * name. Outside both, it stays in the daemon's environment, unread.
222
+ */
223
+ const IMAGE_ENV = "DYNAMICAGENTS_IMAGE";
224
+ // --- the object -------------------------------------------------------------
225
+ /**
226
+ * Named rather than the anonymous class expression an app would write here.
227
+ *
228
+ * This package emits declarations, and `DurableObject` brings `ctx` and `env` in
229
+ * protected — which makes the mixin's return type unnameable in a `.d.ts` and
230
+ * fails the build with TS4094. A named base gives the emitted type something to
231
+ * refer to. It costs one identifier and is invisible to a consumer.
232
+ */
233
+ class WorkspaceContainerHost extends DurableObject {
234
+ }
235
+ /**
236
+ * `withWorkspaceContainer` adds one method, `getWorkspaceContainer()`, over
237
+ * `this.ctx.container` — the runtime's own container handle, started with the
238
+ * image and instance size this object asks for. Stopping it is this object's
239
+ * job — see {@link CONTAINER_IDLE_MS}.
240
+ */
241
+ const WorkspaceContainerBase = withWorkspaceContainer(WorkspaceContainerHost);
242
+ /**
243
+ * One caller's checkout of one repository, and the container that mounts it.
244
+ *
245
+ * Abstract because a Durable Object class takes no constructor arguments, so
246
+ * per-agent configuration cannot arrive that way. {@link workspaceConfig} is the
247
+ * seam, for the same reason Think's own hooks are methods rather than options.
248
+ *
249
+ * ## Why `backend` and `#workspace` are lazy
250
+ *
251
+ * **Base class fields run before subclass fields**, so as plain fields they
252
+ * would read `undefined` from any `workspaceConfig()` that touches a subclass
253
+ * field — which `ClaudeCoordinatorWorkspace`'s does, for its credential store.
254
+ * Memoised getters remove the hazard rather than documenting it, which is what
255
+ * lets a subclass implement the seam however it likes.
256
+ */
257
+ export class WorkspaceObjectBase extends WorkspaceContainerBase {
258
+ #configMemo;
259
+ get #cfg() {
260
+ return (this.#configMemo ??= this.workspaceConfig());
261
+ }
262
+ /** This object's log prefix, so one workspace stays tellable from another. */
263
+ get #tag() {
264
+ return this.#cfg.label;
265
+ }
266
+ /** This agent's container-idle window — see {@link WorkspaceObjectConfig}. */
267
+ get #containerIdleMs() {
268
+ return this.#cfg.containerIdleMs ?? CONTAINER_IDLE_MS;
269
+ }
270
+ /**
271
+ * How long an install may run before it is killed.
272
+ *
273
+ * A getter because the fallback has to be the same number at every site an
274
+ * install is bounded, and a `??` repeated at each is a chance to write a
275
+ * different one.
276
+ */
277
+ get #installTimeoutMs() {
278
+ return this.#cfg.installPlan.timeoutMs ?? 20 * 60_000;
279
+ }
280
+ /**
281
+ * The one alarm, multiplexed across every reason this object wakes.
282
+ *
283
+ * A callback is registered under a **name**, and a schedule row persists that
284
+ * name rather than a closure: the object is re-created on every wake, so
285
+ * anything captured here would not survive one. Registration therefore happens
286
+ * unconditionally, in a field initializer, every time.
287
+ *
288
+ * `hostOwns` is the acknowledgement that this class defines `alarm()` and
289
+ * `fetch()` itself. A lifecycle installs its handlers only where the host has
290
+ * none, so without saying so here the scheduler would be installed and never
291
+ * fire, with no error anywhere. `alarm()` below calls through, which is what
292
+ * makes the declaration true. **`fetch()` deliberately does not** — it serves
293
+ * `computerd`'s capnweb WebSocket upgrade, and a lifecycle's `fetch` declines
294
+ * any upgrade no installed capability claims.
295
+ */
296
+ #wake = installScheduler(this, {
297
+ hostOwns: ["alarm", "fetch"],
298
+ callbacks: {
299
+ installRun: () => this.#install.onRun(),
300
+ installWatch: () => this.#install.onWatch(),
301
+ idleReclaim: () => this.#onIdleReclaim(),
302
+ containerIdle: (payload) => this.#onContainerIdle(payload),
303
+ containerWarm: () => this.#onContainerWarm(),
304
+ syncRetry: (payload) => this.#onSyncDrain(payload)
305
+ },
306
+ onError: (err) => {
307
+ console.error(`[${this.#tag}] a scheduled callback failed for good`, {
308
+ id: this.ctx.id.toString(),
309
+ err: String(err)
310
+ });
311
+ }
312
+ });
313
+ /** Reclaim a workspace nobody has touched. Moved forward by every `#touch`. */
314
+ #idleReclaim = namedDeadline({
315
+ storage: this.ctx.storage,
316
+ scheduler: this.#wake.scheduler,
317
+ key: IDLE_RECLAIM_ID,
318
+ callback: "idleReclaim"
319
+ });
320
+ /** Stop a container nobody is using. Moved forward by every `#touch`. */
321
+ #containerIdle = namedDeadline({
322
+ storage: this.ctx.storage,
323
+ scheduler: this.#wake.scheduler,
324
+ key: CONTAINER_IDLE_ID,
325
+ callback: "containerIdle"
326
+ });
327
+ /**
328
+ * Start a cold container in the background — see {@link #warmIfCold}.
329
+ *
330
+ * A deadline rather than a bare `scheduler.set`, for the reason {@link #touch}
331
+ * gives: armed per tool call, so anything that did not cancel the row standing
332
+ * would leave one per call.
333
+ */
334
+ #containerWarm = namedDeadline({
335
+ storage: this.ctx.storage,
336
+ scheduler: this.#wake.scheduler,
337
+ key: CONTAINER_WARM_ID,
338
+ callback: "containerWarm"
339
+ });
340
+ /**
341
+ * Come back and move the next block of an outstanding pull.
342
+ *
343
+ * **The callback name is on disk in deployed objects, so it may not change.**
344
+ * A schedule row persists its callback by name, and the scheduler drops a row
345
+ * whose callback it cannot find with one line in the log — a rename would
346
+ * strand every pull already scheduled.
347
+ */
348
+ #syncDrain = namedDeadline({
349
+ storage: this.ctx.storage,
350
+ scheduler: this.#wake.scheduler,
351
+ key: SYNC_DRAIN_ID,
352
+ callback: "syncRetry"
353
+ });
354
+ /**
355
+ * The dependency install — see `./install-job.ts`, which owns every guard on it.
356
+ *
357
+ * The seam is what only this object can answer: where its storage and its
358
+ * scheduler are, what the workspace is, and the entry-point bookkeeping an
359
+ * install still owes on the way in.
360
+ */
361
+ #install = new InstallJob({
362
+ storage: this.ctx.storage,
363
+ scheduler: this.#wake.scheduler,
364
+ workspace: () => this.#workspace,
365
+ plan: () => this.#cfg.installPlan,
366
+ timeoutMs: () => this.#installTimeoutMs,
367
+ headroom: () => this.#storageHeadroom(),
368
+ touch: () => this.#touch(),
369
+ ready: () => this.#ready(),
370
+ waitUntil: (promise) => this.ctx.waitUntil(promise),
371
+ containerGone: () => this.#containerGone(),
372
+ tag: () => this.#tag,
373
+ id: () => this.ctx.id.toString()
374
+ });
375
+ /**
376
+ * The GitHub credential, read at the moment it would be handed over.
377
+ *
378
+ * `#`-private, which is the only spelling actually unreachable over RPC — see
379
+ * {@link WorkspaceGitConfig.tokenBinding} for why that matters here and not
380
+ * for the rest of the config.
381
+ *
382
+ * Indexed rather than named: the subclass says which binding holds it, because
383
+ * this package cannot name a consumer's `Env`.
384
+ */
385
+ #token() {
386
+ const env = this.env;
387
+ return env[this.#cfg.git.tokenBinding];
388
+ }
389
+ /** Clone, fetch and push, with the credential — see `./git-host.ts`. */
390
+ #gitHost = new WorkspaceGitHost({
391
+ git: () => this.#workspace.git,
392
+ token: () => this.#token(),
393
+ tag: () => this.#tag
394
+ });
395
+ /** The host's half of syncing — see `./sync.ts`. */
396
+ #sync = new WorkspaceSync({
397
+ workspace: () => this.#workspace,
398
+ containerRunning: () => this.ctx.container?.running === true,
399
+ deadline: () => this.#syncDrain,
400
+ tag: () => this.#tag,
401
+ id: () => this.ctx.id.toString()
402
+ });
403
+ /**
404
+ * The container backend.
405
+ *
406
+ * `container: () => this` hands the backend this object's own container.
407
+ * `workspace` is how `computerd` dials *back* in: the runtime builds a loopback
408
+ * binding from the exported `WorkspaceProxy` class and the values below, which
409
+ * is why the consuming Worker's entry point must re-export `WorkspaceProxy`
410
+ * and why dropping that export breaks the container with no compile error.
411
+ *
412
+ * Nothing sets `egressHost`; the default `computer.internal` is the host the
413
+ * container's outbound HTTP is intercepted on, internal to that loopback.
414
+ *
415
+ * **The image rides in the env so that a deploy replaces the container.** The
416
+ * backend reuses a running container whose launch — env, network, size, image
417
+ * key — matches the one it asks for, and the image a key resolves to is not
418
+ * part of that: a container started before a deploy would go on running the
419
+ * old image under the new Worker. The library here and the `computerd` in the
420
+ * image are one release, and a daemon from across a version line fails to
421
+ * authenticate (`[stage=auth]`). With the prepared image's reference in the
422
+ * env as {@link IMAGE_ENV}, a new image is a new launch, and the next connect
423
+ * replaces the container. With no image prepared under {@link IMAGE_NAME}
424
+ * there is nothing to add, and the backend's start says so.
425
+ *
426
+ * The binding name, the egress policy and the instance size are the
427
+ * subclass's — see {@link WorkspaceObjectConfig}, which carries the warnings
428
+ * on each.
429
+ */
430
+ #backendMemo;
431
+ get backend() {
432
+ return (this.#backendMemo ??= this.#newBackend());
433
+ }
434
+ #newBackend() {
435
+ const image = this.ctx.container?.images?.[IMAGE_NAME];
436
+ return new ContainerBackend({
437
+ container: () => this,
438
+ workspace: {
439
+ binding: this.#cfg.binding,
440
+ id: this.ctx.id.toString()
441
+ },
442
+ egress: this.#cfg.egress,
443
+ name: IMAGE_NAME,
444
+ instance: this.#cfg.instance,
445
+ containerEnv: image === undefined ? {} : { [IMAGE_ENV]: image }
446
+ });
447
+ }
448
+ #workspaceMemo;
449
+ get #workspace() {
450
+ return (this.#workspaceMemo ??= new Workspace(this.#workspaceOptions()));
451
+ }
452
+ /**
453
+ * The container's TLS trust, which `#ready` below keeps current.
454
+ *
455
+ * See `./ca-trust.ts` for why the answer lives in memory and what clears it.
456
+ */
457
+ #trust = new ContainerTrust({
458
+ workspace: () => this.#workspace,
459
+ container: () => this.ctx.container,
460
+ tag: () => this.#tag,
461
+ id: () => this.ctx.id.toString(),
462
+ onExit: () => void this.#containerGone()
463
+ });
464
+ /**
465
+ * The identity every repository in the container starts out attributed to.
466
+ *
467
+ * See `./git-identity.ts` for why the container needs one of its own when
468
+ * `/repo` already configures the checkouts it clones.
469
+ */
470
+ #gitIdentity = new ContainerGitIdentity({
471
+ workspace: () => this.#workspace,
472
+ author: () => this.#cfg.git.author,
473
+ tag: () => this.#tag,
474
+ id: () => this.ctx.id.toString()
475
+ });
476
+ /** Where installs write — see `./container-deps.ts`. */
477
+ #deps = new ContainerDeps({
478
+ workspace: () => this.#workspace,
479
+ workspaceDir: WORKSPACE_DIR,
480
+ tag: () => this.#tag,
481
+ id: () => this.ctx.id.toString()
482
+ });
483
+ /** Everything believed about a container, dropped when it goes. */
484
+ async #containerGone() {
485
+ this.#trust.forget();
486
+ this.#gitIdentity.forget();
487
+ this.#deps.forget();
488
+ await this.#install.containerGone();
489
+ }
490
+ /**
491
+ * Set once {@link reclaimIfIdle} has emptied storage, until the alarm it armed
492
+ * resets the isolate. Anything reaching this instance in between is addressing
493
+ * storage that is gone.
494
+ */
495
+ #reclaimed = false;
496
+ /**
497
+ * The start already in flight, shared by everyone who asks for one.
498
+ *
499
+ * In memory, like {@link file://./ca-trust.ts}'s flag and for the same reason:
500
+ * it describes a container, and an isolate that lost it asks again.
501
+ */
502
+ #readying;
503
+ /**
504
+ * Open the workspace, and make sure the container behind it can speak TLS and
505
+ * installs onto its own disk.
506
+ *
507
+ * **Every path that might start a container goes through here**, and the CA
508
+ * install hangs off container *liveness* rather than off the install job.
509
+ * Whether a container is new and whether it is due dependencies are different
510
+ * questions: an install declines to arm for a `skipped` or `idle` state,
511
+ * returns early when one is already in flight, and again when the workspace is
512
+ * full — so hanging trust off it leaves a replaced container talking to an
513
+ * untrusted CA with nothing pending that would fix it, and leaves it worst
514
+ * exactly when egress is what the agent needs to dig itself out.
515
+ *
516
+ * `ctx.container?.running` is read **before** `ready()`, because `ready()` is
517
+ * what starts a stopped container — afterwards every container looks running
518
+ * and the distinction is gone.
519
+ *
520
+ * Cheap enough to sit on the busiest entry point in the object: at most one
521
+ * extra exec per isolate, plus one per container it sees start. An unchanged
522
+ * container costs a boolean.
523
+ *
524
+ * Private, like everything else in here that is not an RPC: every non-`#`
525
+ * method is reachable over RPC — see {@link WorkspaceGitConfig.tokenBinding}.
526
+ *
527
+ * **One start at a time**, because {@link #warmIfCold} makes overlap ordinary:
528
+ * a warm and the first `bash` want the container at the same moment, as do
529
+ * a warm and the install alarm. Two starts means two trust commands through a
530
+ * half-established connection, the second pushing the tree again behind the
531
+ * first. Cleared on settle, so the next caller asks about the container that
532
+ * is there now — and on {@link READY_DEADLINE_MS}, so a start that hangs holds
533
+ * its callers for that long and no longer.
534
+ */
535
+ async #ready() {
536
+ this.#readying ??= readyWithin(this.#readyNow(), READY_DEADLINE_MS).finally(() => {
537
+ this.#readying = undefined;
538
+ });
539
+ return this.#readying;
540
+ }
541
+ /** One start, run for whoever got there first — see {@link #ready}. */
542
+ async #readyNow() {
543
+ // Read **before** `ready()`, which starts a stopped container.
544
+ if (!this.ctx.container?.running)
545
+ await this.#containerGone();
546
+ await this.#workspace.ready();
547
+ // Before the first exec, which pushes the whole tree into a new container.
548
+ await this.#purgeSyncedTrees();
549
+ await this.#trust.ensure();
550
+ await this.#gitIdentity.ensure();
551
+ const found = await this.#deps.ensure(await this.#install.dir());
552
+ if (found)
553
+ await this.#install.reconcile(found.marker);
554
+ }
555
+ /**
556
+ * Delete every `node_modules` that was synced into this object, once.
557
+ *
558
+ * Left in place, each is pushed into every new container — where the bind
559
+ * mount hides it but computerd still holds it in memory.
560
+ */
561
+ async #purgeSyncedTrees() {
562
+ if (await this.ctx.storage.get(SYNCED_TREES_PURGED_KEY))
563
+ return;
564
+ const startedAt = Date.now();
565
+ const fs = this.#workspace.fs;
566
+ try {
567
+ const trees = await fs
568
+ .find(WORKSPACE_DIR, "**/node_modules", {
569
+ exclude: ["**/.git", "**/node_modules/*"]
570
+ })
571
+ // No workspace directory yet, so nothing was ever synced.
572
+ .catch((err) => {
573
+ if (err?.code === "ENOENT")
574
+ return [];
575
+ throw err;
576
+ });
577
+ for (const tree of trees) {
578
+ if (tree.type === "dir")
579
+ await fs.rm(tree.path, { recursive: true });
580
+ }
581
+ await this.ctx.storage.put(SYNCED_TREES_PURGED_KEY, Date.now());
582
+ if (trees.length > 0)
583
+ console.info(`[${this.#tag}] purged synced dependency trees`, {
584
+ id: this.ctx.id.toString(),
585
+ trees: trees.map((t) => t.path),
586
+ ms: Date.now() - startedAt
587
+ });
588
+ }
589
+ catch (err) {
590
+ // Left unmarked, so the next start tries again; a command must not fail
591
+ // for it.
592
+ console.warn(`[${this.#tag}] could not purge synced dependency trees`, {
593
+ id: this.ctx.id.toString(),
594
+ err: String(err)
595
+ });
596
+ }
597
+ }
598
+ #workspaceOptions() {
599
+ return {
600
+ // `ctx.storage.sql.exec` returns a narrower row type than
601
+ // `DurableObjectStorageLike` declares and the two are invariant, so the
602
+ // cast goes through `unknown`. The runtime shapes match; this is the
603
+ // pattern the package's own example uses.
604
+ storage: this.ctx.storage,
605
+ backends: [this.backend],
606
+ // One line per committed sync block and one per finished operation, into
607
+ // the same Workers Observability view as the spans below — blocks,
608
+ // entries, bytes, and the CPU headroom each block left.
609
+ //
610
+ // **It covers the syncs this object drives, not the ones a command drives
611
+ // for itself.** `pull()` and `push()` log; the push a `runtime.exec` does
612
+ // before it spawns — the expensive one, since a fresh container takes the
613
+ // whole tree — logs nothing. Its cost shows up as a `workspace.sync.push`
614
+ // span, and on `ca-trust.ts`'s line.
615
+ syncTelemetryEnabled: true,
616
+ // One span per sync push, sync pull, exec spawn and filesystem op, into
617
+ // the same Workers Observability view the rest of this Worker traces to.
618
+ // Needs `observability.traces.enabled` in wrangler.jsonc; without the
619
+ // feature flag `tracing` is undefined and this degrades to a no-op.
620
+ observer: createCloudflareObserver({ tracing }),
621
+ // Git, running **here** rather than in the container.
622
+ //
623
+ // This is what lets the GitHub token stay on this side of the boundary.
624
+ // `createGitClient` binds isomorphic-git to `provider()` — the local
625
+ // SQLite store, not the wire — so a clone, fetch or push executes next to
626
+ // the data it writes, and the container never holds a credential at all.
627
+ // Credentialed `git` in the container cannot: it executes whatever
628
+ // `.git/config` and `.git/hooks` in the checkout name.
629
+ //
630
+ // Needs `@platformatic/vfs`, an optional peer of `@cloudflare/computer`:
631
+ // the adapter that wraps `provider()` into an isomorphic-git FsClient
632
+ // imports it lazily and throws a named error when it is absent.
633
+ git: createGitClient(),
634
+ // Only the commit-producing subcommands read this, and the three
635
+ // operations driven from here — clone, fetch, push — are not among them.
636
+ // Set anyway so that a `pull` or `merge` added later fails on the merge
637
+ // itself rather than on `MissingIdentityError`, and set to the same pair
638
+ // `/repo` writes into the checkout's own config at clone time (see
639
+ // `author` in each agent's `plugins.ts`), so a commit cannot be
640
+ // attributed differently depending on which side made it.
641
+ defaultGitIdentity: this.#cfg.git.author
642
+ };
643
+ }
644
+ /**
645
+ * Repair a lost alarm on the way in.
646
+ *
647
+ * The one failure a scheduler cannot defend against from the inside: the
648
+ * runtime retries a throwing `alarm()` a bounded number of times and then
649
+ * stops for good, and a deleted-class migration takes the alarm with the
650
+ * storage. Both leave schedule rows due with nothing coming for them, and the
651
+ * symptom is silence.
652
+ *
653
+ * So every RPC into this object checks. That fully covers the sync drain,
654
+ * `install-watch` and `container-idle`, which only matter while somebody is
655
+ * using the workspace. It does **not** cover `idle-reclaim`, which by
656
+ * definition fires when nobody is — that one has the agent's weekly cron
657
+ * poking `reclaimIfIdle` as its backstop.
658
+ */
659
+ async #repairAlarm() {
660
+ try {
661
+ await this.#wake.start();
662
+ await this.#wake.rearm();
663
+ }
664
+ catch (err) {
665
+ console.error(`[${this.#tag}] could not repair the alarm`, {
666
+ id: this.ctx.id.toString(),
667
+ err: String(err)
668
+ });
669
+ }
670
+ }
671
+ /**
672
+ * What `getWorkspace(stub)` calls from outside this object.
673
+ *
674
+ * The one piece of `withWorkspace` reimplemented here — see the file comment
675
+ * for why the mixin is not used. `ready()` first, because the stub is only
676
+ * meaningful once the workspace has opened its store.
677
+ */
678
+ async __getWorkspaceStub() {
679
+ // The busiest entry point by far, and therefore the one that keeps both the
680
+ // idle clock and the alarm honest.
681
+ await this.#touch();
682
+ await this.#repairAlarm();
683
+ await this.#ready();
684
+ await this.#install.armIfTreeMissing();
685
+ return this.#workspace.stub();
686
+ }
687
+ /**
688
+ * The same workspace, for a caller that will only touch the filesystem.
689
+ *
690
+ * **The container is not started, and that is the whole difference.** The
691
+ * filesystem is this object's own SQLite, so a read or write of it needs no
692
+ * container and an absent one cannot block it. {@link __getWorkspaceStub} adds
693
+ * `#ready()`, and the first command in a fresh container re-pushes the whole
694
+ * tree before it runs, with `timeoutMs` reaching the spawned process and
695
+ * nothing earlier.
696
+ *
697
+ * A stub cannot enforce what the caller does with it: `runtime.exec` on this
698
+ * one would run against a container with no CA. The exec paths take the other
699
+ * method — see `openWorkspaceFs` in `./open.ts`.
700
+ */
701
+ async __getWorkspaceFsStub() {
702
+ await this.#touch();
703
+ await this.#repairAlarm();
704
+ await this.#warmIfCold();
705
+ // Indexes mounts and nothing else — no backend connects here, which is what
706
+ // makes this cheap enough to sit in front of every file tool.
707
+ await this.#workspace.ready();
708
+ return this.#workspace.stub();
709
+ }
710
+ /**
711
+ * Ask the alarm to start the container, so the first `bash` finds one that
712
+ * is up and already holding the tree.
713
+ *
714
+ * What this avoids is the push, not the boot — see {@link __getWorkspaceFsStub}.
715
+ * Moving it to the alarm overlaps it with the model's reading.
716
+ *
717
+ * **Only for a workspace with a checkout in it**, since an empty one has
718
+ * nothing to push and nothing to run, and a container it never uses still
719
+ * bills.
720
+ *
721
+ * Only the arming is awaited. A warm that fails costs the next `bash` the
722
+ * start it would have paid for anyway.
723
+ */
724
+ async #warmIfCold() {
725
+ // The case on every call but the first, which is what keeps the reads below
726
+ // off the per-tool-call cost.
727
+ if (this.ctx.container?.running)
728
+ return;
729
+ // Already starting — `#ready()` dedupes that, but arming again would queue a
730
+ // wake-up for work in progress.
731
+ if (this.#readying)
732
+ return;
733
+ const record = await this.ctx.storage.get(CHECKOUT_KEY);
734
+ if (!record?.dir)
735
+ return;
736
+ await this.#containerWarm.set(new Date());
737
+ }
738
+ /**
739
+ * Clone, fetch and push — the operations that need the GitHub token.
740
+ *
741
+ * The work is `./git-host.ts`, which owns why the credential stays on this
742
+ * side of the boundary. What stays here is what only this object can do: the
743
+ * entry-point bookkeeping, and making the workspace current before git reads
744
+ * it.
745
+ */
746
+ async gitClone(req) {
747
+ const behind = await this.#beforeGit();
748
+ return behind ?? (await this.#gitHost.clone(req));
749
+ }
750
+ async gitFetch(req) {
751
+ const behind = await this.#beforeGit();
752
+ return behind ?? (await this.#gitHost.fetch(req));
753
+ }
754
+ async gitPush(req) {
755
+ const behind = await this.#beforeGit();
756
+ return behind ?? (await this.#gitHost.push(req));
757
+ }
758
+ /**
759
+ * A checkout's `origin`, from this object's storage. Starts no container and
760
+ * moves no deadline, like {@link checkoutDir}: a pull request polled for its
761
+ * review is not the workspace in use.
762
+ */
763
+ async gitOrigin(dir) {
764
+ return await this.#gitHost.origin(dir);
765
+ }
766
+ /**
767
+ * Bring the workspace up to date, or say why git must not run.
768
+ *
769
+ * **Git here reads this object's storage**, so it has to be current first. The
770
+ * commits these operations push were made by `git` *in the container*, and
771
+ * they reach this side on that command's own post-exec pull. When that pull
772
+ * did not finish, the workspace still holds the tree as it was before the
773
+ * commit — and isomorphic-git, running here, would push a branch that does not
774
+ * include it. That is the one failure worth stopping for: the push reports
775
+ * success, GitHub shows an older tree, and nothing in either account says
776
+ * why.
777
+ *
778
+ * Refused rather than attempted, because every alternative is worse. Pushing
779
+ * anyway publishes a tree the agent did not produce; pushing "what is here"
780
+ * silently redefines what the caller asked for. A refusal is recoverable — the
781
+ * pull resumes on its own schedule and the operation works when retried.
782
+ *
783
+ * Returns the refusal to hand back, or `undefined` to proceed.
784
+ */
785
+ async #beforeGit() {
786
+ await this.#touch();
787
+ await this.#repairAlarm();
788
+ /**
789
+ * Drained **before** `#ready()`, which is what starts a stopped container.
790
+ *
791
+ * An outstanding pull belongs to the runtime that ran the command. Start a
792
+ * replacement first and the drain can no longer tell: `pull()` reconnects to
793
+ * whatever is running now, finds a filesystem that matches this object
794
+ * because the workspace just pushed it there, and reports a clean
795
+ * completion — for writes that are gone.
796
+ *
797
+ * Asked while the old container is still the only one there, the same call
798
+ * either moves the writes or says plainly there is nothing to move them
799
+ * from.
800
+ */
801
+ const synced = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
802
+ // `failed` refuses alongside `incomplete`, and the reason is that the two
803
+ // differ only in what the *drain* should do next. To git they are one thing:
804
+ // writes that may exist and are not here. Letting a failure through would
805
+ // push the tree this check exists to refuse.
806
+ if (synced === "incomplete" || synced === "failed") {
807
+ // Plain, rather than carrying the failure ladder: a git operation is
808
+ // somebody waiting, which is fresh evidence that the next attempt is worth
809
+ // making soon. The ladder rebuilds from there if the fault persists, and
810
+ // the rate is bounded by how often a model can call a tool.
811
+ await this.#sync.arm();
812
+ console.error(`[${this.#tag}] refusing git: the workspace is behind`, {
813
+ id: this.ctx.id.toString(),
814
+ outcome: synced
815
+ });
816
+ return {
817
+ ok: false,
818
+ message: "the container's most recent changes have not reached the workspace " +
819
+ "yet, and git runs against the workspace — so this would act on a " +
820
+ "tree that is missing them. The transfer resumes on its own; try " +
821
+ "again in a moment."
822
+ };
823
+ }
824
+ await this.#ready();
825
+ return undefined;
826
+ }
827
+ /**
828
+ * Start this checkout's dependency install, and return without waiting.
829
+ *
830
+ * Called from `repo_clone` through the repo plugin's `afterCheckout` hook, so
831
+ * it runs inside a model turn — see `./install-job.ts` for why the drain's owner
832
+ * is the thing that matters here.
833
+ */
834
+ async startInstall(req) {
835
+ return await this.#install.start(req);
836
+ }
837
+ // --- lifecycle ---------------------------------------------------------------
838
+ /**
839
+ * Mark this workspace as in use, and push its reclamation back.
840
+ *
841
+ * Every entry point calls this, which is what makes the idle clock measure
842
+ * *use* rather than "when the agent last said this name". The agent hands a
843
+ * workspace name to a sub-agent once and then never sees the traffic; the
844
+ * workspace sees all of it.
845
+ */
846
+ async #touch() {
847
+ // A reclaim emptied this instance and its reset has not landed yet.
848
+ // Scheduling below would write to a table that is gone, so fail this call
849
+ // and let the caller come back on a fresh object.
850
+ if (this.#reclaimed) {
851
+ this.ctx.abort("workspace reclaimed", { retryAlarm: false });
852
+ }
853
+ const now = Date.now();
854
+ // Everything reaches this object by RPC, which bypasses `fetch` — so this
855
+ // is where the lifecycle gets started, and without it the scheduler's schema
856
+ // is never migrated and the first `set` below runs against nothing. Guarded
857
+ // internally, so calling it on every touch costs one resolved promise.
858
+ await this.#wake.start();
859
+ await this.ctx.storage.put("lastUsedAt", now);
860
+ // Two deadlines *moved*, not two schedules added. This is the hottest path
861
+ // in the object — every entry point calls it — so a bare `scheduler.set`
862
+ // here would leave one row per request, every one of them due.
863
+ await this.#idleReclaim.set(new Date(now + IDLE_RECLAIM_MS));
864
+ await this.#containerIdle.set(new Date(now + this.#containerIdleMs));
865
+ }
866
+ /**
867
+ * Stop the container, and **keep everything on disk**.
868
+ *
869
+ * The counterpart to {@link reclaimIfIdle}, and the difference is the whole
870
+ * reason both exist. A reclaim is for a workspace nobody wants again: it empties
871
+ * storage, so the checkout and the dependency tree go with the container. This is
872
+ * for a workspace that will be worked in again — the next task on the same
873
+ * repository has to skip the clone and the install, which is the cost the whole
874
+ * design is arranged around.
875
+ *
876
+ * **Why a caller needs this at all.** The idle deadline would eventually do it,
877
+ * but it cannot be tuned down to meet a cost target: `containerIdleMs` must
878
+ * exceed the longest command the agent allows, and a container that outlives its
879
+ * work by that much bills for the difference. So a host that knows the work is
880
+ * finished says so, and the deadline goes back to being a backstop for the case
881
+ * where nothing got to say anything.
882
+ *
883
+ * Deliberately does **not** touch: `#touch()` would push the very deadline this
884
+ * is standing in for.
885
+ *
886
+ * `released: false` means the container is still up and the standing deadline
887
+ * still owns it — an install is running, or a pull is still moving blocks. Both
888
+ * resolve themselves: the drain re-arms the deadline to come back and finish,
889
+ * which is the same path `#onContainerIdle` takes. Under a {@link hold} the
890
+ * release is deferred instead, to the last `unhold`.
891
+ */
892
+ async releaseContainer() {
893
+ // Work this object cannot see. Deferred rather than refused: the last
894
+ // `unhold` releases, unless the workspace is used again first.
895
+ const holds = await this.#liveHolds();
896
+ if (holds.length > 0) {
897
+ await this.ctx.storage.put(RELEASE_ASKED_KEY, Date.now());
898
+ console.info(`[${this.#tag}] release deferred while held`, {
899
+ id: this.ctx.id.toString(),
900
+ holds
901
+ });
902
+ return { released: false };
903
+ }
904
+ await this.ctx.storage.delete(RELEASE_ASKED_KEY);
905
+ // An install in flight is "in use" even though nothing has called in.
906
+ // Same rule, and same reason, as `#onContainerIdle`.
907
+ const install = await this.#install.read();
908
+ if (install.state === "running")
909
+ return { released: false };
910
+ // Never stop a container with a pull still moving: that is what turns an
911
+ // outstanding write from late into lost. `#drainBeforeStop` carries the
912
+ // reasoning and re-arms the deadline when it defers.
913
+ const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
914
+ if (await this.#drainBeforeStop(lastUsedAt, 0))
915
+ return { released: false };
916
+ // Dropped rather than left standing: the container is about to be gone, and an
917
+ // alarm that fires afterwards logs a stop against a container that is not
918
+ // running. `#touch` re-arms it the next time this workspace is used.
919
+ await this.#containerIdle.clear();
920
+ await this.#stopContainer("idle");
921
+ return { released: true };
922
+ }
923
+ /**
924
+ * Keep the container up for work this object cannot see, until `untilMs` or
925
+ * {@link unhold}.
926
+ *
927
+ * A Claude Code session a host starts here runs detached: nothing calls in
928
+ * while it works, and the runtime cannot list
929
+ * what is running. So a host done with the workspace cannot tell that the
930
+ * container is not, and {@link releaseContainer} would stop the session with
931
+ * it. A hold is the host saying so. It defers a release; it is not use, so it
932
+ * does not touch, and the idle deadline already outlasts a session.
933
+ *
934
+ * `untilMs` bounds a hold whose `unhold` never comes — a run cut between its
935
+ * start and its end. Past it the hold is ignored.
936
+ */
937
+ async hold(key, untilMs) {
938
+ const holds = await this.#holdMap();
939
+ holds[key] = untilMs;
940
+ await this.ctx.storage.put(HOLDS_KEY, holds);
941
+ }
942
+ /**
943
+ * The work a {@link hold} stood for is over. When it was the last hold, a
944
+ * release asked for while it stood happens now — unless the workspace was
945
+ * used since, in which case the next release is asked for by whoever used it.
946
+ */
947
+ async unhold(key) {
948
+ // Read and written with no other I/O between, so the object's input gate
949
+ // keeps another call from interleaving: the map written is the one decided on.
950
+ const holds = await this.#holdMap();
951
+ delete holds[key];
952
+ await this.ctx.storage.put(HOLDS_KEY, holds);
953
+ if (Object.keys(holds).length > 0)
954
+ return { released: false };
955
+ const asked = await this.ctx.storage.get(RELEASE_ASKED_KEY);
956
+ if (asked === undefined)
957
+ return { released: false };
958
+ // A use in the same millisecond as the ask counts as after it: wrongly
959
+ // keeping a container costs its idle window, wrongly stopping one a session.
960
+ const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
961
+ if (lastUsedAt >= asked) {
962
+ await this.ctx.storage.delete(RELEASE_ASKED_KEY);
963
+ return { released: false };
964
+ }
965
+ return await this.releaseContainer();
966
+ }
967
+ /** Every hold, lapsed ones pruned. */
968
+ async #holdMap() {
969
+ const now = Date.now();
970
+ const stored = (await this.ctx.storage.get(HOLDS_KEY)) ?? {};
971
+ return Object.fromEntries(Object.entries(stored).filter(([, until]) => until > now));
972
+ }
973
+ /** The keys of the holds still standing. */
974
+ async #liveHolds() {
975
+ return Object.keys(await this.#holdMap());
976
+ }
977
+ /**
978
+ * Throw this workspace away if nothing has touched it for `maxIdleMs`.
979
+ *
980
+ * Re-checks the clock rather than trusting the caller: the alarm may have been
981
+ * armed a week ago, and a use since then must win. Safe to call from anywhere
982
+ * for the same reason, which is what lets the agent's cron poke it as a
983
+ * backstop without needing to know anything.
984
+ */
985
+ async reclaimIfIdle(maxIdleMs = IDLE_RECLAIM_MS) {
986
+ const lastUsedAt = await this.ctx.storage.get("lastUsedAt");
987
+ const bytes = this.ctx.storage.sql.databaseSize;
988
+ // Nothing has ever used this object, so there is nothing to reclaim.
989
+ //
990
+ // Load-bearing, not defensive. `lastUsedAt` is written by `#touch()` and
991
+ // removed by the `deleteAll()` below, so an *already reclaimed* workspace
992
+ // reads exactly like a brand new one. Defaulting it to 0 makes both "idle
993
+ // since the epoch", and the weekly sweep then re-reclaims every workspace it
994
+ // has ever reclaimed, recreating storage just to empty it again.
995
+ if (lastUsedAt === undefined)
996
+ return { reclaimed: false, idleMs: 0, bytes };
997
+ const idleMs = Date.now() - lastUsedAt;
998
+ if (idleMs < maxIdleMs)
999
+ return { reclaimed: false, idleMs, bytes };
1000
+ console.info(`[${this.#tag}] reclaiming an idle workspace`, {
1001
+ id: this.ctx.id.toString(),
1002
+ idleDays: Math.round(idleMs / 86_400_000),
1003
+ bytes
1004
+ });
1005
+ await this.#stopContainer("reclaim");
1006
+ // `deleteAll` does not take the alarm with it, so the alarm goes first —
1007
+ // otherwise a reclaimed object wakes once more into empty storage.
1008
+ await this.ctx.storage.deleteAlarm();
1009
+ await this.ctx.storage.deleteAll();
1010
+ // It *also* takes the lifecycle's job queue table, which this instance has
1011
+ // already created and will not create again — so the next schedule set from
1012
+ // this isolate would fail against a table that is gone, and the workspace
1013
+ // would stop arming its timers until something evicted it. The answer is to
1014
+ // stop using this instance: a later call constructs the object fresh on empty
1015
+ // storage, and every table comes back with it.
1016
+ //
1017
+ // **The reset runs from its own alarm invocation, never from here.** An
1018
+ // `abort()` on the way out of an RPC races that RPC's response, and the
1019
+ // weekly sweep reads this result to decide whether to forget the workspace —
1020
+ // so a reset that beat the response would report a failed reclaim for one
1021
+ // that had already emptied the object. A timer would race it the same way;
1022
+ // only a separate invocation is ordered after the response.
1023
+ //
1024
+ // Nothing durable records this. The flag exists to reset *this* isolate, and
1025
+ // an isolate that went away on its own has already achieved that: a fresh
1026
+ // instance creates the tables it needs on the way in.
1027
+ this.#reclaimed = true;
1028
+ await this.ctx.storage.setAlarm(Date.now());
1029
+ return { reclaimed: true, idleMs, bytes };
1030
+ }
1031
+ /**
1032
+ * Stop the container, keeping nothing. The workspace is what persists.
1033
+ *
1034
+ * Logged first because `@cloudflare/computer` reports this exit as
1035
+ * `expected: false`: its own stop is not public, so a raw `destroy()` reads
1036
+ * as a crash in its log.
1037
+ */
1038
+ async #stopContainer(reason) {
1039
+ console.info(`[${this.#tag}] stopping the container`, {
1040
+ id: this.ctx.id.toString(),
1041
+ reason,
1042
+ running: this.ctx.container?.running ?? false
1043
+ });
1044
+ // Hang up the session to computerd first: a destroy that cuts it live
1045
+ // surfaces as an uncaught `Network connection lost`. The workspace
1046
+ // reconnects on its next use, and `close()` swallows its own failures.
1047
+ await this.#workspaceMemo?.close();
1048
+ try {
1049
+ await this.ctx.container?.destroy();
1050
+ }
1051
+ catch (err) {
1052
+ // Already gone, most likely, and a container that cannot be stopped must
1053
+ // not turn a clean reclaim into a failed alarm.
1054
+ console.warn(`[${this.#tag}] could not stop the container`, {
1055
+ id: this.ctx.id.toString(),
1056
+ err: String(err)
1057
+ });
1058
+ }
1059
+ await this.#containerGone();
1060
+ }
1061
+ /**
1062
+ * Whether this object is out of room, and by how much.
1063
+ *
1064
+ * Read on **two** paths, and the second is load-bearing. The install job
1065
+ * consults it because an install is the operation that can move the number
1066
+ * meaningfully. {@link advisories} consults it on every call, which is what
1067
+ * lets a full workspace reach commands that have nothing to do with
1068
+ * dependencies — the write being lost is rarely a dependency's, so a capacity
1069
+ * fact delivered only alongside install state reaches everything except what
1070
+ * it is about.
1071
+ *
1072
+ * Cheap enough for that: `databaseSize` is a local property read, not a query.
1073
+ */
1074
+ #storageHeadroom() {
1075
+ const bytes = this.ctx.storage.sql.databaseSize;
1076
+ if (bytes < STORAGE_CAP_BYTES)
1077
+ return undefined;
1078
+ return { bytes, capBytes: STORAGE_CAP_BYTES };
1079
+ }
1080
+ // --- the dependency install ------------------------------------------------
1081
+ /**
1082
+ * Record what was checked out here, and say whether it is actually visible.
1083
+ *
1084
+ * **The single writer of {@link CHECKOUT_KEY}**, called by the two paths that
1085
+ * know a checkout landed: the repo plugin's `afterCheckout` hook, which fires
1086
+ * only once a tree is established, and `scratch_open`, which fires once
1087
+ * `git init` has exited 0.
1088
+ *
1089
+ * **Why the checkout is recorded apart from the install**, which call sites
1090
+ * point here for rather than restating: an install is conditional and a
1091
+ * checkout is not. `resolveInstallCommand` skips a checkout it finds nothing
1092
+ * to install in — any without a `package.json` — so a path written only as
1093
+ * part of an install is missing exactly there. What that costs is not an
1094
+ * optimisation: {@link checkoutDir} is how a delegated session is told where
1095
+ * to work, so a repository can clone perfectly, report itself correctly
1096
+ * through the repo tools, and never be worked in. The install keeps its own
1097
+ * context for the mirror-image reason — what to re-run on a cold container has
1098
+ * a different lifetime from what is on disk.
1099
+ *
1100
+ * Returns the probe as well as the path, so a caller learns in one round trip
1101
+ * whether the tree is visible rather than discovering it a delegation later.
1102
+ */
1103
+ async noteCheckout(req) {
1104
+ await this.#touch();
1105
+ const record = {
1106
+ dir: req.dir,
1107
+ ...(req.repo ? { repo: req.repo } : {}),
1108
+ kind: req.kind,
1109
+ at: Date.now()
1110
+ };
1111
+ await this.ctx.storage.put(CHECKOUT_KEY, record);
1112
+ const present = await this.#isCheckout(req.dir);
1113
+ console.info(`[${this.#tag}] checkout recorded`, {
1114
+ id: this.ctx.id.toString(),
1115
+ dir: req.dir,
1116
+ kind: req.kind,
1117
+ ...(req.repo ? { repo: req.repo } : {}),
1118
+ present
1119
+ });
1120
+ return { dir: req.dir, present };
1121
+ }
1122
+ /**
1123
+ * Where the work is — a directory holding a git repository, or nothing.
1124
+ *
1125
+ * Two callers depend on that being the meaning rather than "a path somebody
1126
+ * wrote down once": a Claude Code session takes it as its cwd, and
1127
+ * `discardWorkingTree` runs `git reset --hard` in it. Both want the same
1128
+ * question answered, and neither can recover from a confident wrong answer.
1129
+ *
1130
+ * So it is **probed, not remembered**. A record is where to look; `.git` being
1131
+ * there is what makes the answer true. That costs one local read of this
1132
+ * object's own SQLite — everything under the checkout is durable, `.git`
1133
+ * included — and it is what turns a stale record into `undefined` instead of
1134
+ * into a session started in a directory that is no longer a checkout.
1135
+ *
1136
+ * The record is the only source. A workspace that has never recorded one
1137
+ * answers `undefined` until its next checkout does, which `repo_clone` does on
1138
+ * any tree it can fetch and reset.
1139
+ */
1140
+ async checkoutDir() {
1141
+ const record = await this.ctx.storage.get(CHECKOUT_KEY);
1142
+ const dir = record?.dir;
1143
+ if (!dir)
1144
+ return undefined;
1145
+ if (await this.#isCheckout(dir))
1146
+ return dir;
1147
+ // Said out loud because the return value cannot say it: a caller reads
1148
+ // `undefined` as "nothing has been cloned", which is also the right answer
1149
+ // for a workspace nobody ever cloned into. Only this line separates them.
1150
+ console.warn(`[${this.#tag}] a recorded checkout is no longer there`, {
1151
+ id: this.ctx.id.toString(),
1152
+ dir,
1153
+ recorded: record?.kind,
1154
+ // The rest of the record, because this line is read during an incident and
1155
+ // "the checkout for acme/spike went missing forty minutes ago" is a
1156
+ // different investigation from a bare path. It is also the only reader
1157
+ // these two fields have.
1158
+ ...(record?.repo ? { repo: record.repo } : {}),
1159
+ ...(record ? { ageMs: Date.now() - record.at } : {})
1160
+ });
1161
+ return undefined;
1162
+ }
1163
+ /**
1164
+ * Whether `dir` holds a git repository, as this object's own storage sees it.
1165
+ *
1166
+ * `.git` rather than the directory: an empty directory is not a checkout, and
1167
+ * every caller of {@link checkoutDir} needs git to be there — to reset the
1168
+ * tree, or to run a session that will commit in it. It is
1169
+ * also the one probe a scratchpad and a clone answer identically, which is
1170
+ * what lets them share every path below this line.
1171
+ */
1172
+ async #isCheckout(dir) {
1173
+ try {
1174
+ return await pathExists(this.#workspace.fs, `${dir}/.git`);
1175
+ }
1176
+ catch (err) {
1177
+ // A read of local SQLite that threw says nothing about the tree. Treat it
1178
+ // as absent: refusing a delegation costs a tool call, starting a session in a
1179
+ // directory that may not exist costs the run.
1180
+ console.warn(`[${this.#tag}] could not probe a checkout`, {
1181
+ id: this.ctx.id.toString(),
1182
+ dir,
1183
+ err: String(err)
1184
+ });
1185
+ return false;
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Everything currently true about this workspace that a caller must not assume
1190
+ * away — the array `bash`, `edit` and the workspace's writes all consult.
1191
+ *
1192
+ * The policy is not here. `deriveAdvisories` decides which facts matter and
1193
+ * how they are worded; this method gathers what only the object can see and
1194
+ * hands it over. That split is why a host cannot get the severity of its own
1195
+ * workspace wrong.
1196
+ *
1197
+ * **Nothing that starts a long job belongs on this path**, and the rule is
1198
+ * sharper here than anywhere else in the object because every tool call reads
1199
+ * it. `startInstall` in particular must never be reached from here: it hands
1200
+ * its drain to `ctx.waitUntil`, whose lifetime is the invocation's, and an
1201
+ * invocation on this path is a tool call that returns in milliseconds. The
1202
+ * drain outlives its owner, dies mid-`npm ci` with "WritableStream RPC stub
1203
+ * was disposed without calling close()", and leaves a half-written tree.
1204
+ *
1205
+ * A missing dependency tree arms an install here, which runs in the alarm,
1206
+ * which owns no request and outlives every RPC. Here because a command reads
1207
+ * this before its `#ready()` starts a container, so the gate holds it for the
1208
+ * install rather than letting it outrun one.
1209
+ */
1210
+ async advisories() {
1211
+ if (!this.ctx.container?.running)
1212
+ await this.#containerGone();
1213
+ await this.#install.armIfTreeMissing();
1214
+ const install = await this.#install.state();
1215
+ const storage = this.#storageHeadroom();
1216
+ // Only when the record says failed: it is the one state whose reading a
1217
+ // queued repair changes, and every other one would pay a storage read for an
1218
+ // answer nothing looks at.
1219
+ const reinstallArmedAt = install.state === "failed"
1220
+ ? await this.#install.reinstallArmedAt()
1221
+ : undefined;
1222
+ return deriveAdvisories({
1223
+ install,
1224
+ ...(storage ? { storage } : {}),
1225
+ ...(reinstallArmedAt === undefined ? {} : { reinstallArmedAt }),
1226
+ // The probe qualifies `deps-broken` and nothing else, and a queued
1227
+ // reinstall turns a failed record into `deps-building`, which does not
1228
+ // read it. `failed` alone is no longer the condition under which the
1229
+ // question is worth asking, so the caller narrows it — see
1230
+ // `InstallJob.treePresentIfItMatters`, whose contract this keeps true.
1231
+ dependencyTreePresent: reinstallArmedAt === undefined
1232
+ ? await this.#install.treePresentIfItMatters(install)
1233
+ : false
1234
+ });
1235
+ }
1236
+ /**
1237
+ * `computerd`'s outbound WebSocket upgrade, on its way back in.
1238
+ *
1239
+ * The container dials the loopback rather than the other way round, so this
1240
+ * object is the server for its own container's capnweb session. Everything
1241
+ * else on this object is RPC; this is the only HTTP it speaks.
1242
+ */
1243
+ fetch(request) {
1244
+ return this.backend.handleFetch(request);
1245
+ }
1246
+ /**
1247
+ * Every durable wake-up this object has, dispatched from the one alarm.
1248
+ *
1249
+ * **This must not throw.** The runtime retries a failing alarm handler a
1250
+ * bounded number of times and then stops for good — so a throw here would
1251
+ * eventually take every *future* wake-up down with it, permanently, and the
1252
+ * only symptom is that nothing ever happens again.
1253
+ *
1254
+ * Nothing else belongs here. Each reason to wake is a registered callback the
1255
+ * scheduler dispatches by name, so this method neither matches on keys nor
1256
+ * catches per-callback failures — the scheduler retries a failing callback and
1257
+ * reports one that fails for good through `onError`. Nor does it sweep for a
1258
+ * schedule that neither rescheduled nor cleared itself: a one-shot row is
1259
+ * dropped when it runs, so it cannot stay due forever.
1260
+ */
1261
+ async alarm() {
1262
+ // The reset a reclaim asked for, in the invocation it asked for it in.
1263
+ // Nothing else may run on this instance: its storage is gone, and the job
1264
+ // queue's table with it.
1265
+ if (this.#reclaimed) {
1266
+ this.ctx.abort("workspace reclaimed", { retryAlarm: false });
1267
+ return;
1268
+ }
1269
+ try {
1270
+ await this.#wake.alarm();
1271
+ }
1272
+ catch (err) {
1273
+ // A reclaim that ran *from* this alarm emptied storage, the job queue with
1274
+ // it, so settling the job that ran it fails. That is the reclaim working,
1275
+ // and the reset it armed is the next thing to happen here.
1276
+ if (this.#reclaimed)
1277
+ return;
1278
+ console.error(`[${this.#tag}] the alarm failed`, {
1279
+ id: this.ctx.id.toString(),
1280
+ err: String(err)
1281
+ });
1282
+ // One missed wake rather than every future one: re-arm from whatever
1283
+ // survived, since the throw above is what the runtime counts against the
1284
+ // bounded retry that ends in the alarm being abandoned.
1285
+ await this.#wake.rearm().catch(() => { });
1286
+ }
1287
+ }
1288
+ /**
1289
+ * An outstanding pull came due.
1290
+ *
1291
+ * The three answers want three different next moves, which is why the drain
1292
+ * distinguishes them. Progress comes straight back for the next block. A
1293
+ * failure backs off, carrying its own count in the schedule's payload — the
1294
+ * object does not survive between wake-ups, so there is nowhere else to keep
1295
+ * it. Nothing reachable ends the attempt: a container that is gone took the
1296
+ * unpulled writes with it.
1297
+ */
1298
+ async #onSyncDrain(payload) {
1299
+ const outcome = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
1300
+ if (outcome === "incomplete") {
1301
+ await this.#sync.arm();
1302
+ return;
1303
+ }
1304
+ if (outcome === "failed") {
1305
+ await this.#sync.arm((payload?.attempt ?? 0) + 1);
1306
+ }
1307
+ }
1308
+ /** The idle-reclaim deadline came due. */
1309
+ async #onIdleReclaim() {
1310
+ const { reclaimed, idleMs } = await this.reclaimIfIdle();
1311
+ // Not idle after all — something used it since this was armed, and that
1312
+ // `#touch` already moved the deadline to a row of its own. So there is
1313
+ // nothing to re-arm here, and re-arming would create a second one.
1314
+ if (reclaimed)
1315
+ return;
1316
+ console.info(`[${this.#tag}] idle reclaim deferred`, {
1317
+ id: this.ctx.id.toString(),
1318
+ idleMinutes: Math.round(idleMs / 60_000)
1319
+ });
1320
+ }
1321
+ /**
1322
+ * The container-warm wake came due — see {@link #warmIfCold}.
1323
+ *
1324
+ * **Swallows its own failure.** Every other wake here is work somebody waits
1325
+ * for; this one is early, so a container that will not start must cost the
1326
+ * next `bash` a start rather than put the scheduler's retry ladder behind a
1327
+ * deployment fault that will refuse for as long as it stands.
1328
+ */
1329
+ async #onContainerWarm() {
1330
+ // Started between the arming and now, so there is nothing to be early for.
1331
+ // Trust it may still be missing is `#ready()`'s, on the next call that wants
1332
+ // the container.
1333
+ if (this.ctx.container?.running)
1334
+ return;
1335
+ try {
1336
+ await this.#ready();
1337
+ await this.#install.armIfTreeMissing();
1338
+ }
1339
+ catch (err) {
1340
+ console.warn(`[${this.#tag}] could not warm the container`, {
1341
+ id: this.ctx.id.toString(),
1342
+ err: String(err)
1343
+ });
1344
+ }
1345
+ }
1346
+ /** The container-idle deadline came due. */
1347
+ async #onContainerIdle(payload) {
1348
+ // An install still running is "in use" even though nothing has called in —
1349
+ // stopping the container under it would throw away the work and leave the
1350
+ // gate closed until something noticed.
1351
+ const state = await this.#install.read();
1352
+ if (state.state === "running") {
1353
+ await this.#containerIdle.set(new Date(Date.now() + this.#containerIdleMs));
1354
+ return;
1355
+ }
1356
+ // Work nothing calls in for, which the deadline cannot measure: come back
1357
+ // when the first hold lapses, without counting the hold as use.
1358
+ const lapses = Object.values(await this.#holdMap());
1359
+ if (lapses.length > 0) {
1360
+ await this.#containerIdle.set(new Date(Math.min(...lapses)));
1361
+ return;
1362
+ }
1363
+ // Re-read the clock rather than trusting the alarm, the same way
1364
+ // `reclaimIfIdle` does. `#touch` moves the deadline forward, but an alarm
1365
+ // already in flight cannot be recalled — so without this a workspace that
1366
+ // was used a second ago can still have its container stopped by a wake-up
1367
+ // that was scheduled before that use.
1368
+ const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
1369
+ const idleMs = Date.now() - lastUsedAt;
1370
+ if (idleMs < this.#containerIdleMs) {
1371
+ await this.#containerIdle.set(new Date(lastUsedAt + this.#containerIdleMs));
1372
+ return;
1373
+ }
1374
+ /**
1375
+ * The last chance to move anything the container still holds.
1376
+ *
1377
+ * Destroying it is what turns an outstanding pull from late into lost, and
1378
+ * this is the only moment that is knowable in advance — every other way a
1379
+ * container goes away is something happening *to* it. So the drain runs
1380
+ * here, and the deadline defers while it is still making progress.
1381
+ *
1382
+ * The deferral has a wall, because a pull that cannot finish must not keep
1383
+ * a container billing forever. Past it the container goes and the unpulled
1384
+ * writes go with it, which is the honest outcome: they were unreachable
1385
+ * either way, and the alternative is an idle container nobody stops.
1386
+ */
1387
+ if (await this.#drainBeforeStop(lastUsedAt, payload?.attempt ?? 0))
1388
+ return;
1389
+ await this.#stopContainer("idle");
1390
+ }
1391
+ /**
1392
+ * Whether the idle deadline deferred to let an outstanding pull finish.
1393
+ *
1394
+ * `attempt` is how many times the drain has failed in a row, carried on the
1395
+ * schedule because nothing in memory survives a wake-up.
1396
+ */
1397
+ async #drainBeforeStop(lastUsedAt, attempt) {
1398
+ const outcome = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
1399
+ if (outcome === "complete" || outcome === "unavailable")
1400
+ return false;
1401
+ if (Date.now() - lastUsedAt > this.#containerIdleMs + SYNC_DRAIN_GRACE_MS) {
1402
+ console.error(`[${this.#tag}] stopping a container with an unfinished pull`, {
1403
+ id: this.ctx.id.toString(),
1404
+ outcome,
1405
+ graceMinutes: Math.round(SYNC_DRAIN_GRACE_MS / 60_000)
1406
+ });
1407
+ return false;
1408
+ }
1409
+ /**
1410
+ * How soon to come back, and the two outcomes want different answers for the
1411
+ * same reason they do on the drain's own schedule — except that here the
1412
+ * deferral holds a **container** open, so getting it wrong bills as well as
1413
+ * spins. `incomplete` is progress and clears the count; `failed` is a pull
1414
+ * that threw, and returning in a second would put an alarm and an error line
1415
+ * against a broken transport every second until the grace wall, half an hour
1416
+ * later.
1417
+ */
1418
+ const failed = outcome === "failed";
1419
+ const next = failed ? attempt + 1 : 0;
1420
+ console.info(`[${this.#tag}] holding a container for an unfinished pull`, {
1421
+ id: this.ctx.id.toString(),
1422
+ outcome
1423
+ });
1424
+ await this.#containerIdle.set(new Date(Date.now() + (failed ? syncRetryDelayMs(next) : SYNC_DRAIN_RESUME_MS)), { attempt: next });
1425
+ return true;
1426
+ }
1427
+ }