@usine/executor 0.3.0 → 0.4.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 (185) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +116 -19
  3. package/dist/child-command.d.ts +1 -1
  4. package/dist/child-command.d.ts.map +1 -1
  5. package/dist/child-command.js +6 -4
  6. package/dist/child-command.js.map +1 -1
  7. package/dist/child-environment.d.ts +19 -0
  8. package/dist/child-environment.d.ts.map +1 -0
  9. package/dist/child-environment.js +18 -0
  10. package/dist/child-environment.js.map +1 -0
  11. package/dist/claude-login-lock.d.ts +3 -2
  12. package/dist/claude-login-lock.d.ts.map +1 -1
  13. package/dist/claude-login-lock.js +10 -4
  14. package/dist/claude-login-lock.js.map +1 -1
  15. package/dist/cloudflare/client.d.ts +50 -0
  16. package/dist/cloudflare/client.d.ts.map +1 -0
  17. package/dist/cloudflare/client.js +108 -0
  18. package/dist/cloudflare/client.js.map +1 -0
  19. package/dist/cloudflare/cloudflare.d.ts +17 -0
  20. package/dist/cloudflare/cloudflare.d.ts.map +1 -0
  21. package/dist/cloudflare/cloudflare.js +704 -0
  22. package/dist/cloudflare/cloudflare.js.map +1 -0
  23. package/dist/cloudflare/model.d.ts +42 -0
  24. package/dist/cloudflare/model.d.ts.map +1 -0
  25. package/dist/cloudflare/model.js +2 -0
  26. package/dist/cloudflare/model.js.map +1 -0
  27. package/dist/cloudflare/shell/environment.d.ts +12 -0
  28. package/dist/cloudflare/shell/environment.d.ts.map +1 -0
  29. package/dist/cloudflare/shell/environment.js +12 -0
  30. package/dist/cloudflare/shell/environment.js.map +1 -0
  31. package/dist/cloudflare/shell/shell.d.ts +2 -0
  32. package/dist/cloudflare/shell/shell.d.ts.map +1 -0
  33. package/dist/cloudflare/shell/shell.js +155 -0
  34. package/dist/cloudflare/shell/shell.js.map +1 -0
  35. package/dist/durable-session.d.ts +21 -3
  36. package/dist/durable-session.d.ts.map +1 -1
  37. package/dist/durable-session.js +69 -10
  38. package/dist/durable-session.js.map +1 -1
  39. package/dist/exe-dev/commands.d.ts +1 -1
  40. package/dist/exe-dev/commands.d.ts.map +1 -1
  41. package/dist/exe-dev/exe-dev.d.ts +1 -1
  42. package/dist/exe-dev/exe-dev.d.ts.map +1 -1
  43. package/dist/exe-dev/exe-dev.js +9 -1
  44. package/dist/exe-dev/exe-dev.js.map +1 -1
  45. package/dist/exe-dev/preflight.d.ts +2 -2
  46. package/dist/exe-dev/preflight.d.ts.map +1 -1
  47. package/dist/exe-dev/preflight.js +5 -4
  48. package/dist/exe-dev/preflight.js.map +1 -1
  49. package/dist/executor.d.ts +164 -15
  50. package/dist/executor.d.ts.map +1 -1
  51. package/dist/executor.js +100 -3
  52. package/dist/executor.js.map +1 -1
  53. package/dist/guard.d.ts.map +1 -1
  54. package/dist/guard.js +1 -0
  55. package/dist/guard.js.map +1 -1
  56. package/dist/harness-preflight.d.ts +9 -8
  57. package/dist/harness-preflight.d.ts.map +1 -1
  58. package/dist/harness-preflight.js +15 -10
  59. package/dist/harness-preflight.js.map +1 -1
  60. package/dist/local/local.d.ts +1 -1
  61. package/dist/local/local.d.ts.map +1 -1
  62. package/dist/local/local.js +76 -37
  63. package/dist/local/local.js.map +1 -1
  64. package/dist/memory-preflight.d.ts +3 -1
  65. package/dist/memory-preflight.d.ts.map +1 -1
  66. package/dist/memory-preflight.js +1 -1
  67. package/dist/memory-preflight.js.map +1 -1
  68. package/dist/pre-push-hook.d.ts +1 -1
  69. package/dist/pre-push-hook.js +1 -1
  70. package/dist/prepared-attempt.d.ts +8 -0
  71. package/dist/prepared-attempt.d.ts.map +1 -1
  72. package/dist/prepared-attempt.js.map +1 -1
  73. package/dist/provision.d.ts +64 -13
  74. package/dist/provision.d.ts.map +1 -1
  75. package/dist/provision.js +99 -42
  76. package/dist/provision.js.map +1 -1
  77. package/dist/remote/commands.d.ts +1 -1
  78. package/dist/remote/commands.d.ts.map +1 -1
  79. package/dist/remote/commands.js +1 -1
  80. package/dist/remote/commands.js.map +1 -1
  81. package/dist/remote/container.d.ts +90 -0
  82. package/dist/remote/container.d.ts.map +1 -0
  83. package/dist/remote/container.js +480 -0
  84. package/dist/remote/container.js.map +1 -0
  85. package/dist/remote/liveness.d.ts +1 -1
  86. package/dist/remote/liveness.d.ts.map +1 -1
  87. package/dist/remote/liveness.js +6 -4
  88. package/dist/remote/liveness.js.map +1 -1
  89. package/dist/remote/machine.d.ts +52 -6
  90. package/dist/remote/machine.d.ts.map +1 -1
  91. package/dist/remote/remote.d.ts +11 -7
  92. package/dist/remote/remote.d.ts.map +1 -1
  93. package/dist/remote/remote.js +340 -92
  94. package/dist/remote/remote.js.map +1 -1
  95. package/dist/remote/sweep.d.ts +3 -1
  96. package/dist/remote/sweep.d.ts.map +1 -1
  97. package/dist/remote/sweep.js +2 -1
  98. package/dist/remote/sweep.js.map +1 -1
  99. package/dist/salvage.d.ts +18 -10
  100. package/dist/salvage.d.ts.map +1 -1
  101. package/dist/salvage.js +123 -29
  102. package/dist/salvage.js.map +1 -1
  103. package/dist/ssh/api-keys.d.ts +36 -0
  104. package/dist/ssh/api-keys.d.ts.map +1 -0
  105. package/dist/ssh/api-keys.js +72 -0
  106. package/dist/ssh/api-keys.js.map +1 -0
  107. package/dist/ssh/codex-homes.d.ts +21 -0
  108. package/dist/ssh/codex-homes.d.ts.map +1 -0
  109. package/dist/ssh/codex-homes.js +78 -0
  110. package/dist/ssh/codex-homes.js.map +1 -0
  111. package/dist/ssh/container.d.ts +32 -0
  112. package/dist/ssh/container.d.ts.map +1 -0
  113. package/dist/ssh/container.js +282 -0
  114. package/dist/ssh/container.js.map +1 -0
  115. package/dist/ssh/credentials.d.ts +3 -0
  116. package/dist/ssh/credentials.d.ts.map +1 -1
  117. package/dist/ssh/credentials.js +3 -0
  118. package/dist/ssh/credentials.js.map +1 -1
  119. package/dist/ssh/gateway/admin.d.ts +1 -1
  120. package/dist/ssh/gateway/admin.d.ts.map +1 -1
  121. package/dist/ssh/gateway/admin.js +2 -2
  122. package/dist/ssh/gateway/admin.js.map +1 -1
  123. package/dist/ssh/gateway/attachments.d.ts +15 -6
  124. package/dist/ssh/gateway/attachments.d.ts.map +1 -1
  125. package/dist/ssh/gateway/attachments.js +39 -25
  126. package/dist/ssh/gateway/attachments.js.map +1 -1
  127. package/dist/ssh/gateway/environment.d.ts +21 -2
  128. package/dist/ssh/gateway/environment.d.ts.map +1 -1
  129. package/dist/ssh/gateway/environment.js +76 -6
  130. package/dist/ssh/gateway/environment.js.map +1 -1
  131. package/dist/ssh/gateway/forward.d.ts +1 -1
  132. package/dist/ssh/gateway/forward.d.ts.map +1 -1
  133. package/dist/ssh/gateway/forward.js +1 -1
  134. package/dist/ssh/gateway/forward.js.map +1 -1
  135. package/dist/ssh/gateway/preflight.d.ts +51 -12
  136. package/dist/ssh/gateway/preflight.d.ts.map +1 -1
  137. package/dist/ssh/gateway/preflight.js +155 -69
  138. package/dist/ssh/gateway/preflight.js.map +1 -1
  139. package/dist/ssh/limits.d.ts +60 -0
  140. package/dist/ssh/limits.d.ts.map +1 -0
  141. package/dist/ssh/limits.js +84 -0
  142. package/dist/ssh/limits.js.map +1 -0
  143. package/dist/ssh/model.d.ts +97 -9
  144. package/dist/ssh/model.d.ts.map +1 -1
  145. package/dist/ssh/preflight.d.ts +26 -7
  146. package/dist/ssh/preflight.d.ts.map +1 -1
  147. package/dist/ssh/preflight.js +50 -10
  148. package/dist/ssh/preflight.js.map +1 -1
  149. package/dist/ssh/provisioner/boat.d.ts +33 -0
  150. package/dist/ssh/provisioner/boat.d.ts.map +1 -0
  151. package/dist/ssh/provisioner/boat.js +247 -0
  152. package/dist/ssh/provisioner/boat.js.map +1 -0
  153. package/dist/ssh/provisioner/hetzner.d.ts +32 -0
  154. package/dist/ssh/provisioner/hetzner.d.ts.map +1 -0
  155. package/dist/ssh/provisioner/hetzner.js +337 -0
  156. package/dist/ssh/provisioner/hetzner.js.map +1 -0
  157. package/dist/ssh/provisioner/host-key.d.ts +11 -0
  158. package/dist/ssh/provisioner/host-key.d.ts.map +1 -0
  159. package/dist/ssh/provisioner/host-key.js +57 -0
  160. package/dist/ssh/provisioner/host-key.js.map +1 -0
  161. package/dist/ssh/provisioner/http.d.ts +45 -0
  162. package/dist/ssh/provisioner/http.d.ts.map +1 -0
  163. package/dist/ssh/provisioner/http.js +72 -0
  164. package/dist/ssh/provisioner/http.js.map +1 -0
  165. package/dist/ssh/provisioner/machines.d.ts +48 -0
  166. package/dist/ssh/provisioner/machines.d.ts.map +1 -0
  167. package/dist/ssh/provisioner/machines.js +686 -0
  168. package/dist/ssh/provisioner/machines.js.map +1 -0
  169. package/dist/ssh/provisioner/model.d.ts +92 -0
  170. package/dist/ssh/provisioner/model.d.ts.map +1 -0
  171. package/dist/ssh/provisioner/model.js +37 -0
  172. package/dist/ssh/provisioner/model.js.map +1 -0
  173. package/dist/ssh/ssh.d.ts +14 -11
  174. package/dist/ssh/ssh.d.ts.map +1 -1
  175. package/dist/ssh/ssh.js +94 -27
  176. package/dist/ssh/ssh.js.map +1 -1
  177. package/dist/ssh/worker-records.d.ts +18 -0
  178. package/dist/ssh/worker-records.d.ts.map +1 -0
  179. package/dist/ssh/worker-records.js +100 -0
  180. package/dist/ssh/worker-records.js.map +1 -0
  181. package/dist/ssh/workers.d.ts +34 -7
  182. package/dist/ssh/workers.d.ts.map +1 -1
  183. package/dist/ssh/workers.js +92 -18
  184. package/dist/ssh/workers.js.map +1 -1
  185. package/package.json +19 -11
@@ -4,13 +4,14 @@ import { InfraFailure } from "@usine/core/attempt/failure";
4
4
  import { RepositoryName, safeSegment } from "@usine/core/repository";
5
5
  import { harvestFacts, harvestPostedComments, } from "@usine/core/step/harvest";
6
6
  import { AttemptRuntime } from "@usine/durable/attempt-runtime";
7
- import { Deferred, Effect, Exit, Option, Schema, Scope } from "effect";
7
+ import { Deferred, Effect, Exit, Option, Schema, Scope, SynchronizedRef } from "effect";
8
8
  import { durableSessionFor } from "../durable-session.js";
9
9
  import { CheckoutRequest, Executor, ExecutorError, ExecutorOperationError, WorkerCapabilityError, } from "../executor.js";
10
10
  import { verifyHarnessReadiness } from "../harness-preflight.js";
11
+ import { childEnvironment, commitIdentity } from "../child-environment.js";
11
12
  import { keyedLockFor } from "../keyed-lock.js";
12
- import { withGitHooks } from "../pre-push-hook.js";
13
- import { cacheDirectoryName, provisionWorktree, reattachWorktree, recoverWorktree, } from "../provision.js";
13
+ import { cacheDirectoryName, keptCheckoutRecovery, linkedWorktree, provisionWorktree, reattachWorktree, recoverWorktree, recoveryFields, } from "../provision.js";
14
+ import { attemptContainerName, containerCheckoutMarker, containersFor, } from "./container.js";
14
15
  import { workerLeftoversFor } from "./leftovers.js";
15
16
  import { workerWatchesFor } from "./liveness.js";
16
17
  import { abandonedWorktreeSweepFor } from "./sweep.js";
@@ -23,10 +24,31 @@ mkdir -p "$(dirname "$1")"
23
24
  printf '%s' "$2" > "$1"
24
25
  chmod 755 "$1"
25
26
  `.trim();
27
+ /**
28
+ * A script that creates the checkout `$1` as its own repository at commit `$4`: objects come from
29
+ * the cached clone `$2` through an alternate, and missing blobs from the partial-clone remote
30
+ * `$3`. The checkout writes only its own directory, so the cached clone can be read-only.
31
+ */
32
+ const STANDALONE_CHECKOUT_SCRIPT = String.raw `
33
+ set -e
34
+ git init -q "$1"
35
+ printf '%s\n' "$2/objects" > "$1/.git/objects/info/alternates"
36
+ git -C "$1" config core.repositoryformatversion 1
37
+ git -C "$1" config extensions.partialClone origin
38
+ git -C "$1" remote add origin "$3"
39
+ git -C "$1" config remote.origin.promisor true
40
+ git -C "$1" config remote.origin.partialclonefilter blob:none
41
+ git -C "$1" checkout -q --detach "$4"
42
+ `.trim();
43
+ /** A script that removes the checkout `$1`, then its container marker `$2`. */
44
+ const REMOVE_CHECKOUT_SCRIPT = 'rm -rf "$1" && rm -f "$2"';
45
+ /** A script that removes everything inside the directory `$1` and keeps the directory. */
46
+ const EMPTY_DIRECTORY_SCRIPT = 'find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf {} +';
26
47
  /**
27
48
  * Returns the executor for Workers accessed over SSH. Each Attempt's Pi parent is a durable task
28
49
  * in this process, and each child runs on the machine as one SSH command with a pid file next
29
- * to the worktree. `worker` provides the machine, preflight, and repository access.
50
+ * to the worktree, or inside the Attempt's container. `worker` provides the machine, preflight,
51
+ * isolation, and repository access.
30
52
  */
31
53
  export const remoteExecutorFor = (worker) => Effect.gen(function* () {
32
54
  const { commands, label } = worker;
@@ -38,9 +60,22 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
38
60
  const session = yield* durableSessionFor({ interruptGrace: worker.interruptGrace });
39
61
  const runtime = yield* AttemptRuntime;
40
62
  const workerWatches = yield* workerWatchesFor(commands);
41
- const stopChildren = Effect.fn(span("stopChildren"))(function* (operation, machine, worktree) {
63
+ const containers = containersFor(commands);
64
+ const attemptMachine = worker.attemptMachine ??
65
+ ((name, _attemptId) => worker.machineFor(name));
66
+ const leftoversOf = (attempt) => worker.leftovers === undefined
67
+ ? workerLeftovers(attempt.worker).pipe(Effect.map((found) => Option.some(found)))
68
+ : worker.leftovers(attempt, workerLeftovers(attempt.worker));
69
+ const recovered = worker.recovered ?? (() => Effect.void);
70
+ const checkoutHeld = worker.checkoutHeld ?? (() => Effect.void);
71
+ /** Returns `removal` that records the checkout of `attemptId` as gone once it succeeds. */
72
+ const removing = (attemptId, removal) => Effect.tap(removal, () => checkoutHeld(attemptId, false));
73
+ const cachePath = (machine, repository) => `${machine.workerDirectory}/repos/${cacheDirectoryName(repository)}`;
74
+ const stopChildren = Effect.fn(span("stopChildren"))(function* (operation, machine, worktree, attemptId, isolation) {
42
75
  const host = machine.shell.destination;
43
- yield* Effect.annotateCurrentSpan({ host, worktree });
76
+ yield* Effect.annotateCurrentSpan({ host, worktree, isolation: isolation._tag });
77
+ if (isolation._tag === "Container")
78
+ return yield* containers.stopChildren(operation, machine.shell, attemptContainerName(attemptId), worktree);
44
79
  const outcome = yield* commands.run(operation, machine.shell, "sh", [
45
80
  "-c",
46
81
  CHILD_KILL_SCRIPT,
@@ -53,74 +88,212 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
53
88
  children: outcome.trim().split("\n"),
54
89
  });
55
90
  });
56
- /** Runs git on the machine with the Attempt's repository access, refreshing the credential first if it's about to expire. */
57
- const accessGit = (operation, access) => (args) => access.refresh.pipe(Effect.andThen(commands.run(operation, access.shell, "git", args, {
58
- environment: access.gitEnvironment,
59
- scrub: access.scrub,
60
- })));
61
- const harvestCommand = (operation, worktree, access) => (command, args) => access.refresh
62
- .pipe(Effect.andThen(commands.run(operation, access.shell, command, args, {
91
+ /**
92
+ * Returns how commands run in `attemptId`'s existing checkout at `worktree`: in its container
93
+ * if the container marker, which no container can reach, records one, or as the SSH user if
94
+ * it records none. Nothing in the checkout decides, since an Attempt can change it. Fails if
95
+ * the Worker's `isolation` can't run the checkout that way: a container's checkout on a
96
+ * machine without Docker, or a checkout from before Docker, as the SSH user who can now use
97
+ * Docker.
98
+ */
99
+ const isolationOfCheckout = Effect.fn(span("isolationOfCheckout"))(function* (operation, machine, attemptId, worktree, isolation) {
100
+ if (!worker.runsContainers)
101
+ return isolation;
102
+ const host = machine.shell.destination;
103
+ yield* Effect.annotateCurrentSpan({ host, worktree, isolation: isolation._tag });
104
+ const contained = yield* containers.marked(operation, machine.shell, containerCheckoutMarker(machine.workerDirectory, attemptId));
105
+ if (contained === (isolation._tag === "Container"))
106
+ return isolation;
107
+ const message = contained
108
+ ? `The checkout ${worktree} was mounted in an attempt container, and the machine no longer has Docker, so usine runs no command in it as the SSH user. Install Docker, or salvage and remove the checkout by hand.`
109
+ : `The checkout ${worktree} is from before the machine had Docker, and the SSH user can use Docker, so usine runs no command in it as that user. Salvage and remove the checkout by hand.`;
110
+ yield* Effect.logError("Refused to run commands in the checkout", {
111
+ attemptId,
112
+ host,
113
+ worktree,
114
+ contained,
115
+ message,
116
+ });
117
+ return yield* new ExecutorOperationError({
118
+ operation,
119
+ message,
120
+ cause: new Error(contained ? "container marker without Docker" : "no container marker"),
121
+ });
122
+ });
123
+ /** Returns the container of an Attempt, which mounts its checkout and the paths of `access`. */
124
+ const containerSpec = (machine, settings, attempt, worktree, access, harnesses) => ({
125
+ name: attemptContainerName(attempt.attemptId),
126
+ settings,
127
+ worker: attempt.worker,
128
+ attemptId: Option.some(attempt.attemptId),
129
+ checkout: Option.some({ path: worktree, parent: `${machine.workerDirectory}/attempts` }),
130
+ readOnly: [cachePath(machine, attempt.repository), ...access.readOnlyPaths],
131
+ writable: access.writablePaths,
132
+ harnesses,
133
+ environment: access.environment,
134
+ secrets: access.secrets,
135
+ });
136
+ /**
137
+ * Returns where the Attempt's commands in its checkout run. In a container, the container
138
+ * starts on first use in `scope` and is removed when `scope` closes.
139
+ */
140
+ const checkoutHostFor = Effect.fn(span("checkoutHost"))(function* (operation, scope, machine, attempt, worktree, access, isolation, harnesses) {
141
+ if (isolation._tag === "SharedUser") {
142
+ const shell = Effect.succeed(access.shell);
143
+ const shared = {
144
+ isolation,
145
+ shell,
146
+ salvageShell: shell,
147
+ recordOutOfMemory: () => Effect.void,
148
+ };
149
+ return shared;
150
+ }
151
+ const started = yield* SynchronizedRef.make(Option.none());
152
+ const spec = containerSpec(machine, isolation.settings, attempt, worktree, access, harnesses);
153
+ const marker = containerCheckoutMarker(machine.workerDirectory, attempt.attemptId);
154
+ const memory = yield* containers.trackMemory(machine.shell, spec);
155
+ const shell = SynchronizedRef.modifyEffect(started, (current) => Option.match(current, {
156
+ onSome: (shell) => Effect.succeed([shell, current]),
157
+ onNone: () => containers.mark(operation, machine.shell, marker).pipe(Effect.andThen(containers.start(operation, machine.shell, spec)),
158
+ // Added after the start's removal, so it runs before it.
159
+ Effect.tap(() => Effect.addFinalizer(() => memory.recordKills())), Scope.provide(scope), Effect.map((shell) => [shell, Option.some(shell)])),
160
+ }));
161
+ const contained = {
162
+ isolation,
163
+ shell,
164
+ salvageShell: yield* Effect.cached(Effect.tap(shell, () => memory.ensureRunning)),
165
+ recordOutOfMemory: memory.recordTo,
166
+ };
167
+ return contained;
168
+ });
169
+ /**
170
+ * Runs git on the machine with the Attempt's repository access and `author` as the author and
171
+ * committer, refreshing the credential first if it's about to expire.
172
+ */
173
+ const accessGit = (operation, access, author, shell = Effect.succeed(access.shell)) => (args) => {
174
+ const environment = { ...access.gitEnvironment, ...commitIdentity(author) };
175
+ return access.refresh.pipe(Effect.andThen(shell), Effect.flatMap((on) => commands.run(operation, on, "git", args, { environment, scrub: access.scrub })));
176
+ };
177
+ const harvestCommand = (operation, worktree, access, shell) => (command, args) => access.refresh
178
+ .pipe(Effect.andThen(commands.run(operation, shell, command, args, {
63
179
  cwd: worktree,
64
180
  environment: access.environment,
65
181
  scrub: access.scrub,
66
182
  })))
67
183
  .pipe(Effect.map((stdout) => (stdout === "" ? [] : stdout.replace(/\n$/u, "").split("\n"))));
68
- const recover = Effect.fn(span("recover"))(function* (attempt, record) {
69
- yield* Effect.annotateCurrentSpan({ ...attempt });
184
+ /** Returns whether the checkout at `worktree` exists on `machine`. */
185
+ const checkoutExists = (machine, worktree) => Effect.gen(function* () {
186
+ const inspected = yield* commands.inspect("cleanup", machine.shell, "test", [
187
+ "-d",
188
+ worktree,
189
+ ]);
190
+ if (inspected.exitCode === 1)
191
+ return false;
192
+ if (inspected.exitCode !== 0)
193
+ return yield* executorError("Can't inspect the recovery checkout", new Error(inspected.stderr));
194
+ return true;
195
+ });
196
+ /**
197
+ * Removes a container's checkout at `worktree` from the machine, then its container marker.
198
+ * It holds no registration in the cached clone.
199
+ */
200
+ const removeStandalone = (operation, machine, attemptId, worktree) => Effect.asVoid(commands.run(operation, machine.shell, "sh", [
201
+ "-c",
202
+ REMOVE_CHECKOUT_SCRIPT,
203
+ "sh",
204
+ worktree,
205
+ containerCheckoutMarker(machine.workerDirectory, attemptId),
206
+ ]));
207
+ /**
208
+ * Stops the children in `attempt`'s checkout at `worktree`, salvages it with a new
209
+ * credential, records the salvage through `record`, and removes the checkout once its work is
210
+ * pushed. See `recoverWorktree`.
211
+ */
212
+ const reconcileCheckout = Effect.fn(span("reconcileCheckout"))(function* (attempt, machine, worktree, record, commitAuthor) {
213
+ yield* Effect.annotateCurrentSpan({ attemptId: attempt.attemptId, worker: attempt.worker });
214
+ const isolation = yield* isolationOfCheckout("cleanup", machine, attempt.attemptId, worktree, yield* worker.isolation(machine));
215
+ const reconcile = (git, removeCheckout) => recoverWorktree({ attempt, host: machine.shell.destination, worktree, git, removeCheckout }, record);
216
+ const removeCheckout = (git) => removing(attempt.attemptId, isolation._tag === "Container"
217
+ ? removeStandalone("cleanup", machine, attempt.attemptId, worktree)
218
+ : Effect.asVoid(git([
219
+ "--git-dir",
220
+ cachePath(machine, attempt.repository),
221
+ "worktree",
222
+ "remove",
223
+ "--force",
224
+ worktree,
225
+ ])));
226
+ if (attempt.succeeded || attempt.permissions === "read") {
227
+ yield* stopChildren("cleanup", machine, worktree, attempt.attemptId, isolation);
228
+ const git = (args) => commands.run("cleanup", machine.shell, "git", args);
229
+ yield* reconcile(git, removeCheckout(git));
230
+ if (isolation._tag === "Container")
231
+ yield* containers.remove(machine.shell, attemptContainerName(attempt.attemptId));
232
+ return;
233
+ }
234
+ const author = yield* commitAuthor;
235
+ const grant = yield* worker.grantFor(attempt.repository);
236
+ yield* Effect.scoped(Effect.gen(function* () {
237
+ const access = yield* grant.acquire(attempt.attemptId, attempt.worker, attempt.permissions, machine, "cleanup");
238
+ yield* stopChildren("cleanup", machine, worktree, attempt.attemptId, isolation);
239
+ const checkout = yield* checkoutHostFor("cleanup", yield* Effect.scope, machine, attempt, worktree, access, isolation, []);
240
+ const git = accessGit("cleanup", access, author, checkout.shell);
241
+ yield* reconcile(git, removeCheckout(git));
242
+ }));
243
+ });
244
+ const recover = Effect.fn(span("recover"))(function* (attempt, record, commitAuthor) {
245
+ yield* Effect.annotateCurrentSpan(recoveryFields(attempt));
70
246
  yield* session.stopForRecovery(attempt);
71
- const { machine, checkouts } = yield* workerLeftovers(attempt.worker);
247
+ const located = yield* leftoversOf(attempt);
248
+ if (Option.isNone(located)) {
249
+ yield* Effect.logInfo("The Attempt's machine is gone; nothing is left to reconcile", {
250
+ attemptId: attempt.attemptId,
251
+ worker: attempt.worker,
252
+ });
253
+ yield* session.recoverPostedComments(attempt, Option.none(), record);
254
+ return;
255
+ }
256
+ const { machine, checkouts } = located.value;
72
257
  const worktree = `${machine.workerDirectory}/attempts/${safeSegment(attempt.attemptId)}`;
73
- const inspectCheckout = Effect.gen(function* () {
74
- const inspected = yield* commands.inspect("cleanup", machine.shell, "test", [
75
- "-d",
76
- worktree,
77
- ]);
78
- if (inspected.exitCode === 1)
79
- return false;
80
- if (inspected.exitCode !== 0)
81
- return yield* executorError("Can't inspect the recovery checkout", new Error(inspected.stderr));
82
- return true;
83
- });
258
+ yield* Effect.addFinalizer((exit) => recovered(attempt, Exit.isSuccess(exit)));
84
259
  // Checks the cached listing first to avoid an SSH round trip per persisted Attempt.
85
- const present = checkouts.has(worktree) && (yield* inspectCheckout);
260
+ const present = checkouts.has(worktree) && (yield* checkoutExists(machine, worktree));
261
+ yield* checkoutHeld(attempt.attemptId, present);
86
262
  yield* session.recoverPostedComments(attempt, present
87
263
  ? Option.some((spec, windows) => Effect.scoped(Effect.gen(function* () {
88
264
  const grant = yield* worker.grantFor(attempt.repository);
89
265
  const access = yield* grant.acquire(attempt.attemptId, attempt.worker, attempt.permissions, machine, "cleanup");
90
- return yield* harvestPostedComments(spec, harvestCommand("cleanup", worktree, access), windows);
266
+ const checkout = yield* checkoutHostFor("cleanup", yield* Effect.scope, machine, attempt, worktree, access, yield* isolationOfCheckout("cleanup", machine, attempt.attemptId, worktree, yield* worker.isolation(machine)), []);
267
+ return yield* harvestPostedComments(spec, harvestCommand("cleanup", worktree, access, yield* checkout.shell), windows);
91
268
  })))
92
269
  : Option.none(), record);
93
270
  if (!present)
94
271
  return;
95
- const reconcile = (git) => recoverWorktree({
96
- attempt,
97
- host: machine.shell.destination,
98
- worktree,
99
- cache: `${machine.workerDirectory}/repos/${cacheDirectoryName(attempt.repository)}`,
100
- git,
101
- }, record.salvage);
102
- if (attempt.succeeded || attempt.permissions === "read") {
103
- yield* stopChildren("cleanup", machine, worktree);
104
- yield* reconcile((args) => commands.run("cleanup", machine.shell, "git", args));
272
+ yield* reconcileCheckout(attempt, machine, worktree, record.salvage, commitAuthor);
273
+ }, Effect.scoped);
274
+ /**
275
+ * Reconciles the checkout at `worktree` on `machine` that the launch of `request` kept while
276
+ * its parent task's stop was unconfirmed. Does nothing if the checkout is gone.
277
+ */
278
+ const reconcileKept = Effect.fn(span("reconcileKept"))(function* (request, machine, worktree, record) {
279
+ const fields = { attemptId: request.attemptId, worker: request.worker, worktree };
280
+ yield* Effect.annotateCurrentSpan(fields);
281
+ if (!(yield* checkoutExists(machine, worktree))) {
282
+ yield* Effect.logInfo("The kept checkout is gone; nothing to reconcile", fields);
105
283
  return;
106
284
  }
107
- const grant = yield* worker.grantFor(attempt.repository);
108
- yield* Effect.scoped(Effect.gen(function* () {
109
- const access = yield* grant.acquire(attempt.attemptId, attempt.worker, attempt.permissions, machine, "cleanup");
110
- yield* stopChildren("cleanup", machine, worktree);
111
- yield* reconcile(accessGit("cleanup", access));
112
- }));
285
+ yield* reconcileCheckout(keptCheckoutRecovery(request), machine, worktree, record, Effect.succeed(request.commitAuthor));
113
286
  });
114
- const harvest = (workspace, access, windows) => harvestFacts(workspace.spec, harvestCommand("launch", workspace.worktree, access), windows).pipe(Effect.mapError((error) => new InfraFailure({
287
+ const harvest = (workspace, access, shell, windows) => harvestFacts(workspace.spec, harvestCommand("launch", workspace.worktree, access, shell), windows).pipe(Effect.mapError((error) => new InfraFailure({
115
288
  message: `Can't harvest the attempt's checkout: ${error.message}`,
116
289
  cause: error,
117
290
  })));
118
291
  /**
119
- * Checks that a reattached Attempt's parent task was created for this worktree and the
120
- * worktree still exists, then stops children left from before the restart. Returns the
121
- * parent's RunSpec.
292
+ * Checks that a reattached Attempt's parent task was created for this worktree, the worktree
293
+ * still exists, and `isolation` can run commands in it, then stops children left from before
294
+ * the restart. Returns the parent's RunSpec.
122
295
  */
123
- const takeOver = Effect.fn(span("takeOver"))(function* (request, machine, worktree) {
296
+ const takeOver = Effect.fn(span("takeOver"))(function* (request, machine, worktree, isolation) {
124
297
  const { cwd, spec } = yield* session.storedRequest(request.attemptId);
125
298
  if (cwd !== worktree)
126
299
  return yield* executorError(`The reattached attempt's checkout isn't on the machine of this ${label} worker`, new Error(cwd));
@@ -129,37 +302,85 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
129
302
  return yield* executorError("The reattached attempt's checkout no longer exists", new Error(worktree));
130
303
  if (inspected.exitCode !== 0)
131
304
  return yield* executorError("Can't inspect the reattached attempt's checkout", new Error(inspected.stderr.trim()));
132
- yield* stopChildren("prepare", machine, worktree);
305
+ yield* isolationOfCheckout("prepare", machine, request.attemptId, worktree, isolation);
306
+ yield* stopChildren("prepare", machine, worktree, request.attemptId, isolation);
133
307
  return spec;
134
308
  });
309
+ /**
310
+ * Returns the checkout operations of an Attempt in its own container: a standalone repository
311
+ * that borrows the cached clone's objects, with every git command run in the container.
312
+ */
313
+ const containerCheckout = (request, machine, access, worktree, cache, url, checkout) => {
314
+ const inContainer = (operation, command, args) => access.refresh.pipe(
315
+ // The only cleanup command in the checkout is the salvage's git.
316
+ Effect.andThen(operation === "cleanup" ? checkout.salvageShell : checkout.shell), Effect.flatMap((shell) => commands.run(operation, shell, command, args, {
317
+ environment: { ...access.gitEnvironment, ...commitIdentity(request.commitAuthor) },
318
+ scrub: access.scrub,
319
+ })));
320
+ return {
321
+ add: (commit) => commands
322
+ .run("prepare", machine.shell, "mkdir", ["-p", worktree])
323
+ .pipe(Effect.andThen(inContainer("prepare", "sh", [
324
+ "-c",
325
+ STANDALONE_CHECKOUT_SCRIPT,
326
+ "sh",
327
+ worktree,
328
+ cache,
329
+ url,
330
+ commit,
331
+ ])), Effect.asVoid),
332
+ // The container mounts the directory itself, so only its contents go.
333
+ discard: Effect.asVoid(commands.run("prepare", machine.shell, "sh", [
334
+ "-c",
335
+ EMPTY_DIRECTORY_SCRIPT,
336
+ "sh",
337
+ worktree,
338
+ ])),
339
+ git: (operation, args) => inContainer(operation, "git", args),
340
+ writeExecutable: (target, content) => Effect.asVoid(inContainer("prepare", "sh", ["-c", WRITE_EXECUTABLE_SCRIPT, "sh", target, content])),
341
+ remove: (cleanup) => cleanup("worktree-directory", removing(request.attemptId, removeStandalone("cleanup", machine, request.attemptId, worktree))),
342
+ };
343
+ };
135
344
  /** Returns the `WorkspaceOps` for the machine; git runs with the Attempt's repository access. */
136
- const workspaceOps = (request, machine, access, layout) => ({
137
- ensureRoots: Effect.asVoid(commands.run("prepare", machine.shell, "mkdir", [
138
- "-p",
139
- `${machine.workerDirectory}/repos`,
140
- `${machine.workerDirectory}/attempts`,
141
- ])),
142
- removePath: (operation, target) => Effect.asVoid(commands.run(operation, machine.shell, "rm", ["-rf", target])),
143
- cacheExists: Effect.gen(function* () {
144
- const inspected = yield* commands.inspect("prepare", machine.shell, "test", [
145
- "-d",
146
- layout.cache,
147
- ]);
148
- if (inspected.exitCode === 0)
149
- return true;
150
- if (inspected.exitCode === 1)
151
- return false;
152
- return yield* executorError(`Can't inspect the cached clone on worker "${request.worker}"`, new Error(inspected.stderr.trim()));
153
- }),
154
- promoteStaging: Effect.asVoid(commands.run("prepare", machine.shell, "mv", [layout.staging, layout.cache])),
155
- git: (operation, args) => accessGit(operation, access)(args),
156
- writeExecutable: (target, content) => Effect.asVoid(commands.run("prepare", machine.shell, "sh", [
157
- "-c",
158
- WRITE_EXECUTABLE_SCRIPT,
159
- "sh",
160
- target,
161
- content,
162
- ])),
345
+ const workspaceOps = (request, machine, access, layout, checkout) => {
346
+ const host = {
347
+ removePath: (operation, target) => Effect.asVoid(commands.run(operation, machine.shell, "rm", ["-rf", target])),
348
+ cacheExists: Effect.gen(function* () {
349
+ const inspected = yield* commands.inspect("prepare", machine.shell, "test", [
350
+ "-d",
351
+ layout.cache,
352
+ ]);
353
+ if (inspected.exitCode === 0)
354
+ return true;
355
+ if (inspected.exitCode === 1)
356
+ return false;
357
+ return yield* executorError(`Can't inspect the cached clone on worker "${request.worker}"`, new Error(inspected.stderr.trim()));
358
+ }),
359
+ git: (operation, args) => accessGit(operation, access, request.commitAuthor)(args),
360
+ };
361
+ return {
362
+ ...host,
363
+ ensureRoots: Effect.asVoid(commands.run("prepare", machine.shell, "mkdir", [
364
+ "-p",
365
+ `${machine.workerDirectory}/repos`,
366
+ `${machine.workerDirectory}/attempts`,
367
+ ])),
368
+ promoteStaging: Effect.asVoid(commands.run("prepare", machine.shell, "mv", [layout.staging, layout.cache])),
369
+ checkout: checkout.isolation._tag === "Container"
370
+ ? containerCheckout(request, machine, access, layout.worktree, layout.cache, worker.repositoryUrl(machine, request.repository), checkout)
371
+ : linkedCheckout(request.attemptId, linkedWorktree(host, layout, (target, content) => Effect.asVoid(commands.run("prepare", machine.shell, "sh", [
372
+ "-c",
373
+ WRITE_EXECUTABLE_SCRIPT,
374
+ "sh",
375
+ target,
376
+ content,
377
+ ])))),
378
+ };
379
+ };
380
+ /** Returns `ops` whose removal records the checkout as gone once its directory is removed. */
381
+ const linkedCheckout = (attemptId, ops) => ({
382
+ ...ops,
383
+ remove: (cleanup) => ops.remove((resource, effect) => cleanup(resource, resource === "worktree-directory" ? removing(attemptId, effect) : effect)),
163
384
  });
164
385
  const prepare = Effect.fn(span("prepare"))(function* (request) {
165
386
  yield* Effect.annotateCurrentSpan({
@@ -187,13 +408,17 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
187
408
  });
188
409
  }
189
410
  const grant = yield* worker.grantFor(request.repository);
190
- const machine = yield* worker.machineFor(request.worker);
191
- yield* verifyHarnessReadiness(request.worker, [request.agent.brain], worker.harnessProbe(machine), logins);
411
+ const machine = yield* attemptMachine(request.worker, request.attemptId);
412
+ const isolation = yield* worker.isolation(machine);
413
+ const probeBrain = (shell) => verifyHarnessReadiness(request.worker, machine.provisioned, [request.agent.brain], worker.harnessProbe(machine, shell), logins);
414
+ // An Attempt container doesn't exist yet, so its probe runs once the checkout is prepared.
415
+ if (isolation._tag === "SharedUser")
416
+ yield* probeBrain(machine.shell);
192
417
  const host = machine.shell.destination;
193
- const cache = `${machine.workerDirectory}/repos/${cacheDirectoryName(request.repository)}`;
418
+ const cache = cachePath(machine, request.repository);
194
419
  const worktree = `${machine.workerDirectory}/attempts/${safeSegment(request.attemptId)}`;
195
420
  const staging = `${cache}.cloning`;
196
- yield* Effect.annotateCurrentSpan({ host });
421
+ yield* Effect.annotateCurrentSpan({ host, isolation: isolation._tag });
197
422
  yield* Effect.logInfo(request.reattach
198
423
  ? `Reattaching the Attempt to its ${label} checkout`
199
424
  : `Preparing ${label} Attempt workspace`, {
@@ -203,13 +428,18 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
203
428
  repository: request.repository,
204
429
  loop: request.loop,
205
430
  permissions: request.permissions,
431
+ isolation: isolation._tag,
206
432
  worktree,
207
433
  });
208
434
  yield* sweepAbandonedWorktrees(request.attemptId, request.worker, machine.shell, machine.workerDirectory);
209
435
  // A reattachment reuses its checkout before any credential is granted.
210
436
  const reattached = request.reattach
211
- ? Option.some(yield* takeOver(request, machine, worktree))
437
+ ? Option.some(yield* takeOver(request, machine, worktree, isolation))
212
438
  : Option.none();
439
+ const harness = Option.match(reattached, {
440
+ onNone: () => request.agent.brain.harness,
441
+ onSome: (spec) => spec.agent.brain.harness,
442
+ });
213
443
  // A checkout kept after a failed remote stop never closes its scope, so its salvage result
214
444
  // is completed here instead of by the checkout finalizer.
215
445
  const salvaged = yield* Deferred.make();
@@ -220,7 +450,7 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
220
450
  host,
221
451
  });
222
452
  // Closes the checkout scope only if stopping the remote children succeeds.
223
- yield* stopChildren("cleanup", machine, resource.provisioned.workspace.worktree);
453
+ yield* stopChildren("cleanup", machine, resource.provisioned.workspace.worktree, request.attemptId, isolation);
224
454
  yield* Scope.close(resource.scope, exit);
225
455
  const salvage = yield* Effect.exit(resource.provisioned.salvaged);
226
456
  yield* Deferred.done(salvaged, salvage);
@@ -231,11 +461,16 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
231
461
  worktree,
232
462
  message: error.message,
233
463
  })), Effect.ignore, Effect.andThen(Deferred.succeed(salvaged, Option.none()))));
464
+ const prepareScope = yield* Effect.scope;
234
465
  const acquire = Effect.gen(function* () {
235
466
  // Credentials are scoped to the Attempt, not the checkout, so they are released exactly once
236
467
  // even when checkout cleanup is skipped.
237
468
  const access = yield* grant.acquire(request.attemptId, request.worker, request.permissions, machine, "prepare");
238
- const ops = workspaceOps(request, machine, access, { cache, staging });
469
+ // Forked before the checkout's release is registered, so the container is removed after
470
+ // the release salvages the checkout in it.
471
+ const containerScope = yield* Scope.fork(prepareScope);
472
+ const checkoutHost = yield* checkoutHostFor("prepare", containerScope, machine, request, worktree, access, isolation, [harness]);
473
+ const ops = workspaceOps(request, machine, access, { cache, staging, worktree }, checkoutHost);
239
474
  const checkout = Option.match(reattached, {
240
475
  onNone: () => provisionWorktree({
241
476
  request,
@@ -244,7 +479,7 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
244
479
  layout: { cache, staging, worktree },
245
480
  ops,
246
481
  }),
247
- onSome: (spec) => reattachWorktree({ request, host, layout: { cache, worktree }, ops, spec }),
482
+ onSome: (spec) => reattachWorktree({ request, host, layout: { worktree }, ops, spec }),
248
483
  });
249
484
  const { provisioned } = yield* Effect.acquireRelease(Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
250
485
  const scope = yield* Scope.make();
@@ -253,11 +488,14 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
253
488
  const provisioned = yield* restore(checkout).pipe(Scope.provide(scope), Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
254
489
  return { provisioned, scope };
255
490
  })), releaseWorkspace, { interruptible: true });
256
- return { provisioned, access };
491
+ return { provisioned, access, checkoutHost };
257
492
  });
258
- const { provisioned, access } = yield* (request.reattach ? acquire : fetches.withLock(request.repository)(acquire)).pipe(Effect.mapError((cause) => isExecutorError(cause)
493
+ const { provisioned, access, checkoutHost } = yield* (request.reattach ? acquire : fetches.withLock(request.repository)(acquire)).pipe(Effect.mapError((cause) => isExecutorError(cause)
259
494
  ? cause
260
495
  : executorError(`Can't prepare the ${label} attempt workspace`, cause)));
496
+ yield* checkoutHeld(request.attemptId, true);
497
+ if (isolation._tag === "Container")
498
+ yield* probeBrain(yield* checkoutHost.shell);
261
499
  const prepared = provisioned.workspace;
262
500
  yield* Effect.logInfo(`${label} Attempt workspace ready`, {
263
501
  attemptId: request.attemptId,
@@ -267,6 +505,7 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
267
505
  baseCommit: prepared.spec.baseCommit,
268
506
  resume: Option.map(prepared.spec.resume, ({ ref }) => ref),
269
507
  reattach: request.reattach,
508
+ isolation: isolation._tag,
270
509
  worktree,
271
510
  });
272
511
  return {
@@ -275,6 +514,11 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
275
514
  // Uses the RunSpec's brain, including on reattachment. Each child reads the kind when it
276
515
  // starts, so a preflight during the Attempt takes effect.
277
516
  const gatewayCredential = worker.gatewayCredential(machine, prepared.spec.agent.brain.harness);
517
+ yield* checkoutHost.recordOutOfMemory(io.outOfMemory);
518
+ const shell = yield* checkoutHost.shell.pipe(Effect.mapError((error) => new InfraFailure({
519
+ message: `Can't start the attempt's container: ${error.message}`,
520
+ cause: error,
521
+ })));
278
522
  yield* workerWatches.watch({
279
523
  attemptId: request.attemptId,
280
524
  worker: request.worker,
@@ -282,12 +526,15 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
282
526
  report: (notice) => runtime.notify(request.attemptId, notice),
283
527
  });
284
528
  return yield* session.launch(prepared, io, {
285
- host: access.shell,
286
- environment: withGitHooks(access.environment, provisioned.gitHooks),
529
+ host: shell,
530
+ machine: machine.provisioned,
531
+ environment: childEnvironment(access.environment, request.commitAuthor, provisioned.gitHooks),
287
532
  gatewayCredential,
288
- harvest: (windows) => harvest(prepared, access, windows),
289
- harvestComments: (windows) => harvestPostedComments(prepared.spec, harvestCommand("launch", prepared.worktree, access), windows),
533
+ harvest: (windows) => harvest(prepared, access, shell, windows),
534
+ harvestComments: (windows) => harvestPostedComments(prepared.spec, harvestCommand("launch", prepared.worktree, access, shell), windows),
290
535
  keepForReattach: provisioned.keepForReattach,
536
+ keepUntilStopped: provisioned.keepUntilStopped,
537
+ reconcileKept: (record) => reconcileKept(request, machine, worktree, record),
291
538
  });
292
539
  })),
293
540
  salvaged: Deferred.await(salvaged),
@@ -303,6 +550,7 @@ export const remoteExecutorFor = (worker) => Effect.gen(function* () {
303
550
  outcome: session.outcome,
304
551
  preflight: worker.preflight,
305
552
  prepare,
553
+ sweep: worker.sweep ?? Effect.void,
306
554
  });
307
555
  });
308
556
  //# sourceMappingURL=remote.js.map