@tanstack/ai-sandbox 0.2.4 → 0.3.1

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
@@ -0,0 +1,1201 @@
1
+ /**
2
+ * Provider conformance for the two unattended sweeps: `pruneJournals`
3
+ * (`journal-sweep.ts`) and `reapDetachedRuns` (`reap.ts`), against a REAL
4
+ * sandbox.
5
+ *
6
+ * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Both sweeps are almost
7
+ * entirely *shell* — `ls -1`, `stat -c '%Y %n'`, `rm -f`, `tail -c -N | base64`
8
+ * — composed as strings by `journal.ts` and executed by a provider. The unit
9
+ * suites drive fakes: an `exec` that answers from a scripted table, a
10
+ * filesystem that is a `Map`. A fake cannot be wrong about `stat` the way a
11
+ * BusyBox actually is, and on this feature that gap has already produced four
12
+ * defects that every unit test passed (see `takeover-conformance.ts`'s module
13
+ * doc for the roster). So the four properties the sweeps rest on are asserted
14
+ * here through a real shell against real files:
15
+ *
16
+ * 1. **A deletion really deletes, and a keep really keeps.** Asserted with
17
+ * `test -f` through the provider's shell, NEVER `handle.fs.exists`: on
18
+ * local-process the two resolve `/tmp` differently, so an `fs` probe answers
19
+ * about a path the journal was never written to (`journal.ts` rule 3). A
20
+ * sweep that "succeeded" while deleting nothing passes an `fs` probe.
21
+ * 2. **The age gate's self-witness works on THIS shell.** `journalMtimeListCommand`
22
+ * passes the directory as `stat`'s own first operand precisely because
23
+ * BusyBox exits 1 with EMPTY stdout on an unrecognised flag, and an empty
24
+ * parse read as an empty directory would delete every live run's journal. The
25
+ * docker provider's image is `alpine:3` — BusyBox 1.37, where `find -newermt`
26
+ * and `find -printf` are unrecognised — so the docker matrix is the authority
27
+ * on this case, not the local-process one (on Windows local-process execs
28
+ * through git-bash, whose `find`/`stat` are GNU-flavoured).
29
+ * 3. **The reaper never drives a live run.** The `'producing'` case asserts
30
+ * ABSENCE — nothing appended, `close()` not called, not one `runs.update`,
31
+ * `detachedSince` intact — because that is the shape of the defect
32
+ * `probeRunExit` exists to prevent: entering `pipeToRunLog` to "check" writes
33
+ * a terminal status and drops the run out of `listReclaimable` forever.
34
+ * 4. **A shell-hostile runId cannot become a shell-hostile command.** The encode
35
+ * → journal → follow → `ls` → decode → `rm` round trip runs on a runId
36
+ * containing `/`, a space, `;`, `$( )` and an embedded `touch`, with a canary
37
+ * file asserted absent. An ENCODING bug here is arbitrary command execution
38
+ * inside the sandbox, not a cosmetic defect.
39
+ *
40
+ * **What the canary proves, exactly, and what it does not.** It detects a
41
+ * runId reaching the shell WITHOUT `encodeRunId` — that is the mutation it
42
+ * bites on, and it bites hard: `journaledCommand`, `journalFollowCommand`,
43
+ * `journalExitProbeCommand`, `journalStderrReadCommand` and
44
+ * `journalCleanupCommand` all interpolate the path, so the `;touch` executes
45
+ * and the canary appears. It is BLIND to the loss of `journal.ts`'s
46
+ * `shellQuote`, the second and independent layer. Measured: with `shellQuote`
47
+ * reduced to the identity while `encodeRunId` stays, the redirect target
48
+ * becomes `>> /tmp/…/rp-a_3btouch_20_2ftmp…ndjson` — a single shell word of
49
+ * `[A-Za-z0-9._/-]`, because the encoder already removed every character a
50
+ * shell can act on — so no canary fires and NOTHING in this suite, or in any
51
+ * other real-provider suite, changes. Do not read a green run here as licence
52
+ * to "simplify" `shellQuote` away.
53
+ *
54
+ * The quoting is pinned instead by exact-string unit tests in
55
+ * `packages/ai-sandbox/tests/journal.test.ts`, which compare each composed
56
+ * command to a literal containing the quotes. By name, one per command:
57
+ * `journaledCommand` — "redirects stdout to the journal, stderr to its own
58
+ * file, and appends the exit sentinel" plus "quotes an adversarial runId so it
59
+ * cannot inject shell metacharacters"; `journalFollowCommand` — "translates a
60
+ * 0-based consumed-byte count into tail -c +N (1-based)";
61
+ * `journalReadCommand` — "the bounded read drops -f and keeps the base64
62
+ * frame, so a poll cannot hang"; `journalExistsCommand` — "probes through the
63
+ * shell, never through fs.*"; `journalStderrReadCommand` — "reads a BOUNDED
64
+ * tail of the sidecar, base64-framed, stderr silenced";
65
+ * `journalCleanupCommand`, `journalMtimeListCommand` and
66
+ * `journalExitProbeCommand` — the first `it` under each of their `describe`s.
67
+ * Those are the tests that go red on a dropped `shellQuote`; keep them exact.
68
+ *
69
+ * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.
70
+ * As in the journal and takeover suites there is deliberately no silent-skip
71
+ * path: a conformance case that quietly returns prints as a pass, which is how
72
+ * an unimplemented capability ships green.
73
+ *
74
+ * EVERY WAIT IN THIS FILE IS BOUNDED, and every journal directory is unique per
75
+ * case — see {@link caseDir}. This suite DELETES FILES, and
76
+ * `DEFAULT_JOURNAL_DIR` is a fixed absolute path shared with every other test
77
+ * and, on local-process, with a developer's real runs.
78
+ *
79
+ * Vitest is an OPTIONAL peer dependency: this module is imported only from test
80
+ * files, which already run under Vitest.
81
+ */
82
+ import { randomUUID } from 'node:crypto'
83
+ import { describe, expect, it } from 'vitest'
84
+ import { EventType, InMemoryRunStore } from '@tanstack/ai'
85
+ import { InMemoryLockStore } from '@tanstack/ai/locks'
86
+ import {
87
+ EXIT_SENTINEL_KEY,
88
+ decodeJournalRunId,
89
+ exitSentinelLine,
90
+ journalCleanupCommand,
91
+ journalExistsCommand,
92
+ journalListCommand,
93
+ journalMtimeListCommand,
94
+ journalPaths,
95
+ journalReadCommand,
96
+ journalStderrReadCommand,
97
+ journaledCommand,
98
+ parseJournalMtimeListing,
99
+ } from '../journal'
100
+ import { journalReadStrategy, readJournal } from '../journal-reader'
101
+ import { pruneJournals } from '../journal-sweep'
102
+ import { probeRunExit, reapDetachedRuns } from '../reap'
103
+ import { readJournalNdjson } from '../runner'
104
+ import { chunkFingerprint, createRunScopedIdGen } from '../chunk-identity'
105
+ import { waitForJournal } from './journal-conformance'
106
+ import type { JournalPaths } from '../journal'
107
+ import type { SandboxHandle } from '../contracts'
108
+ import type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'
109
+
110
+ export interface ReaperConformanceConfig {
111
+ /** Provider name, used in the describe title. */
112
+ name: string
113
+ /** Create a live sandbox plus its teardown. */
114
+ createHandle: () => Promise<{
115
+ handle: SandboxHandle
116
+ dispose: () => Promise<void>
117
+ }>
118
+ /**
119
+ * Declare that this provider cannot support the sweeps, with the reason.
120
+ * Registers a skipped case whose title carries the reason — a NAMED skip,
121
+ * visible in the reporter. Omit it and the suite runs.
122
+ */
123
+ unsupported?: { reason: string }
124
+ /**
125
+ * Declare that this provider's reads take the POLL strategy rather than the
126
+ * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles.
127
+ *
128
+ * Only the FOLLOW half of the shell-hostile-runId case depends on it, so this
129
+ * does not skip a case; it names itself in that case's title and the follow
130
+ * read is omitted. The declaration is checked against the live handle there, in
131
+ * both directions, so it cannot quietly remove coverage from a provider that
132
+ * can in fact follow.
133
+ */
134
+ followUnsupported?: { reason: string }
135
+ }
136
+
137
+ /** Poll interval handed to providers that cannot follow a growing file. */
138
+ const POLL_INTERVAL_MS = 50
139
+
140
+ /**
141
+ * Quiescence window for the reaper's first append. Short because the agent in
142
+ * these cases has provably stopped (the suite waited for its sentinel) — the
143
+ * gate still runs, it just does not need to wait 5s to observe nothing.
144
+ */
145
+ const FENCE_QUIET_MS = 25
146
+
147
+ /**
148
+ * Bound on a real journal read, so a reader that delivers nothing FAILS instead
149
+ * of parking CI.
150
+ *
151
+ * Never an assertion, and deliberately far above anything a healthy read needs
152
+ * (measured: 10–18s for the follow cases on both providers). Every use site
153
+ * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails
154
+ * naming this backstop rather than as a downstream transcript mismatch — which
155
+ * means this number can be raised freely and must never be the thing a case is
156
+ * tuned against.
157
+ */
158
+ const READ_BACKSTOP_MS = 90_000
159
+
160
+ /** Long enough that nothing in this suite is ever classified as expired. */
161
+ const NEVER_EXPIRES_MS = 60 * 60 * 1000
162
+
163
+ /**
164
+ * A journal directory nothing else on the machine writes to, created fresh for
165
+ * EVERY case.
166
+ *
167
+ * Not `DEFAULT_JOURNAL_DIR`, and not even one directory per suite. Both sweeps
168
+ * under test enumerate a whole directory and then DELETE from it, so a shared
169
+ * directory would let one case's leftovers become another's input — and on
170
+ * local-process the sandbox shell shares the host's real `/tmp`, where
171
+ * `DEFAULT_JOURNAL_DIR` holds a developer's actual runs.
172
+ */
173
+ function caseDir(): string {
174
+ return `/tmp/tanstack-reaper-conformance-${randomUUID()}`
175
+ }
176
+
177
+ /**
178
+ * Unique per run, and it must be: `journalPaths` derives the filename from the
179
+ * runId and the journal is append-only, so a reused id appends BEHIND the
180
+ * previous run's `{"__exit":N}` sentinel and the new run appears to emit nothing
181
+ * at all (see `journal.ts`).
182
+ */
183
+ function uniqueRunId(label: string): string {
184
+ return `rp-${label}-${randomUUID()}`
185
+ }
186
+
187
+ /**
188
+ * Single-quote a shell word, POSIX-style — the same rule `journal.ts`'s private
189
+ * `shellQuote` applies.
190
+ *
191
+ * Duplicated rather than exported from production code on purpose: this exists
192
+ * only for this suite's `rm -rf` teardown, which is not a production operation
193
+ * and must not become one by growing an export for it.
194
+ */
195
+ function quote(value: string): string {
196
+ return `'${value.replaceAll("'", `'\\''`)}'`
197
+ }
198
+
199
+ /** Remove a case's journal directory and everything in it. Best effort. */
200
+ async function removeDir(handle: SandboxHandle, dir: string): Promise<void> {
201
+ try {
202
+ await handle.process.exec(`rm -rf ${quote(dir)}`)
203
+ } catch {
204
+ // The sandbox may already be gone, and on docker it is about to be. Nothing
205
+ // under test depends on the directory being absent afterwards — the cases
206
+ // that DO assert deletion assert it directly, per file.
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Does `path` exist, according to the SANDBOX'S SHELL?
212
+ *
213
+ * `journalExistsCommand` rather than `handle.fs.exists`, for any path and not
214
+ * just a journal: `journal.ts` rule 3 — on local-process `fs.*` resolves `/tmp`
215
+ * under the sandbox root while a shell redirect hits the host's real `/tmp`, so
216
+ * an `fs` probe would answer about a different file and every deletion
217
+ * assertion in this suite would pass vacuously.
218
+ */
219
+ async function fileExists(
220
+ handle: SandboxHandle,
221
+ path: string,
222
+ ): Promise<boolean> {
223
+ const probe = await handle.process.exec(
224
+ // Only `journal` is read by the probe, and its parameter is typed
225
+ // `Pick<JournalPaths, 'journal'>` for exactly this reason: an arbitrary path
226
+ // has no run behind it, so there is no nonce or sidecar to invent.
227
+ journalExistsCommand({ journal: path }),
228
+ )
229
+ return probe.exitCode === 0
230
+ }
231
+
232
+ /** Filename as `ls -1` reports it, for a path inside `dir`. */
233
+ function basename(dir: string, path: string): string {
234
+ return path.slice(dir.length + 1)
235
+ }
236
+
237
+ /**
238
+ * A real agent: a shell command printing one NDJSON line per delta, then
239
+ * exiting.
240
+ *
241
+ * `printf '%s\n' a b c` reuses the format for every operand on GNU coreutils
242
+ * and on BusyBox alike, so this needs no loop. The JSON contains only double
243
+ * quotes, so it is safe inside the POSIX single-quoted words this builds.
244
+ */
245
+ function emitLines(deltas: Array<string>): string {
246
+ return `printf '%s\\n' ${deltas.map((delta) => `'{"delta":"${delta}"}'`).join(' ')}`
247
+ }
248
+
249
+ /**
250
+ * Run a journaled agent to completion, so the `{"__exit":N}` sentinel is in the
251
+ * journal by the time this resolves.
252
+ *
253
+ * `exec`, not `spawn`: `exec` waits, and a bounded wait is the only kind this
254
+ * suite allows. (`SpawnHandle.wait()` is also not safe to call after the fact on
255
+ * every provider — see `journal-conformance.ts`.)
256
+ */
257
+ async function runAgent(
258
+ handle: SandboxHandle,
259
+ paths: JournalPaths,
260
+ deltas: Array<string>,
261
+ ): Promise<void> {
262
+ await handle.process.exec(journaledCommand(emitLines(deltas), paths))
263
+ }
264
+
265
+ /** `ls -1` output as a list of names. */
266
+ async function listNames(
267
+ handle: SandboxHandle,
268
+ dir: string,
269
+ ): Promise<Array<string>> {
270
+ const listing = await handle.process.exec(journalListCommand(dir))
271
+ return listing.stdout
272
+ .split('\n')
273
+ .map((line) => line.trim())
274
+ .filter((line) => line !== '')
275
+ }
276
+
277
+ /** Decode the base64 frame a bounded journal read produces. */
278
+ function decodeJournalRead(stdout: string): string {
279
+ return Buffer.from(stdout.replace(/\s+/g, ''), 'base64').toString('utf8')
280
+ }
281
+
282
+ /**
283
+ * The `stat -c '%Y %n'` listing for `dir`, plus the raw stdout so a case can
284
+ * assert the WITNESS LINE itself rather than only its parsed consequence.
285
+ */
286
+ async function mtimeListing(
287
+ handle: SandboxHandle,
288
+ dir: string,
289
+ ): Promise<{ stdout: string; entries: Map<string, number> }> {
290
+ const probe = await handle.process.exec(journalMtimeListCommand(dir))
291
+ const parsed = parseJournalMtimeListing(probe.stdout, dir)
292
+ if (parsed.kind !== 'listed') {
293
+ throw new Error(
294
+ `reaper conformance: the mtime listing for ${dir} came back unavailable — ` +
295
+ `stat -c '%Y %n' produced no witness line. stdout: ${JSON.stringify(probe.stdout)}`,
296
+ )
297
+ }
298
+ return {
299
+ stdout: probe.stdout,
300
+ entries: new Map(
301
+ parsed.entries.map((entry) => [entry.name, entry.mtimeMs]),
302
+ ),
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Is there a `<seconds> <dir>` line — `stat`'s report on its own first operand?
308
+ *
309
+ * That line, not the exit status, is the evidence the mechanism ran: BusyBox
310
+ * exits 1 both for an EMPTY directory (whose unexpanded glob it cannot stat) and
311
+ * for an unrecognised flag, and only the witness distinguishes them.
312
+ */
313
+ function hasWitnessLine(stdout: string, dir: string): boolean {
314
+ return stdout
315
+ .split('\n')
316
+ .some((line) => /^\d+ (?<path>.+)$/.exec(line.trim())?.[1] === dir)
317
+ }
318
+
319
+ /** Read one file's mtime out of a listing, loudly when it is missing. */
320
+ function mtimeOf(entries: Map<string, number>, name: string): number {
321
+ const mtimeMs = entries.get(name)
322
+ if (mtimeMs === undefined) {
323
+ throw new Error(
324
+ `reaper conformance: ${name} has no mtime in the stat listing, so the age gate cannot be exercised`,
325
+ )
326
+ }
327
+ return mtimeMs
328
+ }
329
+
330
+ /**
331
+ * An in-process event log with real accumulated state, plus the two facts the
332
+ * reaper assertions need: what was appended, and how many times `close()` ran.
333
+ *
334
+ * `close()` is the load-bearing counter. `pipeToRunLog` ALWAYS calls it, so a
335
+ * reaper that entered the pipe to find out whether a run finished would show up
336
+ * here as `closes() === 1` — which ends every attached client's stream — even if
337
+ * it happened to append nothing.
338
+ */
339
+ interface ConformanceLog {
340
+ log: StreamDurability
341
+ stored: () => Array<StreamChunk>
342
+ closes: () => number
343
+ }
344
+
345
+ function conformanceLog(): ConformanceLog {
346
+ const entries: Array<{ offset: string; chunk: StreamChunk }> = []
347
+ let closes = 0
348
+ return {
349
+ log: {
350
+ resumeFrom: () => null,
351
+ append: (chunks) =>
352
+ Promise.resolve(
353
+ chunks.map((chunk) => {
354
+ const offset = `reap:${entries.length}`
355
+ entries.push({ offset, chunk })
356
+ return offset
357
+ }),
358
+ ),
359
+ // Nothing here tails the log — every assertion reads the appended
360
+ // transcript, and a `read` would park until `close()` (see `align.ts`).
361
+ read: () => (async function* empty() {})(),
362
+ close: () => {
363
+ closes += 1
364
+ return Promise.resolve()
365
+ },
366
+ snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry }))),
367
+ },
368
+ stored: () => entries.map((entry) => entry.chunk),
369
+ closes: () => closes,
370
+ }
371
+ }
372
+
373
+ /**
374
+ * A `RunStore` that counts its MUTATIONS, so the leave-alone case can assert
375
+ * that a producing run's record was not written at all.
376
+ *
377
+ * "Status still `'running'`" is too weak on its own: `driverEpoch` is bumped by
378
+ * `withRunClaim` before any status is written, so a reaper that claimed a live
379
+ * run and then bailed would still read as `'running'`. Counting `update` sees
380
+ * that; reading the status does not.
381
+ */
382
+ interface CountingRunStore {
383
+ runs: RunStore
384
+ updates: () => number
385
+ }
386
+
387
+ function countingRunStore(inner: InMemoryRunStore): CountingRunStore {
388
+ let updates = 0
389
+ return {
390
+ runs: {
391
+ createOrResume: (...args) => inner.createOrResume(...args),
392
+ update: (...args) => {
393
+ updates += 1
394
+ return inner.update(...args)
395
+ },
396
+ get: (...args) => inner.get(...args),
397
+ listByThread: (...args) => inner.listByThread(...args),
398
+ listReclaimable: (...args) => inner.listReclaimable(...args),
399
+ findActiveRun: (...args) => inner.findActiveRun(...args),
400
+ },
401
+ updates: () => updates,
402
+ }
403
+ }
404
+
405
+ /** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */
406
+ function contentChunk(messageId: string, delta: string): StreamChunk {
407
+ return {
408
+ type: EventType.TEXT_MESSAGE_CONTENT,
409
+ messageId,
410
+ delta,
411
+ timestamp: Date.now(),
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Narrow one parsed journal line into its chunk.
417
+ *
418
+ * Fields are validated and the chunk REBUILT from them rather than asserted into
419
+ * shape: a cast would let a provider that mangles the bytes reach
420
+ * `chunkFingerprint` as a structurally invalid chunk and fail somewhere
421
+ * unrelated.
422
+ */
423
+ function toChunk(
424
+ runId: string,
425
+ messageId: string,
426
+ value: unknown,
427
+ ): StreamChunk {
428
+ if (typeof value !== 'object' || value === null || !('delta' in value)) {
429
+ throw new Error(
430
+ `reaper conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`,
431
+ )
432
+ }
433
+ const delta = value.delta
434
+ if (typeof delta !== 'string') {
435
+ throw new Error(
436
+ `reaper conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`,
437
+ )
438
+ }
439
+ return contentChunk(messageId, delta)
440
+ }
441
+
442
+ /** Deterministic translator: re-reading the journal reproduces the same chunks. */
443
+ async function* translate(
444
+ runId: string,
445
+ lines: AsyncIterable<unknown>,
446
+ ): AsyncIterable<StreamChunk> {
447
+ const messageId = createRunScopedIdGen(runId)()
448
+ for await (const line of lines) yield toChunk(runId, messageId, line)
449
+ }
450
+
451
+ /** A comparable transcript: each chunk reduced to its fingerprint. */
452
+ function transcript(chunks: Array<StreamChunk>): Array<string> {
453
+ return chunks.map(chunkFingerprint)
454
+ }
455
+
456
+ /** The chunks a run over `deltas` must deliver, exactly once and in order. */
457
+ function expectedTranscript(
458
+ runId: string,
459
+ deltas: Array<string>,
460
+ ): Array<StreamChunk> {
461
+ const messageId = createRunScopedIdGen(runId)()
462
+ return deltas.map((delta) => contentChunk(messageId, delta))
463
+ }
464
+
465
+ /**
466
+ * The reaper's `drive`: read the run's journal from byte 0 and translate it.
467
+ *
468
+ * The read is bounded independently of `signal` so a journal that stops growing
469
+ * fails the case instead of hanging CI.
470
+ *
471
+ * Returns the drive alongside `backstopped()`, the causal witness for
472
+ * {@link READ_BACKSTOP_MS}: the case must assert it is `false` before its
473
+ * transcript assertions, so a read the CLOCK ended fails naming the backstop
474
+ * instead of as a truncated-transcript diff.
475
+ */
476
+ function driveFromJournal(
477
+ handle: SandboxHandle,
478
+ dir: string,
479
+ ): {
480
+ drive: (input: {
481
+ runId: string
482
+ threadId: string
483
+ signal: AbortSignal
484
+ }) => AsyncIterable<StreamChunk>
485
+ /** True if any read this drive started was ended by the backstop clock. */
486
+ backstopped: () => boolean
487
+ } {
488
+ // One entry per `drive` invocation, so a sweep that drives more than one run
489
+ // cannot hide a backstopped read behind a healthy one.
490
+ const backstops: Array<AbortSignal> = []
491
+ return {
492
+ drive: ({ runId, signal }) => {
493
+ // Not the assertion — see {@link READ_BACKSTOP_MS}. `backstopped()` is what
494
+ // proves the clock was not what ended the read.
495
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)
496
+ backstops.push(backstop)
497
+ return translate(
498
+ runId,
499
+ readJournalNdjson(handle, {
500
+ signal: AbortSignal.any([signal, backstop]),
501
+ journal: { runId, dir, pollIntervalMs: POLL_INTERVAL_MS },
502
+ }),
503
+ )
504
+ },
505
+ backstopped: () => backstops.some((s) => s.aborted),
506
+ }
507
+ }
508
+
509
+ /** A `'running'`, DETACHED record — the shape `listReclaimable` selects on. */
510
+ async function detachedRun(
511
+ store: RunStore,
512
+ runId: string,
513
+ threadId: string,
514
+ detachedSince: number,
515
+ ): Promise<void> {
516
+ await store.createOrResume({ runId, threadId, startedAt: Date.now() })
517
+ await store.update(runId, { detachedSince })
518
+ }
519
+
520
+ function sleep(ms: number): Promise<void> {
521
+ return new Promise((resolve) => setTimeout(resolve, ms))
522
+ }
523
+
524
+ /**
525
+ * Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets
526
+ * a fresh sandbox via `createHandle`/`dispose`, a fresh journal directory, and
527
+ * unique runIds, so no case can observe another's files.
528
+ */
529
+ export function runReaperConformance(config: ReaperConformanceConfig): void {
530
+ describe(`reaper conformance — ${config.name}`, () => {
531
+ if (config.unsupported) {
532
+ it.skip(`unsupported: ${config.unsupported.reason}`, () => {
533
+ expect(true).toBe(true)
534
+ })
535
+ return
536
+ }
537
+
538
+ // -----------------------------------------------------------------------
539
+ // 1. `pruneJournals` against a real filesystem.
540
+ // -----------------------------------------------------------------------
541
+ it(
542
+ "deletes a terminal run's journal AND its .err sidecar, while a running run's journal survives the same sweep",
543
+ { timeout: 60_000 },
544
+ async () => {
545
+ const { handle, dispose } = await config.createHandle()
546
+ const dir = caseDir()
547
+ const terminalId = uniqueRunId('terminal')
548
+ const liveId = uniqueRunId('live')
549
+ const terminal = journalPaths(terminalId, dir)
550
+ const live = journalPaths(liveId, dir)
551
+ try {
552
+ await runAgent(handle, terminal, ['1'])
553
+ await runAgent(handle, live, ['1'])
554
+ // Premise: all four files really exist before the sweep, otherwise
555
+ // "deleted" below would be indistinguishable from "never written".
556
+ expect({
557
+ terminalJournal: await fileExists(handle, terminal.journal),
558
+ terminalSidecar: await fileExists(handle, terminal.stderr),
559
+ liveJournal: await fileExists(handle, live.journal),
560
+ }).toEqual({
561
+ terminalJournal: true,
562
+ terminalSidecar: true,
563
+ liveJournal: true,
564
+ })
565
+
566
+ const runs = new InMemoryRunStore()
567
+ await runs.createOrResume({
568
+ runId: terminalId,
569
+ threadId: `${terminalId}-t`,
570
+ startedAt: Date.now(),
571
+ })
572
+ await runs.update(terminalId, {
573
+ status: 'completed',
574
+ finishedAt: Date.now(),
575
+ })
576
+ await runs.createOrResume({
577
+ runId: liveId,
578
+ threadId: `${liveId}-t`,
579
+ startedAt: Date.now(),
580
+ })
581
+
582
+ const result = await pruneJournals({ handle, runs, dir })
583
+ expect(result.deleted).toEqual([terminalId])
584
+ expect(result.failures).toEqual([])
585
+ expect(result.kept).toEqual([
586
+ { runId: liveId, names: expect.any(Array), reason: 'non-terminal' },
587
+ ])
588
+
589
+ // The files, through the shell. A sweep that reported a deletion it did
590
+ // not perform passes every assertion above and fails here.
591
+ expect({
592
+ terminalJournal: await fileExists(handle, terminal.journal),
593
+ terminalSidecar: await fileExists(handle, terminal.stderr),
594
+ liveJournal: await fileExists(handle, live.journal),
595
+ liveSidecar: await fileExists(handle, live.stderr),
596
+ }).toEqual({
597
+ terminalJournal: false,
598
+ terminalSidecar: false,
599
+ liveJournal: true,
600
+ liveSidecar: true,
601
+ })
602
+ } finally {
603
+ await removeDir(handle, dir)
604
+ await dispose()
605
+ }
606
+ },
607
+ )
608
+
609
+ it(
610
+ 'sweeps the same terminal run twice without a failure, and rm -f of an already-absent journal exits 0',
611
+ { timeout: 60_000 },
612
+ async () => {
613
+ const { handle, dispose } = await config.createHandle()
614
+ const dir = caseDir()
615
+ const runId = uniqueRunId('twice')
616
+ const paths = journalPaths(runId, dir)
617
+ try {
618
+ await runAgent(handle, paths, ['1'])
619
+ const runs = new InMemoryRunStore()
620
+ await runs.createOrResume({
621
+ runId,
622
+ threadId: `${runId}-t`,
623
+ startedAt: Date.now(),
624
+ })
625
+ await runs.update(runId, {
626
+ status: 'completed',
627
+ finishedAt: Date.now(),
628
+ })
629
+
630
+ const first = await pruneJournals({ handle, runs, dir })
631
+ expect(first.deleted).toEqual([runId])
632
+
633
+ // The second sweep sees an empty directory. It must report nothing to
634
+ // do rather than a failure — a cron runs this every tick forever.
635
+ const second = await pruneJournals({ handle, runs, dir })
636
+ expect({
637
+ listed: second.listed,
638
+ runIds: second.runIds,
639
+ deleted: second.deleted,
640
+ kept: second.kept,
641
+ failures: second.failures,
642
+ }).toEqual({
643
+ listed: 0,
644
+ runIds: 0,
645
+ deleted: [],
646
+ kept: [],
647
+ failures: [],
648
+ })
649
+
650
+ // And the `rm -f` the sweep issues is itself idempotent on this shell.
651
+ // Asserted directly because the sweep folds a non-zero `rm` into
652
+ // `kept: 'delete-failed'` and would therefore hide it as a keep.
653
+ const rerun = await handle.process.exec(journalCleanupCommand(paths))
654
+ expect(rerun.exitCode).toBe(0)
655
+ } finally {
656
+ await removeDir(handle, dir)
657
+ await dispose()
658
+ }
659
+ },
660
+ )
661
+
662
+ it(
663
+ 'leaves a filename it cannot decode alone, while still sweeping the terminal run beside it',
664
+ { timeout: 60_000 },
665
+ async () => {
666
+ const { handle, dispose } = await config.createHandle()
667
+ const dir = caseDir()
668
+ const runId = uniqueRunId('undecodable')
669
+ const paths = journalPaths(runId, dir)
670
+ // `_1.` is not a two-hex-digit escape, so this name is `malformed` — the
671
+ // shape a truncated or foreign file has. `decodeJournalRunId` must refuse
672
+ // it, and the sweep must keep it WITHOUT asking the store, because a
673
+ // plausible-but-wrong runId could answer `terminal` for someone else.
674
+ const strayName = 'reaper-conformance-stray_1.ndjson'
675
+ const strayPath = `${dir}/${strayName}`
676
+ try {
677
+ await runAgent(handle, paths, ['1'])
678
+ await handle.process.exec(
679
+ `printf 'not a journal\\n' >> ${quote(strayPath)}`,
680
+ )
681
+ expect(await fileExists(handle, strayPath)).toBe(true)
682
+ expect(decodeJournalRunId(strayName).kind).toBe('malformed')
683
+
684
+ const runs = new InMemoryRunStore()
685
+ await runs.createOrResume({
686
+ runId,
687
+ threadId: `${runId}-t`,
688
+ startedAt: Date.now(),
689
+ })
690
+ await runs.update(runId, {
691
+ status: 'completed',
692
+ finishedAt: Date.now(),
693
+ })
694
+
695
+ const result = await pruneJournals({ handle, runs, dir })
696
+ expect(result.deleted).toEqual([runId])
697
+ expect(result.kept).toEqual([
698
+ { names: [strayName], reason: 'undecodable-name' },
699
+ ])
700
+ expect(result.failures).toEqual([])
701
+ expect({
702
+ strayKept: await fileExists(handle, strayPath),
703
+ journalDeleted: !(await fileExists(handle, paths.journal)),
704
+ }).toEqual({ strayKept: true, journalDeleted: true })
705
+ } finally {
706
+ await removeDir(handle, dir)
707
+ await dispose()
708
+ }
709
+ },
710
+ )
711
+
712
+ // -----------------------------------------------------------------------
713
+ // 2. The age gate on a real shell.
714
+ // -----------------------------------------------------------------------
715
+ it(
716
+ "emits stat's self-witness line for a populated directory, so the age gate is usable",
717
+ { timeout: 60_000 },
718
+ async () => {
719
+ const { handle, dispose } = await config.createHandle()
720
+ const dir = caseDir()
721
+ const runId = uniqueRunId('witness')
722
+ const paths = journalPaths(runId, dir)
723
+ try {
724
+ await runAgent(handle, paths, ['1'])
725
+ const listing = await mtimeListing(handle, dir)
726
+ // The witness is what makes "no files" distinguishable from "the
727
+ // mechanism is unavailable". On BusyBox 1.37 — the docker provider's
728
+ // `alpine:3` — `find -newermt`/`-printf` are unrecognised and exit 1
729
+ // with empty stdout, which is exactly why the design is a witness line
730
+ // rather than a `find` and an exit code.
731
+ expect(hasWitnessLine(listing.stdout, dir)).toBe(true)
732
+ expect([...listing.entries.keys()].sort()).toEqual(
733
+ [basename(dir, paths.journal), basename(dir, paths.stderr)].sort(),
734
+ )
735
+ // Real epoch times, not the parser's zeroes: a `%Y` the shell did not
736
+ // expand would parse as no entry at all, and a `stat` that printed
737
+ // something else would land far from now.
738
+ for (const mtimeMs of listing.entries.values()) {
739
+ expect(Math.abs(Date.now() - mtimeMs)).toBeLessThan(120_000)
740
+ }
741
+ } finally {
742
+ await removeDir(handle, dir)
743
+ await dispose()
744
+ }
745
+ },
746
+ )
747
+
748
+ it(
749
+ 'reports an EMPTY journal directory as witness-only rather than unavailable',
750
+ { timeout: 60_000 },
751
+ async () => {
752
+ const { handle, dispose } = await config.createHandle()
753
+ const dir = caseDir()
754
+ try {
755
+ await handle.process.exec(`mkdir -p ${quote(dir)}`)
756
+ const listing = await mtimeListing(handle, dir)
757
+ expect(hasWitnessLine(listing.stdout, dir)).toBe(true)
758
+ expect([...listing.entries.keys()]).toEqual([])
759
+
760
+ // And the sweep agrees: an empty directory is a LISTED age gate, not an
761
+ // unavailable one. `'unavailable'` here would silently disable orphan
762
+ // expiry forever on this provider.
763
+ const result = await pruneJournals({
764
+ handle,
765
+ runs: new InMemoryRunStore(),
766
+ dir,
767
+ })
768
+ expect({
769
+ listed: result.listed,
770
+ ageGate: result.ageGate,
771
+ deleted: result.deleted,
772
+ failures: result.failures,
773
+ }).toEqual({
774
+ listed: 0,
775
+ ageGate: 'listed',
776
+ deleted: [],
777
+ failures: [],
778
+ })
779
+ } finally {
780
+ await removeDir(handle, dir)
781
+ await dispose()
782
+ }
783
+ },
784
+ )
785
+
786
+ it(
787
+ 'keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass',
788
+ { timeout: 120_000 },
789
+ async () => {
790
+ const { handle, dispose } = await config.createHandle()
791
+ const dir = caseDir()
792
+ const olderId = uniqueRunId('older')
793
+ const newerId = uniqueRunId('newer')
794
+ const older = journalPaths(olderId, dir)
795
+ const newer = journalPaths(newerId, dir)
796
+ try {
797
+ await runAgent(handle, older, ['1'])
798
+ // `stat -c '%Y'` is second-granular, so the two runs must be more than
799
+ // one second apart for their ages to be distinguishable at all.
800
+ await sleep(2_500)
801
+ await runAgent(handle, newer, ['1'])
802
+
803
+ // The cutoff is computed from the REAL mtimes the real shell reported,
804
+ // not from a fabricated timestamp: that is the whole point of running
805
+ // this against a provider. `pruneJournals` keeps when the NEWEST of a
806
+ // run's files is strictly newer than the cutoff, so placing the cutoff
807
+ // between the two runs must expire exactly one of them.
808
+ const listing = await mtimeListing(handle, dir)
809
+ const newestOf = (paths: JournalPaths): number =>
810
+ Math.max(
811
+ mtimeOf(listing.entries, basename(dir, paths.journal)),
812
+ mtimeOf(listing.entries, basename(dir, paths.stderr)),
813
+ )
814
+ const olderMtime = newestOf(older)
815
+ const newerMtime = newestOf(newer)
816
+ expect(newerMtime - olderMtime).toBeGreaterThanOrEqual(1_000)
817
+
818
+ const now = Date.now()
819
+ const cutoff = olderMtime + Math.floor((newerMtime - olderMtime) / 2)
820
+ // NEITHER run is in the store, so both take the orphan arm and only the
821
+ // age gate decides between them.
822
+ const result = await pruneJournals({
823
+ handle,
824
+ runs: new InMemoryRunStore(),
825
+ dir,
826
+ now,
827
+ orphanTtlMs: now - cutoff,
828
+ })
829
+ expect(result.ageGate).toBe('listed')
830
+ expect(result.deleted).toEqual([olderId])
831
+ expect(result.kept).toEqual([
832
+ {
833
+ runId: newerId,
834
+ names: expect.any(Array),
835
+ reason: 'orphan-too-recent',
836
+ },
837
+ ])
838
+ expect(result.failures).toEqual([])
839
+ expect({
840
+ olderJournal: await fileExists(handle, older.journal),
841
+ olderSidecar: await fileExists(handle, older.stderr),
842
+ newerJournal: await fileExists(handle, newer.journal),
843
+ newerSidecar: await fileExists(handle, newer.stderr),
844
+ }).toEqual({
845
+ olderJournal: false,
846
+ olderSidecar: false,
847
+ newerJournal: true,
848
+ newerSidecar: true,
849
+ })
850
+ } finally {
851
+ await removeDir(handle, dir)
852
+ await dispose()
853
+ }
854
+ },
855
+ )
856
+
857
+ // -----------------------------------------------------------------------
858
+ // 3. `reapDetachedRuns` end to end.
859
+ // -----------------------------------------------------------------------
860
+ it(
861
+ 'finalizes a detached run whose agent reached its sentinel, and its transcript lands',
862
+ { timeout: 120_000 },
863
+ async () => {
864
+ const { handle, dispose } = await config.createHandle()
865
+ const dir = caseDir()
866
+ const runId = uniqueRunId('finalize')
867
+ const threadId = `${runId}-t`
868
+ const deltas = ['1', '2', '3']
869
+ const paths = journalPaths(runId, dir)
870
+ try {
871
+ const runs = new InMemoryRunStore()
872
+ const detachedSince = Date.now()
873
+ await detachedRun(runs, runId, threadId, detachedSince)
874
+ await runAgent(handle, paths, deltas)
875
+
876
+ // The probe, on its own, before any sweep: this read is what makes the
877
+ // reaper safe, and it must answer from the JOURNAL rather than from the
878
+ // delivery log (which a detached run's dead host stopped appending to).
879
+ expect(await probeRunExit({ handle, runId, dir })).toEqual({
880
+ state: 'finished',
881
+ exitCode: 0,
882
+ })
883
+
884
+ const log = conformanceLog()
885
+ const journalDrive = driveFromJournal(handle, dir)
886
+ const result = await reapDetachedRuns({
887
+ runs,
888
+ locks: new InMemoryLockStore(),
889
+ durability: () => log.log,
890
+ hasFinished: (record) =>
891
+ probeRunExit({ handle, runId: record.runId, dir }),
892
+ drive: journalDrive.drive,
893
+ now: Date.now(),
894
+ detachedRunTtlMs: NEVER_EXPIRES_MS,
895
+ fenceQuietMs: FENCE_QUIET_MS,
896
+ })
897
+
898
+ // The causal witness, before anything downstream — see
899
+ // {@link READ_BACKSTOP_MS}. The reaper's read ends at the sentinel; if
900
+ // the clock ended it instead, the transcript below is short and the
901
+ // failure must name the backstop rather than a missing chunk.
902
+ expect({ backstopped: journalDrive.backstopped() }).toEqual({
903
+ backstopped: false,
904
+ })
905
+ expect({
906
+ considered: result.considered,
907
+ probed: result.probed,
908
+ finalized: result.outcomes.finalized,
909
+ }).toEqual({ considered: 1, probed: 1, finalized: 1 })
910
+ expect(result.runs).toEqual([
911
+ { runId, outcome: 'finalized', status: 'completed', exitCode: 0 },
912
+ ])
913
+ // The transcript, element for element — the reaper's whole purpose is
914
+ // that the run a nobody watched still ends up saved.
915
+ expect(transcript(log.stored())).toEqual(
916
+ transcript(expectedTranscript(runId, deltas)),
917
+ )
918
+ const record = await runs.get(runId)
919
+ expect(record?.status).toBe('completed')
920
+ // NEVER CLEARED: `detachedSince` is what the next sweep selects on, and
921
+ // clearing it would reset the TTL on every pass.
922
+ expect(record?.detachedSince).toBe(detachedSince)
923
+ } finally {
924
+ await removeDir(handle, dir)
925
+ await dispose()
926
+ }
927
+ },
928
+ )
929
+
930
+ it(
931
+ 'reports a still-producing detached run as producing and leaves it completely untouched',
932
+ { timeout: 120_000 },
933
+ async () => {
934
+ const { handle, dispose } = await config.createHandle()
935
+ const dir = caseDir()
936
+ const runId = uniqueRunId('producing')
937
+ const threadId = `${runId}-t`
938
+ const paths = journalPaths(runId, dir)
939
+ const store = countingRunStore(new InMemoryRunStore())
940
+ // A REAL agent that has written a line and is genuinely still alive: no
941
+ // sentinel can be in the journal, and driving it would truncate a healthy
942
+ // run's transcript at line one.
943
+ const agent = await handle.process.spawn(
944
+ journaledCommand(`${emitLines(['1'])}; sleep 30`, paths),
945
+ )
946
+ try {
947
+ const detachedSince = Date.now()
948
+ await detachedRun(store.runs, runId, threadId, detachedSince)
949
+ await waitForJournal(handle, paths)
950
+ const read = await handle.process.exec(journalReadCommand(paths, 0))
951
+ const text = decodeJournalRead(read.stdout)
952
+ // Producing, provably: the first line is there and the sentinel is not.
953
+ expect(text).toContain('{"delta":"1"}')
954
+ expect(text).not.toContain('__exit')
955
+ expect(await probeRunExit({ handle, runId, dir })).toEqual({
956
+ state: 'producing',
957
+ })
958
+
959
+ const log = conformanceLog()
960
+ let driveCalled = false
961
+ let durabilityCalls = 0
962
+ const result = await reapDetachedRuns({
963
+ runs: store.runs,
964
+ locks: new InMemoryLockStore(),
965
+ durability: () => {
966
+ durabilityCalls += 1
967
+ return log.log
968
+ },
969
+ hasFinished: (record) =>
970
+ probeRunExit({ handle, runId: record.runId, dir }),
971
+ drive: () => {
972
+ driveCalled = true
973
+ return (async function* never() {})()
974
+ },
975
+ now: Date.now(),
976
+ detachedRunTtlMs: NEVER_EXPIRES_MS,
977
+ fenceQuietMs: FENCE_QUIET_MS,
978
+ })
979
+
980
+ expect(result.runs).toEqual([{ runId, outcome: 'producing' }])
981
+ expect({
982
+ considered: result.considered,
983
+ probed: result.probed,
984
+ producing: result.outcomes.producing,
985
+ finalized: result.outcomes.finalized,
986
+ expired: result.outcomes.expired,
987
+ failed: result.outcomes.failed,
988
+ }).toEqual({
989
+ considered: 1,
990
+ probed: 1,
991
+ producing: 1,
992
+ finalized: 0,
993
+ expired: 0,
994
+ failed: 0,
995
+ })
996
+
997
+ // ABSENCE, asserted in one object so a regression names which
998
+ // guarantee broke instead of failing on whichever line came first.
999
+ // Every one of these is a way the pre-`probeRunExit` design destroyed a
1000
+ // live run: an append duplicates its prefix, a `close()` ends every
1001
+ // attached client's stream, an `update` writes `'completed'` and drops
1002
+ // the run out of `listReclaimable` forever, and a moved
1003
+ // `detachedSince` restarts its TTL.
1004
+ const record = await store.runs.get(runId)
1005
+ expect({
1006
+ driveCalled,
1007
+ durabilityCalls,
1008
+ appended: log.stored().length,
1009
+ closes: log.closes(),
1010
+ updatesAfterSetup: store.updates() - 1,
1011
+ status: record?.status,
1012
+ detachedSince: record?.detachedSince,
1013
+ driverEpoch: record?.driverEpoch,
1014
+ }).toEqual({
1015
+ driveCalled: false,
1016
+ durabilityCalls: 0,
1017
+ appended: 0,
1018
+ closes: 0,
1019
+ updatesAfterSetup: 0,
1020
+ status: 'running',
1021
+ detachedSince,
1022
+ driverEpoch: undefined,
1023
+ })
1024
+ } finally {
1025
+ // The agent outlives the sweep on purpose; reap it here so no `sleep`
1026
+ // survives the case.
1027
+ try {
1028
+ await agent.kill()
1029
+ } catch {
1030
+ // Already gone, or a provider whose sandbox teardown covers it.
1031
+ }
1032
+ await removeDir(handle, dir)
1033
+ await dispose()
1034
+ }
1035
+ },
1036
+ )
1037
+
1038
+ // -----------------------------------------------------------------------
1039
+ // 4. A shell-hostile runId, end to end. SECURITY-RELEVANT.
1040
+ // -----------------------------------------------------------------------
1041
+ it(
1042
+ 'round-trips a shell-hostile runId through encode, journal, follow, sidecar read, list, decode and delete without executing any of it' +
1043
+ (config.followUnsupported === undefined
1044
+ ? ''
1045
+ : ` (follow read omitted: ${config.followUnsupported.reason})`),
1046
+ { timeout: 120_000 },
1047
+ async () => {
1048
+ const { handle, dispose } = await config.createHandle()
1049
+ const dir = caseDir()
1050
+ const nonce = randomUUID().slice(0, 8)
1051
+ // The canary lives OUTSIDE `dir` so the teardown `rm -rf` cannot be what
1052
+ // makes the final assertion pass.
1053
+ const canary = `/tmp/rp-pwn-${nonce}`
1054
+ // `/` would escape the directory, the space would split the word, `;` and
1055
+ // `$( )` would start new commands, and the `touch` is a real payload with
1056
+ // an observable effect. Every one of these must survive as DATA.
1057
+ //
1058
+ // THE ORDER OF THE PAYLOAD IS DELIBERATE and was measured: the `;touch`
1059
+ // comes BEFORE the space and the `/`. With raw interpolation, the
1060
+ // journaled command's redirect target is one word, so a payload whose
1061
+ // space precedes the `;` (`rp-a b;touch …`) makes the mangled command a
1062
+ // SYNTAX ERROR — the injected `touch` never runs and the canary below
1063
+ // would be decoration that can never fire. With the `;` first, the
1064
+ // vulnerable form parses as a command LIST and the payload really
1065
+ // executes (verified against a hand-composed unquoted, unencoded command
1066
+ // on this provider: canary present). So the canary is a live detector.
1067
+ const runId = `rp-a;touch ${canary};b c/d$(x)-${nonce}`
1068
+ const threadId = `${runId}-t`
1069
+ const paths = journalPaths(runId, dir)
1070
+ const journalName = basename(dir, paths.journal)
1071
+ try {
1072
+ // This case's agent writes to STDERR as well, so the sidecar read
1073
+ // below has real bytes to compare against: an empty sidecar is also
1074
+ // what a `journalStderrReadCommand` that read the wrong path (or
1075
+ // nothing at all) would return, and that read is the only coverage
1076
+ // that command has anywhere.
1077
+ await handle.process.exec(
1078
+ journaledCommand(
1079
+ `${emitLines(['1'])}; printf 'boom\\n' 1>&2`,
1080
+ paths,
1081
+ ),
1082
+ )
1083
+ // THE SECURITY ASSERTION, and deliberately the FIRST one: nothing the
1084
+ // runId contains was executed. It is stated before the cheaper
1085
+ // structural checks below on purpose — a defect that reintroduces raw
1086
+ // interpolation would also fail the filename shape, and a case that
1087
+ // short-circuited there would never prove this probe is live rather
1088
+ // than decorative. Re-asserted after the delete, because the sweep
1089
+ // composes a DIFFERENT command (`rm -f`) from the same id.
1090
+ expect(await fileExists(handle, canary)).toBe(false)
1091
+ expect(await probeRunExit({ handle, runId, dir })).toEqual({
1092
+ state: 'finished',
1093
+ exitCode: 0,
1094
+ })
1095
+ // The encoding is what bought that: the filename carries no character
1096
+ // a shell can act on, and it stays inside the journal directory.
1097
+ expect(journalName).toMatch(/^[A-Za-z0-9._-]+\.ndjson$/)
1098
+ expect(paths.journal.startsWith(`${dir}/`)).toBe(true)
1099
+
1100
+ // THE FOLLOW PATH, against this same hostile id.
1101
+ //
1102
+ // `journalFollowCommand` is the WORST command in the set under a
1103
+ // dropped `encodeRunId`: it interpolates the journal path THREE times
1104
+ // (`mkdir -p`, `: >> path`, `tail -c +N -f path`) and joins its prep
1105
+ // steps with `;` rather than `&&`, so `: >> /tmp/dir/rp-a;touch
1106
+ // <canary>;…` is a complete redirect followed by a command LIST — the
1107
+ // payload runs on EVERY attach, and a failing prep step does not stop
1108
+ // it. Nothing else reaches this command with a hostile runId: the
1109
+ // reaper's own probes are all bounded reads, and the takeover suite,
1110
+ // the only other real-provider consumer of the follow path, builds
1111
+ // alnum-only ids. So it is exercised here, where the hostile id and a
1112
+ // live canary already exist, for the cost of one read.
1113
+ //
1114
+ // The strategy is FORCED rather than capability-derived so this is the
1115
+ // follow command and not the bounded one, and the declaration is
1116
+ // checked against the live handle in both directions — a config that
1117
+ // does not describe the provider must fail rather than silently drop
1118
+ // this read.
1119
+ expect(journalReadStrategy(handle)).toBe(
1120
+ config.followUnsupported === undefined ? 'follow' : 'poll',
1121
+ )
1122
+ if (config.followUnsupported === undefined) {
1123
+ const followed: Array<string> = []
1124
+ // A backstop, so a reader that delivers nothing fails instead of
1125
+ // parking CI — a `tail -f` never ends on its own, so this read has no
1126
+ // other floor. Not the assertion — `backstopped` below proves it was
1127
+ // not what ended the loop.
1128
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)
1129
+ for await (const line of readJournal(handle, {
1130
+ paths,
1131
+ fromByte: 0,
1132
+ strategy: 'follow',
1133
+ signal: backstop,
1134
+ })) {
1135
+ followed.push(line.line)
1136
+ // The agent has already reached its sentinel, so this arrives; a
1137
+ // `tail -f` never ends on its own.
1138
+ if (line.line.includes(EXIT_SENTINEL_KEY)) break
1139
+ }
1140
+ // The causal witness, first: the loop must end on the sentinel
1141
+ // `break`, not on the clock. A backstopped follow read otherwise
1142
+ // reports as a one-element-vs-two array diff that says nothing about
1143
+ // why.
1144
+ expect({ backstopped: backstop.aborted }).toEqual({
1145
+ backstopped: false,
1146
+ })
1147
+ expect(followed).toEqual([
1148
+ '{"delta":"1"}',
1149
+ exitSentinelLine(paths, 0),
1150
+ ])
1151
+ expect(await fileExists(handle, canary)).toBe(false)
1152
+ }
1153
+
1154
+ // The stderr SIDECAR read, which no other conformance case reaches at
1155
+ // all. Same hostile id, same canary, one `exec`.
1156
+ const sidecar = await handle.process.exec(
1157
+ journalStderrReadCommand(paths),
1158
+ )
1159
+ expect(decodeJournalRead(sidecar.stdout)).toBe('boom\n')
1160
+ expect(await fileExists(handle, canary)).toBe(false)
1161
+
1162
+ // encode → journal → list → decode: the sweep's actual path back to a
1163
+ // runId, over a real `ls -1`.
1164
+ const names = await listNames(handle, dir)
1165
+ expect(names.sort()).toEqual(
1166
+ [journalName, basename(dir, paths.stderr)].sort(),
1167
+ )
1168
+ expect(decodeJournalRunId(journalName)).toEqual({
1169
+ kind: 'runId',
1170
+ runId,
1171
+ })
1172
+
1173
+ const runs = new InMemoryRunStore()
1174
+ await runs.createOrResume({ runId, threadId, startedAt: Date.now() })
1175
+ await runs.update(runId, {
1176
+ status: 'completed',
1177
+ finishedAt: Date.now(),
1178
+ })
1179
+ const result = await pruneJournals({ handle, runs, dir })
1180
+ expect(result.deleted).toEqual([runId])
1181
+ expect(result.failures).toEqual([])
1182
+ expect({
1183
+ journalDeleted: !(await fileExists(handle, paths.journal)),
1184
+ sidecarDeleted: !(await fileExists(handle, paths.stderr)),
1185
+ canaryAbsent: !(await fileExists(handle, canary)),
1186
+ }).toEqual({
1187
+ journalDeleted: true,
1188
+ sidecarDeleted: true,
1189
+ canaryAbsent: true,
1190
+ })
1191
+ } finally {
1192
+ await handle.process
1193
+ .exec(`rm -f ${quote(canary)}`)
1194
+ .catch(() => undefined)
1195
+ await removeDir(handle, dir)
1196
+ await dispose()
1197
+ }
1198
+ },
1199
+ )
1200
+ })
1201
+ }