@cat-factory/executor-harness 1.31.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/agent-runner.js +389 -0
  4. package/dist/agent.js +810 -0
  5. package/dist/blueprint.js +367 -0
  6. package/dist/bootstrap.js +99 -0
  7. package/dist/ci-fixer.js +46 -0
  8. package/dist/coding-agent.js +285 -0
  9. package/dist/conflict-resolver.js +138 -0
  10. package/dist/embed.js +8 -0
  11. package/dist/explore.js +74 -0
  12. package/dist/failure.js +47 -0
  13. package/dist/fixer.js +44 -0
  14. package/dist/follow-ups.js +103 -0
  15. package/dist/frontend-infra.js +283 -0
  16. package/dist/fs-utils.js +11 -0
  17. package/dist/git.js +778 -0
  18. package/dist/job.js +409 -0
  19. package/dist/logger.js +27 -0
  20. package/dist/merger.js +135 -0
  21. package/dist/on-call.js +126 -0
  22. package/dist/pi-workspace.js +237 -0
  23. package/dist/pi.js +971 -0
  24. package/dist/process.js +25 -0
  25. package/dist/redact.js +109 -0
  26. package/dist/runner.js +228 -0
  27. package/dist/server.js +135 -0
  28. package/dist/spec.js +754 -0
  29. package/dist/structured-output.js +431 -0
  30. package/dist/tester.js +191 -0
  31. package/package.json +35 -0
  32. package/src/agent-runner.ts +484 -0
  33. package/src/agent.ts +948 -0
  34. package/src/coding-agent.ts +393 -0
  35. package/src/embed.ts +32 -0
  36. package/src/failure.ts +73 -0
  37. package/src/follow-ups.ts +106 -0
  38. package/src/frontend-infra.ts +340 -0
  39. package/src/fs-utils.ts +11 -0
  40. package/src/git.ts +955 -0
  41. package/src/job.ts +766 -0
  42. package/src/logger.ts +45 -0
  43. package/src/pi-workspace.ts +348 -0
  44. package/src/pi.ts +1236 -0
  45. package/src/process.ts +33 -0
  46. package/src/redact.ts +109 -0
  47. package/src/runner.ts +384 -0
  48. package/src/server.ts +153 -0
  49. package/src/structured-output.ts +524 -0
@@ -0,0 +1,126 @@
1
+ import { cloneRepo } from './git.js';
2
+ import { extractJsonObject } from './blueprint.js';
3
+ import { agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
4
+ import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
5
+ import { log } from './logger.js';
6
+ // Async job execution for the on-call agent. The engine dispatches this when the
7
+ // post-release-health gate detects a Datadog regression. The released PR has already
8
+ // merged and its work branch was deleted, so we clone the BASE branch (which contains
9
+ // the merged change), have Pi locate the merged commit (via the PR number / the
10
+ // now-historical head branch) and correlate its diff with the regression evidence
11
+ // (handed in via the user prompt), then return ONLY a JSON assessment of whether THIS
12
+ // change is the likely culprit. The on-call agent makes NO commits and reverts nothing —
13
+ // the engine raises a `release_regression` notification carrying this assessment.
14
+ const ASSESSMENT_SHAPE_HINT = 'Expected an on-call assessment: {"culpritConfidence": number 0..1, "recommendation": ' +
15
+ '"revert"|"hold"|"monitor", "rationale": string, "evidence": string[]}.';
16
+ function clamp01(value, fallback) {
17
+ const n = typeof value === 'number' ? value : Number(value);
18
+ if (!Number.isFinite(n))
19
+ return fallback;
20
+ return Math.min(1, Math.max(0, n));
21
+ }
22
+ function coerceRecommendation(value) {
23
+ return value === 'revert' || value === 'monitor' ? value : 'hold';
24
+ }
25
+ /**
26
+ * Coerce the agent's JSON into a well-formed assessment. A missing confidence defaults
27
+ * to a CONSERVATIVE 0 (don't imply the PR is at fault without evidence); a missing
28
+ * recommendation defaults to `hold` (human decides). Returns null only when no JSON
29
+ * object could be extracted at all.
30
+ */
31
+ function coerceAssessment(raw, summary) {
32
+ if (typeof raw !== 'object' || raw === null)
33
+ return null;
34
+ const o = raw;
35
+ const evidence = Array.isArray(o.evidence)
36
+ ? o.evidence.filter((e) => typeof e === 'string')
37
+ : [];
38
+ return {
39
+ culpritConfidence: clamp01(o.culpritConfidence, 0),
40
+ recommendation: coerceRecommendation(o.recommendation),
41
+ rationale: typeof o.rationale === 'string' && o.rationale ? o.rationale : summary.slice(0, 2000),
42
+ evidence,
43
+ };
44
+ }
45
+ function buildUserPrompt(job) {
46
+ const pr = job.prNumber !== undefined ? `#${job.prNumber}` : '';
47
+ // The PR has already merged into the base branch and its work branch was deleted, so the
48
+ // checkout is the base branch. Point the agent at how to find the merged commit.
49
+ const locate = job.prNumber
50
+ ? `It merged as a commit referencing ${pr} — find it with ` +
51
+ `\`git log --oneline -n 50\` (squash/merge commits include \`(${pr})\`; a merge commit ` +
52
+ `mentions \`#${job.prNumber}\`), then inspect it with \`git show <sha>\`.`
53
+ : job.headBranch
54
+ ? `Its work branch was \`${job.headBranch}\` (now deleted) — find the merged commit in ` +
55
+ `\`git log --oneline -n 50\` and inspect it with \`git show <sha>\`.`
56
+ : `Find the most recent merge/feature commit with \`git log --oneline -n 50\` and inspect ` +
57
+ `it with \`git show <sha>\`.`;
58
+ return [
59
+ job.userPrompt,
60
+ '',
61
+ `You are on the base branch \`${job.repo.baseBranch}\`, which already contains the released ` +
62
+ `pull request ${pr}. ${locate} Correlate that change with the regression evidence above. ` +
63
+ `Beware correlation vs causation.`,
64
+ '',
65
+ 'Respond with ONLY a JSON object {"culpritConfidence":0.0,"recommendation":"revert"|"hold"|"monitor","rationale":"…","evidence":["…"]}.',
66
+ ].join('\n');
67
+ }
68
+ /** Run one on-call job: clone branch → Pi investigates → return the assessment (no commit). */
69
+ export async function handleOnCall(job, opts = {}) {
70
+ const trace = { jobId: job.jobId, repo: `${job.repo.owner}/${job.repo.name}`, branch: job.branch };
71
+ return withWorkspace('on-call', async (dir) => {
72
+ log.info('on-call: cloning base branch', trace);
73
+ await cloneRepo({
74
+ repo: { ...job.repo, baseBranch: job.branch },
75
+ ghToken: job.ghToken,
76
+ dir,
77
+ // Full clone so the agent has the history to locate + diff the merged commit.
78
+ full: true,
79
+ signal: opts.signal,
80
+ });
81
+ log.info('on-call: running agent', trace);
82
+ const { summary, stats, stderrTail, usage } = await runAgentInWorkspace({
83
+ dir,
84
+ systemPrompt: job.systemPrompt,
85
+ userPrompt: buildUserPrompt(job),
86
+ model: job.model,
87
+ harness: job.harness,
88
+ subscriptionToken: job.subscriptionToken,
89
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
90
+ proxyBaseUrl: job.proxyBaseUrl,
91
+ sessionToken: job.sessionToken,
92
+ // Investigation only — no commits/edits, so the no-edit guard must not fire.
93
+ expectsEdits: false,
94
+ }, opts);
95
+ const { value: assessment, diagnostics } = await resolveStructuredOutput({
96
+ label: 'on-call',
97
+ shapeHint: ASSESSMENT_SHAPE_HINT,
98
+ parse: (text) => coerceAssessment(extractJsonObject(text), text),
99
+ }, summary, {
100
+ harness: job.harness,
101
+ subscriptionToken: job.subscriptionToken,
102
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
103
+ proxyBaseUrl: job.proxyBaseUrl,
104
+ sessionToken: job.sessionToken,
105
+ model: job.model,
106
+ jobId: job.jobId,
107
+ signal: opts.signal,
108
+ });
109
+ if (!assessment) {
110
+ return {
111
+ summary,
112
+ stats,
113
+ error: noAssessmentReason(stats, stderrTail, diagnostics),
114
+ ...(usage ? { usage } : {}),
115
+ };
116
+ }
117
+ log.info('on-call: assessed', { ...trace, ...assessment });
118
+ return { onCallAssessment: assessment, summary, stats, ...(usage ? { usage } : {}) };
119
+ });
120
+ }
121
+ function noAssessmentReason(stats, stderrTail, diagnostics) {
122
+ const cause = agentNeverActed(stats)
123
+ ? NEVER_ACTED_CAUSE
124
+ : ' The agent did not return a parseable JSON assessment.';
125
+ return `On-call produced no assessment.${cause}${diagnostics ? diagnosticsSuffix(diagnostics) : ''}${agentOutputTail(stderrTail)}`;
126
+ }
@@ -0,0 +1,237 @@
1
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { CONTEXT_DIR, materializeContextFiles, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
5
+ import { runSubscriptionHarness } from './agent-runner.js';
6
+ // The thin base every container agent shares: an ephemeral working directory, and
7
+ // one Pi run inside it driven by the harness-written context. The agents differ in
8
+ // how the directory is prepared (clone a branch, scaffold from scratch, read files
9
+ // to build the prompt) and what they do with the result (push a branch, open a PR,
10
+ // render files, return JSON) — but the middle (write AGENTS.md + provider config,
11
+ // run Pi, tear the workspace down) is identical, so it lives here once. Carries no
12
+ // secrets beyond the call: the per-job tokens arrive in the spec and are gone when
13
+ // the workspace is removed.
14
+ /**
15
+ * Run `fn` against a fresh temp working directory, always removing it afterwards
16
+ * (even on throw). `prefix` labels the directory (e.g. 'impl', 'merge').
17
+ */
18
+ export async function withWorkspace(prefix, fn) {
19
+ const dir = await mkdtemp(join(tmpdir(), `${prefix}-`));
20
+ try {
21
+ return await fn(dir);
22
+ }
23
+ finally {
24
+ await rm(dir, { recursive: true, force: true });
25
+ }
26
+ }
27
+ /**
28
+ * The PERSISTENT-checkout root in a reused (pooled) container — a stable per-repo
29
+ * directory that survives across jobs so a new run can `git fetch` + switch branch
30
+ * instead of cloning from scratch. Only the local warm-pool transport activates this
31
+ * (by setting `persistentCheckout` on the job); every other runtime uses the ephemeral
32
+ * {@link withWorkspace} path, so this code is dormant there.
33
+ */
34
+ function persistentWorkspaceRoot() {
35
+ return process.env.HARNESS_WORKSPACE_ROOT?.trim() || '/workspace';
36
+ }
37
+ /** Sanitise an owner/name path segment so a repo identity can never escape the root. */
38
+ function safeSegment(value) {
39
+ return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
40
+ }
41
+ // A per-directory async mutex: two jobs that land in the same container share ONE
42
+ // persistent checkout, so they must not mutate its working tree concurrently. The
43
+ // engine runs a run's steps sequentially, so contention is rare — this is correctness
44
+ // insurance (and keeps a stray concurrent dispatch from corrupting the tree).
45
+ const dirLocks = new Map();
46
+ async function withDirLock(dir, fn) {
47
+ const prev = dirLocks.get(dir) ?? Promise.resolve();
48
+ let release;
49
+ const current = new Promise((resolve) => {
50
+ release = resolve;
51
+ });
52
+ // Store the SAME promise we await on for cleanup-identity. (Storing `prev.then(...)`
53
+ // instead would make the tail check below — `=== tail` — never match, so the entry
54
+ // would never be deleted and the map would grow without bound.)
55
+ const tail = prev.then(() => current);
56
+ dirLocks.set(dir, tail);
57
+ await prev.catch(() => { });
58
+ try {
59
+ return await fn();
60
+ }
61
+ finally {
62
+ release();
63
+ // Drop the entry once we're the tail (no later caller has queued behind us), so the
64
+ // map doesn't grow unbounded across distinct repo dirs.
65
+ if (dirLocks.get(dir) === tail)
66
+ dirLocks.delete(dir);
67
+ }
68
+ }
69
+ /**
70
+ * Run `fn` against a STABLE per-repo working directory (`<root>/<owner>/<repo>`) that is
71
+ * NOT removed afterwards — the persistent-checkout analogue of {@link withWorkspace}. The
72
+ * caller (via `prepareExistingCheckout`) clean-sweeps + fetches the dir into the right
73
+ * state before use; serialised per dir so concurrent jobs can't corrupt the shared tree.
74
+ */
75
+ export async function withPersistentWorkspace(repo, fn) {
76
+ const dir = join(persistentWorkspaceRoot(), safeSegment(repo.owner), safeSegment(repo.name));
77
+ return withDirLock(dir, async () => {
78
+ await mkdir(dir, { recursive: true });
79
+ return fn(dir);
80
+ });
81
+ }
82
+ /**
83
+ * Acquire a working directory for a run: a STABLE, reused per-repo checkout when the job
84
+ * opted into persistent checkout (the warm-pool path), else a fresh ephemeral temp dir
85
+ * (every other runtime). The two flows differ ONLY in dir lifecycle — the caller populates
86
+ * the dir (clone vs `prepareExistingCheckout`) itself, so it can keep its flow-specific
87
+ * resume / full-clone / branch logic.
88
+ */
89
+ export async function acquireRepoCheckout(opts, fn) {
90
+ if (opts.persistent)
91
+ return withPersistentWorkspace(opts.repo, fn);
92
+ return withWorkspace(opts.prefix, fn);
93
+ }
94
+ /**
95
+ * Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
96
+ * then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
97
+ * lives outside the checkout so it never lands in a commit; the shared middle of
98
+ * every container agent.
99
+ */
100
+ export async function runAgentInWorkspace(spec, opts = {}) {
101
+ // Materialise any backend-prepared linked context into the checkout up front, so the
102
+ // agent (which can't reach Jira/GitHub) reads it on demand from disk. Shared by both
103
+ // harness paths; kept out of the agent's commits via a local git exclude entry.
104
+ const contextFiles = spec.contextFiles ?? [];
105
+ await materializeContextFiles(spec.dir, contextFiles);
106
+ // Subscription harnesses (Claude Code / Codex) authenticate with the leased
107
+ // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
108
+ // system prompt is passed straight to the CLI; everything around this (clone,
109
+ // push, watchdogs) is unchanged.
110
+ if (spec.harness === 'claude-code' || spec.harness === 'codex') {
111
+ // Ambient (native) mode authenticates with the developer's own CLI login, so no
112
+ // leased token is required; otherwise the leased subscription token is mandatory.
113
+ if (!spec.ambientAuth && !spec.subscriptionToken) {
114
+ throw new Error(`The ${spec.harness} harness requires a subscription token`);
115
+ }
116
+ return runSubscriptionHarness(spec.harness, {
117
+ cwd: spec.dir,
118
+ model: spec.model,
119
+ systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles),
120
+ userPrompt: spec.userPrompt,
121
+ ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
122
+ subscriptionBaseUrl: spec.subscriptionBaseUrl,
123
+ ...(spec.ambientAuth ? { ambientAuth: true } : {}),
124
+ signal: opts.signal,
125
+ onActivity: opts.onActivity,
126
+ onProgress: opts.onProgress,
127
+ });
128
+ }
129
+ if (!spec.proxyBaseUrl || !spec.sessionToken) {
130
+ throw new Error('The Pi harness requires proxyBaseUrl and sessionToken');
131
+ }
132
+ const proxyBaseUrl = spec.proxyBaseUrl;
133
+ const sessionToken = spec.sessionToken;
134
+ // Opt-in web search/fetch (rpiv-web-tools). Two ways it turns on, both no-ops by
135
+ // default:
136
+ // - proxy-backed (the Cloudflare/managed path): the backend set `webSearchProxy`,
137
+ // so point the SearXNG provider at `${proxyBaseUrl}/web-search` with the session
138
+ // token — the search runs server-side, no provider key in the sandbox.
139
+ // - direct (the self-hosted runner-pool path): a provider key is present in the
140
+ // container env, which `webSearchConfigFromEnv` autodetects.
141
+ // The proxy vars are handed to Pi's child via `extraEnv` (not the harness's own
142
+ // process.env), so detection runs against the same merged view the extension sees.
143
+ const extraEnv = spec.webSearchProxy
144
+ ? webSearchProxyEnv(proxyBaseUrl, sessionToken)
145
+ : {};
146
+ const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv });
147
+ if (webSearch)
148
+ await writeWebToolsConfig(webSearch);
149
+ await writeAgentsContext(spec.systemPrompt, {
150
+ webSearch: Boolean(webSearch),
151
+ guidance: spec.webToolsGuidance,
152
+ serviceDirectory: spec.serviceDirectory,
153
+ contextFiles,
154
+ });
155
+ await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
156
+ const { signal, onActivity, onProgress, onSpan } = opts;
157
+ return runPi({
158
+ cwd: spec.dir,
159
+ model: spec.model,
160
+ userPrompt: spec.userPrompt,
161
+ sessionToken,
162
+ signal,
163
+ onActivity,
164
+ onProgress,
165
+ onSpan,
166
+ expectsEdits: spec.expectsEdits ?? true,
167
+ // Start from the env/built-in defaults and apply only the per-knob overrides the
168
+ // backend set for this kind (loosen-only), so an unspecified knob keeps its default.
169
+ guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
170
+ extraEnv,
171
+ });
172
+ }
173
+ /**
174
+ * Append a pointer to the materialised linked context onto a subscription harness's
175
+ * system prompt. The Pi harness surfaces this via AGENTS.md, but Claude Code / Codex
176
+ * take the system prompt straight, so the note has to ride along here. '' files ⇒ the
177
+ * prompt is returned unchanged.
178
+ */
179
+ function subscriptionSystemPrompt(systemPrompt, files) {
180
+ if (!files.length)
181
+ return systemPrompt;
182
+ const list = files.map((f) => `- ${CONTEXT_DIR}/${f.path} — ${f.title}`).join('\n');
183
+ return `${systemPrompt}
184
+
185
+ Linked context (requirements / RFCs / PRDs / tracker issues) for this task is in the
186
+ ${CONTEXT_DIR}/ directory of your checkout — read a file when relevant. Do NOT try to reach
187
+ external systems; everything available is already on disk:
188
+ ${list}`;
189
+ }
190
+ /**
191
+ * True when Pi exited cleanly without a single tool call or token of output — the
192
+ * signature of a run where it never reached the model. Used by every agent's
193
+ * no-op reason to point at the most likely cause (an unreachable proxy / rejected
194
+ * model) rather than a genuine "nothing to do".
195
+ */
196
+ export function agentNeverActed(stats) {
197
+ return stats.toolCalls === 0 && stats.assistantChars === 0;
198
+ }
199
+ /** The full-sentence "never acted" cause shared by the structured no-op reasons. */
200
+ export const NEVER_ACTED_CAUSE = ' The agent never acted (no tool calls, no model output) — it most likely could not reach the model.';
201
+ /**
202
+ * A human-readable cause when the agent's FINAL answer is unusable — its last turn was
203
+ * cut off at the output ceiling, or carried no text at all (an empty completion) — or
204
+ * `undefined` when the final answer looks fine.
205
+ *
206
+ * This is OPT-IN per agent, never a blanket harness rule. Only agents whose work
207
+ * product is a final text/document the pipeline hands ONWARD to be reviewed or parsed
208
+ * (the spec-writer, the blueprinter) should treat a non-undefined result as a hard
209
+ * failure — for them an empty/cut-off final turn means there is nothing trustworthy to
210
+ * review, which is exactly what drove the spec-writer ⇄ companion rework loop. Agents
211
+ * whose product is a side effect (a pushed PR/commit from the coder or ci-fixer, a
212
+ * self-contained validation) legitimately end with no final text and MUST NOT call this.
213
+ */
214
+ export function unusableFinalAnswerCause(diagnostics) {
215
+ if (!diagnostics)
216
+ return undefined;
217
+ if (diagnostics.finalTruncated) {
218
+ return 'its final answer hit the output-token ceiling and was cut off (raise the limit or narrow the task)';
219
+ }
220
+ if (diagnostics.finalAnswerEmpty) {
221
+ return 'its final turn produced no text (an empty completion), so there is no document to read';
222
+ }
223
+ return undefined;
224
+ }
225
+ /**
226
+ * The credential-scrubbed tail where a no-op's real cause shows up: a slice of Pi's
227
+ * stderr, or — when stderr is empty — a slice of its summary. Empty when neither is
228
+ * present. Shared by every agent's no-op reason so the cause is always diagnosable
229
+ * without shelling into the (ephemeral) container.
230
+ */
231
+ export function agentOutputTail(stderrTail, summary) {
232
+ if (stderrTail)
233
+ return ` Agent stderr: ${stderrTail.slice(-700)}`;
234
+ if (summary)
235
+ return ` Agent output: ${summary.slice(0, 700)}`;
236
+ return '';
237
+ }