@markjaquith/agency 3.2.0 → 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.
@@ -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",