@markjaquith/agency 3.2.10 → 3.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.2.10",
3
+ "version": "3.2.11",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,7 +1,39 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test"
2
- import { Effect } from "effect"
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { Effect, Fiber } from "effect"
3
6
  import { spawnProcess } from "./process"
4
7
 
8
+ const waitFor = async <A>(attempt: () => Promise<A>): Promise<A> => {
9
+ const deadline = Date.now() + 2_000
10
+ while (true) {
11
+ try {
12
+ return await attempt()
13
+ } catch (error) {
14
+ if (Date.now() >= deadline) throw error
15
+ await Bun.sleep(10)
16
+ }
17
+ }
18
+ }
19
+
20
+ const isProcessRunning = (pid: number): boolean => {
21
+ try {
22
+ process.kill(pid, 0)
23
+ return true
24
+ } catch (error) {
25
+ if (
26
+ typeof error === "object" &&
27
+ error !== null &&
28
+ "code" in error &&
29
+ error.code === "ESRCH"
30
+ ) {
31
+ return false
32
+ }
33
+ throw error
34
+ }
35
+ }
36
+
5
37
  describe("spawnProcess", () => {
6
38
  test("forwards and captures output in tee mode", async () => {
7
39
  const forwardedStdout: Uint8Array[] = []
@@ -74,4 +106,27 @@ describe("spawnProcess", () => {
74
106
  ).rejects.toThrow("Process timed out")
75
107
  expect(performance.now() - startedAt).toBeLessThan(1_000)
76
108
  })
109
+
110
+ test("terminates the subprocess when interrupted", async () => {
111
+ const directory = await mkdtemp(join(tmpdir(), "agency-process-"))
112
+ const pidPath = join(directory, "pid")
113
+ const script = [
114
+ `process.on("SIGTERM", () => {})`,
115
+ `await Bun.write(${JSON.stringify(pidPath)}, String(process.pid))`,
116
+ `setInterval(() => process.stdout.write("running\\n"), 10)`,
117
+ ].join("\n")
118
+ const fiber = Effect.runFork(spawnProcess([process.execPath, "-e", script]))
119
+
120
+ try {
121
+ const pid = Number(await waitFor(() => readFile(pidPath, "utf8")))
122
+ expect(isProcessRunning(pid)).toBe(true)
123
+
124
+ await Effect.runPromise(Fiber.interrupt(fiber))
125
+
126
+ expect(isProcessRunning(pid)).toBe(false)
127
+ } finally {
128
+ await Effect.runPromise(Fiber.interrupt(fiber))
129
+ await rm(directory, { recursive: true, force: true })
130
+ }
131
+ })
77
132
  })
@@ -21,22 +21,38 @@ interface SpawnOptions {
21
21
  readonly timeoutMs?: number
22
22
  }
23
23
 
24
- const readOutput = async (
24
+ interface OutputReader {
25
+ readonly output: Promise<string>
26
+ readonly cancel: () => Promise<void>
27
+ }
28
+
29
+ const readOutput = (
25
30
  stream: ReadableStream<Uint8Array> | null | undefined,
26
31
  target?: { write(chunk: Uint8Array): unknown },
27
- ) => {
28
- if (!stream) return ""
32
+ ): OutputReader => {
33
+ if (!stream) {
34
+ return { output: Promise.resolve(""), cancel: () => Promise.resolve() }
35
+ }
29
36
 
30
37
  const reader = stream.getReader()
31
- const decoder = new TextDecoder()
32
- let output = ""
33
- while (true) {
34
- const { done, value } = await reader.read()
35
- if (done) break
36
- target?.write(value)
37
- output += decoder.decode(value, { stream: true })
38
+ return {
39
+ output: (async () => {
40
+ const decoder = new TextDecoder()
41
+ let output = ""
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read()
45
+ if (done) break
46
+ target?.write(value)
47
+ output += decoder.decode(value, { stream: true })
48
+ }
49
+ return output + decoder.decode()
50
+ } finally {
51
+ reader.releaseLock()
52
+ }
53
+ })(),
54
+ cancel: () => reader.cancel(),
38
55
  }
39
- return output + decoder.decode()
40
56
  }
41
57
 
42
58
  /**
@@ -70,95 +86,139 @@ export const spawnProcess = (
70
86
  args: readonly string[],
71
87
  options?: SpawnOptions,
72
88
  ): Effect.Effect<ProcessResult, ProcessError> =>
73
- Effect.tryPromise({
74
- try: async () => {
75
- const startedAt = performance.now()
76
- const proc = Bun.spawn([...args], {
77
- cwd: options?.cwd ?? process.cwd(),
78
- stdin: options?.stdin ?? "pipe",
79
- stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
80
- stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
81
- env: options?.env ? { ...process.env, ...options.env } : process.env,
82
- detached: options?.timeoutMs !== undefined,
83
- })
84
- // Start draining stdout/stderr immediately so verbose subprocesses
85
- // cannot block on filled pipe buffers before they exit.
86
- const stdoutPromise =
87
- options?.stdout === "inherit"
88
- ? Promise.resolve("")
89
- : readOutput(
90
- proc.stdout,
91
- options?.stdout === "tee" ? process.stdout : undefined,
92
- )
93
- const stderrPromise =
94
- options?.stderr === "inherit"
95
- ? Promise.resolve("")
96
- : readOutput(
97
- proc.stderr,
98
- options?.stderr === "tee" ? process.stderr : undefined,
99
- )
89
+ Effect.acquireUseRelease(
90
+ Effect.try({
91
+ try: () => {
92
+ const detached = options?.timeoutMs !== undefined
93
+ const proc = Bun.spawn([...args], {
94
+ cwd: options?.cwd ?? process.cwd(),
95
+ stdin: options?.stdin ?? "pipe",
96
+ stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
97
+ stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
98
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
99
+ detached,
100
+ })
101
+ // Start draining stdout/stderr immediately so verbose subprocesses
102
+ // cannot block on filled pipe buffers before they exit.
103
+ const stdout =
104
+ options?.stdout === "inherit"
105
+ ? readOutput(undefined)
106
+ : readOutput(
107
+ proc.stdout,
108
+ options?.stdout === "tee" ? process.stdout : undefined,
109
+ )
110
+ const stderr =
111
+ options?.stderr === "inherit"
112
+ ? readOutput(undefined)
113
+ : readOutput(
114
+ proc.stderr,
115
+ options?.stderr === "tee" ? process.stderr : undefined,
116
+ )
100
117
 
101
- let timedOut = false
102
- let timer: ReturnType<typeof setTimeout> | undefined
103
- const exited =
104
- options?.timeoutMs === undefined
105
- ? proc.exited
106
- : Promise.race([
107
- proc.exited,
108
- new Promise<number>((resolve) => {
109
- timer = setTimeout(async () => {
110
- timedOut = true
111
- try {
112
- process.kill(-proc.pid, "SIGTERM")
113
- } catch {
114
- proc.kill("SIGTERM")
115
- }
116
- const stopped = await Promise.race([
117
- proc.exited.then(() => true),
118
- Bun.sleep(250).then(() => false),
119
- ])
120
- if (!stopped) {
121
- try {
122
- process.kill(-proc.pid, "SIGKILL")
123
- } catch {
124
- proc.kill("SIGKILL")
125
- }
126
- }
127
- resolve(await proc.exited)
128
- }, options.timeoutMs)
129
- }),
118
+ let termination: Promise<void> | undefined
119
+ const terminate = (): Promise<void> => {
120
+ if (proc.exitCode !== null) return Promise.resolve()
121
+ return (termination ??= (async () => {
122
+ const signal = (name: "SIGTERM" | "SIGKILL") => {
123
+ if (!detached) {
124
+ proc.kill(name)
125
+ return
126
+ }
127
+ try {
128
+ process.kill(-proc.pid, name)
129
+ } catch {
130
+ proc.kill(name)
131
+ }
132
+ }
133
+ signal("SIGTERM")
134
+ const stopped = await Promise.race([
135
+ proc.exited.then(() => true),
136
+ Bun.sleep(250).then(() => false),
130
137
  ])
131
- const [exitCode, stdout, stderr] = await Promise.all([
132
- exited,
133
- stdoutPromise,
134
- stderrPromise,
135
- ])
136
- if (timer) clearTimeout(timer)
137
- if (timedOut) {
138
- throw new ProcessError({
138
+ if (!stopped) signal("SIGKILL")
139
+ await proc.exited
140
+ })())
141
+ }
142
+
143
+ return {
144
+ proc,
145
+ stdout,
146
+ stderr,
147
+ terminate,
148
+ startedAt: performance.now(),
149
+ state: {
150
+ timedOut: false,
151
+ timer: undefined as ReturnType<typeof setTimeout> | undefined,
152
+ },
153
+ }
154
+ },
155
+ catch: (error) =>
156
+ new ProcessError({
139
157
  command: args.join(" "),
140
- exitCode:
141
- typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
142
- stderr: stderr.trim(),
143
- timedOut: true,
144
- timeoutMs: options?.timeoutMs,
145
- elapsedMs: Math.round(performance.now() - startedAt),
146
- })
147
- }
158
+ exitCode: -1,
159
+ stderr: error instanceof Error ? error.message : String(error),
160
+ }),
161
+ }),
162
+ ({ proc, stdout, stderr, terminate, startedAt, state }) =>
163
+ Effect.tryPromise({
164
+ try: async () => {
165
+ const exited =
166
+ options?.timeoutMs === undefined
167
+ ? proc.exited
168
+ : Promise.race([
169
+ proc.exited,
170
+ new Promise<number>((resolve) => {
171
+ state.timer = setTimeout(async () => {
172
+ state.timedOut = true
173
+ await terminate()
174
+ resolve(await proc.exited)
175
+ }, options.timeoutMs)
176
+ }),
177
+ ])
178
+ const [exitCode, stdoutOutput, stderrOutput] = await Promise.all([
179
+ exited,
180
+ stdout.output,
181
+ stderr.output,
182
+ ])
183
+ if (state.timer) clearTimeout(state.timer)
184
+ if (state.timedOut) {
185
+ throw new ProcessError({
186
+ command: args.join(" "),
187
+ exitCode:
188
+ typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
189
+ stderr: stderrOutput.trim(),
190
+ timedOut: true,
191
+ timeoutMs: options?.timeoutMs,
192
+ elapsedMs: Math.round(performance.now() - startedAt),
193
+ })
194
+ }
148
195
 
149
- return {
150
- stdout: stdout.trim(),
151
- stderr: stderr.trim(),
152
- exitCode:
153
- typeof exitCode === "number" ? exitCode : (proc.exitCode ?? 0),
154
- }
155
- },
156
- catch: (error) =>
157
- error instanceof ProcessError
158
- ? error
159
- : new ProcessError({
160
- command: args.join(" "),
161
- exitCode: -1,
162
- stderr: error instanceof Error ? error.message : String(error),
163
- }),
164
- })
196
+ return {
197
+ stdout: stdoutOutput.trim(),
198
+ stderr: stderrOutput.trim(),
199
+ exitCode:
200
+ typeof exitCode === "number" ? exitCode : (proc.exitCode ?? 0),
201
+ }
202
+ },
203
+ catch: (error) =>
204
+ error instanceof ProcessError
205
+ ? error
206
+ : new ProcessError({
207
+ command: args.join(" "),
208
+ exitCode: -1,
209
+ stderr: error instanceof Error ? error.message : String(error),
210
+ }),
211
+ }),
212
+ ({ proc, stdout, stderr, terminate, state }) =>
213
+ Effect.promise(async () => {
214
+ if (state.timer) clearTimeout(state.timer)
215
+ const terminating = terminate()
216
+ await Promise.allSettled([stdout.cancel(), stderr.cancel()])
217
+ await Promise.allSettled([
218
+ terminating,
219
+ proc.exited,
220
+ stdout.output,
221
+ stderr.output,
222
+ ])
223
+ }),
224
+ )