@app-vitals/agent-runtime 0.0.1 → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +50 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Process-tree termination for the spawned `claude` CLI.
3
+ *
4
+ * Why this exists: `Subprocess.kill()` signals exactly one process — the
5
+ * direct child. A Claude Code session routinely forks its own children
6
+ * (installs, builds, test runs, dev servers). When the agent's timeout /
7
+ * abort path signalled only the CLI, those grandchildren were reparented to
8
+ * init and kept running — burning CPU, holding ports and lockfiles, and
9
+ * occasionally outliving the agent process itself.
10
+ *
11
+ * Two strategies, chosen at runtime:
12
+ *
13
+ * 1. **cgroup v2 (preferred).** Each invocation gets its own cgroup
14
+ * directory; `Bun.spawn`'s `cgroup` option puts the CLI inside it before
15
+ * its first instruction, so everything it forks is a member too. Writing
16
+ * `1` to the cgroup's `cgroup.kill` then SIGKILLs the entire membership
17
+ * atomically, with no PID-reuse or reparenting races.
18
+ *
19
+ * 2. **procfs walk (fallback).** Container runtimes commonly mount
20
+ * /sys/fs/cgroup read-only with no delegation — that is the case in this
21
+ * project's own agent image (`oven/bun:1-slim` under a Kubernetes
22
+ * securityContext with no privileged/cgroup-namespace grant: `mount`
23
+ * reports `cgroup2 (ro,…)` and `mkdir` under it fails with EROFS). So
24
+ * cgroup mode can never be assumed. When it is unavailable we snapshot
25
+ * the descendant pids from `/proc/<pid>/task/<tid>/children` *before*
26
+ * signalling the root (once the root dies, those parent→child links are
27
+ * gone) and SIGKILL them individually. Known limitation, accepted: a
28
+ * process that deliberately double-forks to detach *before* the kill has
29
+ * already been reparented to init, so it no longer appears anywhere in
30
+ * the tree and survives. Only cgroup membership catches that case; the
31
+ * CLI's own children (installs, builds, test runs) do not double-fork.
32
+ *
33
+ * The choice is made by probing at runtime and caching the answer, rather
34
+ * than by a deploy-time flag — a container that does delegate cgroups gets
35
+ * the stronger guarantee for free, and one that doesn't degrades silently to
36
+ * the fallback. `scripts/verify-cgroup-delegation.ts` prints the
37
+ * resolved mode from inside the real built image on every CI build.
38
+ *
39
+ * Every entry point here is best-effort and never throws: a failure to clean
40
+ * up a child process must not take down the agent process that was trying to
41
+ * clean it up.
42
+ */
43
+
44
+ import {
45
+ existsSync,
46
+ mkdirSync,
47
+ readdirSync,
48
+ readFileSync,
49
+ rmdirSync,
50
+ writeFileSync,
51
+ } from "node:fs";
52
+ import { join } from "node:path";
53
+
54
+ /** Where the cgroup v2 unified hierarchy is mounted on Linux. */
55
+ export const CGROUP_MOUNT_ROOT = "/sys/fs/cgroup";
56
+
57
+ interface CgroupSupport {
58
+ usable: boolean;
59
+ /** The delegated directory new run-cgroups are created under. */
60
+ base?: string;
61
+ /** Human-readable explanation, surfaced by the CI verify script. */
62
+ reason: string;
63
+ }
64
+
65
+ interface KillableProcess {
66
+ readonly pid?: number;
67
+ kill: (signal?: number | NodeJS.Signals) => void;
68
+ }
69
+
70
+ /**
71
+ * The cgroup directory this process itself lives in, per the unified
72
+ * hierarchy line of /proc/self/cgroup (`0::<path>`). Run-cgroups are created
73
+ * as CHILDREN of it rather than at the mount root: that is the shape cgroup
74
+ * delegation actually grants (a container is handed its own subtree, not the
75
+ * whole hierarchy), so it's the only location that can work unprivileged.
76
+ */
77
+ export function resolveCgroupBase(
78
+ mountRoot: string = CGROUP_MOUNT_ROOT,
79
+ procSelfCgroupPath = "/proc/self/cgroup",
80
+ ): string | undefined {
81
+ let raw: string;
82
+ try {
83
+ raw = readFileSync(procSelfCgroupPath, "utf8");
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ const line = raw.split("\n").find((l) => l.startsWith("0::"));
88
+ // No `0::` line means a cgroup v1-only host — there is no cgroup.kill
89
+ // interface there, so the procfs fallback is the only option.
90
+ if (!line) return undefined;
91
+ const rel = line.slice(3).trim();
92
+ return rel === "" || rel === "/" ? mountRoot : join(mountRoot, rel);
93
+ }
94
+
95
+ function describe(err: unknown): string {
96
+ return err instanceof Error ? err.message : String(err);
97
+ }
98
+
99
+ /**
100
+ * Probe, for real, whether we can create child cgroups. Creates a throwaway
101
+ * directory and removes it again — the only way to tell a delegated subtree
102
+ * from a read-only mount without guessing at securityContext.
103
+ */
104
+ export function probeCgroupDelegation(base?: string): CgroupSupport {
105
+ if (process.platform !== "linux") {
106
+ return { usable: false, reason: `not linux (${process.platform})` };
107
+ }
108
+ const resolved = base ?? resolveCgroupBase();
109
+ if (!resolved) {
110
+ return {
111
+ usable: false,
112
+ reason: "no cgroup v2 unified hierarchy in /proc/self/cgroup",
113
+ };
114
+ }
115
+ // A directory without cgroup.procs isn't a cgroup at all (e.g. the mount is
116
+ // missing entirely, or points at a plain directory) — creating run dirs
117
+ // there would silently do nothing useful.
118
+ if (!existsSync(join(resolved, "cgroup.procs"))) {
119
+ return {
120
+ usable: false,
121
+ reason: `${resolved} is not a cgroup v2 directory (no cgroup.procs)`,
122
+ };
123
+ }
124
+ const probeDir = join(
125
+ resolved,
126
+ `shipwright-probe-${process.pid}-${Date.now()}`,
127
+ );
128
+ try {
129
+ mkdirSync(probeDir);
130
+ } catch (err) {
131
+ return {
132
+ usable: false,
133
+ reason: `cgroup delegation not writable at ${resolved}: ${describe(err)}`,
134
+ };
135
+ }
136
+ try {
137
+ rmdirSync(probeDir);
138
+ } catch {
139
+ // Leaving the probe dir behind is harmless; delegation clearly works.
140
+ }
141
+ return {
142
+ usable: true,
143
+ base: resolved,
144
+ reason: `cgroup v2 delegation writable at ${resolved}`,
145
+ };
146
+ }
147
+
148
+ let cachedSupport: CgroupSupport | undefined;
149
+ let runCounter = 0;
150
+
151
+ /** Probe once per process, then reuse the answer. */
152
+ function cgroupSupport(base?: string): CgroupSupport {
153
+ cachedSupport ??= probeCgroupDelegation(base);
154
+ return cachedSupport;
155
+ }
156
+
157
+ /** Test seam, and the reset used after a transient probe failure. */
158
+ export function resetCgroupSupportCache(): void {
159
+ cachedSupport = undefined;
160
+ }
161
+
162
+ /**
163
+ * Latch cgroup mode off for the rest of this process. Called when creating or
164
+ * joining a cgroup fails at runtime (delegation revoked, hierarchy moved), so
165
+ * later invocations go straight to the procfs fallback instead of retrying a
166
+ * syscall we already know fails.
167
+ */
168
+ export function markCgroupUnavailable(reason: string): void {
169
+ cachedSupport = { usable: false, reason };
170
+ }
171
+
172
+ /**
173
+ * Create a dedicated cgroup for one invocation, or return undefined if this
174
+ * container doesn't delegate cgroups (the common case) — in which case the
175
+ * caller spawns normally and `killTree` uses the procfs fallback.
176
+ */
177
+ export function createRunCgroup(base?: string): string | undefined {
178
+ const support = cgroupSupport(base);
179
+ if (!support.usable || !support.base) return undefined;
180
+ runCounter += 1;
181
+ const dir = join(
182
+ support.base,
183
+ `shipwright-run-${process.pid}-${runCounter}-${Date.now()}`,
184
+ );
185
+ try {
186
+ mkdirSync(dir, { recursive: true });
187
+ return dir;
188
+ } catch (err) {
189
+ markCgroupUnavailable(
190
+ `run cgroup creation failed under ${support.base}: ${describe(err)}`,
191
+ );
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * SIGKILL every process in the cgroup, atomically. Returns false (never
198
+ * throws) when the write fails — the cgroup was already removed, the kernel
199
+ * is too old for cgroup.kill (< 5.14), or delegation went away — so callers
200
+ * can fall through to the per-process path.
201
+ */
202
+ export function killCgroup(path: string): boolean {
203
+ try {
204
+ writeFileSync(join(path, "cgroup.kill"), "1");
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Best-effort cleanup of a run cgroup once its process has exited. rmdir
213
+ * fails with EBUSY while members remain, so a failure is retried once after
214
+ * killing whatever is still inside — that leftover membership IS the process
215
+ * leak this module exists to prevent.
216
+ */
217
+ export function removeRunCgroup(path: string): void {
218
+ try {
219
+ rmdirSync(path);
220
+ return;
221
+ } catch {
222
+ // Fall through to the kill-then-retry below.
223
+ }
224
+ killCgroup(path);
225
+ try {
226
+ rmdirSync(path);
227
+ } catch {
228
+ // Give up silently: an orphan cgroup directory is inert, and throwing
229
+ // from a cleanup path would surface as an unhandled rejection.
230
+ }
231
+ }
232
+
233
+ function readChildPids(pid: number, procRoot: string): number[] {
234
+ // Children are tracked per-THREAD, not per-process: a multi-threaded parent
235
+ // records each fork under the thread that made it, so every tid under
236
+ // /proc/<pid>/task must be read, not just the main one.
237
+ const taskDir = join(procRoot, String(pid), "task");
238
+ let tids: string[];
239
+ try {
240
+ tids = readdirSync(taskDir);
241
+ } catch {
242
+ return [];
243
+ }
244
+ const children: number[] = [];
245
+ for (const tid of tids) {
246
+ let raw: string;
247
+ try {
248
+ raw = readFileSync(join(taskDir, tid, "children"), "utf8");
249
+ } catch {
250
+ continue; // thread exited mid-walk
251
+ }
252
+ for (const token of raw.split(/\s+/)) {
253
+ const parsed = Number.parseInt(token, 10);
254
+ if (Number.isInteger(parsed) && parsed > 0) children.push(parsed);
255
+ }
256
+ }
257
+ return children;
258
+ }
259
+
260
+ /**
261
+ * Every transitive descendant of `pid`, breadth-first. A `seen` set guards
262
+ * against the (pathological, but cheap to rule out) case of a cycle from a
263
+ * recycled pid appearing in its own ancestor's children list.
264
+ */
265
+ export function collectDescendantPids(
266
+ pid: number,
267
+ procRoot = "/proc",
268
+ ): number[] {
269
+ const descendants: number[] = [];
270
+ const seen = new Set<number>([pid]);
271
+ const queue: number[] = [pid];
272
+ while (queue.length > 0) {
273
+ const current = queue.shift() as number;
274
+ for (const child of readChildPids(current, procRoot)) {
275
+ if (seen.has(child)) continue;
276
+ seen.add(child);
277
+ descendants.push(child);
278
+ queue.push(child);
279
+ }
280
+ }
281
+ return descendants;
282
+ }
283
+
284
+ /**
285
+ * Terminate a spawned process and everything it spawned.
286
+ *
287
+ * Replaces a bare `proc.kill()` at every timeout/abort call site. Never
288
+ * throws: a process that already exited, a vanished cgroup, and a
289
+ * non-Linux/procfs-less host are all normal outcomes here.
290
+ */
291
+ export function killTree(proc: KillableProcess, cgroupPath?: string): void {
292
+ try {
293
+ // cgroup.kill covers the CLI itself as well as its descendants, so an
294
+ // additional signal to the direct child would be redundant.
295
+ if (cgroupPath && killCgroup(cgroupPath)) return;
296
+
297
+ const pid =
298
+ typeof proc.pid === "number" && proc.pid > 0 ? proc.pid : undefined;
299
+ // Snapshot BEFORE signalling the root: the moment it exits, /proc/<pid>
300
+ // disappears and its children are reparented to init, erasing the only
301
+ // link back to them.
302
+ const descendants = pid ? collectDescendantPids(pid) : [];
303
+
304
+ try {
305
+ // Root gets the caller's default signal (SIGTERM) so the CLI can still
306
+ // flush and exit 143, preserving the existing timeout/abort semantics.
307
+ proc.kill();
308
+ } catch {
309
+ // Already exited — the descendants below are what matter now.
310
+ }
311
+
312
+ for (const descendant of descendants) {
313
+ try {
314
+ // SIGKILL, not SIGTERM: these are arbitrary orphaned build/install
315
+ // processes with no cleanup contract worth waiting on.
316
+ process.kill(descendant, "SIGKILL");
317
+ } catch {
318
+ // Already gone, or not ours to signal.
319
+ }
320
+ }
321
+ } catch {
322
+ // Defense in depth: nothing in a kill path may escape as an unhandled
323
+ // rejection and take down the agent.
324
+ }
325
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Tests for agent/src/runtime/process-tree-kill.ts
3
+ *
4
+ * Strategy: every function takes the cgroup base / procfs root as an injected
5
+ * parameter, so the probe/create/kill/cleanup logic is exercised against a
6
+ * real temp directory that mimics a cgroup v2 hierarchy (a directory holding
7
+ * a `cgroup.procs` control file) instead of the real, usually read-only
8
+ * /sys/fs/cgroup. No mock.module(), no global overrides.
9
+ *
10
+ * Real-subprocess behavior (descendant discovery + tree kill) lives in
11
+ * process-tree-kill.integration.test.ts.
12
+ */
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import {
16
+ chmodSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ mkdtempSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join } from "node:path";
27
+
28
+ const {
29
+ collectDescendantPids,
30
+ createRunCgroup,
31
+ killCgroup,
32
+ killTree,
33
+ markCgroupUnavailable,
34
+ probeCgroupDelegation,
35
+ removeRunCgroup,
36
+ resetCgroupSupportCache,
37
+ resolveCgroupBase,
38
+ } = await import("./process-tree-kill.ts");
39
+
40
+ const tempDirs: string[] = [];
41
+
42
+ /** A temp dir shaped like a delegated cgroup v2 directory. */
43
+ function fakeCgroupBase(): string {
44
+ const dir = mkdtempSync(join(tmpdir(), "shipwright-cgroup-test-"));
45
+ tempDirs.push(dir);
46
+ writeFileSync(join(dir, "cgroup.procs"), "");
47
+ return dir;
48
+ }
49
+
50
+ /** A temp dir that is NOT a cgroup hierarchy (no control files). */
51
+ function plainTempDir(): string {
52
+ const dir = mkdtempSync(join(tmpdir(), "shipwright-plain-test-"));
53
+ tempDirs.push(dir);
54
+ return dir;
55
+ }
56
+
57
+ afterEach(() => {
58
+ resetCgroupSupportCache();
59
+ while (tempDirs.length > 0) {
60
+ const dir = tempDirs.pop();
61
+ if (dir) rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ describe("resolveCgroupBase", () => {
66
+ test("maps the unified-hierarchy line of /proc/self/cgroup onto the mount root", () => {
67
+ const dir = plainTempDir();
68
+ const procSelfCgroup = join(dir, "cgroup");
69
+ writeFileSync(procSelfCgroup, "0::/kubepods/pod123/container456\n");
70
+ expect(resolveCgroupBase("/sys/fs/cgroup", procSelfCgroup)).toBe(
71
+ "/sys/fs/cgroup/kubepods/pod123/container456",
72
+ );
73
+ });
74
+
75
+ test("returns the mount root itself when the process sits at the hierarchy root", () => {
76
+ const dir = plainTempDir();
77
+ const procSelfCgroup = join(dir, "cgroup");
78
+ writeFileSync(procSelfCgroup, "0::/\n");
79
+ expect(resolveCgroupBase("/sys/fs/cgroup", procSelfCgroup)).toBe(
80
+ "/sys/fs/cgroup",
81
+ );
82
+ });
83
+
84
+ test("returns undefined on a v1-only host (no 0:: unified line)", () => {
85
+ const dir = plainTempDir();
86
+ const procSelfCgroup = join(dir, "cgroup");
87
+ writeFileSync(procSelfCgroup, "2:cpu:/foo\n1:name=systemd:/bar\n");
88
+ expect(resolveCgroupBase("/sys/fs/cgroup", procSelfCgroup)).toBeUndefined();
89
+ });
90
+
91
+ test("returns undefined when /proc/self/cgroup is unreadable", () => {
92
+ expect(
93
+ resolveCgroupBase("/sys/fs/cgroup", "/nonexistent/proc/self/cgroup"),
94
+ ).toBeUndefined();
95
+ });
96
+ });
97
+
98
+ describe("probeCgroupDelegation", () => {
99
+ test("reports usable and leaves no probe directory behind when the base is a writable cgroup dir", () => {
100
+ const base = fakeCgroupBase();
101
+ const support = probeCgroupDelegation(base);
102
+ expect(support.usable).toBe(true);
103
+ expect(support.base).toBe(base);
104
+ // Probe directory must be cleaned up — a leaked probe cgroup per boot
105
+ // would accumulate in the delegated hierarchy.
106
+ expect(readdirSync(base)).toEqual(["cgroup.procs"]);
107
+ });
108
+
109
+ test("reports unusable when the base is not a cgroup v2 hierarchy", () => {
110
+ const support = probeCgroupDelegation(plainTempDir());
111
+ expect(support.usable).toBe(false);
112
+ expect(support.reason).toContain("cgroup");
113
+ });
114
+
115
+ test("reports unusable when the base directory does not exist", () => {
116
+ const support = probeCgroupDelegation("/nonexistent/sys/fs/cgroup");
117
+ expect(support.usable).toBe(false);
118
+ });
119
+
120
+ test("reports unusable when the base is read-only (mirrors the deployed container)", () => {
121
+ // Directory permissions do not restrain uid 0, so this assertion is only
122
+ // meaningful as a non-root user (which is how both CI and the deployed
123
+ // pod run).
124
+ if (process.getuid?.() === 0) return;
125
+ const base = fakeCgroupBase();
126
+ // 0o500 = r-x------: the cgroup.procs check still passes, but mkdir of a
127
+ // child cgroup fails with EACCES — exactly the read-only-cgroupfs shape
128
+ // seen in the deployed agent pod.
129
+ chmodSync(base, 0o500);
130
+ const support = probeCgroupDelegation(base);
131
+ chmodSync(base, 0o700);
132
+ expect(support.usable).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe("createRunCgroup / removeRunCgroup", () => {
137
+ test("creates a fresh, unique directory under the delegated base", () => {
138
+ const base = fakeCgroupBase();
139
+ const a = createRunCgroup(base);
140
+ const b = createRunCgroup(base);
141
+ expect(a).toBeDefined();
142
+ expect(b).toBeDefined();
143
+ expect(a).not.toBe(b);
144
+ expect(existsSync(a as string)).toBe(true);
145
+ expect(existsSync(b as string)).toBe(true);
146
+ });
147
+
148
+ test("returns undefined (no throw) when delegation is unavailable", () => {
149
+ expect(createRunCgroup(plainTempDir())).toBeUndefined();
150
+ });
151
+
152
+ test("caches the probe result so an unusable base is probed only once", () => {
153
+ const base = fakeCgroupBase();
154
+ expect(createRunCgroup(base)).toBeDefined();
155
+ markCgroupUnavailable("forced for test");
156
+ // Cached negative result short-circuits without touching the filesystem.
157
+ expect(createRunCgroup(base)).toBeUndefined();
158
+ resetCgroupSupportCache();
159
+ expect(createRunCgroup(base)).toBeDefined();
160
+ });
161
+
162
+ test("removeRunCgroup deletes the directory and never throws when it is already gone", () => {
163
+ const base = fakeCgroupBase();
164
+ const dir = createRunCgroup(base) as string;
165
+ removeRunCgroup(dir);
166
+ expect(existsSync(dir)).toBe(false);
167
+ expect(() => removeRunCgroup(dir)).not.toThrow();
168
+ expect(() => removeRunCgroup("/nonexistent/cgroup/dir")).not.toThrow();
169
+ });
170
+ });
171
+
172
+ describe("killCgroup", () => {
173
+ test("writes 1 to the cgroup.kill interface file", () => {
174
+ const base = fakeCgroupBase();
175
+ const dir = createRunCgroup(base) as string;
176
+ expect(killCgroup(dir)).toBe(true);
177
+ expect(readFileSync(join(dir, "cgroup.kill"), "utf8")).toBe("1");
178
+ });
179
+
180
+ test("returns false instead of throwing when the cgroup is gone", () => {
181
+ expect(killCgroup("/nonexistent/cgroup/dir")).toBe(false);
182
+ });
183
+ });
184
+
185
+ describe("collectDescendantPids", () => {
186
+ test("walks /proc/<pid>/task/<tid>/children transitively", () => {
187
+ const procRoot = plainTempDir();
188
+ // 100 → 200, 201 (via two threads); 200 → 300
189
+ const link = (pid: number, tid: number, children: string) => {
190
+ mkdirSync(join(procRoot, String(pid), "task", String(tid)), {
191
+ recursive: true,
192
+ });
193
+ writeFileSync(
194
+ join(procRoot, String(pid), "task", String(tid), "children"),
195
+ children,
196
+ );
197
+ };
198
+ link(100, 100, "200 ");
199
+ link(100, 111, "201 ");
200
+ link(200, 200, "300 ");
201
+ link(201, 201, "");
202
+ link(300, 300, "");
203
+
204
+ expect(collectDescendantPids(100, procRoot).sort()).toEqual([
205
+ 200, 201, 300,
206
+ ]);
207
+ });
208
+
209
+ test("returns an empty list for an unknown pid instead of throwing", () => {
210
+ expect(collectDescendantPids(999999, plainTempDir())).toEqual([]);
211
+ });
212
+
213
+ test("does not loop forever on a self-referential children file", () => {
214
+ const procRoot = plainTempDir();
215
+ mkdirSync(join(procRoot, "7", "task", "7"), { recursive: true });
216
+ writeFileSync(join(procRoot, "7", "task", "7", "children"), "7 8");
217
+ mkdirSync(join(procRoot, "8", "task", "8"), { recursive: true });
218
+ writeFileSync(join(procRoot, "8", "task", "8", "children"), "7");
219
+ expect(collectDescendantPids(7, procRoot).sort()).toEqual([8]);
220
+ });
221
+ });
222
+
223
+ describe("killTree", () => {
224
+ test("kills the whole cgroup and skips the per-process path when a cgroup is in use", () => {
225
+ const base = fakeCgroupBase();
226
+ const dir = createRunCgroup(base) as string;
227
+ let directKills = 0;
228
+ killTree({ pid: 999999, kill: () => directKills++ }, dir);
229
+ expect(readFileSync(join(dir, "cgroup.kill"), "utf8")).toBe("1");
230
+ // cgroup.kill SIGKILLs every member, the CLI included — no extra signal.
231
+ expect(directKills).toBe(0);
232
+ });
233
+
234
+ test("falls back to the direct kill when the cgroup write fails", () => {
235
+ let directKills = 0;
236
+ killTree(
237
+ { pid: 999999, kill: () => directKills++ },
238
+ "/nonexistent/cgroup/dir",
239
+ );
240
+ expect(directKills).toBe(1);
241
+ });
242
+
243
+ test("kills the process directly when no cgroup is in use", () => {
244
+ let directKills = 0;
245
+ killTree({ pid: 999999, kill: () => directKills++ });
246
+ expect(directKills).toBe(1);
247
+ });
248
+
249
+ test("tolerates a proc with no pid (fully synthetic/fake proc)", () => {
250
+ let directKills = 0;
251
+ expect(() => killTree({ kill: () => directKills++ })).not.toThrow();
252
+ expect(directKills).toBe(1);
253
+ });
254
+
255
+ test("never throws when the underlying kill throws (process already exited)", () => {
256
+ expect(() =>
257
+ killTree({
258
+ pid: 999999,
259
+ kill: () => {
260
+ throw new Error("ESRCH");
261
+ },
262
+ }),
263
+ ).not.toThrow();
264
+ });
265
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * agent/src/runtime/progress-milestones.ts
3
+ * Maps a single `content` block from an `assistant` `stream-json` message to
4
+ * a generic `ProgressPhase` (see `@app-vitals/agent-lib/progress-phases`).
5
+ *
6
+ * LOAD-BEARING INVARIANT: `block.input` is never read here — not logged, not
7
+ * inspected, not passed to any function. Tool inputs can carry absolute
8
+ * filesystem paths, secrets, command lines, or URLs; none of that may ever
9
+ * leave the agent process via this code path. Phase is derived from
10
+ * `block.type` and `block.name` (the tool name) ONLY. Do not add any code
11
+ * here that touches `block.input`.
12
+ */
13
+
14
+ import type { ProgressPhase } from "@app-vitals/agent-lib/progress-phases";
15
+
16
+ /**
17
+ * The shape of one entry in an `assistant` message's `content` array, as
18
+ * emitted by `claude -p --output-format stream-json --verbose`. `input` is
19
+ * intentionally typed as `unknown` and MUST NOT be read by this module — see
20
+ * the file-level invariant above.
21
+ */
22
+ export interface ContentBlock {
23
+ type: string;
24
+ name?: string;
25
+ input?: unknown;
26
+ /** Other block-type-specific fields (e.g. `thinking`, `text`) — never read. */
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /** Tool names (exact match) mapped to their progress phase. */
31
+ const TOOL_PHASE_MAP: Record<string, ProgressPhase> = {
32
+ // Reading: pulling in a known file's full contents.
33
+ Read: "reading",
34
+ NotebookRead: "reading",
35
+ // Searching: looking for something across the codebase by pattern/name,
36
+ // distinct from "reading" a specific known file.
37
+ Grep: "searching",
38
+ Glob: "searching",
39
+ // Web: leaving the local filesystem/codebase entirely.
40
+ WebSearch: "web",
41
+ WebFetch: "web",
42
+ // Editing: mutating files.
43
+ Edit: "editing",
44
+ Write: "editing",
45
+ NotebookEdit: "editing",
46
+ // Running: shell/process execution.
47
+ Bash: "running",
48
+ KillShell: "running",
49
+ BashOutput: "running",
50
+ // Delegating: dispatching work to a subagent.
51
+ Agent: "delegating",
52
+ Task: "delegating",
53
+ // Planning: todo/plan-mode bookkeeping tools.
54
+ TodoWrite: "planning",
55
+ ExitPlanMode: "planning",
56
+ EnterPlanMode: "planning",
57
+ };
58
+
59
+ /**
60
+ * Generic fallback phase for any tool_use block whose name isn't recognized —
61
+ * covers both `mcp__*`-prefixed MCP tools and any other unmapped tool name.
62
+ */
63
+ const GENERIC_TOOL_PHASE: ProgressPhase = "tool";
64
+
65
+ /**
66
+ * Resolve the ProgressPhase for a single `assistant` content block. Returns
67
+ * `undefined` for block types this module doesn't map (e.g. `tool_result`,
68
+ * which only ever appears in `user` messages and is out of scope here).
69
+ *
70
+ * Never throws and never returns `undefined` for a `tool_use` block,
71
+ * regardless of tool name — new tools are added over time, so an unrecognized
72
+ * name always falls back to the generic `"tool"` phase rather than being
73
+ * dropped.
74
+ */
75
+ export function phaseForBlock(block: ContentBlock): ProgressPhase | undefined {
76
+ switch (block.type) {
77
+ case "thinking":
78
+ return "thinking";
79
+ case "text":
80
+ return "writing";
81
+ case "tool_use":
82
+ return TOOL_PHASE_MAP[block.name ?? ""] ?? GENERIC_TOOL_PHASE;
83
+ default:
84
+ return undefined;
85
+ }
86
+ }