@cat-factory/executor-harness 1.82.0 → 1.86.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.
- package/dist/agent-runner.js +50 -32
- package/dist/coding-agent.d.ts +0 -11
- package/dist/coding-agent.js +69 -50
- package/package.json +6 -6
- package/src/agent-runner.ts +54 -29
- package/src/coding-agent.ts +80 -49
package/dist/agent-runner.js
CHANGED
|
@@ -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
|
|
415
|
-
|
|
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
|
-
|
|
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());
|
package/dist/coding-agent.d.ts
CHANGED
|
@@ -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
|
package/dist/coding-agent.js
CHANGED
|
@@ -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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
3
|
+
"version": "1.86.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",
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@hono/node-server": "^2.0.12",
|
|
29
|
-
"@types/node": "^26.1.
|
|
30
|
-
"hono": "^4.12.
|
|
29
|
+
"@types/node": "^26.1.2",
|
|
30
|
+
"hono": "^4.12.33",
|
|
31
31
|
"typescript": "7.0.2",
|
|
32
32
|
"vitest": "^4.1.10",
|
|
33
|
-
"@cat-factory/kernel": "0.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.12.
|
|
33
|
+
"@cat-factory/kernel": "0.212.0",
|
|
34
|
+
"@cat-factory/server": "0.192.0",
|
|
35
|
+
"@cat-factory/spend": "0.12.143"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
580
|
-
|
|
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
|
-
|
|
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())
|
package/src/coding-agent.ts
CHANGED
|
@@ -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
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|