@dynamicagents/plugins 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -77
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +55 -0
- package/dist/claude-code/cards.js +283 -0
- package/dist/claude-code/config.d.ts +28 -63
- package/dist/claude-code/config.js +0 -11
- package/dist/claude-code/egress.d.ts +8 -1
- package/dist/claude-code/egress.js +42 -4
- package/dist/claude-code/events.d.ts +106 -9
- package/dist/claude-code/events.js +201 -56
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +16 -90
- package/dist/claude-code/index.js +14 -276
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +366 -0
- package/dist/claude-code/run.d.ts +230 -116
- package/dist/claude-code/run.js +380 -167
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +288 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +1 -5
- package/dist/computer/context.js +7 -10
- package/dist/computer/file-lock.js +5 -4
- package/dist/computer/gate.d.ts +4 -4
- package/dist/computer/gate.js +5 -5
- package/dist/computer/index.d.ts +40 -258
- package/dist/computer/index.js +71 -324
- package/dist/computer/paths.d.ts +36 -16
- package/dist/computer/paths.js +42 -29
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +1 -40
- package/dist/computer/render.js +2 -68
- package/dist/computer/tools-exec.js +12 -13
- package/dist/computer/tools-file.d.ts +9 -1
- package/dist/computer/tools-file.js +22 -82
- package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +4 -5
- package/dist/repo/checkout.js +31 -8
- package/dist/repo/context.d.ts +35 -9
- package/dist/repo/context.js +101 -49
- package/dist/repo/index.d.ts +73 -28
- package/dist/repo/index.js +112 -96
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.js +1 -1
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
- package/dist/repo/tools-review.d.ts +9 -0
- package/dist/repo/tools-review.js +67 -47
- package/dist/repo/tools-worktree.js +144 -60
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +4 -4
- package/dist/{computer → workspace}/advisory.js +2 -2
- package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
- package/dist/{computer/host → workspace}/ca-trust.js +12 -3
- package/dist/{computer/host → workspace}/container-deps.d.ts +4 -0
- package/dist/{computer/host → workspace}/container-deps.js +26 -1
- package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
- package/dist/{computer/host → workspace}/container-fault.js +23 -8
- package/dist/workspace/deps-snapshot.d.ts +110 -0
- package/dist/workspace/deps-snapshot.js +424 -0
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
- package/dist/{computer/host → workspace}/git-host.js +72 -2
- package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
- package/dist/{computer/host → workspace}/git-identity.js +1 -1
- package/dist/workspace/index.d.ts +44 -47
- package/dist/workspace/index.js +52 -112
- package/dist/{computer/host → workspace}/install-job.d.ts +53 -4
- package/dist/{computer/host → workspace}/install-job.js +346 -178
- package/dist/{computer → workspace}/install.d.ts +17 -8
- package/dist/{computer → workspace}/install.js +24 -6
- package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +125 -27
- package/dist/{computer/host/workspace.js → workspace/object.js} +341 -58
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/{computer → workspace}/shell.d.ts +5 -5
- package/dist/{computer → workspace}/shell.js +6 -6
- package/dist/{computer/host → workspace}/wake.d.ts +3 -1
- package/package.json +17 -37
- package/dist/arc-agi/analysis.d.ts +0 -322
- package/dist/arc-agi/analysis.js +0 -802
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -24
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -40
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -24
- package/dist/arc-agi/main-agent.js +0 -62
- package/dist/arc-agi/recipe.d.ts +0 -48
- package/dist/arc-agi/recipe.js +0 -97
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -29
- package/dist/arc-agi/soul.js +0 -79
- package/dist/arc-agi/store.d.ts +0 -153
- package/dist/arc-agi/store.js +0 -215
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -574
- package/dist/arc-agi/types.d.ts +0 -211
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -133
- package/dist/computer/read.js +0 -200
- package/dist/computer/tools-find.js +0 -216
- package/dist/recall/index.d.ts +0 -105
- package/dist/recall/index.js +0 -181
- package/dist/repo/tools-forge.d.ts +0 -4
- package/dist/triage/index.d.ts +0 -360
- package/dist/triage/index.js +0 -311
- /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
- /package/dist/{computer/host → workspace}/sync.js +0 -0
- /package/dist/{computer/host → workspace}/wake.js +0 -0
|
@@ -4,20 +4,20 @@ import { DurableObject, tracing } from "cloudflare:workers";
|
|
|
4
4
|
// object owes on waking, which is the callbacks registered on it below.
|
|
5
5
|
import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
|
|
6
6
|
import { Workspace } from "@cloudflare/computer";
|
|
7
|
-
import {
|
|
7
|
+
import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container";
|
|
8
8
|
import { createGitClient } from "@cloudflare/computer/git";
|
|
9
9
|
import { createCloudflareObserver } from "@cloudflare/computer/observe/cloudflare";
|
|
10
|
-
import { MAX_TOOL_CALL_MS } from "@dynamicagents/core";
|
|
11
10
|
import { ContainerTrust } from "./ca-trust.js";
|
|
12
11
|
import { ContainerDeps } from "./container-deps.js";
|
|
12
|
+
import { DepsSnapshot } from "./deps-snapshot.js";
|
|
13
13
|
import { ContainerGitIdentity } from "./git-identity.js";
|
|
14
14
|
import { InstallJob } from "./install-job.js";
|
|
15
15
|
import { WorkspaceGitHost } from "./git-host.js";
|
|
16
16
|
import { SYNC_DRAIN_BUDGET_MS, SYNC_DRAIN_RESUME_MS, WorkspaceSync, syncRetryDelayMs } from "./sync.js";
|
|
17
|
-
// Leaf modules rather than
|
|
17
|
+
// Leaf modules rather than `./index.ts` — see the same import in
|
|
18
18
|
// {@link file://./install-job.ts} for why the barrel is not reachable from here.
|
|
19
|
-
import { deriveAdvisories } from "
|
|
20
|
-
import { pathExists } from "
|
|
19
|
+
import { deriveAdvisories } from "./advisory.js";
|
|
20
|
+
import { pathExists } from "./read.js";
|
|
21
21
|
/**
|
|
22
22
|
* A workspace: one Durable Object, one container, one repository.
|
|
23
23
|
*
|
|
@@ -30,7 +30,7 @@ import { pathExists } from "../read.js";
|
|
|
30
30
|
* A directory inside the plugin rather than a subpath beside it, so that one
|
|
31
31
|
* capability stays one import path and a consumer cannot take the tools without
|
|
32
32
|
* the object they address. The bundle cost of that is nothing: `sideEffects` is
|
|
33
|
-
* false, so an agent that only calls `
|
|
33
|
+
* false, so an agent that only calls `bash` carries no container backend and
|
|
34
34
|
* no isomorphic-git.
|
|
35
35
|
*
|
|
36
36
|
* What this file must not do is import the plugin barrel. The barrel re-exports
|
|
@@ -43,7 +43,7 @@ import { pathExists } from "../read.js";
|
|
|
43
43
|
* FUSE at `/workspace`. Commands run against the same tree the Worker reads over
|
|
44
44
|
* RPC, and the tree outlives the container.
|
|
45
45
|
*
|
|
46
|
-
* **One repository per object**, because
|
|
46
|
+
* **One repository per object**, because `@cloudflare/computer` is strictly 1 DO ↔ 1
|
|
47
47
|
* container: the id derives from caller *and* repository (see `workspaceName`),
|
|
48
48
|
* so two repositories for one caller are two objects and two containers.
|
|
49
49
|
*
|
|
@@ -63,10 +63,11 @@ import { pathExists } from "../read.js";
|
|
|
63
63
|
* and each carries the reasoning for its own decisions:
|
|
64
64
|
*
|
|
65
65
|
* - `./install-job.ts` — when a dependency install runs, and every guard on it.
|
|
66
|
+
* - `./deps-snapshot.ts` — starting a container from one that already installed.
|
|
66
67
|
* - `./sync.ts` — the host's half of syncing, which nothing else will do.
|
|
67
68
|
* - `./ca-trust.ts` — making a container able to speak TLS.
|
|
68
69
|
* - `./git-identity.ts` — who a container's commits belong to.
|
|
69
|
-
* - `./git-host.ts` — the operations that hold the
|
|
70
|
+
* - `./git-host.ts` — the operations that hold the GitHub credential.
|
|
70
71
|
*
|
|
71
72
|
* Each takes a small explicit seam, and that is the test: a module that needed
|
|
72
73
|
* the whole object back would be a module that did not want extracting.
|
|
@@ -77,7 +78,7 @@ export const WORKSPACE_DIR = "/workspace";
|
|
|
77
78
|
* The Durable Object name for one caller's checkout of one repository.
|
|
78
79
|
*
|
|
79
80
|
* Exported because several places must agree on it and none can see the others:
|
|
80
|
-
* the plugin that resolves the stub, the agent that hands it down to
|
|
81
|
+
* the plugin that resolves the stub, the agent that hands it down to sub-agents,
|
|
81
82
|
* and the cancellation path. A pipe rather than a slash, so the caller half
|
|
82
83
|
* cannot forge a repository boundary by containing one.
|
|
83
84
|
*
|
|
@@ -90,7 +91,7 @@ export const WORKSPACE_DIR = "/workspace";
|
|
|
90
91
|
*
|
|
91
92
|
* `repo` is not always a repository. A host may pass a sentinel through here as
|
|
92
93
|
* one — a scratchpad is modelled that way, which is what keys it to its own
|
|
93
|
-
* object and its own container. Anything that is not a
|
|
94
|
+
* object and its own container. Anything that is not a GitHub name works, as long
|
|
94
95
|
* as no caller could clone something that collides with it.
|
|
95
96
|
*/
|
|
96
97
|
export function workspaceName(callerKey, repo) {
|
|
@@ -123,31 +124,40 @@ const IDLE_RECLAIM_ID = "idle-reclaim-id";
|
|
|
123
124
|
* Source-only workspaces are small (6.3 MB for slack-gatekeeper), which makes
|
|
124
125
|
* this hygiene rather than cost control — but unbounded hygiene is still
|
|
125
126
|
* unbounded.
|
|
127
|
+
*
|
|
128
|
+
* Exported because a host keeping work in a workspace has to say how long that
|
|
129
|
+
* work survives untouched.
|
|
126
130
|
*/
|
|
127
|
-
const IDLE_RECLAIM_MS = 7 * 24 * 60 * 60 * 1000;
|
|
131
|
+
export const IDLE_RECLAIM_MS = 7 * 24 * 60 * 60 * 1000;
|
|
128
132
|
/** Where the container-idle deadline keeps its current schedule id. */
|
|
129
133
|
const CONTAINER_IDLE_ID = "container-idle-id";
|
|
134
|
+
/** The holds on the container — see `hold` — as key → the time each lapses. */
|
|
135
|
+
const HOLDS_KEY = "container-holds";
|
|
136
|
+
/** When a release was asked for while a hold stood, for the last `unhold`. */
|
|
137
|
+
const RELEASE_ASKED_KEY = "container-release-asked";
|
|
130
138
|
/** Where the container-warm wake keeps its current schedule id. */
|
|
131
139
|
const CONTAINER_WARM_ID = "container-warm-id";
|
|
140
|
+
/** Where the dependency-snapshot wake keeps its current schedule id. */
|
|
141
|
+
const DEPS_SNAPSHOT_ID = "deps-snapshot-id";
|
|
132
142
|
/**
|
|
133
143
|
* How long a container stays up after the last command **started**.
|
|
134
144
|
*
|
|
135
|
-
* Ours to schedule
|
|
136
|
-
*
|
|
137
|
-
*
|
|
145
|
+
* Ours to schedule. The backend replaces a container but never stops one, and
|
|
146
|
+
* there is no `@cloudflare/containers` `Container` here with a `sleepAfter` to
|
|
147
|
+
* lean on: a container nobody is using runs until this deadline stops it.
|
|
138
148
|
*
|
|
139
149
|
* **This must exceed the longest command the agent allows**, and breaking that
|
|
140
150
|
* kills work in flight. Measured from when a command *starts*: `#touch()` arms
|
|
141
151
|
* the clock on the way into this object, and a running command touches nothing
|
|
142
152
|
* again until it finishes — `handle.result()` is one long await and the FUSE
|
|
143
|
-
* traffic under it never surfaces as an RPC. Set equal to
|
|
144
|
-
*
|
|
153
|
+
* traffic under it never surfaces as an RPC. Set equal to `DEFAULT_TIMEOUT_MS`
|
|
154
|
+
* in `./exec.ts`, the two timers race and whichever fires first destroys
|
|
145
155
|
* the container the other depends on. Twenty minutes is double that ceiling;
|
|
146
|
-
* raise it, never lower it, if `
|
|
156
|
+
* raise it, never lower it, if `bash` is given a longer timeout.
|
|
147
157
|
*
|
|
148
158
|
* **The default, not the policy.** "Longest command" is a fact about the agent,
|
|
149
159
|
* so one whose commands run longer must say so via
|
|
150
|
-
* {@link WorkspaceObjectConfig.containerIdleMs} — `claude-
|
|
160
|
+
* {@link WorkspaceObjectConfig.containerIdleMs} — `claude-coordinator` holds a
|
|
151
161
|
* `claude -p` session open for its whole 40-minute timeout.
|
|
152
162
|
*/
|
|
153
163
|
const CONTAINER_IDLE_MS = 20 * 60_000;
|
|
@@ -174,13 +184,13 @@ const SYNC_DRAIN_GRACE_MS = 30 * 60_000;
|
|
|
174
184
|
/**
|
|
175
185
|
* How long a container start may take before the calls waiting on it are refused.
|
|
176
186
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
187
|
+
* Late, because refusing a start that was only slow clears `#readying` while it
|
|
188
|
+
* runs, and the next caller starts a second one beside it — and a start pushing
|
|
189
|
+
* a large tree has taken nine minutes. Not later, because a turn is cut at
|
|
190
|
+
* fifteen: past that the model reads no reason, and its next turn joins the
|
|
191
|
+
* same stuck start.
|
|
182
192
|
*/
|
|
183
|
-
const READY_DEADLINE_MS =
|
|
193
|
+
const READY_DEADLINE_MS = 9 * 60_000;
|
|
184
194
|
/**
|
|
185
195
|
* `work`, refused once `ms` pass without it settling. The work itself goes on;
|
|
186
196
|
* only the wait for it ends — see {@link READY_DEADLINE_MS}.
|
|
@@ -199,6 +209,22 @@ export function readyWithin(work, ms) {
|
|
|
199
209
|
* see {@link WorkspaceObjectBase.#purgeSyncedTrees}.
|
|
200
210
|
*/
|
|
201
211
|
const SYNCED_TREES_PURGED_KEY = "deps:purged";
|
|
212
|
+
/**
|
|
213
|
+
* The `images` key a container starts from: `containers[].images.app` in the
|
|
214
|
+
* consumer's wrangler config. Named to the backend rather than left as its
|
|
215
|
+
* default, because {@link IMAGE_ENV} has to be read under the same key.
|
|
216
|
+
*/
|
|
217
|
+
const IMAGE_NAME = "app";
|
|
218
|
+
/**
|
|
219
|
+
* The env name the prepared image's reference rides under — see `backend` for
|
|
220
|
+
* why it rides at all.
|
|
221
|
+
*
|
|
222
|
+
* Nothing reads it, and the spelling keeps it that way. `computerd` hands a
|
|
223
|
+
* command its allowlist plus every `COMPUTER_VAR_` name, and reads its own
|
|
224
|
+
* configuration from `COMPUTERD_`, where a later release could claim this
|
|
225
|
+
* name. Outside both, it stays in the daemon's environment, unread.
|
|
226
|
+
*/
|
|
227
|
+
const IMAGE_ENV = "DYNAMICAGENTS_IMAGE";
|
|
202
228
|
// --- the object -------------------------------------------------------------
|
|
203
229
|
/**
|
|
204
230
|
* Named rather than the anonymous class expression an app would write here.
|
|
@@ -212,10 +238,9 @@ class WorkspaceContainerHost extends DurableObject {
|
|
|
212
238
|
}
|
|
213
239
|
/**
|
|
214
240
|
* `withWorkspaceContainer` adds one method, `getWorkspaceContainer()`, over
|
|
215
|
-
* `this.ctx.container` — the runtime's own container handle
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* everything else.
|
|
241
|
+
* `this.ctx.container` — the runtime's own container handle, started with the
|
|
242
|
+
* image and instance size this object asks for. Stopping it is this object's
|
|
243
|
+
* job — see {@link CONTAINER_IDLE_MS}.
|
|
219
244
|
*/
|
|
220
245
|
const WorkspaceContainerBase = withWorkspaceContainer(WorkspaceContainerHost);
|
|
221
246
|
/**
|
|
@@ -223,14 +248,13 @@ const WorkspaceContainerBase = withWorkspaceContainer(WorkspaceContainerHost);
|
|
|
223
248
|
*
|
|
224
249
|
* Abstract because a Durable Object class takes no constructor arguments, so
|
|
225
250
|
* per-agent configuration cannot arrive that way. {@link workspaceConfig} is the
|
|
226
|
-
* seam,
|
|
227
|
-
* uses for exactly the same reason.
|
|
251
|
+
* seam, for the same reason Think's own hooks are methods rather than options.
|
|
228
252
|
*
|
|
229
253
|
* ## Why `backend` and `#workspace` are lazy
|
|
230
254
|
*
|
|
231
255
|
* **Base class fields run before subclass fields**, so as plain fields they
|
|
232
256
|
* would read `undefined` from any `workspaceConfig()` that touches a subclass
|
|
233
|
-
* field — which `
|
|
257
|
+
* field — which `ClaudeCoordinatorWorkspace`'s does, for its credential store.
|
|
234
258
|
* Memoised getters remove the hazard rather than documenting it, which is what
|
|
235
259
|
* lets a subclass implement the seam however it likes.
|
|
236
260
|
*/
|
|
@@ -281,6 +305,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
281
305
|
idleReclaim: () => this.#onIdleReclaim(),
|
|
282
306
|
containerIdle: (payload) => this.#onContainerIdle(payload),
|
|
283
307
|
containerWarm: () => this.#onContainerWarm(),
|
|
308
|
+
depsSnapshot: () => this.#onDepsSnapshot(),
|
|
284
309
|
syncRetry: (payload) => this.#onSyncDrain(payload)
|
|
285
310
|
},
|
|
286
311
|
onError: (err) => {
|
|
@@ -317,6 +342,18 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
317
342
|
key: CONTAINER_WARM_ID,
|
|
318
343
|
callback: "containerWarm"
|
|
319
344
|
});
|
|
345
|
+
/**
|
|
346
|
+
* Snapshot a container that just installed — see `./deps-snapshot.ts`.
|
|
347
|
+
*
|
|
348
|
+
* From the alarm rather than the install's drain, which a model turn's
|
|
349
|
+
* `waitUntil` may own.
|
|
350
|
+
*/
|
|
351
|
+
#snapshotDue = namedDeadline({
|
|
352
|
+
storage: this.ctx.storage,
|
|
353
|
+
scheduler: this.#wake.scheduler,
|
|
354
|
+
key: DEPS_SNAPSHOT_ID,
|
|
355
|
+
callback: "depsSnapshot"
|
|
356
|
+
});
|
|
320
357
|
/**
|
|
321
358
|
* Come back and move the next block of an outstanding pull.
|
|
322
359
|
*
|
|
@@ -349,11 +386,24 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
349
386
|
ready: () => this.#ready(),
|
|
350
387
|
waitUntil: (promise) => this.ctx.waitUntil(promise),
|
|
351
388
|
containerGone: () => this.#containerGone(),
|
|
389
|
+
installedRecords: (tree) => this.#snapshot.dueEntries(tree),
|
|
390
|
+
installed: async () => void (await this.#snapshotDue.set(new Date())),
|
|
391
|
+
tag: () => this.#tag,
|
|
392
|
+
id: () => this.ctx.id.toString()
|
|
393
|
+
});
|
|
394
|
+
/** The next container's dependency trees — see `./deps-snapshot.ts`. */
|
|
395
|
+
#snapshot = new DepsSnapshot({
|
|
396
|
+
storage: this.ctx.storage,
|
|
397
|
+
container: () => this.ctx.container,
|
|
398
|
+
image: () => this.#image,
|
|
399
|
+
workspace: () => this.#workspace,
|
|
400
|
+
rootFingerprint: (dir) => this.#install.rootFingerprint(dir),
|
|
401
|
+
tree: () => this.#install.tree(),
|
|
352
402
|
tag: () => this.#tag,
|
|
353
403
|
id: () => this.ctx.id.toString()
|
|
354
404
|
});
|
|
355
405
|
/**
|
|
356
|
-
* The
|
|
406
|
+
* The GitHub credential, read at the moment it would be handed over.
|
|
357
407
|
*
|
|
358
408
|
* `#`-private, which is the only spelling actually unreachable over RPC — see
|
|
359
409
|
* {@link WorkspaceGitConfig.tokenBinding} for why that matters here and not
|
|
@@ -392,19 +442,47 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
392
442
|
* Nothing sets `egressHost`; the default `computer.internal` is the host the
|
|
393
443
|
* container's outbound HTTP is intercepted on, internal to that loopback.
|
|
394
444
|
*
|
|
395
|
-
* The
|
|
396
|
-
*
|
|
445
|
+
* **The image rides in the env so that a deploy replaces the container.** The
|
|
446
|
+
* backend reuses a running container whose launch — env, network, size, image
|
|
447
|
+
* key — matches the one it asks for, and the image a key resolves to is not
|
|
448
|
+
* part of that: a container started before a deploy would go on running the
|
|
449
|
+
* old image under the new Worker. The library here and the `computerd` in the
|
|
450
|
+
* image are one release, and a daemon from across a version line fails to
|
|
451
|
+
* authenticate (`[stage=auth]`). With the prepared image's reference in the
|
|
452
|
+
* env as {@link IMAGE_ENV}, a new image is a new launch, and the next connect
|
|
453
|
+
* replaces the container. With no image prepared under {@link IMAGE_NAME}
|
|
454
|
+
* there is nothing to add, and the backend's start says so.
|
|
455
|
+
*
|
|
456
|
+
* The binding name, the egress policy and the instance size are the
|
|
457
|
+
* subclass's — see {@link WorkspaceObjectConfig}, which carries the warnings
|
|
458
|
+
* on each.
|
|
459
|
+
*
|
|
460
|
+
* `launch` is how a start restores a dependency snapshot instead of booting
|
|
461
|
+
* the image — see `./deps-snapshot.ts`. Never in the env: the env is part of
|
|
462
|
+
* what a running container is matched against.
|
|
397
463
|
*/
|
|
398
464
|
#backendMemo;
|
|
399
465
|
get backend() {
|
|
400
|
-
return (this.#backendMemo ??=
|
|
466
|
+
return (this.#backendMemo ??= this.#newBackend());
|
|
467
|
+
}
|
|
468
|
+
/** The image a start boots, as the deploy prepared it. */
|
|
469
|
+
get #image() {
|
|
470
|
+
return this.ctx.container?.images?.[IMAGE_NAME];
|
|
471
|
+
}
|
|
472
|
+
#newBackend() {
|
|
473
|
+
const image = this.#image;
|
|
474
|
+
return new ContainerBackend({
|
|
401
475
|
container: () => this,
|
|
402
476
|
workspace: {
|
|
403
477
|
binding: this.#cfg.binding,
|
|
404
478
|
id: this.ctx.id.toString()
|
|
405
479
|
},
|
|
406
|
-
egress: this.#cfg.egress
|
|
407
|
-
|
|
480
|
+
egress: this.#cfg.egress,
|
|
481
|
+
name: IMAGE_NAME,
|
|
482
|
+
instance: this.#cfg.instance,
|
|
483
|
+
containerEnv: image === undefined ? {} : { [IMAGE_ENV]: image },
|
|
484
|
+
launch: this.#snapshot.launch
|
|
485
|
+
});
|
|
408
486
|
}
|
|
409
487
|
#workspaceMemo;
|
|
410
488
|
get #workspace() {
|
|
@@ -447,6 +525,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
447
525
|
this.#gitIdentity.forget();
|
|
448
526
|
this.#deps.forget();
|
|
449
527
|
await this.#install.containerGone();
|
|
528
|
+
await this.#snapshot.forgetDue();
|
|
450
529
|
}
|
|
451
530
|
/**
|
|
452
531
|
* Set once {@link reclaimIfIdle} has emptied storage, until the alarm it armed
|
|
@@ -486,7 +565,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
486
565
|
* method is reachable over RPC — see {@link WorkspaceGitConfig.tokenBinding}.
|
|
487
566
|
*
|
|
488
567
|
* **One start at a time**, because {@link #warmIfCold} makes overlap ordinary:
|
|
489
|
-
* a warm and the first `
|
|
568
|
+
* a warm and the first `bash` want the container at the same moment, as do
|
|
490
569
|
* a warm and the install alarm. Two starts means two trust commands through a
|
|
491
570
|
* half-established connection, the second pushing the tree again behind the
|
|
492
571
|
* first. Cleared on settle, so the next caller asks about the container that
|
|
@@ -499,19 +578,53 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
499
578
|
});
|
|
500
579
|
return this.#readying;
|
|
501
580
|
}
|
|
502
|
-
/**
|
|
581
|
+
/**
|
|
582
|
+
* One start, run for whoever got there first — see {@link #ready}.
|
|
583
|
+
*
|
|
584
|
+
* A cold start restores the dependency snapshot when one fits, and the marker
|
|
585
|
+
* the setup reads decides whether what came up is adopted as installed — see
|
|
586
|
+
* `./deps-snapshot.ts`.
|
|
587
|
+
*/
|
|
503
588
|
async #readyNow() {
|
|
504
589
|
// Read **before** `ready()`, which starts a stopped container.
|
|
505
|
-
|
|
590
|
+
let restoring;
|
|
591
|
+
if (!this.ctx.container?.running) {
|
|
506
592
|
await this.#containerGone();
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
await this.#
|
|
593
|
+
restoring = await this.#snapshot.arm(() => this.#expectedTree());
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
await this.#workspace.ready();
|
|
597
|
+
// Before the first exec, which pushes the whole tree into a new container.
|
|
598
|
+
await this.#purgeSyncedTrees();
|
|
599
|
+
await this.#trust.ensure();
|
|
600
|
+
await this.#gitIdentity.ensure();
|
|
601
|
+
const found = await this.#deps.ensure(restoring?.dir ?? (await this.#install.dir()));
|
|
602
|
+
if (found)
|
|
603
|
+
await this.#install.reconcile(found.marker);
|
|
604
|
+
if (restoring &&
|
|
605
|
+
(await this.#snapshot.restored(restoring, found?.marker)))
|
|
606
|
+
await this.#install.adopt(restoring);
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
this.#snapshot.disarm();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* The install a cold start would owe: where the last one ran, or the checkout
|
|
614
|
+
* when none has — a worktree seeded before its first install.
|
|
615
|
+
*/
|
|
616
|
+
async #expectedTree() {
|
|
617
|
+
const target = (await this.#install.target()) ?? (await this.#checkoutTarget());
|
|
618
|
+
if (!target)
|
|
619
|
+
return undefined;
|
|
620
|
+
const fingerprint = await this.#install.fingerprintFor(target);
|
|
621
|
+
return fingerprint ? { dir: target.dir, fingerprint } : undefined;
|
|
622
|
+
}
|
|
623
|
+
async #checkoutTarget() {
|
|
624
|
+
const record = await this.ctx.storage.get(CHECKOUT_KEY);
|
|
625
|
+
if (!record?.dir)
|
|
626
|
+
return undefined;
|
|
627
|
+
return { dir: record.dir, ...(record.repo ? { repo: record.repo } : {}) };
|
|
515
628
|
}
|
|
516
629
|
/**
|
|
517
630
|
* Delete every `node_modules` that was synced into this object, once.
|
|
@@ -581,7 +694,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
581
694
|
observer: createCloudflareObserver({ tracing }),
|
|
582
695
|
// Git, running **here** rather than in the container.
|
|
583
696
|
//
|
|
584
|
-
// This is what lets the
|
|
697
|
+
// This is what lets the GitHub token stay on this side of the boundary.
|
|
585
698
|
// `createGitClient` binds isomorphic-git to `provider()` — the local
|
|
586
699
|
// SQLite store, not the wire — so a clone, fetch or push executes next to
|
|
587
700
|
// the data it writes, and the container never holds a credential at all.
|
|
@@ -657,7 +770,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
657
770
|
*
|
|
658
771
|
* A stub cannot enforce what the caller does with it: `runtime.exec` on this
|
|
659
772
|
* one would run against a container with no CA. The exec paths take the other
|
|
660
|
-
* method — see `openWorkspaceFs` in
|
|
773
|
+
* method — see `openWorkspaceFs` in `./open.ts`.
|
|
661
774
|
*/
|
|
662
775
|
async __getWorkspaceFsStub() {
|
|
663
776
|
await this.#touch();
|
|
@@ -669,7 +782,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
669
782
|
return this.#workspace.stub();
|
|
670
783
|
}
|
|
671
784
|
/**
|
|
672
|
-
* Ask the alarm to start the container, so the first `
|
|
785
|
+
* Ask the alarm to start the container, so the first `bash` finds one that
|
|
673
786
|
* is up and already holding the tree.
|
|
674
787
|
*
|
|
675
788
|
* What this avoids is the push, not the boot — see {@link __getWorkspaceFsStub}.
|
|
@@ -679,7 +792,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
679
792
|
* nothing to push and nothing to run, and a container it never uses still
|
|
680
793
|
* bills.
|
|
681
794
|
*
|
|
682
|
-
* Only the arming is awaited. A warm that fails costs the next `
|
|
795
|
+
* Only the arming is awaited. A warm that fails costs the next `bash` the
|
|
683
796
|
* start it would have paid for anyway.
|
|
684
797
|
*/
|
|
685
798
|
async #warmIfCold() {
|
|
@@ -697,7 +810,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
697
810
|
await this.#containerWarm.set(new Date());
|
|
698
811
|
}
|
|
699
812
|
/**
|
|
700
|
-
* Clone, fetch and push — the operations that need the
|
|
813
|
+
* Clone, fetch and push — the operations that need the GitHub token.
|
|
701
814
|
*
|
|
702
815
|
* The work is `./git-host.ts`, which owns why the credential stays on this
|
|
703
816
|
* side of the boundary. What stays here is what only this object can do: the
|
|
@@ -716,6 +829,14 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
716
829
|
const behind = await this.#beforeGit();
|
|
717
830
|
return behind ?? (await this.#gitHost.push(req));
|
|
718
831
|
}
|
|
832
|
+
/**
|
|
833
|
+
* A checkout's `origin`, from this object's storage. Starts no container and
|
|
834
|
+
* moves no deadline, like {@link checkoutDir}: a pull request polled for its
|
|
835
|
+
* review is not the workspace in use.
|
|
836
|
+
*/
|
|
837
|
+
async gitOrigin(dir) {
|
|
838
|
+
return await this.#gitHost.origin(dir);
|
|
839
|
+
}
|
|
719
840
|
/**
|
|
720
841
|
* Bring the workspace up to date, or say why git must not run.
|
|
721
842
|
*
|
|
@@ -725,7 +846,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
725
846
|
* did not finish, the workspace still holds the tree as it was before the
|
|
726
847
|
* commit — and isomorphic-git, running here, would push a branch that does not
|
|
727
848
|
* include it. That is the one failure worth stopping for: the push reports
|
|
728
|
-
* success,
|
|
849
|
+
* success, GitHub shows an older tree, and nothing in either account says
|
|
729
850
|
* why.
|
|
730
851
|
*
|
|
731
852
|
* Refused rather than attempted, because every alternative is worse. Pushing
|
|
@@ -787,13 +908,33 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
787
908
|
async startInstall(req) {
|
|
788
909
|
return await this.#install.start(req);
|
|
789
910
|
}
|
|
911
|
+
/**
|
|
912
|
+
* This workspace's dependency snapshot, for another of the same class to start
|
|
913
|
+
* from — see {@link seedDepsSnapshot}. Starts no container and moves no
|
|
914
|
+
* deadline.
|
|
915
|
+
*/
|
|
916
|
+
async depsSnapshot() {
|
|
917
|
+
return await this.#snapshot.get();
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Start this workspace's next container from `record`, another workspace's
|
|
921
|
+
* dependency snapshot, when it fits the checkout here — a worktree at the same
|
|
922
|
+
* path as the checkout it was cut from. Call it after the checkout and before
|
|
923
|
+
* the first command.
|
|
924
|
+
*
|
|
925
|
+
* A snapshot is that workspace's whole disk, so offer one only between
|
|
926
|
+
* workspaces of the same caller.
|
|
927
|
+
*/
|
|
928
|
+
async seedDepsSnapshot(record) {
|
|
929
|
+
return await this.#snapshot.seed(record, () => this.#expectedTree());
|
|
930
|
+
}
|
|
790
931
|
// --- lifecycle ---------------------------------------------------------------
|
|
791
932
|
/**
|
|
792
933
|
* Mark this workspace as in use, and push its reclamation back.
|
|
793
934
|
*
|
|
794
935
|
* Every entry point calls this, which is what makes the idle clock measure
|
|
795
936
|
* *use* rather than "when the agent last said this name". The agent hands a
|
|
796
|
-
* workspace name to a
|
|
937
|
+
* workspace name to a sub-agent once and then never sees the traffic; the
|
|
797
938
|
* workspace sees all of it.
|
|
798
939
|
*/
|
|
799
940
|
async #touch() {
|
|
@@ -816,6 +957,116 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
816
957
|
await this.#idleReclaim.set(new Date(now + IDLE_RECLAIM_MS));
|
|
817
958
|
await this.#containerIdle.set(new Date(now + this.#containerIdleMs));
|
|
818
959
|
}
|
|
960
|
+
/**
|
|
961
|
+
* Stop the container, and **keep everything on disk**.
|
|
962
|
+
*
|
|
963
|
+
* The counterpart to {@link reclaimIfIdle}, and the difference is the whole
|
|
964
|
+
* reason both exist. A reclaim is for a workspace nobody wants again: it empties
|
|
965
|
+
* storage, so the checkout and the dependency snapshot go with the container.
|
|
966
|
+
* This is for a workspace that will be worked in again — the next task on the
|
|
967
|
+
* same repository skips the clone, and the install by restoring the snapshot.
|
|
968
|
+
*
|
|
969
|
+
* **Why a caller needs this at all.** The idle deadline would eventually do it,
|
|
970
|
+
* but it cannot be tuned down to meet a cost target: `containerIdleMs` must
|
|
971
|
+
* exceed the longest command the agent allows, and a container that outlives its
|
|
972
|
+
* work by that much bills for the difference. So a host that knows the work is
|
|
973
|
+
* finished says so, and the deadline goes back to being a backstop for the case
|
|
974
|
+
* where nothing got to say anything.
|
|
975
|
+
*
|
|
976
|
+
* Deliberately does **not** touch: `#touch()` would push the very deadline this
|
|
977
|
+
* is standing in for.
|
|
978
|
+
*
|
|
979
|
+
* `released: false` means the container is still up and the standing deadline
|
|
980
|
+
* still owns it — an install is running, or a pull is still moving blocks. Both
|
|
981
|
+
* resolve themselves: the drain re-arms the deadline to come back and finish,
|
|
982
|
+
* which is the same path `#onContainerIdle` takes. Under a {@link hold} the
|
|
983
|
+
* release is deferred instead, to the last `unhold`.
|
|
984
|
+
*/
|
|
985
|
+
async releaseContainer() {
|
|
986
|
+
// Work this object cannot see. Deferred rather than refused: the last
|
|
987
|
+
// `unhold` releases, unless the workspace is used again first.
|
|
988
|
+
const holds = await this.#liveHolds();
|
|
989
|
+
if (holds.length > 0) {
|
|
990
|
+
await this.ctx.storage.put(RELEASE_ASKED_KEY, Date.now());
|
|
991
|
+
console.info(`[${this.#tag}] release deferred while held`, {
|
|
992
|
+
id: this.ctx.id.toString(),
|
|
993
|
+
holds
|
|
994
|
+
});
|
|
995
|
+
return { released: false };
|
|
996
|
+
}
|
|
997
|
+
await this.ctx.storage.delete(RELEASE_ASKED_KEY);
|
|
998
|
+
// An install in flight is "in use" even though nothing has called in.
|
|
999
|
+
// Same rule, and same reason, as `#onContainerIdle`.
|
|
1000
|
+
const install = await this.#install.read();
|
|
1001
|
+
if (install.state === "running")
|
|
1002
|
+
return { released: false };
|
|
1003
|
+
// Never stop a container with a pull still moving: that is what turns an
|
|
1004
|
+
// outstanding write from late into lost. `#drainBeforeStop` carries the
|
|
1005
|
+
// reasoning and re-arms the deadline when it defers.
|
|
1006
|
+
const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
|
|
1007
|
+
if (await this.#drainBeforeStop(lastUsedAt, 0))
|
|
1008
|
+
return { released: false };
|
|
1009
|
+
// Dropped rather than left standing: the container is about to be gone, and an
|
|
1010
|
+
// alarm that fires afterwards logs a stop against a container that is not
|
|
1011
|
+
// running. `#touch` re-arms it the next time this workspace is used.
|
|
1012
|
+
await this.#containerIdle.clear();
|
|
1013
|
+
await this.#stopContainer("idle");
|
|
1014
|
+
return { released: true };
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Keep the container up for work this object cannot see, until `untilMs` or
|
|
1018
|
+
* {@link unhold}.
|
|
1019
|
+
*
|
|
1020
|
+
* A Claude Code session a host starts here runs detached: nothing calls in
|
|
1021
|
+
* while it works, and the runtime cannot list
|
|
1022
|
+
* what is running. So a host done with the workspace cannot tell that the
|
|
1023
|
+
* container is not, and {@link releaseContainer} would stop the session with
|
|
1024
|
+
* it. A hold is the host saying so. It defers a release; it is not use, so it
|
|
1025
|
+
* does not touch, and the idle deadline already outlasts a session.
|
|
1026
|
+
*
|
|
1027
|
+
* `untilMs` bounds a hold whose `unhold` never comes — a run cut between its
|
|
1028
|
+
* start and its end. Past it the hold is ignored.
|
|
1029
|
+
*/
|
|
1030
|
+
async hold(key, untilMs) {
|
|
1031
|
+
const holds = await this.#holdMap();
|
|
1032
|
+
holds[key] = untilMs;
|
|
1033
|
+
await this.ctx.storage.put(HOLDS_KEY, holds);
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* The work a {@link hold} stood for is over. When it was the last hold, a
|
|
1037
|
+
* release asked for while it stood happens now — unless the workspace was
|
|
1038
|
+
* used since, in which case the next release is asked for by whoever used it.
|
|
1039
|
+
*/
|
|
1040
|
+
async unhold(key) {
|
|
1041
|
+
// Read and written with no other I/O between, so the object's input gate
|
|
1042
|
+
// keeps another call from interleaving: the map written is the one decided on.
|
|
1043
|
+
const holds = await this.#holdMap();
|
|
1044
|
+
delete holds[key];
|
|
1045
|
+
await this.ctx.storage.put(HOLDS_KEY, holds);
|
|
1046
|
+
if (Object.keys(holds).length > 0)
|
|
1047
|
+
return { released: false };
|
|
1048
|
+
const asked = await this.ctx.storage.get(RELEASE_ASKED_KEY);
|
|
1049
|
+
if (asked === undefined)
|
|
1050
|
+
return { released: false };
|
|
1051
|
+
// A use in the same millisecond as the ask counts as after it: wrongly
|
|
1052
|
+
// keeping a container costs its idle window, wrongly stopping one a session.
|
|
1053
|
+
const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
|
|
1054
|
+
if (lastUsedAt >= asked) {
|
|
1055
|
+
await this.ctx.storage.delete(RELEASE_ASKED_KEY);
|
|
1056
|
+
return { released: false };
|
|
1057
|
+
}
|
|
1058
|
+
return await this.releaseContainer();
|
|
1059
|
+
}
|
|
1060
|
+
/** Every hold, lapsed ones pruned. */
|
|
1061
|
+
async #holdMap() {
|
|
1062
|
+
const now = Date.now();
|
|
1063
|
+
const stored = (await this.ctx.storage.get(HOLDS_KEY)) ?? {};
|
|
1064
|
+
return Object.fromEntries(Object.entries(stored).filter(([, until]) => until > now));
|
|
1065
|
+
}
|
|
1066
|
+
/** The keys of the holds still standing. */
|
|
1067
|
+
async #liveHolds() {
|
|
1068
|
+
return Object.keys(await this.#holdMap());
|
|
1069
|
+
}
|
|
819
1070
|
/**
|
|
820
1071
|
* Throw this workspace away if nothing has touched it for `maxIdleMs`.
|
|
821
1072
|
*
|
|
@@ -883,6 +1134,16 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
883
1134
|
reason,
|
|
884
1135
|
running: this.ctx.container?.running ?? false
|
|
885
1136
|
});
|
|
1137
|
+
// An install's snapshot the wake has not taken yet would go with the disk
|
|
1138
|
+
// it is of, so it is taken here first. Not for a reclaim, which keeps
|
|
1139
|
+
// nothing. Either way, one in flight needs the container running.
|
|
1140
|
+
if (reason === "idle")
|
|
1141
|
+
await this.#snapshot.takeDue();
|
|
1142
|
+
await this.#snapshot.settled();
|
|
1143
|
+
// Hang up the session to computerd first: a destroy that cuts it live
|
|
1144
|
+
// surfaces as an uncaught `Network connection lost`. The workspace
|
|
1145
|
+
// reconnects on its next use, and `close()` swallows its own failures.
|
|
1146
|
+
await this.#workspaceMemo?.close();
|
|
886
1147
|
try {
|
|
887
1148
|
await this.ctx.container?.destroy();
|
|
888
1149
|
}
|
|
@@ -1013,7 +1274,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
1013
1274
|
}
|
|
1014
1275
|
catch (err) {
|
|
1015
1276
|
// A read of local SQLite that threw says nothing about the tree. Treat it
|
|
1016
|
-
// as absent: refusing a delegation costs a
|
|
1277
|
+
// as absent: refusing a delegation costs a tool call, starting a session in a
|
|
1017
1278
|
// directory that may not exist costs the run.
|
|
1018
1279
|
console.warn(`[${this.#tag}] could not probe a checkout`, {
|
|
1019
1280
|
id: this.ctx.id.toString(),
|
|
@@ -1025,7 +1286,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
1025
1286
|
}
|
|
1026
1287
|
/**
|
|
1027
1288
|
* Everything currently true about this workspace that a caller must not assume
|
|
1028
|
-
* away — the array `
|
|
1289
|
+
* away — the array `bash`, `edit` and the workspace's writes all consult.
|
|
1029
1290
|
*
|
|
1030
1291
|
* The policy is not here. `deriveAdvisories` decides which facts matter and
|
|
1031
1292
|
* how they are worded; this method gathers what only the object can see and
|
|
@@ -1143,6 +1404,21 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
1143
1404
|
await this.#sync.arm((payload?.attempt ?? 0) + 1);
|
|
1144
1405
|
}
|
|
1145
1406
|
}
|
|
1407
|
+
/** An install finished in this container — see `./deps-snapshot.ts`. */
|
|
1408
|
+
async #onDepsSnapshot() {
|
|
1409
|
+
// The record is read from this object's storage — each root's inputs, and
|
|
1410
|
+
// which roots there are — so the install's own pull has to have landed: it
|
|
1411
|
+
// carries the submodules a bootstrap cloned. One still moving comes back;
|
|
1412
|
+
// one that failed is left to the stop, which drains before it takes.
|
|
1413
|
+
const synced = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
|
|
1414
|
+
if (synced === "incomplete") {
|
|
1415
|
+
await this.#snapshotDue.set(new Date(Date.now() + SYNC_DRAIN_RESUME_MS));
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (synced === "failed")
|
|
1419
|
+
return;
|
|
1420
|
+
await this.#snapshot.takeDue();
|
|
1421
|
+
}
|
|
1146
1422
|
/** The idle-reclaim deadline came due. */
|
|
1147
1423
|
async #onIdleReclaim() {
|
|
1148
1424
|
const { reclaimed, idleMs } = await this.reclaimIfIdle();
|
|
@@ -1161,7 +1437,7 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
1161
1437
|
*
|
|
1162
1438
|
* **Swallows its own failure.** Every other wake here is work somebody waits
|
|
1163
1439
|
* for; this one is early, so a container that will not start must cost the
|
|
1164
|
-
* next `
|
|
1440
|
+
* next `bash` a start rather than put the scheduler's retry ladder behind a
|
|
1165
1441
|
* deployment fault that will refuse for as long as it stands.
|
|
1166
1442
|
*/
|
|
1167
1443
|
async #onContainerWarm() {
|
|
@@ -1191,6 +1467,13 @@ export class WorkspaceObjectBase extends WorkspaceContainerBase {
|
|
|
1191
1467
|
await this.#containerIdle.set(new Date(Date.now() + this.#containerIdleMs));
|
|
1192
1468
|
return;
|
|
1193
1469
|
}
|
|
1470
|
+
// Work nothing calls in for, which the deadline cannot measure: come back
|
|
1471
|
+
// when the first hold lapses, without counting the hold as use.
|
|
1472
|
+
const lapses = Object.values(await this.#holdMap());
|
|
1473
|
+
if (lapses.length > 0) {
|
|
1474
|
+
await this.#containerIdle.set(new Date(Math.min(...lapses)));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1194
1477
|
// Re-read the clock rather than trusting the alarm, the same way
|
|
1195
1478
|
// `reclaimIfIdle` does. `#touch` moves the deadline forward, but an alarm
|
|
1196
1479
|
// already in flight cannot be recalled — so without this a workspace that
|