@markjaquith/agency 3.2.1 → 3.2.2

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/README.md CHANGED
@@ -1107,13 +1107,21 @@ without creating a pull request. It requires a valid, registered writable
1107
1107
  checkout in `working` state, fetches the configured delivery remote, verifies
1108
1108
  that the declared base is in the publication history, validates every outgoing
1109
1109
  commit's description, author, and conflict state, and refuses non-fast-forward
1110
- updates.
1110
+ updates. Local commit validation runs before network access when cached remote
1111
+ state is available, and the network fetch is limited to the declared base and
1112
+ delivery branch.
1111
1113
 
1112
1114
  YAML `branch` must exactly match the checked-out local branch, the worktree must
1113
1115
  be clean, and `HEAD` is pushed with upstream tracking. Missing commit descriptions
1114
1116
  or authors stop publication with exact commits and remediation commands. Push
1115
1117
  reports deterministic fetch, inspection, validation, and publication progress on
1116
- stderr, including while `--json` reserves stdout for one machine result.
1118
+ stderr, including while `--json` reserves stdout for one machine result. Git
1119
+ authentication is non-interactive. Fetch defaults to a 30-second deadline and
1120
+ one retry for transient failures; push defaults to a 120-second deadline. Set
1121
+ `AGENCY_PUSH_FETCH_TIMEOUT_MS` or `AGENCY_PUSH_TIMEOUT_MS` to positive millisecond
1122
+ values to override them. After a failed or timed-out push, Agency compares the
1123
+ exact remote delivery ref with the expected tip before reporting success, a safe
1124
+ retryable failure, or an unknown publication outcome.
1117
1125
 
1118
1126
  Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
1119
1127
  including readiness checks and durable PR recording. It accepts draft, title,
package/cli-main.ts CHANGED
@@ -828,6 +828,42 @@ let usageFlagNames = rawArguments
828
828
  .filter((argument) => argument.startsWith("--"))
829
829
  .map((argument) => argument.slice(2).split("=", 1)[0]!)
830
830
 
831
+ const pushUsageDetails = (error?: unknown) => {
832
+ if (usageCommandPath !== "push") return {}
833
+ const seen = new Set<object>()
834
+ const visit = (
835
+ value: unknown,
836
+ ): { stage?: string; category?: string } | null => {
837
+ if (typeof value !== "object" || value === null || seen.has(value))
838
+ return null
839
+ seen.add(value)
840
+ if (
841
+ "stage" in value &&
842
+ typeof value.stage === "string" &&
843
+ "category" in value &&
844
+ typeof value.category === "string"
845
+ ) {
846
+ return { stage: value.stage, category: value.category }
847
+ }
848
+ for (const nestedValue of [
849
+ ...Object.values(value),
850
+ ...Object.getOwnPropertySymbols(value).map(
851
+ (symbol) => (value as Record<symbol, unknown>)[symbol],
852
+ ),
853
+ ]) {
854
+ const nested = visit(nestedValue)
855
+ if (nested) return nested
856
+ }
857
+ return null
858
+ }
859
+ const details = error ? visit(error) : null
860
+ return {
861
+ vcs: "git" as const,
862
+ terminalStage: details?.stage ?? (error ? "unknown" : "publish"),
863
+ category: details?.category ?? (error ? "unknown" : "success"),
864
+ }
865
+ }
866
+
831
867
  try {
832
868
  const {
833
869
  commandName,
@@ -921,6 +957,7 @@ try {
921
957
  durationMs: performance.now() - invocationStartedAt,
922
958
  outcome: exitStatus === 0 ? "success" : "failure",
923
959
  exitStatus,
960
+ ...pushUsageDetails(),
924
961
  },
925
962
  VERSION,
926
963
  )
@@ -932,6 +969,7 @@ try {
932
969
  durationMs: performance.now() - invocationStartedAt,
933
970
  outcome: "failure",
934
971
  exitStatus: 1,
972
+ ...pushUsageDetails(error),
935
973
  },
936
974
  VERSION,
937
975
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -28,9 +28,10 @@ describe("push command", () => {
28
28
  publish: (_cwd: string, options: any) => {
29
29
  for (const stage of [
30
30
  "context",
31
- "fetch",
32
31
  "inspect",
33
32
  "validate",
33
+ "fetch",
34
+ "validate",
34
35
  "publish",
35
36
  ] as const)
36
37
  options.onProgress(stage)
@@ -44,9 +45,10 @@ describe("push command", () => {
44
45
  expect(logs).toEqual([JSON.stringify(result, null, 2)])
45
46
  expect(updates).toEqual([
46
47
  "start:Inspecting Agency execution context",
47
- "start:Fetching remote state",
48
48
  "start:Selecting the publication tip",
49
49
  "start:Validating outgoing changes",
50
+ "start:Fetching remote state",
51
+ "start:Validating outgoing changes",
50
52
  "start:Publishing the declared branch",
51
53
  "succeed:Published task/example to origin",
52
54
  ])
@@ -10,6 +10,7 @@ const stageMessage = {
10
10
  inspect: "Selecting the publication tip",
11
11
  validate: "Validating outgoing changes",
12
12
  publish: "Publishing the declared branch",
13
+ reconcile: "Confirming the remote publication outcome",
13
14
  } as const
14
15
 
15
16
  export const push = (
@@ -22,6 +23,7 @@ export const push = (
22
23
  const showProgress = !options.silent
23
24
  const result = yield* publications
24
25
  .publish(options.cwd ?? process.cwd(), {
26
+ forwardOutput: options.verbose,
25
27
  onProgress: showProgress
26
28
  ? (stage) => progress.start(stageMessage[stage])
27
29
  : undefined,
@@ -161,4 +161,28 @@ describe("machine protocol", () => {
161
161
  },
162
162
  })
163
163
  })
164
+
165
+ test("uses dynamic protocol metadata without duplicating it in fields", () => {
166
+ expect(
167
+ errorEnvelope({
168
+ _tag: "PushError",
169
+ message: "remote timed out",
170
+ protocolCode: "PUSH_TIMEOUT",
171
+ retryable: true,
172
+ remediation: "Retry after checking connectivity.",
173
+ category: "timeout",
174
+ stage: "fetch",
175
+ }),
176
+ ).toEqual({
177
+ version: 1,
178
+ ok: false,
179
+ error: {
180
+ code: "PUSH_TIMEOUT",
181
+ message: "remote timed out",
182
+ fields: { category: "timeout", stage: "fetch" },
183
+ retryable: true,
184
+ remediation: "Retry after checking connectivity.",
185
+ },
186
+ })
187
+ })
164
188
  })
package/src/protocol.ts CHANGED
@@ -198,6 +198,36 @@ const errorTag = (error: unknown): string | undefined => {
198
198
  return undefined
199
199
  }
200
200
 
201
+ const unwrapEffectError = (error: unknown): unknown => {
202
+ const seen = new Set<object>()
203
+ const visit = (value: unknown): unknown => {
204
+ if (typeof value !== "object" || value === null || seen.has(value))
205
+ return null
206
+ seen.add(value)
207
+ if ("protocolCode" in value && typeof value.protocolCode === "string") {
208
+ return value
209
+ }
210
+ for (const nested of [
211
+ ...Object.values(value),
212
+ ...Object.getOwnPropertySymbols(value).map(
213
+ (symbol) => (value as Record<symbol, unknown>)[symbol],
214
+ ),
215
+ ]) {
216
+ const found = visit(nested)
217
+ if (found) return found
218
+ }
219
+ if (
220
+ "_tag" in value &&
221
+ typeof value._tag === "string" &&
222
+ value._tag.endsWith("Error")
223
+ ) {
224
+ return value
225
+ }
226
+ return null
227
+ }
228
+ return visit(error) ?? error
229
+ }
230
+
201
231
  const errorMessage = (error: unknown): string => {
202
232
  if (
203
233
  typeof error === "object" &&
@@ -219,6 +249,9 @@ const errorFields = (error: unknown): Record<string, unknown> => {
219
249
  key !== "name" &&
220
250
  key !== "message" &&
221
251
  key !== "cause" &&
252
+ key !== "protocolCode" &&
253
+ key !== "retryable" &&
254
+ key !== "remediation" &&
222
255
  value !== undefined,
223
256
  ),
224
257
  )
@@ -231,17 +264,36 @@ export const successEnvelope = (result: unknown): SuccessEnvelope => ({
231
264
  })
232
265
 
233
266
  export const errorEnvelope = (error: unknown): ErrorEnvelope => {
234
- const metadata = errorMetadata[errorTag(error) ?? ""] ?? {
267
+ const normalized = unwrapEffectError(error)
268
+ const defaults = errorMetadata[errorTag(normalized) ?? ""] ?? {
235
269
  code: "COMMAND_FAILED",
236
270
  retryable: false,
237
271
  }
272
+ const dynamic =
273
+ typeof normalized === "object" && normalized !== null
274
+ ? {
275
+ ...("protocolCode" in normalized &&
276
+ typeof normalized.protocolCode === "string"
277
+ ? { code: normalized.protocolCode }
278
+ : {}),
279
+ ...("retryable" in normalized &&
280
+ typeof normalized.retryable === "boolean"
281
+ ? { retryable: normalized.retryable }
282
+ : {}),
283
+ ...("remediation" in normalized &&
284
+ typeof normalized.remediation === "string"
285
+ ? { remediation: normalized.remediation }
286
+ : {}),
287
+ }
288
+ : {}
238
289
  return {
239
290
  version: PROTOCOL_VERSION,
240
291
  ok: false,
241
292
  error: {
242
- ...metadata,
243
- message: errorMessage(error),
244
- fields: errorFields(error),
293
+ ...defaults,
294
+ ...dynamic,
295
+ message: errorMessage(normalized),
296
+ fields: errorFields(normalized),
245
297
  },
246
298
  }
247
299
  }
@@ -245,6 +245,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
245
245
  readonly forwardOutput?: boolean
246
246
  readonly passthrough?: boolean
247
247
  readonly env?: Record<string, string>
248
+ readonly timeoutMs?: number
248
249
  },
249
250
  ) =>
250
251
  pipe(
@@ -264,6 +265,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
264
265
  ? "tee"
265
266
  : "pipe",
266
267
  env: options?.env,
268
+ timeoutMs: options?.timeoutMs,
267
269
  }),
268
270
  Effect.mapError(
269
271
  (processError) =>
@@ -1,8 +1,9 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
4
- import { join } from "node:path"
3
+ import { chmod, mkdir } from "node:fs/promises"
4
+ import { join, resolve } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { errorEnvelope } from "../protocol"
6
7
  import { PushService } from "./PushService"
7
8
  import { TaskService } from "./TaskService"
8
9
  import { WorktreeService } from "./WorktreeService"
@@ -130,9 +131,18 @@ describe("PushService", () => {
130
131
  }
131
132
  }
132
133
 
133
- const publish = (taskPath: string) =>
134
+ const publish = (
135
+ taskPath: string,
136
+ options: {
137
+ fetchTimeoutMs?: number
138
+ pushTimeoutMs?: number
139
+ retryDelayMs?: number
140
+ } = {},
141
+ ) =>
134
142
  runTestEffect(
135
- PushService.pipe(Effect.flatMap((service) => service.publish(taskPath))),
143
+ PushService.pipe(
144
+ Effect.flatMap((service) => service.publish(taskPath, options)),
145
+ ),
136
146
  )
137
147
 
138
148
  const remoteBranch = async (remote: string) =>
@@ -157,6 +167,17 @@ describe("PushService", () => {
157
167
  )
158
168
  }
159
169
 
170
+ const prePushHook = async (checkout: string) =>
171
+ resolve(
172
+ checkout,
173
+ (
174
+ await requireCommand(
175
+ ["git", "rev-parse", "--git-path", "hooks/pre-push"],
176
+ checkout,
177
+ )
178
+ ).stdout,
179
+ )
180
+
160
181
  test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
161
182
  const fixture = await setup()
162
183
  await configureAuthor(fixture.checkout)
@@ -299,4 +320,89 @@ describe("PushService", () => {
299
320
  "has an invalid author",
300
321
  )
301
322
  })
323
+
324
+ test("bounds a stalled pre-push hook and confirms non-publication", async () => {
325
+ const fixture = await setup()
326
+ await configureAuthor(fixture.checkout)
327
+ await Bun.write(join(fixture.checkout, "feature.txt"), "timeout\n")
328
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
329
+ await requireCommand(
330
+ ["git", "commit", "-m", "Add timed publication"],
331
+ fixture.checkout,
332
+ )
333
+ const hook = await prePushHook(fixture.checkout)
334
+ await Bun.write(hook, "#!/bin/sh\nsleep 30\n")
335
+ await chmod(hook, 0o755)
336
+
337
+ const startedAt = performance.now()
338
+ const failure = await publish(fixture.taskPath, {
339
+ pushTimeoutMs: 25,
340
+ }).catch((error) => error)
341
+ expect(performance.now() - startedAt).toBeLessThan(1_000)
342
+ expect(errorEnvelope(failure).error).toMatchObject({
343
+ code: "PUSH_TIMEOUT",
344
+ fields: { category: "timeout", stage: "publish" },
345
+ retryable: true,
346
+ })
347
+ await expect(remoteBranch(fixture.remote)).rejects.toThrow()
348
+ })
349
+
350
+ test("classifies hook rejection separately from transport failure", async () => {
351
+ const fixture = await setup()
352
+ await configureAuthor(fixture.checkout)
353
+ await Bun.write(join(fixture.checkout, "feature.txt"), "rejected\n")
354
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
355
+ await requireCommand(
356
+ ["git", "commit", "-m", "Add rejected publication"],
357
+ fixture.checkout,
358
+ )
359
+ const hook = await prePushHook(fixture.checkout)
360
+ await Bun.write(
361
+ hook,
362
+ "#!/bin/sh\necho 'pre-push hook declined' >&2\nexit 1\n",
363
+ )
364
+ await chmod(hook, 0o755)
365
+
366
+ const failure = await publish(fixture.taskPath).catch((error) => error)
367
+ expect(errorEnvelope(failure).error).toMatchObject({
368
+ code: "PUSH_HOOK_REJECTED",
369
+ fields: { category: "hook_rejection", stage: "publish" },
370
+ retryable: false,
371
+ })
372
+ })
373
+
374
+ test("retries one transient fetch with non-interactive authentication", async () => {
375
+ const fixture = await setup()
376
+ await configureAuthor(fixture.checkout)
377
+ await Bun.write(join(fixture.checkout, "feature.txt"), "retried\n")
378
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
379
+ await requireCommand(
380
+ ["git", "commit", "-m", "Add retried publication"],
381
+ fixture.checkout,
382
+ )
383
+ const attempts = join(fixture.root, "upload-pack-attempts")
384
+ const uploadPack = join(fixture.root, "upload-pack")
385
+ await Bun.write(
386
+ uploadPack,
387
+ `#!/bin/sh
388
+ echo x >> ${JSON.stringify(attempts)}
389
+ test "$GIT_TERMINAL_PROMPT" = 0 || exit 2
390
+ test "$GCM_INTERACTIVE" = Never || exit 2
391
+ if test "$(wc -l < ${JSON.stringify(attempts)})" -eq 1; then
392
+ echo 'Connection reset by peer' >&2
393
+ exit 1
394
+ fi
395
+ exec git-upload-pack "$@"
396
+ `,
397
+ )
398
+ await chmod(uploadPack, 0o755)
399
+ await requireCommand(
400
+ ["git", "config", "remote.origin.uploadpack", uploadPack],
401
+ fixture.checkout,
402
+ )
403
+
404
+ const result = await publish(fixture.taskPath, { retryDelayMs: 0 })
405
+ expect(result.tip).toBe(await remoteBranch(fixture.remote))
406
+ expect((await Bun.file(attempts).text()).trim().split("\n")).toHaveLength(2)
407
+ })
302
408
  })
@@ -1,4 +1,4 @@
1
- import { Data, Effect } from "effect"
1
+ import { Data, Effect, Either } from "effect"
2
2
  import { ContextService } from "./ContextService"
3
3
  import { FileSystemService } from "./FileSystemService"
4
4
  import { PhaseService } from "./PhaseService"
@@ -6,8 +6,77 @@ import { TaskService } from "./TaskService"
6
6
  import { WorkbaseService } from "./WorkbaseService"
7
7
  import { parseGitCommits, type PushCommitMetadata } from "./push-validation"
8
8
 
9
+ type PushCategory =
10
+ | "precondition"
11
+ | "commit_validation"
12
+ | "remote_divergence"
13
+ | "hook_rejection"
14
+ | "authentication"
15
+ | "transport"
16
+ | "timeout"
17
+ | "ambiguous_publication"
18
+ | "git"
19
+
20
+ const categoryMetadata: Record<
21
+ PushCategory,
22
+ { code: string; retryable: boolean; remediation: string }
23
+ > = {
24
+ precondition: {
25
+ code: "PUSH_PRECONDITION",
26
+ retryable: false,
27
+ remediation: "Resolve the local publication precondition and retry.",
28
+ },
29
+ commit_validation: {
30
+ code: "PUSH_COMMIT_VALIDATION",
31
+ retryable: false,
32
+ remediation: "Rewrite the reported outgoing commits and retry.",
33
+ },
34
+ remote_divergence: {
35
+ code: "PUSH_REMOTE_DIVERGENCE",
36
+ retryable: false,
37
+ remediation: "Rebase onto the remote delivery branch before retrying.",
38
+ },
39
+ hook_rejection: {
40
+ code: "PUSH_HOOK_REJECTED",
41
+ retryable: false,
42
+ remediation: "Resolve the hook failure and retry; hooks are not bypassed.",
43
+ },
44
+ authentication: {
45
+ code: "PUSH_AUTHENTICATION",
46
+ retryable: false,
47
+ remediation: "Authenticate Git for the configured remote and retry.",
48
+ },
49
+ transport: {
50
+ code: "PUSH_TRANSPORT",
51
+ retryable: true,
52
+ remediation: "Check remote connectivity and retry.",
53
+ },
54
+ timeout: {
55
+ code: "PUSH_TIMEOUT",
56
+ retryable: true,
57
+ remediation: "Check remote connectivity and retry the bounded operation.",
58
+ },
59
+ ambiguous_publication: {
60
+ code: "PUSH_OUTCOME_UNKNOWN",
61
+ retryable: false,
62
+ remediation:
63
+ "Inspect the exact remote delivery ref before retrying publication.",
64
+ },
65
+ git: {
66
+ code: "PUSH_GIT_ERROR",
67
+ retryable: false,
68
+ remediation: "Resolve the reported Git failure and retry.",
69
+ },
70
+ }
71
+
9
72
  class PushError extends Data.TaggedError("PushError")<{
10
73
  readonly message: string
74
+ readonly category: PushCategory
75
+ readonly stage: PushStage
76
+ readonly elapsedMs?: number
77
+ readonly protocolCode: string
78
+ readonly retryable: boolean
79
+ readonly remediation: string
11
80
  }> {}
12
81
 
13
82
  interface CommandResult {
@@ -28,46 +97,162 @@ interface PushResult {
28
97
  readonly tip: string
29
98
  }
30
99
 
31
- type PushStage = "context" | "fetch" | "inspect" | "validate" | "publish"
100
+ type PushStage =
101
+ | "context"
102
+ | "inspect"
103
+ | "validate"
104
+ | "fetch"
105
+ | "publish"
106
+ | "reconcile"
32
107
 
33
108
  interface PushOptions {
34
109
  readonly onProgress?: (stage: PushStage) => void
110
+ readonly fetchTimeoutMs?: number
111
+ readonly pushTimeoutMs?: number
112
+ readonly retryDelayMs?: number
113
+ readonly forwardOutput?: boolean
35
114
  }
36
115
 
37
116
  const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
38
117
 
118
+ const pushError = (
119
+ message: string,
120
+ category: PushCategory,
121
+ stage: PushStage,
122
+ elapsedMs?: number,
123
+ ) =>
124
+ new PushError({
125
+ message,
126
+ category,
127
+ stage,
128
+ ...(elapsedMs === undefined ? {} : { elapsedMs }),
129
+ protocolCode: categoryMetadata[category].code,
130
+ retryable: categoryMetadata[category].retryable,
131
+ remediation: categoryMetadata[category].remediation,
132
+ })
133
+
134
+ const processTimeout = (error: unknown): { elapsedMs?: number } | null => {
135
+ if (typeof error !== "object" || error === null) return null
136
+ if ("timedOut" in error && error.timedOut === true) {
137
+ return {
138
+ elapsedMs:
139
+ "elapsedMs" in error && typeof error.elapsedMs === "number"
140
+ ? error.elapsedMs
141
+ : undefined,
142
+ }
143
+ }
144
+ return "cause" in error ? processTimeout(error.cause) : null
145
+ }
146
+
147
+ const classifyGitFailure = (
148
+ message: string,
149
+ stage: PushStage,
150
+ elapsedMs?: number,
151
+ ) => {
152
+ const normalized = message.toLowerCase()
153
+ const category: PushCategory =
154
+ /authentication failed|permission denied|could not read username|terminal prompts disabled|credential/.test(
155
+ normalized,
156
+ )
157
+ ? "authentication"
158
+ : /pre-push hook|hook declined|remote rejected/.test(normalized)
159
+ ? "hook_rejection"
160
+ : /non-fast-forward|fetch first|stale info/.test(normalized)
161
+ ? "remote_divergence"
162
+ : /could not resolve|connection|network|remote end hung up|unable to access|repository not found/.test(
163
+ normalized,
164
+ )
165
+ ? "transport"
166
+ : "git"
167
+ return pushError(message, category, stage, elapsedMs)
168
+ }
169
+
39
170
  const requireCommand = (
40
171
  fs: FileSystemService,
41
172
  args: readonly string[],
42
173
  cwd: string,
43
174
  label: string,
175
+ stage: PushStage,
176
+ options: {
177
+ readonly timeoutMs?: number
178
+ readonly env?: Record<string, string>
179
+ readonly forwardOutput?: boolean
180
+ } = {},
44
181
  ) =>
45
- fs.runCommand(args, { cwd, captureOutput: true }).pipe(
46
- Effect.flatMap((result) =>
47
- result.exitCode === 0
48
- ? Effect.succeed(result)
49
- : Effect.fail(
50
- new PushError({
51
- message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
52
- }),
53
- ),
54
- ),
55
- )
182
+ Effect.suspend(() => {
183
+ const startedAt = performance.now()
184
+ return fs
185
+ .runCommand(args, {
186
+ cwd,
187
+ captureOutput: true,
188
+ forwardOutput: options.forwardOutput,
189
+ env: options.env,
190
+ timeoutMs: options.timeoutMs,
191
+ })
192
+ .pipe(
193
+ Effect.mapError((error) => {
194
+ const timeout = processTimeout(error)
195
+ const elapsedMs =
196
+ timeout?.elapsedMs ?? Math.round(performance.now() - startedAt)
197
+ return timeout
198
+ ? pushError(
199
+ `${label} timed out after ${elapsedMs} ms`,
200
+ "timeout",
201
+ stage,
202
+ elapsedMs,
203
+ )
204
+ : classifyGitFailure(`${label}: ${error.message}`, stage, elapsedMs)
205
+ }),
206
+ Effect.flatMap((result) =>
207
+ result.exitCode === 0
208
+ ? Effect.succeed(result)
209
+ : Effect.fail(
210
+ classifyGitFailure(
211
+ `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
212
+ stage,
213
+ Math.round(performance.now() - startedAt),
214
+ ),
215
+ ),
216
+ ),
217
+ )
218
+ })
56
219
 
57
220
  const git = (
58
221
  fs: FileSystemService,
59
222
  cwd: string,
60
223
  args: readonly string[],
61
224
  label: string,
62
- ) => requireCommand(fs, ["git", ...args], cwd, label)
225
+ stage: PushStage,
226
+ options?: Parameters<typeof requireCommand>[5],
227
+ ) => requireCommand(fs, ["git", ...args], cwd, label, stage, options)
63
228
 
64
- const gitRevision = (fs: FileSystemService, cwd: string, revision: string) =>
229
+ const gitRevision = (
230
+ fs: FileSystemService,
231
+ cwd: string,
232
+ revision: string,
233
+ stage: PushStage = "inspect",
234
+ ) =>
65
235
  fs
66
236
  .runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
67
237
  cwd,
68
238
  captureOutput: true,
239
+ timeoutMs: 10_000,
69
240
  })
70
241
  .pipe(
242
+ Effect.mapError((error) => {
243
+ const timeout = processTimeout(error)
244
+ return timeout
245
+ ? pushError(
246
+ `Git revision inspection timed out after ${timeout.elapsedMs ?? 10_000} ms`,
247
+ "timeout",
248
+ stage,
249
+ timeout.elapsedMs,
250
+ )
251
+ : classifyGitFailure(
252
+ `Failed to inspect Git revision '${revision}': ${error.message}`,
253
+ stage,
254
+ )
255
+ }),
71
256
  Effect.map((result) =>
72
257
  result.exitCode === 0 ? result.stdout.trim() || null : null,
73
258
  ),
@@ -78,20 +263,37 @@ const gitAncestor = (
78
263
  cwd: string,
79
264
  ancestor: string,
80
265
  descendant: string,
266
+ stage: PushStage,
81
267
  ) =>
82
268
  fs
83
269
  .runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
84
270
  cwd,
85
271
  captureOutput: true,
272
+ timeoutMs: 10_000,
86
273
  })
87
274
  .pipe(
275
+ Effect.mapError((error) => {
276
+ const timeout = processTimeout(error)
277
+ return timeout
278
+ ? pushError(
279
+ `Git ancestry inspection timed out after ${timeout.elapsedMs ?? 10_000} ms`,
280
+ "timeout",
281
+ stage,
282
+ timeout.elapsedMs,
283
+ )
284
+ : classifyGitFailure(
285
+ `Failed to inspect Git ancestry: ${error.message}`,
286
+ stage,
287
+ )
288
+ }),
88
289
  Effect.flatMap((result) => {
89
290
  if (result.exitCode === 0) return Effect.succeed(true)
90
291
  if (result.exitCode === 1) return Effect.succeed(false)
91
292
  return Effect.fail(
92
- new PushError({
93
- message: `Failed to inspect Git ancestry: ${result.stderr.trim()}`,
94
- }),
293
+ classifyGitFailure(
294
+ `Failed to inspect Git ancestry: ${result.stderr.trim()}`,
295
+ stage,
296
+ ),
95
297
  )
96
298
  }),
97
299
  )
@@ -101,9 +303,11 @@ const validateGitCommits = (
101
303
  base: string,
102
304
  ) => {
103
305
  if (commits.length === 0) {
104
- throw new PushError({
105
- message: `No commits to publish after base '${base}'`,
106
- })
306
+ throw pushError(
307
+ `No commits to publish after base '${base}'`,
308
+ "precondition",
309
+ "validate",
310
+ )
107
311
  }
108
312
  const issues: string[] = []
109
313
  for (const commit of commits) {
@@ -118,66 +322,152 @@ const validateGitCommits = (
118
322
  )
119
323
  }
120
324
  }
121
- if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
325
+ if (issues.length > 0)
326
+ throw pushError(issues.join("\n"), "commit_validation", "validate")
327
+ }
328
+
329
+ const positiveInteger = (value: string | undefined, fallback: number) => {
330
+ const parsed = Number.parseInt(value ?? "", 10)
331
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
122
332
  }
123
333
 
334
+ const gitEnvironment = (): Record<string, string> => ({
335
+ GIT_TERMINAL_PROMPT: "0",
336
+ GCM_INTERACTIVE: "Never",
337
+ GIT_SSH_COMMAND:
338
+ process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes -o ConnectTimeout=15",
339
+ })
340
+
124
341
  const publishGit = (
125
342
  fs: FileSystemService,
126
343
  checkout: string,
127
344
  remote: string,
128
345
  branch: string,
129
346
  base: string,
130
- onProgress?: (stage: PushStage) => void,
347
+ options: PushOptions,
131
348
  ) =>
132
349
  Effect.gen(function* () {
350
+ const onProgress = options.onProgress
351
+ const fetchTimeoutMs =
352
+ options.fetchTimeoutMs ??
353
+ positiveInteger(process.env.AGENCY_PUSH_FETCH_TIMEOUT_MS, 30_000)
354
+ const pushTimeoutMs =
355
+ options.pushTimeoutMs ??
356
+ positiveInteger(process.env.AGENCY_PUSH_TIMEOUT_MS, 120_000)
357
+ const retryDelayMs = options.retryDelayMs ?? 250
358
+ const env = gitEnvironment()
133
359
  onProgress?.("inspect")
134
360
  const currentBranch = yield* git(
135
361
  fs,
136
362
  checkout,
137
363
  ["symbolic-ref", "--quiet", "--short", "HEAD"],
138
364
  "Git checkout must be attached to the declared branch",
365
+ "inspect",
139
366
  )
140
367
  if (currentBranch.stdout.trim() !== branch) {
141
- return yield* new PushError({
142
- message: `Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
143
- })
368
+ return yield* pushError(
369
+ `Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
370
+ "precondition",
371
+ "inspect",
372
+ )
144
373
  }
145
374
  const status = yield* git(
146
375
  fs,
147
376
  checkout,
148
377
  ["status", "--porcelain=v1"],
149
378
  "Failed to inspect Git status",
379
+ "inspect",
150
380
  )
151
381
  if (status.stdout.length > 0) {
152
- return yield* new PushError({
153
- message:
154
- "Cannot publish a dirty Git worktree; commit or discard changes first",
155
- })
382
+ return yield* pushError(
383
+ "Cannot publish a dirty Git worktree; commit or discard changes first",
384
+ "precondition",
385
+ "inspect",
386
+ )
156
387
  }
157
388
 
158
- onProgress?.("fetch")
159
- yield* git(
389
+ const initialTip = yield* gitRevision(fs, checkout, "HEAD")
390
+ const initialBase = yield* gitRevision(
160
391
  fs,
161
392
  checkout,
162
- ["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
163
- `Failed to fetch remote '${remote}'`,
393
+ `refs/remotes/${remote}/${base}`,
164
394
  )
395
+ if (initialTip && initialBase) {
396
+ const cachedBaseIsAncestor = yield* gitAncestor(
397
+ fs,
398
+ checkout,
399
+ initialBase,
400
+ initialTip,
401
+ "validate",
402
+ )
403
+ if (cachedBaseIsAncestor) {
404
+ onProgress?.("validate")
405
+ const initialLog = yield* git(
406
+ fs,
407
+ checkout,
408
+ [
409
+ "log",
410
+ "--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
411
+ `${initialBase}..${initialTip}`,
412
+ ],
413
+ "Failed to inspect outgoing Git commits",
414
+ "validate",
415
+ )
416
+ yield* Effect.try({
417
+ try: () =>
418
+ validateGitCommits(parseGitCommits(initialLog.stdout), base),
419
+ catch: (cause) => cause as PushError,
420
+ })
421
+ }
422
+ }
423
+
424
+ onProgress?.("fetch")
425
+ const fetchRemote = (
426
+ attempt: number,
427
+ ): Effect.Effect<CommandResult, PushError> =>
428
+ git(
429
+ fs,
430
+ checkout,
431
+ [
432
+ "fetch",
433
+ "--prune",
434
+ remote,
435
+ `+refs/heads/${base}:refs/remotes/${remote}/${base}`,
436
+ `+refs/heads/${branch}*:refs/remotes/${remote}/${branch}*`,
437
+ ],
438
+ `Failed to fetch remote '${remote}'`,
439
+ "fetch",
440
+ { timeoutMs: fetchTimeoutMs, env },
441
+ ).pipe(
442
+ Effect.catchAll((error) =>
443
+ attempt < 2 && ["timeout", "transport"].includes(error.category)
444
+ ? Effect.sleep(retryDelayMs + Math.floor(Math.random() * 100)).pipe(
445
+ Effect.flatMap(() => fetchRemote(attempt + 1)),
446
+ )
447
+ : Effect.fail(error),
448
+ ),
449
+ )
450
+ yield* fetchRemote(1)
165
451
  const [tip, baseRevision] = yield* Effect.all(
166
452
  [
167
- gitRevision(fs, checkout, "HEAD"),
168
- gitRevision(fs, checkout, `refs/remotes/${remote}/${base}`),
453
+ gitRevision(fs, checkout, "HEAD", "validate"),
454
+ gitRevision(fs, checkout, `refs/remotes/${remote}/${base}`, "validate"),
169
455
  ],
170
456
  { concurrency: "unbounded" },
171
457
  )
172
458
  if (!tip || !baseRevision) {
173
- return yield* new PushError({
174
- message: `Declared base '${base}' was not found on remote '${remote}'`,
175
- })
459
+ return yield* pushError(
460
+ `Declared base '${base}' was not found on remote '${remote}'`,
461
+ "precondition",
462
+ "validate",
463
+ )
176
464
  }
177
- if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
178
- return yield* new PushError({
179
- message: `Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
180
- })
465
+ if (!(yield* gitAncestor(fs, checkout, baseRevision, tip, "validate"))) {
466
+ return yield* pushError(
467
+ `Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
468
+ "precondition",
469
+ "validate",
470
+ )
181
471
  }
182
472
 
183
473
  onProgress?.("validate")
@@ -190,6 +480,7 @@ const publishGit = (
190
480
  `${baseRevision}..${tip}`,
191
481
  ],
192
482
  "Failed to inspect outgoing Git commits",
483
+ "validate",
193
484
  )
194
485
  yield* Effect.try({
195
486
  try: () => validateGitCommits(parseGitCommits(log.stdout), base),
@@ -200,11 +491,17 @@ const publishGit = (
200
491
  fs,
201
492
  checkout,
202
493
  `refs/remotes/${remote}/${branch}`,
494
+ "validate",
203
495
  )
204
- if (remoteTip && !(yield* gitAncestor(fs, checkout, remoteTip, tip))) {
205
- return yield* new PushError({
206
- message: `Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
207
- })
496
+ if (
497
+ remoteTip &&
498
+ !(yield* gitAncestor(fs, checkout, remoteTip, tip, "validate"))
499
+ ) {
500
+ return yield* pushError(
501
+ `Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
502
+ "remote_divergence",
503
+ "validate",
504
+ )
208
505
  }
209
506
 
210
507
  onProgress?.("publish")
@@ -217,19 +514,58 @@ const publishGit = (
217
514
  `+refs/heads/*:refs/remotes/${remote}/*`,
218
515
  ],
219
516
  `Failed to configure remote '${remote}' tracking`,
517
+ "publish",
220
518
  )
221
- yield* git(
222
- fs,
223
- checkout,
224
- [
225
- "push",
226
- "-u",
227
- remote,
228
- `HEAD:refs/heads/${branch}`,
229
- `--force-if-includes`,
230
- ],
231
- `Failed to push declared branch '${branch}'`,
519
+ const pushed = yield* Effect.either(
520
+ git(
521
+ fs,
522
+ checkout,
523
+ [
524
+ "push",
525
+ "-u",
526
+ remote,
527
+ `HEAD:refs/heads/${branch}`,
528
+ `--force-if-includes`,
529
+ ],
530
+ `Failed to push declared branch '${branch}'`,
531
+ "publish",
532
+ {
533
+ timeoutMs: pushTimeoutMs,
534
+ env,
535
+ forwardOutput: options.forwardOutput,
536
+ },
537
+ ),
232
538
  )
539
+ if (Either.isLeft(pushed)) {
540
+ onProgress?.("reconcile")
541
+ const reconciled = yield* Effect.either(
542
+ git(
543
+ fs,
544
+ checkout,
545
+ ["ls-remote", "--heads", remote, `refs/heads/${branch}`],
546
+ `Failed to reconcile remote branch '${branch}'`,
547
+ "reconcile",
548
+ { timeoutMs: fetchTimeoutMs, env },
549
+ ),
550
+ )
551
+ if (Either.isLeft(reconciled)) {
552
+ return yield* pushError(
553
+ `Publication outcome is unknown after '${pushed.left.message}' and remote reconciliation also failed: ${reconciled.left.message}`,
554
+ "ambiguous_publication",
555
+ "reconcile",
556
+ )
557
+ }
558
+ const reconciledTip = reconciled.right.stdout.split(/\s+/, 1)[0] || null
559
+ if (reconciledTip === tip) return { tip }
560
+ if (reconciledTip !== remoteTip) {
561
+ return yield* pushError(
562
+ `Publication outcome is unknown: remote branch '${branch}' changed to ${reconciledTip ?? "a missing ref"} while publishing ${tip}`,
563
+ "ambiguous_publication",
564
+ "reconcile",
565
+ )
566
+ }
567
+ return yield* pushed.left
568
+ }
233
569
  return { tip }
234
570
  })
235
571
 
@@ -249,48 +585,58 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
249
585
  compact: true,
250
586
  })
251
587
  if (!context.validation.valid) {
252
- return yield* new PushError({
253
- message: "Cannot publish from an invalid Agency workbase",
254
- })
588
+ return yield* pushError(
589
+ "Cannot publish from an invalid Agency workbase",
590
+ "precondition",
591
+ "context",
592
+ )
255
593
  }
256
594
  if (context.target.kind !== "task" && context.target.kind !== "phase") {
257
- return yield* new PushError({
258
- message: "agency push must run from an execution task or phase",
259
- })
595
+ return yield* pushError(
596
+ "agency push must run from an execution task or phase",
597
+ "precondition",
598
+ "context",
599
+ )
260
600
  }
261
601
  if (
262
602
  context.authority.mode !== "execution" ||
263
603
  !context.authority.writable
264
604
  ) {
265
- return yield* new PushError({
266
- message:
267
- "Current Agency target has no writable execution authority",
268
- })
605
+ return yield* pushError(
606
+ "Current Agency target has no writable execution authority",
607
+ "precondition",
608
+ "context",
609
+ )
269
610
  }
270
611
  if (
271
612
  !context.workspace?.writable?.materialized ||
272
613
  !context.workspace.writable.registered
273
614
  ) {
274
- return yield* new PushError({
275
- message:
276
- "Current Agency writable checkout is not materialized and registered",
277
- })
615
+ return yield* pushError(
616
+ "Current Agency writable checkout is not materialized and registered",
617
+ "precondition",
618
+ "context",
619
+ )
278
620
  }
279
621
  const blockers = context.graph.readiness.blockers.filter(
280
622
  (blocker) =>
281
623
  blocker.kind === "dependency" || blocker.kind === "validation",
282
624
  )
283
625
  if (blockers.length > 0) {
284
- return yield* new PushError({
285
- message: `Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
286
- })
626
+ return yield* pushError(
627
+ `Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
628
+ "precondition",
629
+ "context",
630
+ )
287
631
  }
288
632
 
289
633
  const taskId = context.target.taskId
290
634
  if (!taskId) {
291
- return yield* new PushError({
292
- message: "Current Agency execution target has no task ID",
293
- })
635
+ return yield* pushError(
636
+ "Current Agency execution target has no task ID",
637
+ "precondition",
638
+ "context",
639
+ )
294
640
  }
295
641
  const phaseId =
296
642
  context.target.kind === "phase" ? context.target.phaseId : undefined
@@ -303,14 +649,18 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
303
649
  : null
304
650
  : task.data
305
651
  if (!execution || "review" in execution) {
306
- return yield* new PushError({
307
- message: "Current Agency target is not a delivery execution unit",
308
- })
652
+ return yield* pushError(
653
+ "Current Agency target is not a delivery execution unit",
654
+ "precondition",
655
+ "context",
656
+ )
309
657
  }
310
658
  if (execution.status !== "working") {
311
- return yield* new PushError({
312
- message: `Cannot publish Agency work with status '${execution.status}'; status must be working`,
313
- })
659
+ return yield* pushError(
660
+ `Cannot publish Agency work with status '${execution.status}'; status must be working`,
661
+ "precondition",
662
+ "context",
663
+ )
314
664
  }
315
665
  const checkout = context.authority.writable.checkoutPath
316
666
  const { config } = yield* workbase.loadConfig(context.workbase.root)
@@ -321,7 +671,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
321
671
  remote,
322
672
  execution.branch,
323
673
  execution.base,
324
- options.onProgress,
674
+ options,
325
675
  )
326
676
  return {
327
677
  vcs: "git",
@@ -24,6 +24,13 @@ describe("usage logging", () => {
24
24
  durationMs: 12.4,
25
25
  outcome: "success",
26
26
  exitStatus: 0,
27
+ ...(commandPath === "context"
28
+ ? {
29
+ vcs: "git" as const,
30
+ terminalStage: "publish",
31
+ category: "success",
32
+ }
33
+ : {}),
27
34
  },
28
35
  "1.2.3",
29
36
  env,
@@ -35,7 +42,7 @@ describe("usage logging", () => {
35
42
  )
36
43
  expect(await exportUsageEvents(env)).toEqual([
37
44
  expect.objectContaining({
38
- version: 1,
45
+ version: 2,
39
46
  sessionId: "session-1",
40
47
  sessionSequence: 1,
41
48
  agencyVersion: "1.2.3",
@@ -48,6 +55,9 @@ describe("usage logging", () => {
48
55
  expect.objectContaining({
49
56
  sessionSequence: 2,
50
57
  commandPath: "context",
58
+ vcs: "git",
59
+ terminalStage: "publish",
60
+ category: "success",
51
61
  }),
52
62
  ])
53
63
  })
package/src/usage-log.ts CHANGED
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"
2
2
  import { mkdir } from "node:fs/promises"
3
3
  import { dirname, join } from "node:path"
4
4
 
5
- const USAGE_EVENT_VERSION = 1 as const
5
+ const USAGE_EVENT_VERSION = 2 as const
6
6
  const DEFAULT_RETENTION_DAYS = 90
7
7
 
8
8
  export interface UsageEvent {
@@ -11,6 +11,9 @@ export interface UsageEvent {
11
11
  readonly durationMs: number
12
12
  readonly exitStatus: number
13
13
  readonly outcome: "success" | "failure"
14
+ readonly vcs?: "git"
15
+ readonly terminalStage?: string
16
+ readonly category?: string
14
17
  }
15
18
 
16
19
  const stateDirectory = (env: NodeJS.ProcessEnv) =>
@@ -50,6 +53,13 @@ const openDatabase = async (env: NodeJS.ProcessEnv) => {
50
53
  exit_status INTEGER NOT NULL
51
54
  )
52
55
  `)
56
+ for (const column of ["vcs", "terminal_stage", "category"]) {
57
+ try {
58
+ database.run(`ALTER TABLE usage_events ADD COLUMN ${column} TEXT`)
59
+ } catch {
60
+ // Existing databases already have migrated columns.
61
+ }
62
+ }
53
63
  database.run(
54
64
  "CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
55
65
  )
@@ -74,11 +84,11 @@ export async function recordUsageEvent(
74
84
  INSERT INTO usage_events (
75
85
  event_version, session_id, session_sequence, occurred_at,
76
86
  agency_version, command_path, flag_names, duration_ms,
77
- outcome, exit_status
87
+ outcome, exit_status, vcs, terminal_stage, category
78
88
  ) VALUES (
79
89
  ?, ?,
80
90
  (SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
81
- ?, ?, ?, ?, ?, ?, ?
91
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
82
92
  )
83
93
  `)
84
94
  .run(
@@ -92,6 +102,9 @@ export async function recordUsageEvent(
92
102
  Math.max(0, Math.round(event.durationMs)),
93
103
  event.outcome,
94
104
  event.exitStatus,
105
+ event.vcs ?? null,
106
+ event.terminalStage ?? null,
107
+ event.category ?? null,
95
108
  )
96
109
  if (Math.random() < 0.01) {
97
110
  const days = retentionDays(env)
@@ -119,7 +132,7 @@ export async function exportUsageEvents(
119
132
  .query(`
120
133
  SELECT event_version, session_id, session_sequence, occurred_at,
121
134
  agency_version, command_path, flag_names, duration_ms,
122
- outcome, exit_status
135
+ outcome, exit_status, vcs, terminal_stage, category
123
136
  FROM usage_events ORDER BY occurred_at, id
124
137
  `)
125
138
  .all() as Record<string, string | number>[]
@@ -134,6 +147,11 @@ export async function exportUsageEvents(
134
147
  durationMs: row.duration_ms,
135
148
  outcome: row.outcome,
136
149
  exitStatus: row.exit_status,
150
+ ...(row.vcs == null ? {} : { vcs: row.vcs }),
151
+ ...(row.terminal_stage == null
152
+ ? {}
153
+ : { terminalStage: row.terminal_stage }),
154
+ ...(row.category == null ? {} : { category: row.category }),
137
155
  }))
138
156
  } catch {
139
157
  return []
@@ -64,4 +64,14 @@ describe("spawnProcess", () => {
64
64
  expect(result.stderr).toContain("err:0:")
65
65
  expect(result.stderr).toContain(`err:${lineCount - 1}:`)
66
66
  })
67
+
68
+ test("terminates timed-out process groups", async () => {
69
+ const startedAt = performance.now()
70
+ await expect(
71
+ Effect.runPromise(
72
+ spawnProcess(["sh", "-c", "sleep 30 & wait"], { timeoutMs: 25 }),
73
+ ),
74
+ ).rejects.toThrow("Process timed out")
75
+ expect(performance.now() - startedAt).toBeLessThan(1_000)
76
+ })
67
77
  })
@@ -18,6 +18,7 @@ interface SpawnOptions {
18
18
  readonly stdout?: "pipe" | "inherit" | "tee"
19
19
  readonly stderr?: "pipe" | "inherit" | "tee"
20
20
  readonly env?: Record<string, string>
21
+ readonly timeoutMs?: number
21
22
  }
22
23
 
23
24
  const readOutput = async (
@@ -45,8 +46,14 @@ class ProcessError extends Data.TaggedError("ProcessError")<{
45
46
  command: string
46
47
  exitCode: number
47
48
  stderr: string
49
+ timedOut?: boolean
50
+ timeoutMs?: number
51
+ elapsedMs?: number
48
52
  }> {
49
53
  override get message(): string {
54
+ if (this.timedOut) {
55
+ return `Process timed out after ${this.elapsedMs ?? this.timeoutMs} ms: ${this.command}${this.stderr ? `\n${this.stderr}` : ""}`
56
+ }
50
57
  return (
51
58
  this.stderr ||
52
59
  `Process failed with exit code ${this.exitCode}: ${this.command}`
@@ -65,12 +72,14 @@ export const spawnProcess = (
65
72
  ): Effect.Effect<ProcessResult, ProcessError> =>
66
73
  Effect.tryPromise({
67
74
  try: async () => {
75
+ const startedAt = performance.now()
68
76
  const proc = Bun.spawn([...args], {
69
77
  cwd: options?.cwd ?? process.cwd(),
70
78
  stdin: options?.stdin ?? "pipe",
71
79
  stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
72
80
  stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
73
81
  env: options?.env ? { ...process.env, ...options.env } : process.env,
82
+ detached: options?.timeoutMs !== undefined,
74
83
  })
75
84
  // Start draining stdout/stderr immediately so verbose subprocesses
76
85
  // cannot block on filled pipe buffers before they exit.
@@ -89,11 +98,53 @@ export const spawnProcess = (
89
98
  options?.stderr === "tee" ? process.stderr : undefined,
90
99
  )
91
100
 
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
+ }),
130
+ ])
92
131
  const [exitCode, stdout, stderr] = await Promise.all([
93
- proc.exited,
132
+ exited,
94
133
  stdoutPromise,
95
134
  stderrPromise,
96
135
  ])
136
+ if (timer) clearTimeout(timer)
137
+ if (timedOut) {
138
+ throw new ProcessError({
139
+ 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
+ }
97
148
 
98
149
  return {
99
150
  stdout: stdout.trim(),
@@ -103,9 +154,11 @@ export const spawnProcess = (
103
154
  }
104
155
  },
105
156
  catch: (error) =>
106
- new ProcessError({
107
- command: args.join(" "),
108
- exitCode: -1,
109
- stderr: error instanceof Error ? error.message : String(error),
110
- }),
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
+ }),
111
164
  })