@cat-factory/executor-harness 1.132.3 → 1.135.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/README.md +49 -0
- package/dist/agent-capabilities.d.ts +21 -24
- package/dist/agent-capabilities.js +22 -50
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +18 -2
- package/dist/agent-runner.js +29 -231
- package/dist/agent-shared.d.ts +14 -5
- package/dist/agent-shared.js +14 -5
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -138
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- package/dist/claude-cli.d.ts +90 -0
- package/dist/claude-cli.js +181 -0
- package/dist/claude-home.d.ts +41 -0
- package/dist/claude-home.js +159 -0
- package/dist/coding-agent.d.ts +35 -0
- package/dist/coding-agent.js +213 -41
- package/dist/docker-status.d.ts +89 -0
- package/dist/docker-status.js +147 -0
- package/dist/frontend-infra.js +4 -3
- package/dist/git.d.ts +48 -5
- package/dist/git.js +93 -26
- package/dist/guard-driver.d.ts +71 -0
- package/dist/guard-driver.js +171 -0
- package/dist/harness-server.js +13 -0
- package/dist/infra-standup.d.ts +69 -0
- package/dist/infra-standup.js +182 -0
- package/dist/job.d.ts +10 -0
- package/dist/multi-repo-coding.d.ts +17 -0
- package/dist/multi-repo-coding.js +61 -16
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +126 -57
- package/dist/pi.d.ts +8 -0
- package/dist/pi.js +16 -9
- package/dist/progress-guard.d.ts +56 -10
- package/dist/progress-guard.js +84 -22
- package/dist/runner.d.ts +1 -1
- package/dist/salvage.d.ts +180 -0
- package/dist/salvage.js +289 -0
- package/dist/workspace-probe.d.ts +85 -0
- package/dist/workspace-probe.js +124 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +25 -51
- package/src/agent-env.ts +49 -0
- package/src/agent-runner.ts +40 -267
- package/src/agent-shared.ts +16 -5
- package/src/agent.ts +7 -164
- package/src/captured-command.ts +3 -2
- package/src/claude-cli.ts +217 -0
- package/src/claude-home.ts +233 -0
- package/src/coding-agent.ts +252 -44
- package/src/docker-status.ts +201 -0
- package/src/frontend-infra.ts +4 -3
- package/src/git.ts +104 -26
- package/src/guard-driver.ts +203 -0
- package/src/harness-server.ts +13 -0
- package/src/infra-standup.ts +218 -0
- package/src/job.ts +10 -0
- package/src/multi-repo-coding.ts +65 -16
- package/src/pi-workspace.ts +161 -57
- package/src/pi.ts +27 -12
- package/src/progress-guard.ts +110 -34
- package/src/runner.ts +1 -1
- package/src/salvage.ts +407 -0
- package/src/workspace-probe.ts +155 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
claudeAllowedToolPatterns,
|
|
6
|
+
mcpServerSecretValues,
|
|
7
|
+
writeClaudeMcpConfig,
|
|
8
|
+
type McpServerSpec,
|
|
9
|
+
type SkillSpec,
|
|
10
|
+
} from './agent-capabilities.js'
|
|
11
|
+
import type { Logger } from './logger.js'
|
|
12
|
+
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
|
|
13
|
+
import { registerKnownSecrets } from './redact.js'
|
|
14
|
+
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// The PER-RUN Claude Code config home: everything written for one claude-code job and torn down
|
|
18
|
+
// with it — the isolated `CLAUDE_CONFIG_DIR`, its onboarding pre-seed, the run's native skills,
|
|
19
|
+
// its MCP config, and the child env that points the CLI at all of it.
|
|
20
|
+
//
|
|
21
|
+
// The sibling of `codex-home.ts`, extracted from `runClaudeCode` for the same reason: the run
|
|
22
|
+
// loop's own job is streaming and reducing the CLI's events, while this is a directory with a
|
|
23
|
+
// lifecycle that holds a credential.
|
|
24
|
+
//
|
|
25
|
+
// CRITICAL, and why it is a temp dir rather than anything under the checkout: several handlers
|
|
26
|
+
// finish with `git add -A` + push, so a `.claude/` directory inside `opts.cwd` would publish any
|
|
27
|
+
// cached credential to the PR branch.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** What one claude-code job needs written into its own home. */
|
|
31
|
+
export interface ClaudeHomeOptions {
|
|
32
|
+
/** The decrypted subscription OAuth token. Required unless `ambientAuth`. */
|
|
33
|
+
subscriptionToken?: string
|
|
34
|
+
/**
|
|
35
|
+
* Anthropic-compatible base URL for a non-Anthropic Claude-Code vendor (GLM/Kimi): present ⇒
|
|
36
|
+
* ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN, absent ⇒ CLAUDE_CODE_OAUTH_TOKEN.
|
|
37
|
+
*/
|
|
38
|
+
subscriptionBaseUrl?: string
|
|
39
|
+
/** Run the developer's own CLI login instead: no isolated home, nothing installed. */
|
|
40
|
+
ambientAuth?: boolean
|
|
41
|
+
/** Skills to install natively under `<configHome>/skills/<name>/`. */
|
|
42
|
+
skills?: SkillSpec[]
|
|
43
|
+
/** Tool servers to scope to this job's config. */
|
|
44
|
+
mcpServers?: McpServerSpec[]
|
|
45
|
+
/** Job-scoped child env (tester secrets, a private-registry npmrc pointer). */
|
|
46
|
+
extraEnv?: Record<string, string>
|
|
47
|
+
log?: Logger
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
|
|
52
|
+
* `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
|
|
53
|
+
* expects) plus every resource file at its path within the skill directory. Resource sub-paths
|
|
54
|
+
* were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
|
|
55
|
+
*
|
|
56
|
+
* The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
|
|
57
|
+
* scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
|
|
58
|
+
* or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
|
|
59
|
+
* would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
|
|
60
|
+
* valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
|
|
61
|
+
*/
|
|
62
|
+
async function writeNativeSkill(skillsRoot: string, skill: SkillSpec): Promise<void> {
|
|
63
|
+
const dir = join(skillsRoot, skill.name)
|
|
64
|
+
await mkdir(dir, { recursive: true })
|
|
65
|
+
const name = JSON.stringify(skill.name)
|
|
66
|
+
const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '))
|
|
67
|
+
const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`
|
|
68
|
+
await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8')
|
|
69
|
+
for (const resource of skill.resources) {
|
|
70
|
+
const dest = join(dir, resource.relPath)
|
|
71
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
72
|
+
await writeFile(dest, resource.content, 'utf8')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
|
|
78
|
+
* return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
|
|
79
|
+
*
|
|
80
|
+
* Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
|
|
81
|
+
* ambient run on a developer's own machine can never silently hand the agent their personal ones.
|
|
82
|
+
* And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
|
|
83
|
+
* whole-session, not MCP-scoped, so it carries `builtIns`, the SAME list this run declared with
|
|
84
|
+
* `--tools`, in the same entry; see `claudeAllowedToolPatterns` for why that list is threaded in
|
|
85
|
+
* rather than re-derived, and how the run's permission mode treats an allow-list.
|
|
86
|
+
*
|
|
87
|
+
* The config carries this job's resolved credentials, so it goes in the isolated config home when
|
|
88
|
+
* we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
|
|
89
|
+
* commit) and never a shared HOME path (a concurrent job would clobber it).
|
|
90
|
+
*/
|
|
91
|
+
async function setUpClaudeMcp(
|
|
92
|
+
servers: McpServerSpec[] | undefined,
|
|
93
|
+
configHome: string | undefined,
|
|
94
|
+
builtIns: readonly string[],
|
|
95
|
+
): Promise<{ args: string[]; cleanup: () => Promise<void> }> {
|
|
96
|
+
const noop = { args: [], cleanup: async () => {} }
|
|
97
|
+
if (!servers?.length) return noop
|
|
98
|
+
// Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
|
|
99
|
+
// that tail is carried onto the step's diagnostics.
|
|
100
|
+
registerKnownSecrets(mcpServerSecretValues(servers))
|
|
101
|
+
const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')))
|
|
102
|
+
const owned = home === configHome ? undefined : home
|
|
103
|
+
const cleanup = async (): Promise<void> => {
|
|
104
|
+
if (owned) await rm(owned, { recursive: true, force: true }).catch(() => {})
|
|
105
|
+
}
|
|
106
|
+
const configPath = await writeClaudeMcpConfig(home, servers)
|
|
107
|
+
if (!configPath) return { args: [], cleanup }
|
|
108
|
+
const allowedTools = claudeAllowedToolPatterns(servers, builtIns)
|
|
109
|
+
return {
|
|
110
|
+
args: [
|
|
111
|
+
'--mcp-config',
|
|
112
|
+
configPath,
|
|
113
|
+
'--strict-mcp-config',
|
|
114
|
+
...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
|
|
115
|
+
],
|
|
116
|
+
cleanup,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The isolated, per-run home the `claude` CLI runs against: a temp config dir OUTSIDE the cloned
|
|
122
|
+
* checkout, pre-seeded past the first-launch prompts, carrying the run's native skills and MCP
|
|
123
|
+
* config, plus the child env pointing the CLI at it. {@link ClaudeRunHome.dispose} is the other
|
|
124
|
+
* half of the same concern — the leased credential must never outlive the run — so acquisition
|
|
125
|
+
* and teardown are defined together rather than split across a `finally` forty lines away.
|
|
126
|
+
*
|
|
127
|
+
* Ambient (native) mode has NO home: the developer's installed CLI uses its own `~/.claude`
|
|
128
|
+
* login, so nothing is created, nothing is pre-seeded, and `dispose` only clears the MCP config.
|
|
129
|
+
*/
|
|
130
|
+
export interface ClaudeRunHome {
|
|
131
|
+
/** The per-run config dir; `undefined` in ambient mode (the developer's own login is used). */
|
|
132
|
+
configHome: string | undefined
|
|
133
|
+
/** The CLI argv selecting the run's tool servers; empty when it has none. */
|
|
134
|
+
mcpArgs: string[]
|
|
135
|
+
/** The child-process env (see {@link buildClaudeEnv}). */
|
|
136
|
+
env: Record<string, string>
|
|
137
|
+
dispose: () => Promise<void>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function openClaudeRunHome(
|
|
141
|
+
opts: ClaudeHomeOptions,
|
|
142
|
+
tools: readonly string[],
|
|
143
|
+
): Promise<ClaudeRunHome> {
|
|
144
|
+
// Native (ambient) mode: run the developer's installed `claude` with its OWN login —
|
|
145
|
+
// no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
|
|
146
|
+
// Claude Code persists user config/credentials under its config dir; point that at an
|
|
147
|
+
// isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
|
|
148
|
+
// agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
|
|
149
|
+
// stray `.claude/` directory — and any cached credential in it — into the pushed branch.
|
|
150
|
+
// Mirrors the Codex CODEX_HOME isolation (`codex-home.ts`); removed by `dispose`.
|
|
151
|
+
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
152
|
+
throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
|
|
153
|
+
}
|
|
154
|
+
const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
|
|
155
|
+
|
|
156
|
+
// The config dir is brand-new every run, so Claude Code would otherwise treat this
|
|
157
|
+
// as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
|
|
158
|
+
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
159
|
+
// hanging the job until the watchdog kills it. Pre-seed the config that marks those
|
|
160
|
+
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
161
|
+
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
162
|
+
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
163
|
+
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
164
|
+
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
165
|
+
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
166
|
+
if (configHome) {
|
|
167
|
+
await writeOnboardingPreseed(configHome)
|
|
168
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
172
|
+
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
173
|
+
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
174
|
+
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
175
|
+
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
176
|
+
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
177
|
+
// still names the skills.
|
|
178
|
+
if (configHome) {
|
|
179
|
+
for (const skill of opts.skills ?? []) {
|
|
180
|
+
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
185
|
+
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
186
|
+
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome, tools)
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
configHome,
|
|
190
|
+
mcpArgs: mcp.args,
|
|
191
|
+
env: buildClaudeEnv(opts, configHome),
|
|
192
|
+
dispose: async () => {
|
|
193
|
+
// The ambient-mode MCP config dir (credential-bearing) never outlives the run.
|
|
194
|
+
await mcp.cleanup()
|
|
195
|
+
if (!configHome) return
|
|
196
|
+
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
197
|
+
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
198
|
+
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
199
|
+
await retainSessionTranscripts(configHome, ['projects'], {
|
|
200
|
+
label: 'claude-code',
|
|
201
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
202
|
+
})
|
|
203
|
+
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
204
|
+
await rm(configHome, { recursive: true, force: true }).catch(() => {})
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build the child-process env for the `claude` CLI: an isolated config home plus subscription
|
|
211
|
+
* auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
|
|
212
|
+
* non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
|
|
213
|
+
* (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
|
|
214
|
+
* keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
|
|
215
|
+
*/
|
|
216
|
+
function buildClaudeEnv(
|
|
217
|
+
opts: ClaudeHomeOptions,
|
|
218
|
+
configHome: string | undefined,
|
|
219
|
+
): Record<string, string> {
|
|
220
|
+
// The job-scoped env rides along in BOTH modes; the credential/config vars below are what
|
|
221
|
+
// ambient mode drops (the developer's own logged-in `~/.claude` is used instead).
|
|
222
|
+
if (opts.ambientAuth) return { ...opts.extraEnv }
|
|
223
|
+
return {
|
|
224
|
+
...opts.extraEnv,
|
|
225
|
+
CLAUDE_CONFIG_DIR: configHome!,
|
|
226
|
+
...(opts.subscriptionBaseUrl
|
|
227
|
+
? {
|
|
228
|
+
ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
|
|
229
|
+
ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken!,
|
|
230
|
+
}
|
|
231
|
+
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/coding-agent.ts
CHANGED
|
@@ -66,6 +66,12 @@ import {
|
|
|
66
66
|
withPrTemplateNote,
|
|
67
67
|
type PrTemplateResolution,
|
|
68
68
|
} from './pr-template.js'
|
|
69
|
+
import {
|
|
70
|
+
describeSalvage,
|
|
71
|
+
salvageUntrackedWork,
|
|
72
|
+
type SalvageDelivery,
|
|
73
|
+
type SalvageReport,
|
|
74
|
+
} from './salvage.js'
|
|
69
75
|
|
|
70
76
|
// The shared skeleton for the container coding agents that clone a repo, run Pi
|
|
71
77
|
// against it and push the result on a branch. The implementation (`/run`) and
|
|
@@ -254,6 +260,13 @@ export interface CodingAgentOutcome {
|
|
|
254
260
|
* attached to a perfectly successful run and the PR still opens.
|
|
255
261
|
*/
|
|
256
262
|
reproductionReport?: ReproductionReport
|
|
263
|
+
/**
|
|
264
|
+
* What became of the new files the agent created and never committed. Absent means there were
|
|
265
|
+
* none to consider; `status: 'refused'` or `'failed'` means work was left behind and is NOT in
|
|
266
|
+
* the push, which the backend must be able to tell a human rather than presenting the run as a
|
|
267
|
+
* clean pass.
|
|
268
|
+
*/
|
|
269
|
+
salvage?: SalvageReport
|
|
257
270
|
}
|
|
258
271
|
|
|
259
272
|
/**
|
|
@@ -335,7 +348,7 @@ function createWorkBranchPusher(args: {
|
|
|
335
348
|
logger: Logger
|
|
336
349
|
signal: AbortSignal | undefined
|
|
337
350
|
}): {
|
|
338
|
-
pushWorkOnce: () => Promise<void>
|
|
351
|
+
pushWorkOnce: (override?: AbortSignal) => Promise<void>
|
|
339
352
|
inFlightPush: () => Promise<void> | null
|
|
340
353
|
checkpoint: ReturnType<typeof setInterval>
|
|
341
354
|
} {
|
|
@@ -345,10 +358,16 @@ function createWorkBranchPusher(args: {
|
|
|
345
358
|
// force push against. Starts unset even on a RESUMED branch: the tip we merely cloned is an
|
|
346
359
|
// earlier run's work, so a rewrite of it is refused (and re-driven) rather than forced away.
|
|
347
360
|
let publishedSha: string | undefined
|
|
348
|
-
|
|
361
|
+
// `override` replaces the RUN's signal for this one push. Every ordinary push rides the run's
|
|
362
|
+
// signal, so a watchdog kill stops it. The rescue push cannot: the run's signal is ABORTED on
|
|
363
|
+
// exactly the paths that need a rescue, and an aborted signal makes `execFile` reject before it
|
|
364
|
+
// spawns, so a rescue on it is a guaranteed no-op. See {@link withSalvagedWork}.
|
|
365
|
+
const pushWorkOnce = (override?: AbortSignal): Promise<void> => {
|
|
349
366
|
if (pushInFlight) return pushInFlight
|
|
367
|
+
const pushSignal = override ?? signal
|
|
350
368
|
pushInFlight = (async () => {
|
|
351
|
-
if (!(await unpublishedWorkBranchTip({ dir, baseSha, publishedSha, signal })))
|
|
369
|
+
if (!(await unpublishedWorkBranchTip({ dir, baseSha, publishedSha, signal: pushSignal })))
|
|
370
|
+
return
|
|
352
371
|
// The rule the lease is entitled to lives beside the push ({@link workBranchLease}); the
|
|
353
372
|
// warn is here, because a withheld lease is how a rewrite this pass cannot claim fails the
|
|
354
373
|
// push it is about to make, and the run's log is where that is read.
|
|
@@ -357,7 +376,7 @@ function createWorkBranchPusher(args: {
|
|
|
357
376
|
branch: spec.pushBranch,
|
|
358
377
|
baseSha,
|
|
359
378
|
publishedSha,
|
|
360
|
-
signal,
|
|
379
|
+
signal: pushSignal,
|
|
361
380
|
onWithheld: (probe) =>
|
|
362
381
|
logger.warn('coding-agent: push lease withheld, the branch dropped its pre-run tip', {
|
|
363
382
|
baseSha,
|
|
@@ -365,7 +384,7 @@ function createWorkBranchPusher(args: {
|
|
|
365
384
|
probe,
|
|
366
385
|
}),
|
|
367
386
|
})
|
|
368
|
-
publishedSha = await pushBranch(dir, spec.pushBranch, spec.ghToken,
|
|
387
|
+
publishedSha = await pushBranch(dir, spec.pushBranch, spec.ghToken, pushSignal, lease)
|
|
369
388
|
})().finally(() => {
|
|
370
389
|
pushInFlight = null
|
|
371
390
|
})
|
|
@@ -399,6 +418,50 @@ function createWorkBranchPusher(args: {
|
|
|
399
418
|
return { pushWorkOnce, inFlightPush, checkpoint }
|
|
400
419
|
}
|
|
401
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Exclude the harness's own sentinel files from this checkout's git, and start tailing the
|
|
423
|
+
* follow-up one when the run streams follow-ups.
|
|
424
|
+
*
|
|
425
|
+
* Each sentinel is a file the PLATFORM writes into the agent's cwd (its effort self-assessment,
|
|
426
|
+
* its PR briefing, its follow-up items), so a `git add -A` by the agent would commit the
|
|
427
|
+
* platform's own bookkeeping into a customer's pull request. The exclude goes in
|
|
428
|
+
* `.git/info/exclude`, which is per-clone and never lands in the repo. `readEffortReport` also
|
|
429
|
+
* removes its file after the run, but that cannot un-stage a mid-run commit; only the exclude
|
|
430
|
+
* prevents one. A bare filename pattern matches at any depth, so a monorepo `workDir` is covered.
|
|
431
|
+
*
|
|
432
|
+
* The caller owns the returned interval's lifetime (it clears `followUpTick`). Extracted from
|
|
433
|
+
* {@link runCodingAgent} for the per-function line budget.
|
|
434
|
+
*/
|
|
435
|
+
async function armCheckoutSentinels(args: {
|
|
436
|
+
dir: string
|
|
437
|
+
workDir: string
|
|
438
|
+
spec: CodingAgentSpec
|
|
439
|
+
logger: Logger
|
|
440
|
+
opts: RunOptions
|
|
441
|
+
}): Promise<{
|
|
442
|
+
followUpTailer: FollowUpTailer | undefined
|
|
443
|
+
followUpTick: ReturnType<typeof setInterval> | undefined
|
|
444
|
+
}> {
|
|
445
|
+
const { dir, workDir, spec, logger, opts } = args
|
|
446
|
+
const { signal } = opts
|
|
447
|
+
await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
|
|
448
|
+
await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
|
|
449
|
+
|
|
450
|
+
// The follow-up sentinel lives in the agent's working directory (its cwd), where the prompt
|
|
451
|
+
// tells it to write; the other two are read from both the checkout root and the cwd.
|
|
452
|
+
const followUpTailer =
|
|
453
|
+
spec.streamFollowUps && opts.onFollowUp
|
|
454
|
+
? new FollowUpTailer(join(workDir, FOLLOW_UPS_FILENAME), opts.onFollowUp, logger)
|
|
455
|
+
: undefined
|
|
456
|
+
if (!followUpTailer) return { followUpTailer: undefined, followUpTick: undefined }
|
|
457
|
+
await excludeFromGit(dir, FOLLOW_UPS_FILENAME, signal)
|
|
458
|
+
const followUpTick = setInterval(() => {
|
|
459
|
+
void followUpTailer.poll()
|
|
460
|
+
}, followUpPollIntervalMs())
|
|
461
|
+
followUpTick.unref?.()
|
|
462
|
+
return { followUpTailer, followUpTick }
|
|
463
|
+
}
|
|
464
|
+
|
|
402
465
|
export async function runCodingAgent(
|
|
403
466
|
spec: CodingAgentSpec,
|
|
404
467
|
opts: RunOptions = {},
|
|
@@ -435,33 +498,16 @@ export async function runCodingAgent(
|
|
|
435
498
|
const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir
|
|
436
499
|
if (serviceDirectory) await mkdir(workDir, { recursive: true })
|
|
437
500
|
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
// Follow-up companion: tail the Coder's sentinel file and stream new items out on the
|
|
450
|
-
// job view. Locally exclude it from git first so the agent's own `git add` can never
|
|
451
|
-
// stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
|
|
452
|
-
// lives in the agent's working directory (its cwd), where the prompt tells it to write.
|
|
453
|
-
const followUpTailer =
|
|
454
|
-
spec.streamFollowUps && opts.onFollowUp
|
|
455
|
-
? new FollowUpTailer(join(workDir, FOLLOW_UPS_FILENAME), opts.onFollowUp, logger)
|
|
456
|
-
: undefined
|
|
457
|
-
let followUpTick: ReturnType<typeof setInterval> | undefined
|
|
458
|
-
if (followUpTailer) {
|
|
459
|
-
await excludeFromGit(dir, FOLLOW_UPS_FILENAME, signal)
|
|
460
|
-
followUpTick = setInterval(() => {
|
|
461
|
-
void followUpTailer.poll()
|
|
462
|
-
}, followUpPollIntervalMs())
|
|
463
|
-
followUpTick.unref?.()
|
|
464
|
-
}
|
|
501
|
+
// The harness's own side-channel files in this checkout: excluded from git so the agent's
|
|
502
|
+
// `git add` can never stage one into the PR, and the follow-up one tailed while the agent
|
|
503
|
+
// works. See {@link armCheckoutSentinels}.
|
|
504
|
+
const { followUpTailer, followUpTick } = await armCheckoutSentinels({
|
|
505
|
+
dir,
|
|
506
|
+
workDir,
|
|
507
|
+
spec,
|
|
508
|
+
logger,
|
|
509
|
+
opts,
|
|
510
|
+
})
|
|
465
511
|
|
|
466
512
|
// DEPENDENCY PREPOPULATION: install the service's dependencies into the checkout BEFORE the
|
|
467
513
|
// agent's first turn, so it reads real packages instead of inferring capabilities from a
|
|
@@ -639,6 +685,23 @@ export async function runCodingAgent(
|
|
|
639
685
|
agentRun,
|
|
640
686
|
prTemplate,
|
|
641
687
|
})
|
|
688
|
+
} catch (error) {
|
|
689
|
+
// The run was killed mid-flight: the progress guard tripped, a watchdog fired, or the
|
|
690
|
+
// container is going away. Everything the agent had not committed dies with the checkout,
|
|
691
|
+
// and on a greenfield task that is all of it. Salvage it onto the work branch and push,
|
|
692
|
+
// so a retry resumes on top of the work instead of starting over.
|
|
693
|
+
//
|
|
694
|
+
// Best-effort and non-masking: the ORIGINAL failure is what the run reports, so a salvage
|
|
695
|
+
// that itself fails may not replace it. What the salvage found is joined onto that
|
|
696
|
+
// failure's message instead, because "the run was aborted" and "its work is on the branch,
|
|
697
|
+
// reviewed by nobody" are one fact a person needs together.
|
|
698
|
+
//
|
|
699
|
+
// The checkpoint is stopped HERE rather than only in the `finally` below: it fires
|
|
700
|
+
// `pushWorkOnce`, which coalesces, so a checkpoint starting behind the rescue would be
|
|
701
|
+
// handed the rescue's push and a rescue starting behind a checkpoint would be handed a
|
|
702
|
+
// push made BEFORE the salvage commit existed — reporting as pushed a commit that is not.
|
|
703
|
+
clearInterval(checkpoint)
|
|
704
|
+
throw await withSalvagedWork(error, { dir, logger, pushWorkOnce, inFlightPush })
|
|
642
705
|
} finally {
|
|
643
706
|
// Safety net for the throw path (the happy path already cleared these above).
|
|
644
707
|
clearInterval(checkpoint)
|
|
@@ -649,6 +712,141 @@ export async function runCodingAgent(
|
|
|
649
712
|
)
|
|
650
713
|
}
|
|
651
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Prefix a run's summary with the salvage note, when there is one worth a human's attention.
|
|
717
|
+
*
|
|
718
|
+
* The test is the same in all three cases: did the agent produce something the push does NOT
|
|
719
|
+
* carry, which nothing else on a passing run would say. A refused or failed salvage is that, and
|
|
720
|
+
* so is a withheld secret-bearing file — the run looks clean and the file is not on the branch.
|
|
721
|
+
* A salvage that simply worked needs no note here: its files ARE in the push, and the commit
|
|
722
|
+
* message on the branch says where they came from.
|
|
723
|
+
*/
|
|
724
|
+
function withSalvageNote(summary: string, salvage: SalvageReport): string {
|
|
725
|
+
const missedWork = salvage.status === 'refused' || salvage.status === 'failed'
|
|
726
|
+
if (!missedWork && (salvage.withheld?.length ?? 0) === 0) return summary
|
|
727
|
+
const note = describeSalvage(salvage)
|
|
728
|
+
return note ? `${note}\n\n${summary}` : summary
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* How long the rescue of an aborted run's work gets, on its own clock.
|
|
733
|
+
*
|
|
734
|
+
* Bounded because the run's own bounds no longer apply: the rescue deliberately runs OFF the run's
|
|
735
|
+
* signal (see {@link rescueSignal}), so without this a wedged git command would hold the container
|
|
736
|
+
* open until the platform reclaims it. Generous enough for a status, an add, a commit and a push
|
|
737
|
+
* over a slow network, and each git command inside it still carries its own tighter ceiling.
|
|
738
|
+
* Overridable via env for tests.
|
|
739
|
+
*/
|
|
740
|
+
function salvageRescueMs(): number {
|
|
741
|
+
const n = Number(process.env.JOB_SALVAGE_RESCUE_MS)
|
|
742
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 120_000
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* The signal the rescue runs on: a FRESH one, never the run's.
|
|
747
|
+
*
|
|
748
|
+
* The rescue exists for the runs whose lifetime is already over — a watchdog fired, the guard
|
|
749
|
+
* tripped, the container is being evicted — and on those paths `opts.signal` is ABORTED. Node's
|
|
750
|
+
* `execFile` rejects on an already-aborted signal before it spawns anything, so passing it here
|
|
751
|
+
* makes every git call in the salvage and its push fail instantly: the rescue would be a
|
|
752
|
+
* guaranteed no-op in precisely the cases it was written for. Its own timeout is what bounds it
|
|
753
|
+
* instead.
|
|
754
|
+
*/
|
|
755
|
+
function rescueSignal(): AbortSignal {
|
|
756
|
+
return AbortSignal.timeout(salvageRescueMs())
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Salvage what an aborted run left uncommitted, push it, and return the error to rethrow with the
|
|
761
|
+
* salvage stated on it.
|
|
762
|
+
*
|
|
763
|
+
* Returns rather than throws so the caller's `throw` stays visible at the call site, and so this
|
|
764
|
+
* can never REPLACE the failure being reported: a salvage that throws is swallowed, because the
|
|
765
|
+
* reason the run died is strictly more useful than the reason its rescue did.
|
|
766
|
+
*
|
|
767
|
+
* The push is what makes the salvage worth anything — the commit lives in a container that is
|
|
768
|
+
* about to be reclaimed — and it is reported HONESTLY: a commit that could not be pushed is lost
|
|
769
|
+
* exactly as the uncommitted files would have been, so the note says so rather than naming a sha
|
|
770
|
+
* nobody will ever be able to fetch.
|
|
771
|
+
*
|
|
772
|
+
* Two things have to happen before the salvage, and the caller has already stopped the checkpoint
|
|
773
|
+
* interval for the first. The second is here: any push the checkpoint had IN FLIGHT is drained,
|
|
774
|
+
* because `pushWorkOnce` coalesces onto it and would otherwise hand the rescue a push that was
|
|
775
|
+
* made before the salvage commit existed.
|
|
776
|
+
*
|
|
777
|
+
* Exported for its test: every collaborator it needs is a parameter, so the ordering and the
|
|
778
|
+
* signal it pushes on can be asserted against a real repository without a container.
|
|
779
|
+
*/
|
|
780
|
+
export async function withSalvagedWork(
|
|
781
|
+
error: unknown,
|
|
782
|
+
args: {
|
|
783
|
+
dir: string
|
|
784
|
+
logger: Logger
|
|
785
|
+
pushWorkOnce: (override?: AbortSignal) => Promise<void>
|
|
786
|
+
inFlightPush: () => Promise<void> | null
|
|
787
|
+
},
|
|
788
|
+
): Promise<unknown> {
|
|
789
|
+
const cause = error instanceof Error ? error.message : String(error)
|
|
790
|
+
const signal = rescueSignal()
|
|
791
|
+
await drainInFlightPush(args.inFlightPush, args.logger)
|
|
792
|
+
const note = await salvageUntrackedWork({
|
|
793
|
+
dir: args.dir,
|
|
794
|
+
occasion: { kind: 'aborted', cause },
|
|
795
|
+
logger: args.logger,
|
|
796
|
+
signal,
|
|
797
|
+
})
|
|
798
|
+
.then(async (report) => {
|
|
799
|
+
if (report.status !== 'committed') return describeSalvage(report)
|
|
800
|
+
return describeSalvage(report, await deliverSalvage(args, signal))
|
|
801
|
+
})
|
|
802
|
+
.catch((salvageError: unknown) => {
|
|
803
|
+
args.logger.error('coding-agent: salvage of an aborted run failed', {
|
|
804
|
+
reason: salvageError instanceof Error ? salvageError.message : String(salvageError),
|
|
805
|
+
})
|
|
806
|
+
return undefined
|
|
807
|
+
})
|
|
808
|
+
if (!note) return error
|
|
809
|
+
if (error instanceof Error) {
|
|
810
|
+
error.message = `${error.message} ${note}`
|
|
811
|
+
return error
|
|
812
|
+
}
|
|
813
|
+
return new Error(`${cause} ${note}`)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Wait out the checkpoint push already running, so the rescue's own push is not coalesced onto it.
|
|
818
|
+
*
|
|
819
|
+
* Its outcome is irrelevant and never propagates: it was a best-effort checkpoint of work that
|
|
820
|
+
* predates the salvage, and the rescue is about to push again anyway.
|
|
821
|
+
*/
|
|
822
|
+
async function drainInFlightPush(
|
|
823
|
+
inFlightPush: () => Promise<void> | null,
|
|
824
|
+
logger: Logger,
|
|
825
|
+
): Promise<void> {
|
|
826
|
+
const pending = inFlightPush()
|
|
827
|
+
if (!pending) return
|
|
828
|
+
await pending.catch((error: unknown) => {
|
|
829
|
+
logger.warn('coding-agent: the checkpoint push in flight at the abort did not land', {
|
|
830
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
831
|
+
})
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Push the salvage commit, reporting whether it actually landed rather than assuming it did. */
|
|
836
|
+
async function deliverSalvage(
|
|
837
|
+
args: { logger: Logger; pushWorkOnce: (override?: AbortSignal) => Promise<void> },
|
|
838
|
+
signal: AbortSignal,
|
|
839
|
+
): Promise<SalvageDelivery> {
|
|
840
|
+
try {
|
|
841
|
+
await args.pushWorkOnce(signal)
|
|
842
|
+
return { pushed: true }
|
|
843
|
+
} catch (error) {
|
|
844
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
845
|
+
args.logger.error('coding-agent: the salvage commit could not be pushed', { reason })
|
|
846
|
+
return { pushed: false, reason }
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
652
850
|
/**
|
|
653
851
|
* Clone (or RESUME an existing branch) into `dir`, fetch any read-only reference branches, and
|
|
654
852
|
* capture the pre-run branch tip. Extracted from {@link runCodingAgent} so its body stays small;
|
|
@@ -812,7 +1010,7 @@ async function finalizeCodingRun(args: {
|
|
|
812
1010
|
prTemplate,
|
|
813
1011
|
} = args
|
|
814
1012
|
const { signal } = opts
|
|
815
|
-
const {
|
|
1013
|
+
const { stats, stderrTail, usage, callMetrics, effortReport } = agentRun
|
|
816
1014
|
let outcome: CodingAgentOutcome
|
|
817
1015
|
|
|
818
1016
|
// Stop tailing the follow-up sentinel and flush any items written after the last
|
|
@@ -845,17 +1043,25 @@ async function finalizeCodingRun(args: {
|
|
|
845
1043
|
const inflight = inFlightPush()
|
|
846
1044
|
if (inflight) await inflight.catch(() => {})
|
|
847
1045
|
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
858
|
-
|
|
1046
|
+
// Recover the untracked, non-ignored files the agent left behind. `commitTrackedEdits` above
|
|
1047
|
+
// only captures edits to ALREADY tracked files, so a NEW file the agent created and forgot to
|
|
1048
|
+
// commit used to be listed, warned about and dropped — and on a greenfield task EVERY file is
|
|
1049
|
+
// new, which made that warning the whole deliverable going in the bin. Observable is not
|
|
1050
|
+
// recovered, so commit them. Guardrails (a dependency/build deny-list, a file-count and byte
|
|
1051
|
+
// bound, an all-or-nothing refusal over it) live in `salvage.ts`; this path is coding mode by
|
|
1052
|
+
// construction, which is the other rule it must obey.
|
|
1053
|
+
const salvage = await salvageUntrackedWork({
|
|
1054
|
+
dir,
|
|
1055
|
+
occasion: { kind: 'settled' },
|
|
1056
|
+
logger,
|
|
1057
|
+
...(signal ? { signal } : {}),
|
|
1058
|
+
})
|
|
1059
|
+
// A salvage that COMMITTED needs no announcement: its files are in the push and its commit
|
|
1060
|
+
// message says where they came from. A refused or failed one means work the agent produced is
|
|
1061
|
+
// NOT in the pull request, on a run that otherwise reads as a clean pass — so say it in the
|
|
1062
|
+
// summary, which is the harness's own account of the run and already reaches the step a human
|
|
1063
|
+
// reads. The agent's text follows it, unchanged.
|
|
1064
|
+
const summary = withSalvageNote(agentRun.summary, salvage)
|
|
859
1065
|
|
|
860
1066
|
// A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
|
|
861
1067
|
// run already carries prior work — UNLESS that branch turns out to have nothing ahead
|
|
@@ -886,6 +1092,7 @@ async function finalizeCodingRun(args: {
|
|
|
886
1092
|
...(usage ? { usage } : {}),
|
|
887
1093
|
...(callMetrics ? { callMetrics } : {}),
|
|
888
1094
|
...(effortReport ? { effortReport } : {}),
|
|
1095
|
+
...(salvage.status === 'none' ? {} : { salvage }),
|
|
889
1096
|
}
|
|
890
1097
|
} else {
|
|
891
1098
|
opts.onPhase?.('push')
|
|
@@ -901,6 +1108,7 @@ async function finalizeCodingRun(args: {
|
|
|
901
1108
|
...(callMetrics ? { callMetrics } : {}),
|
|
902
1109
|
...(effortReport ? { effortReport } : {}),
|
|
903
1110
|
...(prDescription ? { prDescription } : {}),
|
|
1111
|
+
...(salvage.status === 'none' ? {} : { salvage }),
|
|
904
1112
|
}
|
|
905
1113
|
}
|
|
906
1114
|
|