@cat-factory/executor-harness 1.50.14 → 1.50.18
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 +55 -47
- package/dist/agent.js +46 -22
- package/dist/coding-agent.js +15 -2
- package/dist/effort.js +84 -0
- package/dist/job.js +42 -24
- package/dist/pi-workspace.js +14 -2
- package/package.json +3 -3
- package/src/agent-runner.ts +67 -48
- package/src/agent.ts +171 -123
- package/src/coding-agent.ts +38 -21
- package/src/effort.ts +99 -0
- package/src/job.ts +50 -23
- package/src/pi-workspace.ts +15 -2
- package/src/pi.ts +7 -0
package/dist/agent-runner.js
CHANGED
|
@@ -319,21 +319,7 @@ export async function runClaudeCode(opts) {
|
|
|
319
319
|
: join(homedir(), '.claude', 'skills');
|
|
320
320
|
await writeNativeSkill(skillsRoot, opts.skill).catch(() => { });
|
|
321
321
|
}
|
|
322
|
-
|
|
323
|
-
// non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
|
|
324
|
-
// points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
|
|
325
|
-
// Ambient mode injects neither — the CLI uses the developer's logged-in `~/.claude`.
|
|
326
|
-
const env = opts.ambientAuth
|
|
327
|
-
? {}
|
|
328
|
-
: {
|
|
329
|
-
CLAUDE_CONFIG_DIR: configHome,
|
|
330
|
-
...(opts.subscriptionBaseUrl
|
|
331
|
-
? {
|
|
332
|
-
ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
|
|
333
|
-
ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken,
|
|
334
|
-
}
|
|
335
|
-
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
|
|
336
|
-
};
|
|
322
|
+
const env = buildClaudeEnv(opts, configHome);
|
|
337
323
|
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
338
324
|
// subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
|
|
339
325
|
// heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
|
|
@@ -369,38 +355,7 @@ export async function runClaudeCode(opts) {
|
|
|
369
355
|
...appendArgs,
|
|
370
356
|
],
|
|
371
357
|
}, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
372
|
-
|
|
373
|
-
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
374
|
-
attributeCumulativeUsage(calls, usage);
|
|
375
|
-
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
376
|
-
// fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
|
|
377
|
-
// never appear on the parent stream, so this is the only place they are accounted.
|
|
378
|
-
await subagents?.stop();
|
|
379
|
-
const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
|
|
380
|
-
const subCalls = subagents?.calls() ?? [];
|
|
381
|
-
const mergedCalls = [...calls, ...subCalls];
|
|
382
|
-
// INVARIANT (do not "fix" this into a double count): the run total is the parent usage
|
|
383
|
-
// PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
|
|
384
|
-
// is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
|
|
385
|
-
// ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
|
|
386
|
-
// ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
|
|
387
|
-
// spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
|
|
388
|
-
// transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
|
|
389
|
-
// sibling parent session transcript (whose usage `result` already totals), so neither
|
|
390
|
-
// `calls` nor `usage` can already contain the subagent spend.
|
|
391
|
-
const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
|
|
392
|
-
? {
|
|
393
|
-
inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
|
|
394
|
-
outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
|
|
395
|
-
}
|
|
396
|
-
: undefined;
|
|
397
|
-
return {
|
|
398
|
-
summary,
|
|
399
|
-
stats,
|
|
400
|
-
stderrTail,
|
|
401
|
-
...(mergedUsage ? { usage: mergedUsage } : {}),
|
|
402
|
-
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
403
|
-
};
|
|
358
|
+
return await assembleClaudeOutcome({ summary, stats, stderrTail, calls, usage, subagents });
|
|
404
359
|
}
|
|
405
360
|
finally {
|
|
406
361
|
await subagents?.stop();
|
|
@@ -417,6 +372,59 @@ export async function runClaudeCode(opts) {
|
|
|
417
372
|
}
|
|
418
373
|
}
|
|
419
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Build the child-process env for the `claude` CLI: an isolated config home plus subscription
|
|
377
|
+
* auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
|
|
378
|
+
* non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
|
|
379
|
+
* (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
|
|
380
|
+
* keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
|
|
381
|
+
*/
|
|
382
|
+
function buildClaudeEnv(opts, configHome) {
|
|
383
|
+
if (opts.ambientAuth)
|
|
384
|
+
return {};
|
|
385
|
+
return {
|
|
386
|
+
CLAUDE_CONFIG_DIR: configHome,
|
|
387
|
+
...(opts.subscriptionBaseUrl
|
|
388
|
+
? {
|
|
389
|
+
ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
|
|
390
|
+
ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken,
|
|
391
|
+
}
|
|
392
|
+
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Merge the parent-loop telemetry with the subagents' out-of-band usage + per-call metrics into
|
|
397
|
+
* the run outcome. INVARIANT (do not "fix" this into a double count): the run total is the parent
|
|
398
|
+
* usage PLUS the subagent usage because the two are disjoint sources — the parent `usage` (the
|
|
399
|
+
* terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
|
|
400
|
+
* exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
|
|
401
|
+
* {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
|
|
402
|
+
*/
|
|
403
|
+
async function assembleClaudeOutcome(args) {
|
|
404
|
+
const { summary, stats, stderrTail, calls, usage, subagents } = args;
|
|
405
|
+
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
406
|
+
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
407
|
+
attributeCumulativeUsage(calls, usage);
|
|
408
|
+
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
409
|
+
// fold the subagents' usage + per-call telemetry into the run's outcome.
|
|
410
|
+
await subagents?.stop();
|
|
411
|
+
const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
|
|
412
|
+
const subCalls = subagents?.calls() ?? [];
|
|
413
|
+
const mergedCalls = [...calls, ...subCalls];
|
|
414
|
+
const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
|
|
415
|
+
? {
|
|
416
|
+
inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
|
|
417
|
+
outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
|
|
418
|
+
}
|
|
419
|
+
: undefined;
|
|
420
|
+
return {
|
|
421
|
+
summary,
|
|
422
|
+
stats,
|
|
423
|
+
stderrTail,
|
|
424
|
+
...(mergedUsage ? { usage: mergedUsage } : {}),
|
|
425
|
+
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
420
428
|
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
421
429
|
function todosToProgress(todos) {
|
|
422
430
|
if (!Array.isArray(todos))
|
package/dist/agent.js
CHANGED
|
@@ -228,6 +228,14 @@ async function cloneServiceCheckout(dir, job, signal) {
|
|
|
228
228
|
});
|
|
229
229
|
return deriveWorkDir(dir, job.repo.serviceDirectory);
|
|
230
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
233
|
+
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
234
|
+
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
235
|
+
*/
|
|
236
|
+
function mergeEffort(result, effortReport) {
|
|
237
|
+
return effortReport ? { ...result, effortReport } : result;
|
|
238
|
+
}
|
|
231
239
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
232
240
|
export async function handleAgent(job, opts = {}) {
|
|
233
241
|
// Private-registry auth first, before any mode runs: every mode with a checkout may
|
|
@@ -457,7 +465,7 @@ async function runExploreMode(job, opts) {
|
|
|
457
465
|
try {
|
|
458
466
|
opts.onPhase?.('agent');
|
|
459
467
|
logger.info('agent(explore): running agent', { serviceDirectory });
|
|
460
|
-
const { summary, stats, stderrTail, usage, callMetrics, diagnostics: runDiag, } = await runAgentInWorkspace({
|
|
468
|
+
const { summary, stats, stderrTail, usage, callMetrics, diagnostics: runDiag, effortReport, } = await runAgentInWorkspace({
|
|
461
469
|
dir: workDir,
|
|
462
470
|
systemPrompt: job.systemPrompt,
|
|
463
471
|
userPrompt,
|
|
@@ -477,7 +485,7 @@ async function runExploreMode(job, opts) {
|
|
|
477
485
|
contextFiles: job.contextFiles,
|
|
478
486
|
guardLimits: job.guardLimits,
|
|
479
487
|
}, opts);
|
|
480
|
-
return await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal });
|
|
488
|
+
return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
|
|
481
489
|
}
|
|
482
490
|
finally {
|
|
483
491
|
restoreSecrets();
|
|
@@ -666,14 +674,14 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
666
674
|
guardLimits: job.guardLimits,
|
|
667
675
|
multiRepo: true,
|
|
668
676
|
}, opts);
|
|
669
|
-
return finalizeExploreResult(job, {
|
|
677
|
+
return mergeEffort(await finalizeExploreResult(job, {
|
|
670
678
|
summary: run.summary,
|
|
671
679
|
stats: run.stats,
|
|
672
680
|
stderrTail: run.stderrTail,
|
|
673
681
|
usage: run.usage,
|
|
674
682
|
callMetrics: run.callMetrics,
|
|
675
683
|
runDiag: run.diagnostics,
|
|
676
|
-
}, { infraSetupFields: {}, logger, signal: opts.signal });
|
|
684
|
+
}, { infraSetupFields: {}, logger, signal: opts.signal }), run.effortReport);
|
|
677
685
|
});
|
|
678
686
|
}
|
|
679
687
|
/**
|
|
@@ -737,14 +745,12 @@ async function runCodingMode(job, opts) {
|
|
|
737
745
|
return result;
|
|
738
746
|
}
|
|
739
747
|
/**
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
* the in-place fixers (and for a seed-only kind like `repro-test`).
|
|
748
|
+
* Assemble the {@link runCodingAgent} spec for the ordinary single-repo coding flow. Extracted
|
|
749
|
+
* from {@link runSingleRepoCoding} so the many optional-field spreads don't inflate that
|
|
750
|
+
* function's cyclomatic complexity; the mapping is a straight field copy off `job`.
|
|
744
751
|
*/
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } = await runCodingAgent({
|
|
752
|
+
function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
753
|
+
return {
|
|
748
754
|
kind: 'agent',
|
|
749
755
|
jobId: job.jobId,
|
|
750
756
|
repo: job.repo,
|
|
@@ -781,10 +787,22 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
781
787
|
},
|
|
782
788
|
}
|
|
783
789
|
: {}),
|
|
784
|
-
}
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The ordinary single-repo coding flow: clone `branch` (or resume `newBranch`), run the agent,
|
|
794
|
+
* commit + push to `pushBranch`, and open `pr` when one is set and the run produced changes. A
|
|
795
|
+
* no-op is a failure for the implementer (`noChangesIsError` default) and a non-fatal no-op for
|
|
796
|
+
* the in-place fixers (and for a seed-only kind like `repro-test`).
|
|
797
|
+
*/
|
|
798
|
+
async function runSingleRepoCoding(job, opts) {
|
|
799
|
+
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
800
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
|
|
785
801
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
786
802
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
787
803
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {};
|
|
804
|
+
// The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
|
|
805
|
+
const effort = effortReport ? { effortReport } : {};
|
|
788
806
|
if (!pushed) {
|
|
789
807
|
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
790
808
|
if (job.noChangesIsError === false) {
|
|
@@ -796,6 +814,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
796
814
|
...(usage ? { usage } : {}),
|
|
797
815
|
...(callMetrics ? { callMetrics } : {}),
|
|
798
816
|
...ralphVerdict,
|
|
817
|
+
...effort,
|
|
799
818
|
};
|
|
800
819
|
}
|
|
801
820
|
return {
|
|
@@ -807,6 +826,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
807
826
|
failureCause: 'no-changes',
|
|
808
827
|
...(usage ? { usage } : {}),
|
|
809
828
|
...(callMetrics ? { callMetrics } : {}),
|
|
829
|
+
...effort,
|
|
810
830
|
};
|
|
811
831
|
}
|
|
812
832
|
// Changes are on the branch. Open a PR only when the job asked for one.
|
|
@@ -839,6 +859,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
839
859
|
stats,
|
|
840
860
|
...(usage ? { usage } : {}),
|
|
841
861
|
...(callMetrics ? { callMetrics } : {}),
|
|
862
|
+
...effort,
|
|
842
863
|
};
|
|
843
864
|
}
|
|
844
865
|
return {
|
|
@@ -850,6 +871,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
850
871
|
failureCause: 'no-changes',
|
|
851
872
|
...(usage ? { usage } : {}),
|
|
852
873
|
...(callMetrics ? { callMetrics } : {}),
|
|
874
|
+
...effort,
|
|
853
875
|
};
|
|
854
876
|
}
|
|
855
877
|
return {
|
|
@@ -861,6 +883,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
861
883
|
...(usage ? { usage } : {}),
|
|
862
884
|
...(callMetrics ? { callMetrics } : {}),
|
|
863
885
|
...ralphVerdict,
|
|
886
|
+
...effort,
|
|
864
887
|
};
|
|
865
888
|
}
|
|
866
889
|
return {
|
|
@@ -871,6 +894,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
871
894
|
...(usage ? { usage } : {}),
|
|
872
895
|
...(callMetrics ? { callMetrics } : {}),
|
|
873
896
|
...ralphVerdict,
|
|
897
|
+
...effort,
|
|
874
898
|
};
|
|
875
899
|
}
|
|
876
900
|
/**
|
|
@@ -935,7 +959,7 @@ async function runConflictResolution(job, opts) {
|
|
|
935
959
|
logger.info('agent(conflict): resolving conflicts with agent', { conflicted });
|
|
936
960
|
const diff = await conflictDiff(dir, conflicted, signal);
|
|
937
961
|
const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt);
|
|
938
|
-
const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
|
|
962
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
939
963
|
dir,
|
|
940
964
|
systemPrompt: job.systemPrompt,
|
|
941
965
|
userPrompt,
|
|
@@ -956,7 +980,7 @@ async function runConflictResolution(job, opts) {
|
|
|
956
980
|
logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
|
|
957
981
|
unresolved: unresolved.length,
|
|
958
982
|
});
|
|
959
|
-
return {
|
|
983
|
+
return mergeEffort({
|
|
960
984
|
pushed: false,
|
|
961
985
|
branch: job.branch,
|
|
962
986
|
summary,
|
|
@@ -965,21 +989,21 @@ async function runConflictResolution(job, opts) {
|
|
|
965
989
|
failureCause: 'agent',
|
|
966
990
|
...(usage ? { usage } : {}),
|
|
967
991
|
...(callMetrics ? { callMetrics } : {}),
|
|
968
|
-
};
|
|
992
|
+
}, effortReport);
|
|
969
993
|
}
|
|
970
994
|
// Complete the merge commit with the agent's resolution staged, then push.
|
|
971
995
|
await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal);
|
|
972
996
|
opts.onPhase?.('push');
|
|
973
997
|
logger.info('agent(conflict): pushing resolved branch', { ...stats });
|
|
974
998
|
await pushBranch(dir, job.branch, job.ghToken, signal);
|
|
975
|
-
return {
|
|
999
|
+
return mergeEffort({
|
|
976
1000
|
pushed: true,
|
|
977
1001
|
branch: job.branch,
|
|
978
1002
|
summary,
|
|
979
1003
|
stats,
|
|
980
1004
|
...(usage ? { usage } : {}),
|
|
981
1005
|
...(callMetrics ? { callMetrics } : {}),
|
|
982
|
-
};
|
|
1006
|
+
}, effortReport);
|
|
983
1007
|
});
|
|
984
1008
|
}
|
|
985
1009
|
/**
|
|
@@ -1055,7 +1079,7 @@ async function runBootstrap(job, opts) {
|
|
|
1055
1079
|
}
|
|
1056
1080
|
opts.onPhase?.('agent');
|
|
1057
1081
|
logger.info('agent(bootstrap): running agent');
|
|
1058
|
-
const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
|
|
1082
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
1059
1083
|
dir,
|
|
1060
1084
|
systemPrompt: job.systemPrompt,
|
|
1061
1085
|
userPrompt: job.userPrompt,
|
|
@@ -1075,14 +1099,14 @@ async function runBootstrap(job, opts) {
|
|
|
1075
1099
|
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1076
1100
|
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
|
|
1077
1101
|
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
|
|
1078
|
-
return {
|
|
1102
|
+
return mergeEffort({
|
|
1079
1103
|
summary,
|
|
1080
1104
|
stats,
|
|
1081
1105
|
error,
|
|
1082
1106
|
failureCause: 'agent',
|
|
1083
1107
|
...(usage ? { usage } : {}),
|
|
1084
1108
|
...(callMetrics ? { callMetrics } : {}),
|
|
1085
|
-
};
|
|
1109
|
+
}, effortReport);
|
|
1086
1110
|
}
|
|
1087
1111
|
opts.onPhase?.('push');
|
|
1088
1112
|
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
|
|
@@ -1097,13 +1121,13 @@ async function runBootstrap(job, opts) {
|
|
|
1097
1121
|
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1098
1122
|
});
|
|
1099
1123
|
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
|
|
1100
|
-
return {
|
|
1124
|
+
return mergeEffort({
|
|
1101
1125
|
defaultBranch: boot.target.defaultBranch,
|
|
1102
1126
|
summary,
|
|
1103
1127
|
stats,
|
|
1104
1128
|
...(usage ? { usage } : {}),
|
|
1105
1129
|
...(callMetrics ? { callMetrics } : {}),
|
|
1106
|
-
};
|
|
1130
|
+
}, effortReport);
|
|
1107
1131
|
});
|
|
1108
1132
|
}
|
|
1109
1133
|
/**
|
package/dist/coding-agent.js
CHANGED
|
@@ -5,6 +5,7 @@ import { killChildProcess, spawnDetached } from './process.js';
|
|
|
5
5
|
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
|
|
6
6
|
import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
7
7
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
8
|
+
import { EFFORT_REPORT_FILE } from './effort.js';
|
|
8
9
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
9
10
|
import { log } from './logger.js';
|
|
10
11
|
/**
|
|
@@ -105,6 +106,13 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
105
106
|
const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir;
|
|
106
107
|
if (serviceDirectory)
|
|
107
108
|
await mkdir(workDir, { recursive: true });
|
|
109
|
+
// Every container agent is asked to write its effort self-assessment to `.cat-effort.json`
|
|
110
|
+
// in its cwd (the backend appends EFFORT_REPORT_GUIDANCE to every container prompt). Locally
|
|
111
|
+
// exclude it from git — exactly like the follow-ups sentinel below — so the agent's own
|
|
112
|
+
// `git add` can never stage it into the PR. `readEffortReport` also removes it after the run,
|
|
113
|
+
// but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
|
|
114
|
+
// filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
|
|
115
|
+
await excludeFromGit(dir, EFFORT_REPORT_FILE, signal);
|
|
108
116
|
// Follow-up companion: tail the Coder's sentinel file and stream new items out on the
|
|
109
117
|
// job view. Locally exclude it from git first so the agent's own `git add` can never
|
|
110
118
|
// stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
|
|
@@ -278,7 +286,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
|
|
|
278
286
|
async function finalizeCodingRun(args) {
|
|
279
287
|
const { dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
|
|
280
288
|
const { signal } = opts;
|
|
281
|
-
const { summary, stats, stderrTail, usage, callMetrics } = agentRun;
|
|
289
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
|
|
282
290
|
let outcome;
|
|
283
291
|
// Stop tailing the follow-up sentinel and flush any items written after the last
|
|
284
292
|
// tick, so a fast final burst still reaches the job view before the run is recorded.
|
|
@@ -335,6 +343,7 @@ async function finalizeCodingRun(args) {
|
|
|
335
343
|
...(stderrTail ? { stderrTail } : {}),
|
|
336
344
|
...(usage ? { usage } : {}),
|
|
337
345
|
...(callMetrics ? { callMetrics } : {}),
|
|
346
|
+
...(effortReport ? { effortReport } : {}),
|
|
338
347
|
};
|
|
339
348
|
}
|
|
340
349
|
else {
|
|
@@ -349,6 +358,7 @@ async function finalizeCodingRun(args) {
|
|
|
349
358
|
...(stderrTail ? { stderrTail } : {}),
|
|
350
359
|
...(usage ? { usage } : {}),
|
|
351
360
|
...(callMetrics ? { callMetrics } : {}),
|
|
361
|
+
...(effortReport ? { effortReport } : {}),
|
|
352
362
|
};
|
|
353
363
|
}
|
|
354
364
|
// Ralph loop: run the programmatic completion command against the pushed/committed
|
|
@@ -528,7 +538,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
528
538
|
// note + the backend system-prompt section explain the layout.
|
|
529
539
|
opts.onPhase?.('agent');
|
|
530
540
|
logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) });
|
|
531
|
-
const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
|
|
541
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
532
542
|
dir: root,
|
|
533
543
|
systemPrompt: job.systemPrompt,
|
|
534
544
|
userPrompt: job.userPrompt,
|
|
@@ -560,6 +570,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
560
570
|
stats,
|
|
561
571
|
...(usage ? { usage } : {}),
|
|
562
572
|
...(callMetrics ? { callMetrics } : {}),
|
|
573
|
+
...(effortReport ? { effortReport } : {}),
|
|
563
574
|
};
|
|
564
575
|
}
|
|
565
576
|
return {
|
|
@@ -571,6 +582,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
571
582
|
failureCause: 'no-changes',
|
|
572
583
|
...(usage ? { usage } : {}),
|
|
573
584
|
...(callMetrics ? { callMetrics } : {}),
|
|
585
|
+
...(effortReport ? { effortReport } : {}),
|
|
574
586
|
};
|
|
575
587
|
}
|
|
576
588
|
logger.info('multi-repo: complete', {
|
|
@@ -587,6 +599,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
587
599
|
stats,
|
|
588
600
|
...(usage ? { usage } : {}),
|
|
589
601
|
...(callMetrics ? { callMetrics } : {}),
|
|
602
|
+
...(effortReport ? { effortReport } : {}),
|
|
590
603
|
};
|
|
591
604
|
});
|
|
592
605
|
}
|
package/dist/effort.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The agent effort self-assessment side channel. Every container agent is asked
|
|
5
|
+
// (via the backend-composed system prompt) to end its run by writing a short JSON
|
|
6
|
+
// self-assessment — how hard the work was, what reduced its effectiveness, the key
|
|
7
|
+
// obstacles — to a sentinel file in its working directory. The harness reads it after
|
|
8
|
+
// the agent finishes, removes it (so it never lands in a commit), and forwards it on
|
|
9
|
+
// the job result; the backend records it on the step and surfaces it in run details.
|
|
10
|
+
//
|
|
11
|
+
// The filename is kept in sync with `EFFORT_REPORT_FILE` in `@cat-factory/agents`
|
|
12
|
+
// (the executor-harness has no dependency on that package), exactly like CONTEXT_DIR
|
|
13
|
+
// and the follow-ups sentinel. The shape mirrors the contracts `AgentEffortReport`.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** The sentinel file the agent writes its effort self-assessment to (relative to its cwd). */
|
|
16
|
+
export const EFFORT_REPORT_FILE = '.cat-effort.json';
|
|
17
|
+
/**
|
|
18
|
+
* Read + parse + REMOVE the agent's effort sentinel file from `cwd`. Lenient: returns undefined
|
|
19
|
+
* when the file is absent (the agent wrote none), unreadable, not JSON, or carries nothing
|
|
20
|
+
* meaningful. Never throws — a malformed self-report must never fail an otherwise-good run.
|
|
21
|
+
*/
|
|
22
|
+
export async function readEffortReport(cwd) {
|
|
23
|
+
const path = join(cwd, EFFORT_REPORT_FILE);
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = await readFile(path, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined; // no report written — the common case
|
|
30
|
+
}
|
|
31
|
+
// Remove it so it never lands in a commit (defence in depth; the backend also excludes it).
|
|
32
|
+
await rm(path, { force: true }).catch(() => { });
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return coerceEffort(parsed);
|
|
41
|
+
}
|
|
42
|
+
/** Coerce arbitrary parsed JSON into a clean {@link EffortReport}, or undefined when it carries nothing. */
|
|
43
|
+
function coerceEffort(value) {
|
|
44
|
+
if (typeof value !== 'object' || value === null)
|
|
45
|
+
return undefined;
|
|
46
|
+
const o = value;
|
|
47
|
+
const report = { difficulty: clampDifficulty(o.difficulty) };
|
|
48
|
+
if (typeof o.summary === 'string' && o.summary.trim()) {
|
|
49
|
+
report.summary = o.summary.trim().slice(0, 2000);
|
|
50
|
+
}
|
|
51
|
+
if (typeof o.reducedEffectiveness === 'string' && o.reducedEffectiveness.trim()) {
|
|
52
|
+
report.reducedEffectiveness = o.reducedEffectiveness.trim().slice(0, 2000);
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(o.obstacles)) {
|
|
55
|
+
const obstacles = o.obstacles
|
|
56
|
+
.filter((x) => typeof x === 'string' && x.trim().length > 0)
|
|
57
|
+
.map((x) => x.trim().slice(0, 500))
|
|
58
|
+
.slice(0, 20);
|
|
59
|
+
if (obstacles.length)
|
|
60
|
+
report.obstacles = obstacles;
|
|
61
|
+
}
|
|
62
|
+
// Nothing beyond a defaulted difficulty ⇒ the agent didn't really report anything; drop it so
|
|
63
|
+
// run details don't show an empty "5/10, no detail" card for a stray/blank file.
|
|
64
|
+
if (report.summary === undefined &&
|
|
65
|
+
report.reducedEffectiveness === undefined &&
|
|
66
|
+
report.obstacles === undefined &&
|
|
67
|
+
!isFiniteNumber(o.difficulty)) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return report;
|
|
71
|
+
}
|
|
72
|
+
function clampDifficulty(v) {
|
|
73
|
+
const n = isFiniteNumber(v)
|
|
74
|
+
? v
|
|
75
|
+
: typeof v === 'string' && v.trim() !== ''
|
|
76
|
+
? Number(v)
|
|
77
|
+
: Number.NaN;
|
|
78
|
+
if (!Number.isFinite(n))
|
|
79
|
+
return 5;
|
|
80
|
+
return Math.min(10, Math.max(1, Math.round(n)));
|
|
81
|
+
}
|
|
82
|
+
function isFiniteNumber(v) {
|
|
83
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
84
|
+
}
|
package/dist/job.js
CHANGED
|
@@ -555,6 +555,24 @@ function isReservedEnvName(key) {
|
|
|
555
555
|
const lower = key.toLowerCase();
|
|
556
556
|
return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p));
|
|
557
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
|
|
560
|
+
* malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
|
|
561
|
+
* names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
|
|
562
|
+
* dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
|
|
563
|
+
* replace it with a URL and the build would no longer find its tools. Extracted from the infra
|
|
564
|
+
* parsers to keep their cyclomatic complexity down.
|
|
565
|
+
*/
|
|
566
|
+
function parseInfraEnv(raw) {
|
|
567
|
+
const env = {};
|
|
568
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
569
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
570
|
+
if (key && !isReservedEnvName(key) && typeof val === 'string')
|
|
571
|
+
env[key] = val;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return env;
|
|
575
|
+
}
|
|
558
576
|
/** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
|
|
559
577
|
function parseFrontendInfraSpec(o) {
|
|
560
578
|
const packageManager = o.packageManager === 'pnpm' || o.packageManager === 'npm' || o.packageManager === 'yarn'
|
|
@@ -562,18 +580,7 @@ function parseFrontendInfraSpec(o) {
|
|
|
562
580
|
: undefined;
|
|
563
581
|
const serveMode = o.serveMode === 'static' || o.serveMode === 'command' ? o.serveMode : undefined;
|
|
564
582
|
const envInjection = o.envInjection === 'build' || o.envInjection === 'runtime' ? o.envInjection : undefined;
|
|
565
|
-
|
|
566
|
-
// binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved names
|
|
567
|
-
// that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
|
|
568
|
-
// dropped too: they are spread over `process.env` at build time, so a binding named `PATH`
|
|
569
|
-
// would replace it with a URL and the build would no longer find its tools.
|
|
570
|
-
const env = {};
|
|
571
|
-
if (typeof o.env === 'object' && o.env !== null) {
|
|
572
|
-
for (const [key, val] of Object.entries(o.env)) {
|
|
573
|
-
if (key && !isReservedEnvName(key) && typeof val === 'string')
|
|
574
|
-
env[key] = val;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
583
|
+
const env = parseInfraEnv(o.env);
|
|
577
584
|
const servePort = port(o.servePort);
|
|
578
585
|
const wiremockPort = port(o.wiremockPort);
|
|
579
586
|
// The app's monorepo subdirectory becomes the install/build/serve cwd, so it goes through the
|
|
@@ -732,11 +739,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
732
739
|
ghToken: str(o.ghToken, 'ghToken'),
|
|
733
740
|
repo: parseRepoSpec(repo),
|
|
734
741
|
branch: str(o.branch, 'branch'),
|
|
735
|
-
...(
|
|
736
|
-
...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
|
|
737
|
-
...(o.webSearch === true ? { webSearch: true } : {}),
|
|
738
|
-
...(o.full === true ? { full: true } : {}),
|
|
739
|
-
...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
|
|
742
|
+
...collectOptionalRequestFields(o),
|
|
740
743
|
...(bootstrap ? { bootstrap } : {}),
|
|
741
744
|
...(output ? { output } : {}),
|
|
742
745
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
@@ -744,20 +747,35 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
744
747
|
...(skill ? { skill } : {}),
|
|
745
748
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
746
749
|
...(infra ? { infra } : {}),
|
|
747
|
-
...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
|
|
748
|
-
...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
|
|
749
|
-
...(typeof o.commitMessage === 'string' && o.commitMessage
|
|
750
|
-
? { commitMessage: o.commitMessage }
|
|
751
|
-
: {}),
|
|
752
750
|
...(pr ? { pr } : {}),
|
|
753
751
|
...(peerRepos.length ? { peerRepos } : {}),
|
|
754
752
|
...(referenceRepos.length ? { referenceRepos } : {}),
|
|
755
753
|
...(referenceBranches.length ? { referenceBranches } : {}),
|
|
756
754
|
...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
|
|
755
|
+
...(guardLimits ? { guardLimits } : {}),
|
|
756
|
+
...(validation ? { validation } : {}),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* The optional {@link AgentJob} fields read directly off the request `o` (booleans + trimmed
|
|
761
|
+
* strings). Extracted from {@link assembleAgentJob} to keep its cyclomatic complexity down; every
|
|
762
|
+
* key is unique so grouping the conditional spreads is behaviour-neutral (spread order is
|
|
763
|
+
* irrelevant with no colliding keys).
|
|
764
|
+
*/
|
|
765
|
+
function collectOptionalRequestFields(o) {
|
|
766
|
+
return {
|
|
767
|
+
...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
|
|
768
|
+
...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
|
|
769
|
+
...(o.webSearch === true ? { webSearch: true } : {}),
|
|
770
|
+
...(o.full === true ? { full: true } : {}),
|
|
771
|
+
...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
|
|
772
|
+
...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
|
|
773
|
+
...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
|
|
774
|
+
...(typeof o.commitMessage === 'string' && o.commitMessage
|
|
775
|
+
? { commitMessage: o.commitMessage }
|
|
776
|
+
: {}),
|
|
757
777
|
...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
|
|
758
778
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
759
779
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
760
|
-
...(guardLimits ? { guardLimits } : {}),
|
|
761
|
-
...(validation ? { validation } : {}),
|
|
762
780
|
};
|
|
763
781
|
}
|
package/dist/pi-workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { readEffortReport } from './effort.js';
|
|
4
5
|
import { log } from './logger.js';
|
|
5
6
|
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
6
7
|
import { runSubscriptionHarness } from './agent-runner.js';
|
|
@@ -134,7 +135,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
134
135
|
if (!spec.ambientAuth && !spec.subscriptionToken) {
|
|
135
136
|
throw new Error(`The ${spec.harness} harness requires a subscription token`);
|
|
136
137
|
}
|
|
137
|
-
|
|
138
|
+
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
138
139
|
cwd: spec.dir,
|
|
139
140
|
model: spec.model,
|
|
140
141
|
systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles),
|
|
@@ -148,6 +149,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
148
149
|
onProgress: opts.onProgress,
|
|
149
150
|
...(opts.log ? { log: opts.log } : {}),
|
|
150
151
|
});
|
|
152
|
+
return withEffortReport(spec.dir, subOutcome);
|
|
151
153
|
}
|
|
152
154
|
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
153
155
|
throw new Error('The Pi harness requires proxyBaseUrl and sessionToken');
|
|
@@ -178,7 +180,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
178
180
|
});
|
|
179
181
|
await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
|
|
180
182
|
const { signal, onActivity, onProgress, onSpan } = opts;
|
|
181
|
-
|
|
183
|
+
const piOutcome = await runPi({
|
|
182
184
|
cwd: spec.dir,
|
|
183
185
|
model: spec.model,
|
|
184
186
|
userPrompt: spec.userPrompt,
|
|
@@ -193,6 +195,16 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
193
195
|
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
194
196
|
extraEnv,
|
|
195
197
|
});
|
|
198
|
+
return withEffortReport(spec.dir, piOutcome);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Lift the agent's effort self-assessment off its sentinel file in `dir` and fold it onto the
|
|
202
|
+
* run outcome. Shared by both harness paths so EVERY container agent's effort report is captured
|
|
203
|
+
* in one place. Never throws (a bad/absent report just yields no `effortReport`).
|
|
204
|
+
*/
|
|
205
|
+
async function withEffortReport(dir, outcome) {
|
|
206
|
+
const effortReport = await readEffortReport(dir);
|
|
207
|
+
return effortReport ? { ...outcome, effortReport } : outcome;
|
|
196
208
|
}
|
|
197
209
|
/**
|
|
198
210
|
* Append a pointer to the materialised linked context onto a subscription harness's
|