@cat-factory/executor-harness 1.82.0 → 1.84.0

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.
@@ -326,6 +326,52 @@ async function setUpClaudeMcp(servers, configHome) {
326
326
  cleanup,
327
327
  };
328
328
  }
329
+ /**
330
+ * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
331
+ * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
332
+ * the assistant turn (`rememberTool`) and hands the following user turn's content to `feedGuard`,
333
+ * which pairs each `tool_result`'s `is_error` with that name. The FIRST reason trips it: the
334
+ * diagnostic is recorded (readable via `reason()`, which the catch surfaces over the generic abort
335
+ * message) and `guardAbort` fires — folded into streamCli's signal so a tripped guard kills the CLI
336
+ * the same way the external watchdog does. Disabled when the caller supplies no limits (only the
337
+ * external watchdog then bounds the run).
338
+ *
339
+ * Split out of {@link runClaudeCode} for the per-function line budget.
340
+ */
341
+ function createClaudeProgressGuard(opts) {
342
+ const guard = opts.guardLimits
343
+ ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
344
+ : undefined;
345
+ const toolNames = new Map();
346
+ const guardAbort = new AbortController();
347
+ let guardReason;
348
+ const feedGuard = (content) => {
349
+ if (!guard || guardReason)
350
+ return;
351
+ for (const block of content) {
352
+ if (!isObject(block) || block.type !== 'tool_result')
353
+ continue;
354
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
355
+ const name = id ? toolNames.get(id) : undefined;
356
+ if (id)
357
+ toolNames.delete(id);
358
+ if (!name)
359
+ continue;
360
+ const reason = guard.observeSignal({ name, isError: block.is_error === true });
361
+ if (reason) {
362
+ guardReason = reason;
363
+ guardAbort.abort();
364
+ return;
365
+ }
366
+ }
367
+ };
368
+ return {
369
+ rememberTool: (id, name) => toolNames.set(id, name),
370
+ feedGuard,
371
+ guardAbort,
372
+ reason: () => guardReason,
373
+ };
374
+ }
329
375
  export async function runClaudeCode(opts) {
330
376
  const stats = { toolCalls: 0, assistantChars: 0 };
331
377
  let summary = '';
@@ -411,37 +457,8 @@ export async function runClaudeCode(opts) {
411
457
  // `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
412
458
  // `guardAbort` (folded into streamCli's signal below) and the run then fails with its
413
459
  // diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
414
- const guard = opts.guardLimits
415
- ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
416
- : undefined;
417
- const toolNames = new Map();
418
- const guardAbort = new AbortController();
419
- let guardReason;
420
- // Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
421
- // with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
422
- // it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
423
- // below surfaces `guardReason` over the generic abort message). A standalone closure so the
424
- // per-block loop doesn't nest onEvent past the readable-depth limit.
425
- const feedGuard = (content) => {
426
- if (!guard || guardReason)
427
- return;
428
- for (const block of content) {
429
- if (!isObject(block) || block.type !== 'tool_result')
430
- continue;
431
- const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
432
- const name = id ? toolNames.get(id) : undefined;
433
- if (id)
434
- toolNames.delete(id);
435
- if (!name)
436
- continue;
437
- const reason = guard.observeSignal({ name, isError: block.is_error === true });
438
- if (reason) {
439
- guardReason = reason;
440
- guardAbort.abort();
441
- return;
442
- }
443
- }
444
- };
460
+ const progressGuard = createClaudeProgressGuard(opts);
461
+ const { rememberTool, feedGuard, guardAbort } = progressGuard;
445
462
  const onEvent = (event, meta) => {
446
463
  const type = event.type;
447
464
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
@@ -463,7 +480,7 @@ export async function runClaudeCode(opts) {
463
480
  // Remember each call's name against its id so the guard can pair it with the
464
481
  // `is_error` its `tool_result` carries on the next `user` turn.
465
482
  if (typeof block.id === 'string' && typeof block.name === 'string') {
466
- toolNames.set(block.id, block.name);
483
+ rememberTool(block.id, block.name);
467
484
  }
468
485
  if (block.name === 'TodoWrite') {
469
486
  const progress = todosToProgress(block.input?.todos);
@@ -571,6 +588,7 @@ export async function runClaudeCode(opts) {
571
588
  // report is appended after them when the CLI managed to emit one before it was killed, which
572
589
  // is uncommon but is the same evidence a bad exit now carries — a guard trip is no reason to
573
590
  // discard it.
591
+ const guardReason = progressGuard.reason();
574
592
  if (guardReason) {
575
593
  const tail = err?.stderrTail;
576
594
  const report = capReport(redact(terminalReport, secrets).trim());
@@ -162,17 +162,6 @@ export interface CodingAgentOutcome {
162
162
  */
163
163
  reproductionReport?: ReproductionReport;
164
164
  }
165
- /**
166
- * Clone (or RESUME an existing branch) → write context → run Pi → push the branch
167
- * iff it carries work. The agent commits its OWN work (it alone knows which files
168
- * belong vs scratch/artifacts it created), so the harness never blanket-stages:
169
- * {@link commitTrackedEdits} is only a safety net for forgotten edits to ALREADY
170
- * tracked files, and the run is judged a no-op only when the branch never advanced
171
- * past its pre-run tip ({@link branchHasCommitsSince}). The harness owns push + PR;
172
- * it checkpoints (pushes) periodically so an evicted run's commits survive and a
173
- * retry resumes on them. Returns the run's summary/stats, whether it pushed, and
174
- * whether it resumed; callers decide what to do after a push (open a PR, or nothing).
175
- */
176
165
  export declare function runCodingAgent(spec: CodingAgentSpec, opts?: RunOptions): Promise<CodingAgentOutcome>;
177
166
  /**
178
167
  * The Ralph-loop validation watchdog: the longest a completion command may run before it is
@@ -42,6 +42,64 @@ function followUpPollIntervalMs() {
42
42
  * retry resumes on them. Returns the run's summary/stats, whether it pushed, and
43
43
  * whether it resumed; callers decide what to do after a push (open a PR, or nothing).
44
44
  */
45
+ /**
46
+ * The work-branch push machinery for one coding run: a single coalesced push plus the periodic
47
+ * checkpoint that keeps mid-run commits durable. Split out of {@link runCodingAgent} for the
48
+ * per-function line budget; the caller owns the interval's lifetime (it clears `checkpoint`).
49
+ *
50
+ * Serialize all pushes to the work branch through a single in-flight promise. A checkpoint tick
51
+ * and the final push (or two slow checkpoint ticks) must never run `git push` to the same branch
52
+ * concurrently: overlapping pushes race on the remote ref and can make a push fail with a
53
+ * ref-lock / non-fast-forward error — which, on the FINAL push, would fail the whole run even
54
+ * though the work is committed. `pushWorkOnce` coalesces concurrent callers onto one push and only
55
+ * pushes once the branch has advanced past `baseSha`.
56
+ *
57
+ * Only push once the branch has advanced past its pre-run tip: pushing while it still sits at
58
+ * `baseSha` would create the work branch at the base commit (a zero-diff branch), which a later
59
+ * retry would see via `remoteBranchExists` and treat as resumable work — then fail to open a PR
60
+ * ("no commits between base and head"). So a run that never commits leaves NO branch behind,
61
+ * preserving the clean no-op outcome.
62
+ */
63
+ function createWorkBranchPusher(args) {
64
+ const { dir, spec, baseSha, logger, signal } = args;
65
+ let pushInFlight = null;
66
+ const pushWorkOnce = () => {
67
+ if (pushInFlight)
68
+ return pushInFlight;
69
+ pushInFlight = (async () => {
70
+ if (!(await branchHasCommitsSince(dir, baseSha, signal)))
71
+ return;
72
+ await pushBranch(dir, spec.pushBranch, spec.ghToken, signal);
73
+ })().finally(() => {
74
+ pushInFlight = null;
75
+ });
76
+ return pushInFlight;
77
+ };
78
+ // Read the in-flight push, if any. A function (with an explicit return type) so the
79
+ // value isn't subject to the caller's straight-line narrowing — `pushInFlight` is
80
+ // only ever assigned inside closures, which flow analysis can't observe.
81
+ const inFlightPush = () => pushInFlight;
82
+ // Checkpoint the agent's committed work to the branch periodically so an eviction
83
+ // mid-run doesn't lose it (a retry then resumes from the pushed commits). The
84
+ // agent commits its own work; this only PUSHES already-committed commits, so it
85
+ // never races the agent's staging. Best-effort: a failed checkpoint is skipped.
86
+ // Surface checkpoint-push failures at warn with a running count: a checkpoint losing
87
+ // a race is harmless once, but a steadily-climbing count means mid-run work is NOT
88
+ // being durably checkpointed, so an eviction would lose it — previously invisible at
89
+ // info level. Still best-effort: a failed checkpoint never fails the run.
90
+ let checkpointFailures = 0;
91
+ const checkpoint = setInterval(() => {
92
+ pushWorkOnce().catch((err) => {
93
+ checkpointFailures++;
94
+ logger.warn('coding-agent: checkpoint push failed', {
95
+ reason: err instanceof Error ? err.message : String(err),
96
+ checkpointFailures,
97
+ });
98
+ });
99
+ }, checkpointIntervalMs());
100
+ checkpoint.unref?.();
101
+ return { pushWorkOnce, inFlightPush, checkpoint };
102
+ }
45
103
  export async function runCodingAgent(spec, opts = {}) {
46
104
  const { signal } = opts;
47
105
  // The registry already binds jobId/repo/branch; add the coding kind + the push branch
@@ -51,56 +109,17 @@ export async function runCodingAgent(spec, opts = {}) {
51
109
  // Clone (or resume) the checkout, fetch any read-only reference branches, and capture the
52
110
  // pre-run branch tip. See {@link prepareCodingCheckout} for the resume-safety invariants.
53
111
  const { resumed, baseSha } = await prepareCodingCheckout(dir, spec, logger, opts);
54
- // Serialize all pushes to the work branch through a single in-flight promise.
55
- // A checkpoint tick and the final push (or two slow checkpoint ticks) must never
56
- // run `git push` to the same branch concurrently: overlapping pushes race on the
57
- // remote ref and can make a push fail with a ref-lock / non-fast-forward error —
58
- // which, on the FINAL push, would fail the whole run even though the work is
59
- // committed. `pushWorkOnce` coalesces concurrent callers onto one push and only
60
- // pushes once the branch has advanced past `baseSha` (see below).
61
- //
62
- // Only push once the branch has advanced past its pre-run tip: pushing while it
63
- // still sits at `baseSha` would create the work branch at the base commit (a
64
- // zero-diff branch), which a later retry would see via `remoteBranchExists` and
65
- // treat as resumable work — then fail to open a PR ("no commits between base and
66
- // head"). So a run that never commits leaves NO branch behind, preserving the
67
- // clean no-op outcome.
68
- let pushInFlight = null;
69
- const pushWorkOnce = () => {
70
- if (pushInFlight)
71
- return pushInFlight;
72
- pushInFlight = (async () => {
73
- if (!(await branchHasCommitsSince(dir, baseSha, signal)))
74
- return;
75
- await pushBranch(dir, spec.pushBranch, spec.ghToken, signal);
76
- })().finally(() => {
77
- pushInFlight = null;
78
- });
79
- return pushInFlight;
80
- };
81
- // Read the in-flight push, if any. A function (with an explicit return type) so the
82
- // value isn't subject to the caller's straight-line narrowing — `pushInFlight` is
83
- // only ever assigned inside closures, which flow analysis can't observe.
84
- const inFlightPush = () => pushInFlight;
85
- // Checkpoint the agent's committed work to the branch periodically so an eviction
86
- // mid-run doesn't lose it (a retry then resumes from the pushed commits). The
87
- // agent commits its own work; this only PUSHES already-committed commits, so it
88
- // never races the agent's staging. Best-effort: a failed checkpoint is skipped.
89
- // Surface checkpoint-push failures at warn with a running count: a checkpoint losing
90
- // a race is harmless once, but a steadily-climbing count means mid-run work is NOT
91
- // being durably checkpointed, so an eviction would lose it — previously invisible at
92
- // info level. Still best-effort: a failed checkpoint never fails the run.
93
- let checkpointFailures = 0;
94
- const checkpoint = setInterval(() => {
95
- pushWorkOnce().catch((err) => {
96
- checkpointFailures++;
97
- logger.warn('coding-agent: checkpoint push failed', {
98
- reason: err instanceof Error ? err.message : String(err),
99
- checkpointFailures,
100
- });
101
- });
102
- }, checkpointIntervalMs());
103
- checkpoint.unref?.();
112
+ // The work-branch push machinery: one coalesced in-flight push plus the periodic
113
+ // checkpoint that keeps mid-run commits durable across an eviction. Lifted into
114
+ // {@link createWorkBranchPusher} so this callback stays within the per-function line budget;
115
+ // the invariants it upholds are documented there.
116
+ const { pushWorkOnce, inFlightPush, checkpoint } = createWorkBranchPusher({
117
+ dir,
118
+ spec,
119
+ baseSha,
120
+ logger,
121
+ signal,
122
+ });
104
123
  // In a monorepo the service lives in a subdirectory: run Pi with its cwd set to
105
124
  // that subtree (git stays rooted at `dir` so commits/pushes still cover the whole
106
125
  // checkout). Created if missing so a coder scaffolding a brand-new service into an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.82.0",
3
+ "version": "1.84.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.12.32",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.201.0",
34
- "@cat-factory/server": "0.185.1",
35
- "@cat-factory/spend": "0.12.131"
33
+ "@cat-factory/kernel": "0.202.0",
34
+ "@cat-factory/spend": "0.12.133",
35
+ "@cat-factory/server": "0.187.0"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
@@ -488,6 +488,56 @@ async function setUpClaudeMcp(
488
488
  }
489
489
  }
490
490
 
491
+ /**
492
+ * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
493
+ * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
494
+ * the assistant turn (`rememberTool`) and hands the following user turn's content to `feedGuard`,
495
+ * which pairs each `tool_result`'s `is_error` with that name. The FIRST reason trips it: the
496
+ * diagnostic is recorded (readable via `reason()`, which the catch surfaces over the generic abort
497
+ * message) and `guardAbort` fires — folded into streamCli's signal so a tripped guard kills the CLI
498
+ * the same way the external watchdog does. Disabled when the caller supplies no limits (only the
499
+ * external watchdog then bounds the run).
500
+ *
501
+ * Split out of {@link runClaudeCode} for the per-function line budget.
502
+ */
503
+ function createClaudeProgressGuard(opts: SubscriptionRunOptions): {
504
+ rememberTool: (id: string, name: string) => void
505
+ feedGuard: (content: unknown[]) => void
506
+ guardAbort: AbortController
507
+ reason: () => string | undefined
508
+ } {
509
+ const guard = opts.guardLimits
510
+ ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
511
+ : undefined
512
+ const toolNames = new Map<string, string>()
513
+ const guardAbort = new AbortController()
514
+ let guardReason: string | undefined
515
+
516
+ const feedGuard = (content: unknown[]): void => {
517
+ if (!guard || guardReason) return
518
+ for (const block of content) {
519
+ if (!isObject(block) || block.type !== 'tool_result') continue
520
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
521
+ const name = id ? toolNames.get(id) : undefined
522
+ if (id) toolNames.delete(id)
523
+ if (!name) continue
524
+ const reason = guard.observeSignal({ name, isError: block.is_error === true })
525
+ if (reason) {
526
+ guardReason = reason
527
+ guardAbort.abort()
528
+ return
529
+ }
530
+ }
531
+ }
532
+
533
+ return {
534
+ rememberTool: (id, name) => toolNames.set(id, name),
535
+ feedGuard,
536
+ guardAbort,
537
+ reason: () => guardReason,
538
+ }
539
+ }
540
+
491
541
  export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
492
542
  const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
493
543
  let summary = ''
@@ -576,34 +626,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
576
626
  // `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
577
627
  // `guardAbort` (folded into streamCli's signal below) and the run then fails with its
578
628
  // diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
579
- const guard = opts.guardLimits
580
- ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
581
- : undefined
582
- const toolNames = new Map<string, string>()
583
- const guardAbort = new AbortController()
584
- let guardReason: string | undefined
585
-
586
- // Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
587
- // with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
588
- // it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
589
- // below surfaces `guardReason` over the generic abort message). A standalone closure so the
590
- // per-block loop doesn't nest onEvent past the readable-depth limit.
591
- const feedGuard = (content: unknown[]): void => {
592
- if (!guard || guardReason) return
593
- for (const block of content) {
594
- if (!isObject(block) || block.type !== 'tool_result') continue
595
- const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
596
- const name = id ? toolNames.get(id) : undefined
597
- if (id) toolNames.delete(id)
598
- if (!name) continue
599
- const reason = guard.observeSignal({ name, isError: block.is_error === true })
600
- if (reason) {
601
- guardReason = reason
602
- guardAbort.abort()
603
- return
604
- }
605
- }
606
- }
629
+ const progressGuard = createClaudeProgressGuard(opts)
630
+ const { rememberTool, feedGuard, guardAbort } = progressGuard
607
631
 
608
632
  const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
609
633
  const type = event.type
@@ -625,7 +649,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
625
649
  // Remember each call's name against its id so the guard can pair it with the
626
650
  // `is_error` its `tool_result` carries on the next `user` turn.
627
651
  if (typeof block.id === 'string' && typeof block.name === 'string') {
628
- toolNames.set(block.id, block.name)
652
+ rememberTool(block.id, block.name)
629
653
  }
630
654
  if (block.name === 'TodoWrite') {
631
655
  const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
@@ -739,6 +763,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
739
763
  // report is appended after them when the CLI managed to emit one before it was killed, which
740
764
  // is uncommon but is the same evidence a bad exit now carries — a guard trip is no reason to
741
765
  // discard it.
766
+ const guardReason = progressGuard.reason()
742
767
  if (guardReason) {
743
768
  const tail = (err as { stderrTail?: string } | undefined)?.stderrTail
744
769
  const report = capReport(redact(terminalReport, secrets).trim())
@@ -260,6 +260,75 @@ function followUpPollIntervalMs(): number {
260
260
  * retry resumes on them. Returns the run's summary/stats, whether it pushed, and
261
261
  * whether it resumed; callers decide what to do after a push (open a PR, or nothing).
262
262
  */
263
+ /**
264
+ * The work-branch push machinery for one coding run: a single coalesced push plus the periodic
265
+ * checkpoint that keeps mid-run commits durable. Split out of {@link runCodingAgent} for the
266
+ * per-function line budget; the caller owns the interval's lifetime (it clears `checkpoint`).
267
+ *
268
+ * Serialize all pushes to the work branch through a single in-flight promise. A checkpoint tick
269
+ * and the final push (or two slow checkpoint ticks) must never run `git push` to the same branch
270
+ * concurrently: overlapping pushes race on the remote ref and can make a push fail with a
271
+ * ref-lock / non-fast-forward error — which, on the FINAL push, would fail the whole run even
272
+ * though the work is committed. `pushWorkOnce` coalesces concurrent callers onto one push and only
273
+ * pushes once the branch has advanced past `baseSha`.
274
+ *
275
+ * Only push once the branch has advanced past its pre-run tip: pushing while it still sits at
276
+ * `baseSha` would create the work branch at the base commit (a zero-diff branch), which a later
277
+ * retry would see via `remoteBranchExists` and treat as resumable work — then fail to open a PR
278
+ * ("no commits between base and head"). So a run that never commits leaves NO branch behind,
279
+ * preserving the clean no-op outcome.
280
+ */
281
+ function createWorkBranchPusher(args: {
282
+ dir: string
283
+ spec: CodingAgentSpec
284
+ baseSha: string
285
+ logger: Logger
286
+ signal: AbortSignal | undefined
287
+ }): {
288
+ pushWorkOnce: () => Promise<void>
289
+ inFlightPush: () => Promise<void> | null
290
+ checkpoint: ReturnType<typeof setInterval>
291
+ } {
292
+ const { dir, spec, baseSha, logger, signal } = args
293
+ let pushInFlight: Promise<void> | null = null
294
+ const pushWorkOnce = (): Promise<void> => {
295
+ if (pushInFlight) return pushInFlight
296
+ pushInFlight = (async () => {
297
+ if (!(await branchHasCommitsSince(dir, baseSha, signal))) return
298
+ await pushBranch(dir, spec.pushBranch, spec.ghToken, signal)
299
+ })().finally(() => {
300
+ pushInFlight = null
301
+ })
302
+ return pushInFlight
303
+ }
304
+ // Read the in-flight push, if any. A function (with an explicit return type) so the
305
+ // value isn't subject to the caller's straight-line narrowing — `pushInFlight` is
306
+ // only ever assigned inside closures, which flow analysis can't observe.
307
+ const inFlightPush = (): Promise<void> | null => pushInFlight
308
+
309
+ // Checkpoint the agent's committed work to the branch periodically so an eviction
310
+ // mid-run doesn't lose it (a retry then resumes from the pushed commits). The
311
+ // agent commits its own work; this only PUSHES already-committed commits, so it
312
+ // never races the agent's staging. Best-effort: a failed checkpoint is skipped.
313
+ // Surface checkpoint-push failures at warn with a running count: a checkpoint losing
314
+ // a race is harmless once, but a steadily-climbing count means mid-run work is NOT
315
+ // being durably checkpointed, so an eviction would lose it — previously invisible at
316
+ // info level. Still best-effort: a failed checkpoint never fails the run.
317
+ let checkpointFailures = 0
318
+ const checkpoint = setInterval(() => {
319
+ pushWorkOnce().catch((err) => {
320
+ checkpointFailures++
321
+ logger.warn('coding-agent: checkpoint push failed', {
322
+ reason: err instanceof Error ? err.message : String(err),
323
+ checkpointFailures,
324
+ })
325
+ })
326
+ }, checkpointIntervalMs())
327
+ checkpoint.unref?.()
328
+
329
+ return { pushWorkOnce, inFlightPush, checkpoint }
330
+ }
331
+
263
332
  export async function runCodingAgent(
264
333
  spec: CodingAgentSpec,
265
334
  opts: RunOptions = {},
@@ -275,55 +344,17 @@ export async function runCodingAgent(
275
344
  // pre-run branch tip. See {@link prepareCodingCheckout} for the resume-safety invariants.
276
345
  const { resumed, baseSha } = await prepareCodingCheckout(dir, spec, logger, opts)
277
346
 
278
- // Serialize all pushes to the work branch through a single in-flight promise.
279
- // A checkpoint tick and the final push (or two slow checkpoint ticks) must never
280
- // run `git push` to the same branch concurrently: overlapping pushes race on the
281
- // remote ref and can make a push fail with a ref-lock / non-fast-forward error —
282
- // which, on the FINAL push, would fail the whole run even though the work is
283
- // committed. `pushWorkOnce` coalesces concurrent callers onto one push and only
284
- // pushes once the branch has advanced past `baseSha` (see below).
285
- //
286
- // Only push once the branch has advanced past its pre-run tip: pushing while it
287
- // still sits at `baseSha` would create the work branch at the base commit (a
288
- // zero-diff branch), which a later retry would see via `remoteBranchExists` and
289
- // treat as resumable work — then fail to open a PR ("no commits between base and
290
- // head"). So a run that never commits leaves NO branch behind, preserving the
291
- // clean no-op outcome.
292
- let pushInFlight: Promise<void> | null = null
293
- const pushWorkOnce = (): Promise<void> => {
294
- if (pushInFlight) return pushInFlight
295
- pushInFlight = (async () => {
296
- if (!(await branchHasCommitsSince(dir, baseSha, signal))) return
297
- await pushBranch(dir, spec.pushBranch, spec.ghToken, signal)
298
- })().finally(() => {
299
- pushInFlight = null
300
- })
301
- return pushInFlight
302
- }
303
- // Read the in-flight push, if any. A function (with an explicit return type) so the
304
- // value isn't subject to the caller's straight-line narrowing — `pushInFlight` is
305
- // only ever assigned inside closures, which flow analysis can't observe.
306
- const inFlightPush = (): Promise<void> | null => pushInFlight
307
-
308
- // Checkpoint the agent's committed work to the branch periodically so an eviction
309
- // mid-run doesn't lose it (a retry then resumes from the pushed commits). The
310
- // agent commits its own work; this only PUSHES already-committed commits, so it
311
- // never races the agent's staging. Best-effort: a failed checkpoint is skipped.
312
- // Surface checkpoint-push failures at warn with a running count: a checkpoint losing
313
- // a race is harmless once, but a steadily-climbing count means mid-run work is NOT
314
- // being durably checkpointed, so an eviction would lose it — previously invisible at
315
- // info level. Still best-effort: a failed checkpoint never fails the run.
316
- let checkpointFailures = 0
317
- const checkpoint = setInterval(() => {
318
- pushWorkOnce().catch((err) => {
319
- checkpointFailures++
320
- logger.warn('coding-agent: checkpoint push failed', {
321
- reason: err instanceof Error ? err.message : String(err),
322
- checkpointFailures,
323
- })
324
- })
325
- }, checkpointIntervalMs())
326
- checkpoint.unref?.()
347
+ // The work-branch push machinery: one coalesced in-flight push plus the periodic
348
+ // checkpoint that keeps mid-run commits durable across an eviction. Lifted into
349
+ // {@link createWorkBranchPusher} so this callback stays within the per-function line budget;
350
+ // the invariants it upholds are documented there.
351
+ const { pushWorkOnce, inFlightPush, checkpoint } = createWorkBranchPusher({
352
+ dir,
353
+ spec,
354
+ baseSha,
355
+ logger,
356
+ signal,
357
+ })
327
358
 
328
359
  // In a monorepo the service lives in a subdirectory: run Pi with its cwd set to
329
360
  // that subtree (git stays rooted at `dir` so commits/pushes still cover the whole