@tanstack/ai-sandbox 0.2.3 → 0.3.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 (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. package/src/store.ts +0 -83
package/src/runner.ts CHANGED
@@ -6,8 +6,27 @@
6
6
  *
7
7
  * This is intentionally transport-minimal: a stdout NDJSON pipe. Multi-client
8
8
  * reconnect / replay belongs to the persistence/EventLog layer, not here.
9
+ *
10
+ * The `journal` option adds a second, opt-in transport: instead of holding the
11
+ * agent's stdout pipe directly, the host redirects it into an append-only file
12
+ * inside the sandbox and tails that file. See `journal.ts` for why (host death
13
+ * cannot SIGPIPE the agent, and a later host can resume the same file from byte
14
+ * 0). `spawnNdjson`'s signature and unjournaled behavior are unchanged; every
15
+ * existing caller keeps working exactly as before.
9
16
  */
17
+ import {
18
+ journalCleanupCommand,
19
+ journalPaths,
20
+ journalStderrReadCommand,
21
+ journaledCommand,
22
+ parseExitSentinel,
23
+ } from './journal'
24
+ import { readJournal } from './journal-reader'
25
+ import { decodeBase64Stream } from './journal-bytes'
26
+ import { awaitAttachableJournal } from './attach-preflight'
27
+ import type { JournalPaths } from './journal'
10
28
  import type { ProcessOptions, SandboxHandle } from './contracts'
29
+ import type { RunStore } from '@tanstack/ai'
11
30
 
12
31
  export interface SpawnNdjsonOptions extends ProcessOptions {
13
32
  /**
@@ -20,6 +39,97 @@ export interface SpawnNdjsonOptions extends ProcessOptions {
20
39
  * e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv.
21
40
  */
22
41
  input?: string
42
+ /**
43
+ * Route the agent's stdout through an in-sandbox journal rather than holding
44
+ * its pipe directly.
45
+ *
46
+ * This is what makes a run survive host death: with nothing piped, there is
47
+ * no reader whose disappearance can SIGPIPE the agent, and a later host reads
48
+ * the same file from byte 0. Opt-in so every existing caller (and every
49
+ * existing test) is unaffected.
50
+ */
51
+ journal?: JournalOptions
52
+ }
53
+
54
+ /** Journaling configuration for {@link spawnNdjson}. */
55
+ export interface JournalOptions {
56
+ /** Run id the journal path is derived from. Must match across hosts. */
57
+ runId: string
58
+ /** Journal directory. Defaults to `/tmp/tanstack-runs`. */
59
+ dir?: string
60
+ /**
61
+ * Read an EXISTING journal instead of starting the agent. The read still
62
+ * begins at byte 0 — the alignment step, not the reader, decides what has
63
+ * already been delivered.
64
+ */
65
+ attach?: boolean
66
+ /** Poll interval for providers that cannot follow. */
67
+ pollIntervalMs?: number
68
+ /**
69
+ * Run record store, consulted ONLY on an attach and only when the journal is
70
+ * absent, to tell "not written yet" from "will never be written" (see
71
+ * `attach-preflight.ts`). Optional so an attach with no store wired keeps the
72
+ * bounded wait while losing the unknown/terminal classification.
73
+ */
74
+ runs?: RunStore
75
+ /**
76
+ * Bounded wait for a live run's journal to appear on an attach, AND — on every
77
+ * path, attach or fresh — the bound on the read's first byte
78
+ * (`ReadJournalOptions.firstByteTimeoutMs`). One knob for both because they
79
+ * bound the same question from two sides: the preflight covers "the file does
80
+ * not exist", the read covers "the file exists but nothing is writing to it",
81
+ * and a caller that widens one always means to widen the other.
82
+ *
83
+ * Defaults to `DEFAULT_ATTACH_JOURNAL_WAIT_MS`.
84
+ */
85
+ attachWaitMs?: number
86
+ }
87
+
88
+ type JournaledOptions = SpawnNdjsonOptions & { journal: JournalOptions }
89
+
90
+ function isJournaled(options: SpawnNdjsonOptions): options is JournaledOptions {
91
+ return options.journal !== undefined
92
+ }
93
+
94
+ function resolvePaths(options: JournaledOptions) {
95
+ return journalPaths(options.journal.runId, options.journal.dir)
96
+ }
97
+
98
+ /**
99
+ * Strip the runner-only options, leaving what `handle.process.spawn` accepts.
100
+ *
101
+ * `signal` is deliberately KEPT: on the UNJOURNALED path the host holds the
102
+ * agent's stdout pipe, so a client disconnect should take the process down with
103
+ * it. The journaled path must not forward it — see
104
+ * {@link toJournaledSpawnOptions}.
105
+ */
106
+ function toProcessOptions(options: SpawnNdjsonOptions): ProcessOptions {
107
+ const { onNonJsonLine, input, journal, ...rest } = options
108
+ void onNonJsonLine
109
+ void input
110
+ void journal
111
+ return rest
112
+ }
113
+
114
+ /**
115
+ * The journaled agent's spawn options: {@link toProcessOptions} MINUS `signal`.
116
+ *
117
+ * The request's signal must never reach the agent process on this path. Providers
118
+ * DO act on it at spawn time — local-process registers it to `killTree` the
119
+ * process group, and daytona and docker honor it too — so forwarding it means a
120
+ * client disconnect kills the journaled agent. The agent then writes no exit
121
+ * sentinel, and a successor host takes over a run that is already dead: the exact
122
+ * opposite of the guarantee documented on {@link startJournaledAgent}, and of the
123
+ * reason the journal is a file rather than a pipe.
124
+ *
125
+ * The signal is still honored for the READ. `readJournalNdjson` forwards it to
126
+ * `readJournal` and `awaitAttachableJournal` on its own, so a disconnecting
127
+ * client stops tailing immediately. Only the agent spawn outlives the request.
128
+ */
129
+ function toJournaledSpawnOptions(options: JournaledOptions): ProcessOptions {
130
+ const { signal, ...rest } = toProcessOptions(options)
131
+ void signal
132
+ return rest
23
133
  }
24
134
 
25
135
  /** Split a stream of arbitrary string chunks into complete lines. */
@@ -40,16 +150,250 @@ export async function* toLines(
40
150
  if (buffer.length > 0) yield buffer
41
151
  }
42
152
 
153
+ /**
154
+ * Start the agent with its stdout (and the `{"__exit":N}` sentinel) redirected
155
+ * into the journal, then return.
156
+ *
157
+ * Deliberately does NOT wait for the process and does NOT read its stdout: the
158
+ * whole point of journaling is that the host holds no handle on the agent's
159
+ * output, so a host that dies mid-run cannot take the agent down with it (no
160
+ * pipe to SIGPIPE). The spawned process is left running in the sandbox; the
161
+ * sentinel line the wrapper appends on exit is how anyone — this host or a
162
+ * successor — learns it finished. Stdin is still written directly to the
163
+ * spawned process, exactly as the unjournaled path does, since that transport
164
+ * is unaffected by where stdout goes.
165
+ */
166
+ export async function startJournaledAgent(
167
+ handle: SandboxHandle,
168
+ command: string,
169
+ options: JournaledOptions,
170
+ ): Promise<void> {
171
+ const paths = resolvePaths(options)
172
+ const proc = await handle.process.spawn(
173
+ journaledCommand(command, paths),
174
+ toJournaledSpawnOptions(options),
175
+ )
176
+ if (options.input !== undefined) {
177
+ await proc.stdin.write(options.input)
178
+ await proc.stdin.end()
179
+ }
180
+ }
181
+
182
+ /** Chars of stderr attached to a non-zero-exit error, on both paths. */
183
+ const STDERR_ERROR_CHARS = 1000
184
+
185
+ async function* singleValue(value: string): AsyncIterable<string> {
186
+ yield value
187
+ }
188
+
189
+ /**
190
+ * Read the tail of a run's stderr sidecar, for the error message only.
191
+ *
192
+ * Returns `''` on ANY failure — a provider whose `exec` rejects, a sidecar that
193
+ * no longer exists, a base64 frame the provider truncated. The caller is on its
194
+ * way to throwing the real failure (the agent's non-zero exit), and losing a
195
+ * diagnostic suffix must never replace that error with a cleanup error. Decoding
196
+ * is deliberately lossy: `journalStderrReadCommand` reads the LAST N bytes, so
197
+ * byte 0 of the frame can sit mid-character.
198
+ */
199
+ async function readStderrTail(
200
+ handle: SandboxHandle,
201
+ paths: JournalPaths,
202
+ ): Promise<string> {
203
+ try {
204
+ const result = await handle.process.exec(journalStderrReadCommand(paths))
205
+ const decoder = new TextDecoder()
206
+ let text = ''
207
+ for await (const bytes of decodeBase64Stream(singleValue(result.stdout))) {
208
+ text += decoder.decode(bytes, { stream: true })
209
+ }
210
+ text += decoder.decode()
211
+ return text.trim()
212
+ } catch {
213
+ return ''
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Delete a terminal run's journal. Best effort by construction: see
219
+ * {@link journalCleanupCommand} for why a failure here cannot be allowed to fail
220
+ * a run that has already finished.
221
+ */
222
+ async function cleanupJournal(
223
+ handle: SandboxHandle,
224
+ paths: JournalPaths,
225
+ ): Promise<void> {
226
+ try {
227
+ await handle.process.exec(journalCleanupCommand(paths))
228
+ } catch {
229
+ // The sandbox may already be gone, `/tmp` may be read-only, the provider's
230
+ // `exec` may reject. Nothing about a completed run depends on the files
231
+ // still existing OR on them being gone, so there is nothing to report.
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Read a run's journal and yield each line parsed as JSON.
237
+ *
238
+ * Always reads from byte 0 — the alignment step (a later phase), not this
239
+ * reader, decides what a client has already seen. Stops at the `{"__exit":N}`
240
+ * sentinel, and throws for a non-zero N so the calling adapter's existing
241
+ * `catch` turns it into a `RUN_ERROR`, the same observable outcome the
242
+ * unjournaled path produces from a non-zero `wait()`. There is nothing to
243
+ * `wait()` on here: the host holds a `tail`, not the agent process, so the
244
+ * sentinel line IS the exit code.
245
+ *
246
+ * The sentinel is also what bounds journal growth: reaching it means the run is
247
+ * terminal, and a terminal run's record is the event log, so both journal files
248
+ * are deleted before this iterable finishes. The ordering below is load-bearing
249
+ * and is asserted, not merely commented:
250
+ *
251
+ * - The sentinel is captured and the loop is `break`-ed, so the source's
252
+ * `finally` kills the `tail` BEFORE the `rm` runs — the reader is stopped, then
253
+ * its input is deleted, never the other way round.
254
+ * - `exitCode` stays `undefined` if the journal stream ends without a sentinel.
255
+ * NOTHING is deleted on that path either way — the run may be mid-flight and a
256
+ * successor host may still need every byte — but the two causes are then
257
+ * separated by `options.signal.aborted`: an aborted consumer returns quietly,
258
+ * while a stream that died on its own (killed `tail`, destroyed sandbox, torn
259
+ * pipe) THROWS. Returning for both is how a truncated read used to reach the
260
+ * client as a normally-completing run.
261
+ * - A non-zero sentinel deletes too. The run is terminal either way.
262
+ * - The stderr sidecar is read BEFORE the deletion that destroys it, so a
263
+ * non-zero exit carries up to {@link STDERR_ERROR_CHARS} chars of the agent's
264
+ * own diagnostics, exactly as the unjournaled path below does. That closes the
265
+ * "Known regression" this function used to document; the read is bounded and
266
+ * failure-swallowing (see {@link readStderrTail}), so it cannot turn a run
267
+ * failure into a cleanup failure.
268
+ *
269
+ * On an ATTACH (`journal.attach === true`) the read is preceded by
270
+ * {@link awaitAttachableJournal}, which fails fast for a runId the store does not
271
+ * know or has already terminalized and otherwise waits a BOUNDED time for a live
272
+ * run's journal to appear. Without it, an attach to a runId with no journal
273
+ * created an empty one (`journalFollowCommand` does that deliberately) and tailed
274
+ * it forever — no sentinel, no error, no timeout.
275
+ *
276
+ * One case this does NOT bound: a run that reaches its sentinel while DETACHED
277
+ * has no host reading it, so nothing observes the sentinel and nothing here
278
+ * runs. Sweeping those is `pruneJournals`' job (`journal-sweep.ts`): it consults
279
+ * the run store's status for each journal it finds and deletes only the terminal
280
+ * ones, from a cron the application schedules rather than from a run.
281
+ */
282
+ export async function* readJournalNdjson(
283
+ handle: SandboxHandle,
284
+ options: JournaledOptions,
285
+ ): AsyncIterable<unknown> {
286
+ const paths = resolvePaths(options)
287
+ // ATTACH ONLY, and before the first read. `journalFollowCommand` CREATES the
288
+ // journal it tails, so an attach for a runId that never had one would
289
+ // otherwise create an empty file and tail it forever with no sentinel ever
290
+ // arriving. A fresh run must not be gated: its journal is created by its own
291
+ // `journaledCommand` spawn, which `spawnNdjson` has just issued.
292
+ if (options.journal.attach === true) {
293
+ await awaitAttachableJournal(handle, {
294
+ paths,
295
+ runId: options.journal.runId,
296
+ ...(options.journal.runs === undefined
297
+ ? {}
298
+ : { runs: options.journal.runs }),
299
+ ...(options.journal.attachWaitMs === undefined
300
+ ? {}
301
+ : { waitMs: options.journal.attachWaitMs }),
302
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
303
+ })
304
+ }
305
+ let exitCode: number | undefined
306
+ for await (const { line } of readJournal(handle, {
307
+ paths,
308
+ fromByte: 0,
309
+ runId: options.journal.runId,
310
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
311
+ ...(options.journal.pollIntervalMs === undefined
312
+ ? {}
313
+ : { pollIntervalMs: options.journal.pollIntervalMs }),
314
+ ...(options.journal.attachWaitMs === undefined
315
+ ? {}
316
+ : { firstByteTimeoutMs: options.journal.attachWaitMs }),
317
+ })) {
318
+ const trimmed = line.trim()
319
+ if (trimmed === '') continue
320
+ // The sentinel test comes FIRST and is nonce-checked, so an agent line that
321
+ // merely looks like a sentinel is delivered as the event it is instead of
322
+ // truncating the run (see `parseExitSentinel`).
323
+ const sentinel = parseExitSentinel(trimmed, paths)
324
+ if (sentinel !== null) {
325
+ exitCode = sentinel
326
+ break
327
+ }
328
+ let parsed: unknown
329
+ try {
330
+ parsed = JSON.parse(trimmed)
331
+ } catch {
332
+ options.onNonJsonLine?.(trimmed)
333
+ continue
334
+ }
335
+ yield parsed
336
+ }
337
+
338
+ // The stream ended with no sentinel. Two very different causes share this
339
+ // shape, and collapsing them is how a torn-down read used to be recorded as a
340
+ // short but SUCCESSFUL run:
341
+ //
342
+ // - The CONSUMER aborted (lease lost, client gone, host shutting down). Not a
343
+ // failure and not terminal: return, having deleted nothing, because a
344
+ // successor host may still need every byte. `pipeToRunLog`'s post-loop check
345
+ // turns this into `'aborted'`.
346
+ // - The read DIED (the `tail` was killed, the sandbox was destroyed, the pipe
347
+ // was torn down). The iterable ends without an error, so returning here made
348
+ // the adapter emit a normally-completing but silently TRUNCATED run — in a
349
+ // function whose documented job is to throw so the adapter converts it to a
350
+ // `RUN_ERROR`. `signal.aborted` is what tells the two apart, and the throw
351
+ // matches the shape the unjournaled path already uses for a bad exit.
352
+ if (exitCode === undefined) {
353
+ if (options.signal?.aborted === true) return
354
+ throw new Error(
355
+ `Agent journal stream for run ${options.journal.runId} ended without an exit sentinel ` +
356
+ `(${paths.journal}). The run was NOT observed to finish: the tail was torn down, ` +
357
+ `the sandbox went away, or the agent's shell died before writing its sentinel. ` +
358
+ `Both journal files are left in place for a successor host.`,
359
+ )
360
+ }
361
+
362
+ const stderr = exitCode === 0 ? '' : await readStderrTail(handle, paths)
363
+ await cleanupJournal(handle, paths)
364
+ if (exitCode !== 0) {
365
+ throw new Error(
366
+ `Agent process exited with code ${exitCode}` +
367
+ (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''),
368
+ )
369
+ }
370
+ }
371
+
43
372
  /**
44
373
  * Spawn `command` in the sandbox and yield each stdout line parsed as JSON.
45
- * Resolves the spawn handle's exit via `wait()` after stdout closes; a non-zero
46
- * exit with no events surfaced is the adapter's concern to detect.
374
+ *
375
+ * Without `options.journal`, behavior is byte-identical to before: resolves
376
+ * the spawn handle's exit via `wait()` after stdout closes; a non-zero exit
377
+ * with no events surfaced is the adapter's concern to detect.
378
+ *
379
+ * With `options.journal`, the agent's stdout is redirected into an in-sandbox
380
+ * journal (unless `journal.attach` is set, meaning a run already in flight)
381
+ * and then read back from byte 0 — one code path for a fresh run and an
382
+ * attach, both going through {@link readJournalNdjson}.
47
383
  */
48
384
  export async function* spawnNdjson(
49
385
  handle: SandboxHandle,
50
386
  command: string,
51
387
  options: SpawnNdjsonOptions = {},
52
388
  ): AsyncIterable<unknown> {
389
+ if (isJournaled(options)) {
390
+ if (options.journal.attach !== true) {
391
+ await startJournaledAgent(handle, command, options)
392
+ }
393
+ yield* readJournalNdjson(handle, options)
394
+ return
395
+ }
396
+
53
397
  const { onNonJsonLine, input, ...processOptions } = options
54
398
  const proc = await handle.process.spawn(command, processOptions)
55
399
 
@@ -93,7 +437,7 @@ export async function* spawnNdjson(
93
437
  const stderr = stderrChunks.join('').trim()
94
438
  throw new Error(
95
439
  `Agent process exited with code ${exitCode}` +
96
- (stderr ? `: ${stderr.slice(0, 1000)}` : ''),
440
+ (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''),
97
441
  )
98
442
  }
99
443
  }
package/src/sandbox.ts CHANGED
@@ -9,11 +9,13 @@
9
9
  import { bootstrapWorkspace } from './bootstrap'
10
10
  import { resolveAllSecrets } from './secrets'
11
11
  import { computeSandboxKey } from './key'
12
- import { InMemoryLockStore, InMemorySandboxStore } from './store'
12
+ import { InMemoryLockStore } from '@tanstack/ai/locks'
13
+ import type { LockStore } from '@tanstack/ai/locks'
13
14
  import type { SandboxFileHookEvent } from '@tanstack/ai'
15
+ import { InMemorySandboxInstanceStore } from './instance-store'
16
+ import type { SandboxInstanceStore } from './instance-store'
14
17
  import type { SandboxHandle, SandboxProvider } from './contracts'
15
18
  import type { SandboxKeyInput } from './key'
16
- import type { LockStore, SandboxStore } from './store'
17
19
  import type { SandboxPolicy } from './policy'
18
20
  import type { WorkspaceDefinition } from './workspace'
19
21
 
@@ -69,7 +71,7 @@ export interface SandboxEnsureContext {
69
71
  threadId: string
70
72
  runId: string
71
73
  /** Persistence seam; falls back to an in-memory store when absent. */
72
- store?: SandboxStore
74
+ store?: SandboxInstanceStore
73
75
  /** Lock seam; falls back to an in-memory lock when absent. */
74
76
  locks?: LockStore
75
77
  tenant?: { userId?: string; orgId?: string }
@@ -109,9 +111,16 @@ function parseMaxAgeMs(value: string | undefined): number | undefined {
109
111
  return undefined
110
112
  }
111
113
 
114
+ /**
115
+ * Bound for the unfenced teardown `destroy` call (see `destroy` below). Long
116
+ * enough that a slow provider API still completes, short enough that a wedged
117
+ * one cannot pin the process forever.
118
+ */
119
+ const DESTROY_TIMEOUT_MS = 60 * 1000
120
+
112
121
  // Process-lifetime fallbacks shared across all definitions so concurrent
113
122
  // ensures for the same key serialize even without an injected store/lock.
114
- const fallbackStore = new InMemorySandboxStore()
123
+ const fallbackStore = new InMemorySandboxInstanceStore()
115
124
  const fallbackLocks = new InMemoryLockStore()
116
125
 
117
126
  export function defineSandbox(config: SandboxConfig): SandboxDefinition {
@@ -242,10 +251,31 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
242
251
  const key = computeSandboxKey(keyInputFor(ctx))
243
252
  const existing = await store.get(key)
244
253
  if (!existing) return
245
- await config.provider.destroy({
246
- id: existing.providerSandboxId,
247
- signal: ctx.signal,
248
- })
254
+ /*
255
+ * TEARDOWN IS DELIBERATELY NOT FENCED BY `ctx.signal`.
256
+ *
257
+ * `destroy` runs on every teardown path INCLUDING the one caused by that
258
+ * very signal aborting, so forwarding it hands the provider a signal that is
259
+ * already aborted: a provider that honors it does nothing and returns
260
+ * successfully, and `store.delete` below then removes the only pointer to a
261
+ * live, billed sandbox. `SandboxInstanceStore` has no `list` (see the note
262
+ * at the top of `reclaim.ts`), so that sandbox is unreachable from then on.
263
+ *
264
+ * Same reasoning as `close()` never being fenced by the run claim (see
265
+ * `fenceDurability` in `claim.ts`): cleanup must outlive whatever cancelled
266
+ * the work. A fresh controller with its own bounded timeout keeps the call
267
+ * from hanging forever without letting the caller's abort cancel it.
268
+ */
269
+ const teardown = new AbortController()
270
+ const timer = setTimeout(() => teardown.abort(), DESTROY_TIMEOUT_MS)
271
+ try {
272
+ await config.provider.destroy({
273
+ id: existing.providerSandboxId,
274
+ signal: teardown.signal,
275
+ })
276
+ } finally {
277
+ clearTimeout(timer)
278
+ }
249
279
  await store.delete(key)
250
280
  }
251
281
 
package/src/shell.ts CHANGED
@@ -59,8 +59,23 @@ export interface BootstrapShell {
59
59
  export interface BootstrapShellOptions {
60
60
  /** Working directory to start the shell in (passed as ProcessOptions.cwd). */
61
61
  cwd?: string
62
+ /**
63
+ * Belt-and-braces deadline for a single `run()` to see its sentinel. The
64
+ * primary termination condition is the stdout stream ending (see
65
+ * {@link createBootstrapShell}); this only catches a shell that is alive,
66
+ * silent, and never going to answer. Generous by default because setup steps
67
+ * legitimately run for a long time (`npm install`, image pulls).
68
+ */
69
+ commandTimeoutMs?: number
62
70
  }
63
71
 
72
+ /** Default {@link BootstrapShellOptions.commandTimeoutMs} — 30 minutes. */
73
+ const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
74
+
75
+ /** Race marker for the per-command deadline. A symbol cannot collide with a
76
+ * literal stdout line (a line of text `'timeout'` would). */
77
+ const TIMED_OUT = Symbol('bootstrap-shell-timeout')
78
+
64
79
  /**
65
80
  * Spawn one `sh` process and return a {@link BootstrapShell} that drives it
66
81
  * via the sentinel-echo protocol.
@@ -87,18 +102,35 @@ export async function createBootstrapShell(
87
102
  * the iterator open. Buffer chunks into lines manually.
88
103
  */
89
104
  const lineBuffer: Array<string> = []
90
- let pending: Array<(line: string) => void> = []
105
+ // `null` means "the stdout stream ended" — distinct from an empty line, which
106
+ // `sh` emits constantly. Collapsing the two is what let a dead shell feed an
107
+ // infinite supply of `''` into a sentinel-hunting loop.
108
+ let pending: Array<(line: string | null) => void> = []
91
109
  let streamDone = false
110
+ let streamError: unknown
92
111
 
93
112
  /** Feed the stdout async-iterable into the shared line queue. */
94
113
  async function drainStdout(): Promise<void> {
95
114
  let partial = ''
96
- for await (const chunk of proc.stdout) {
97
- partial += chunk
98
- const parts = partial.split('\n')
99
- // All but the last element are complete lines.
100
- for (let i = 0; i < parts.length - 1; i++) {
101
- const line = parts[i] as string
115
+ try {
116
+ for await (const chunk of proc.stdout) {
117
+ partial += chunk
118
+ const parts = partial.split('\n')
119
+ // All but the last element are complete lines.
120
+ for (let i = 0; i < parts.length - 1; i++) {
121
+ const line = parts[i] as string
122
+ const resolver = pending.shift()
123
+ if (resolver !== undefined) {
124
+ resolver(line)
125
+ } else {
126
+ lineBuffer.push(line)
127
+ }
128
+ }
129
+ partial = parts[parts.length - 1] as string
130
+ }
131
+ // Flush any trailing partial line.
132
+ if (partial.length > 0) {
133
+ const line = partial
102
134
  const resolver = pending.shift()
103
135
  if (resolver !== undefined) {
104
136
  resolver(line)
@@ -106,39 +138,40 @@ export async function createBootstrapShell(
106
138
  lineBuffer.push(line)
107
139
  }
108
140
  }
109
- partial = parts[parts.length - 1] as string
110
- }
111
- // Flush any trailing partial line.
112
- if (partial.length > 0) {
113
- const line = partial
114
- const resolver = pending.shift()
115
- if (resolver !== undefined) {
116
- resolver(line)
117
- } else {
118
- lineBuffer.push(line)
141
+ } catch (error) {
142
+ // A throw while iterating stdout (transport reset, provider stream error)
143
+ // must NOT leave waiters parked on a promise nobody resolves. Record it so
144
+ // `run()` can name the cause, and fall through to the `finally` that
145
+ // unblocks everyone.
146
+ streamError = error
147
+ } finally {
148
+ streamDone = true
149
+ // Unblock any remaining waiters with the end-of-stream marker.
150
+ for (const resolver of pending) {
151
+ resolver(null)
119
152
  }
153
+ pending = []
120
154
  }
121
- streamDone = true
122
- // Resolve any remaining waiters with an empty sentinel so they unblock.
123
- for (const resolver of pending) {
124
- resolver('')
125
- }
126
- pending = []
127
155
  }
128
156
 
129
- // Start draining immediately; do NOT await — runs concurrently.
157
+ /*
158
+ * Start draining immediately; do NOT await — runs concurrently. The `try/catch`
159
+ * inside `drainStdout` means this promise never rejects, so there is no
160
+ * unhandled rejection while nothing is awaiting it, and `dispose()` can await
161
+ * it unconditionally.
162
+ */
130
163
  const drainPromise = drainStdout()
131
164
 
132
- /** Read the next line from the shared queue. */
133
- function nextLine(): Promise<string> {
165
+ /** Read the next line from the shared queue, or `null` once stdout ended. */
166
+ function nextLine(): Promise<string | null> {
134
167
  const buffered = lineBuffer.shift()
135
168
  if (buffered !== undefined) {
136
169
  return Promise.resolve(buffered)
137
170
  }
138
171
  if (streamDone) {
139
- return Promise.resolve('')
172
+ return Promise.resolve(null)
140
173
  }
141
- return new Promise<string>((resolve) => {
174
+ return new Promise<string | null>((resolve) => {
142
175
  pending.push(resolve)
143
176
  })
144
177
  }
@@ -162,18 +195,53 @@ export async function createBootstrapShell(
162
195
 
163
196
  const outputLines: Array<string> = []
164
197
 
165
- // Read lines until we find the sentinel.
166
- for (;;) {
167
- const line = await nextLine()
168
- if (line.startsWith(`${sentinel} `)) {
169
- const codeStr = line.slice(sentinel.length + 1).trim()
170
- const exitCode = parseInt(codeStr, 10)
171
- return {
172
- exitCode: Number.isFinite(exitCode) ? exitCode : 1,
173
- stdout: outputLines.join('\n'),
198
+ /*
199
+ * Read lines until we find the sentinel — but the wait MUST be able to end
200
+ * without one. `sh` can exit before it ever prints the sentinel (a missing
201
+ * binary, an OOM kill, the provider reaping the sandbox mid-bootstrap), and
202
+ * a loop whose only exit is the sentinel then spins on end-of-stream
203
+ * forever, pushing into `outputLines` until the host process dies of memory
204
+ * exhaustion. Two independent terminators:
205
+ * 1. `nextLine()` yields `null` the moment stdout is done — the real fix,
206
+ * it fires as soon as the shell is gone.
207
+ * 2. A deadline, for a shell that stays alive and simply never answers.
208
+ */
209
+ let timer: ReturnType<typeof setTimeout> | undefined
210
+ const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
211
+ timer = setTimeout(
212
+ () => resolve(TIMED_OUT),
213
+ opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
214
+ )
215
+ })
216
+
217
+ try {
218
+ for (;;) {
219
+ const line = await Promise.race([nextLine(), deadline])
220
+ if (line === TIMED_OUT) {
221
+ throw new Error(
222
+ `bootstrap shell: timed out after ${
223
+ opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
224
+ }ms waiting for the sentinel of command: ${command}`,
225
+ )
226
+ }
227
+ if (line === null) {
228
+ throw new Error(
229
+ `bootstrap shell: the shell exited before the sentinel was printed; command: ${command}`,
230
+ streamError === undefined ? undefined : { cause: streamError },
231
+ )
232
+ }
233
+ if (line.startsWith(`${sentinel} `)) {
234
+ const codeStr = line.slice(sentinel.length + 1).trim()
235
+ const exitCode = parseInt(codeStr, 10)
236
+ return {
237
+ exitCode: Number.isFinite(exitCode) ? exitCode : 1,
238
+ stdout: outputLines.join('\n'),
239
+ }
174
240
  }
241
+ outputLines.push(line)
175
242
  }
176
- outputLines.push(line)
243
+ } finally {
244
+ clearTimeout(timer)
177
245
  }
178
246
  }
179
247