@cat-factory/executor-harness 1.50.12 → 1.50.16
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 +30 -29
- package/dist/agent.js +31 -15
- package/dist/coding-agent.js +15 -2
- package/dist/effort.js +84 -0
- package/dist/pi-workspace.js +14 -2
- package/dist/subagents.js +73 -18
- package/package.json +3 -3
- package/src/agent-runner.ts +29 -31
- package/src/agent.ts +121 -82
- package/src/coding-agent.ts +38 -21
- package/src/effort.ts +99 -0
- package/src/job.ts +7 -0
- package/src/pi-workspace.ts +15 -2
- package/src/pi.ts +7 -0
- package/src/subagents.ts +88 -24
package/src/agent-runner.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { Logger } from './logger.js'
|
|
|
13
13
|
import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
|
|
14
14
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
15
15
|
import { redact, secretsToRedact } from './redact.js'
|
|
16
|
-
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
16
|
+
import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js'
|
|
17
17
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
|
|
18
18
|
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
19
19
|
|
|
@@ -325,16 +325,18 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
325
325
|
]
|
|
326
326
|
const calls: HarnessCallMetric[] = []
|
|
327
327
|
|
|
328
|
-
// ADR 0026 D2.1:
|
|
329
|
-
// their terminal tool_results
|
|
330
|
-
// turns don't)
|
|
331
|
-
//
|
|
332
|
-
//
|
|
328
|
+
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
329
|
+
// sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
|
|
330
|
+
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
331
|
+
// progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
|
|
332
|
+
// shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
|
|
333
|
+
// update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
|
|
334
|
+
// and never marks it done, which used to gate the slice signal off and pin progress at 0%.
|
|
333
335
|
const sliceTracker = createSliceTracker()
|
|
334
|
-
let
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
337
|
-
const progress = sliceTracker.progress()
|
|
336
|
+
let lastTodo: TodoProgress | undefined
|
|
337
|
+
const emitProgress = (): void => {
|
|
338
|
+
if (!opts.onProgress) return
|
|
339
|
+
const progress = pickProgress(lastTodo, sliceTracker.progress())
|
|
338
340
|
if (progress) opts.onProgress(progress)
|
|
339
341
|
}
|
|
340
342
|
|
|
@@ -347,21 +349,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
347
349
|
stats.assistantChars += text.length
|
|
348
350
|
stats.toolCalls += toolUses
|
|
349
351
|
for (const block of content) {
|
|
350
|
-
if (
|
|
351
|
-
isObject(block) &&
|
|
352
|
-
block.type === 'tool_use' &&
|
|
353
|
-
block.name === 'TodoWrite' &&
|
|
354
|
-
opts.onProgress
|
|
355
|
-
) {
|
|
352
|
+
if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
|
|
356
353
|
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
357
|
-
if (progress)
|
|
358
|
-
sawTodoPlan = true
|
|
359
|
-
opts.onProgress(progress)
|
|
360
|
-
}
|
|
354
|
+
if (progress) lastTodo = progress
|
|
361
355
|
}
|
|
362
356
|
}
|
|
363
357
|
sliceTracker.onAssistant(content)
|
|
364
|
-
|
|
358
|
+
emitProgress()
|
|
365
359
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
366
360
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
367
361
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
@@ -383,7 +377,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
383
377
|
const content = (event.message as Record<string, unknown>).content
|
|
384
378
|
if (Array.isArray(content)) {
|
|
385
379
|
sliceTracker.onUser(content)
|
|
386
|
-
|
|
380
|
+
emitProgress()
|
|
387
381
|
messages.push({ role: 'tool', content })
|
|
388
382
|
}
|
|
389
383
|
} else if (type === 'result') {
|
|
@@ -446,13 +440,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
446
440
|
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
|
|
447
441
|
}
|
|
448
442
|
|
|
449
|
-
// ADR 0026
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
443
|
+
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
444
|
+
// subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
|
|
445
|
+
// heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
|
|
446
|
+
// lifted into the run's telemetry. The CLI writes them per-session under
|
|
447
|
+
// `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/*.jsonl`, so we watch the
|
|
448
|
+
// `projects` tree and let the watcher discover the `subagents/` dir (the session uuid isn't
|
|
449
|
+
// known up front). Ambient mode has no isolated home to watch. Best-effort — a
|
|
450
|
+
// missing/renamed transcript layout just yields no extra signal.
|
|
454
451
|
const subagents = configHome
|
|
455
|
-
? startSubagentWatcher(join(configHome, '
|
|
452
|
+
? startSubagentWatcher(join(configHome, 'projects'), {
|
|
456
453
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
457
454
|
secrets,
|
|
458
455
|
model: opts.model,
|
|
@@ -502,9 +499,10 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
502
499
|
// is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
|
|
503
500
|
// ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
|
|
504
501
|
// ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
|
|
505
|
-
// spend. The subagent tokens live exclusively in the `subagents/*.jsonl`
|
|
506
|
-
//
|
|
507
|
-
//
|
|
502
|
+
// spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
|
|
503
|
+
// transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
|
|
504
|
+
// sibling parent session transcript (whose usage `result` already totals), so neither
|
|
505
|
+
// `calls` nor `usage` can already contain the subagent spend.
|
|
508
506
|
const mergedUsage =
|
|
509
507
|
usage || subUsage.inputTokens || subUsage.outputTokens
|
|
510
508
|
? {
|
package/src/agent.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
unmergedPaths,
|
|
32
32
|
} from './git.js'
|
|
33
33
|
import type { PiRunStats, RunDiagnostics } from './pi.js'
|
|
34
|
+
import type { EffortReport } from './effort.js'
|
|
34
35
|
import {
|
|
35
36
|
makeDirClaimer,
|
|
36
37
|
noChangesReason,
|
|
@@ -312,6 +313,15 @@ async function cloneServiceCheckout(
|
|
|
312
313
|
return deriveWorkDir(dir, job.repo.serviceDirectory)
|
|
313
314
|
}
|
|
314
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
318
|
+
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
319
|
+
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
320
|
+
*/
|
|
321
|
+
function mergeEffort(result: AgentResult, effortReport: EffortReport | undefined): AgentResult {
|
|
322
|
+
return effortReport ? { ...result, effortReport } : result
|
|
323
|
+
}
|
|
324
|
+
|
|
315
325
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
316
326
|
export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
|
|
317
327
|
// Private-registry auth first, before any mode runs: every mode with a checkout may
|
|
@@ -558,6 +568,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
558
568
|
usage,
|
|
559
569
|
callMetrics,
|
|
560
570
|
diagnostics: runDiag,
|
|
571
|
+
effortReport,
|
|
561
572
|
} = await runAgentInWorkspace(
|
|
562
573
|
{
|
|
563
574
|
dir: workDir,
|
|
@@ -582,10 +593,13 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
582
593
|
opts,
|
|
583
594
|
)
|
|
584
595
|
|
|
585
|
-
return
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
596
|
+
return mergeEffort(
|
|
597
|
+
await finalizeExploreResult(
|
|
598
|
+
job,
|
|
599
|
+
{ summary, stats, stderrTail, usage, callMetrics, runDiag },
|
|
600
|
+
{ infra, infraSetupFields, logger, signal: opts.signal },
|
|
601
|
+
),
|
|
602
|
+
effortReport,
|
|
589
603
|
)
|
|
590
604
|
} finally {
|
|
591
605
|
restoreSecrets()
|
|
@@ -808,17 +822,20 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
808
822
|
},
|
|
809
823
|
opts,
|
|
810
824
|
)
|
|
811
|
-
return
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
825
|
+
return mergeEffort(
|
|
826
|
+
await finalizeExploreResult(
|
|
827
|
+
job,
|
|
828
|
+
{
|
|
829
|
+
summary: run.summary,
|
|
830
|
+
stats: run.stats,
|
|
831
|
+
stderrTail: run.stderrTail,
|
|
832
|
+
usage: run.usage,
|
|
833
|
+
callMetrics: run.callMetrics,
|
|
834
|
+
runDiag: run.diagnostics,
|
|
835
|
+
},
|
|
836
|
+
{ infraSetupFields: {}, logger, signal: opts.signal },
|
|
837
|
+
),
|
|
838
|
+
run.effortReport,
|
|
822
839
|
)
|
|
823
840
|
})
|
|
824
841
|
}
|
|
@@ -894,7 +911,7 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
|
|
|
894
911
|
*/
|
|
895
912
|
async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
896
913
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
897
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } =
|
|
914
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } =
|
|
898
915
|
await runCodingAgent(
|
|
899
916
|
{
|
|
900
917
|
kind: 'agent',
|
|
@@ -939,6 +956,8 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
939
956
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
940
957
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
941
958
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {}
|
|
959
|
+
// The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
|
|
960
|
+
const effort = effortReport ? { effortReport } : {}
|
|
942
961
|
|
|
943
962
|
if (!pushed) {
|
|
944
963
|
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
@@ -951,6 +970,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
951
970
|
...(usage ? { usage } : {}),
|
|
952
971
|
...(callMetrics ? { callMetrics } : {}),
|
|
953
972
|
...ralphVerdict,
|
|
973
|
+
...effort,
|
|
954
974
|
}
|
|
955
975
|
}
|
|
956
976
|
return {
|
|
@@ -962,6 +982,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
962
982
|
failureCause: 'no-changes',
|
|
963
983
|
...(usage ? { usage } : {}),
|
|
964
984
|
...(callMetrics ? { callMetrics } : {}),
|
|
985
|
+
...effort,
|
|
965
986
|
}
|
|
966
987
|
}
|
|
967
988
|
|
|
@@ -995,6 +1016,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
995
1016
|
stats,
|
|
996
1017
|
...(usage ? { usage } : {}),
|
|
997
1018
|
...(callMetrics ? { callMetrics } : {}),
|
|
1019
|
+
...effort,
|
|
998
1020
|
}
|
|
999
1021
|
}
|
|
1000
1022
|
return {
|
|
@@ -1010,6 +1032,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1010
1032
|
failureCause: 'no-changes',
|
|
1011
1033
|
...(usage ? { usage } : {}),
|
|
1012
1034
|
...(callMetrics ? { callMetrics } : {}),
|
|
1035
|
+
...effort,
|
|
1013
1036
|
}
|
|
1014
1037
|
}
|
|
1015
1038
|
return {
|
|
@@ -1021,6 +1044,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1021
1044
|
...(usage ? { usage } : {}),
|
|
1022
1045
|
...(callMetrics ? { callMetrics } : {}),
|
|
1023
1046
|
...ralphVerdict,
|
|
1047
|
+
...effort,
|
|
1024
1048
|
}
|
|
1025
1049
|
}
|
|
1026
1050
|
return {
|
|
@@ -1031,6 +1055,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1031
1055
|
...(usage ? { usage } : {}),
|
|
1032
1056
|
...(callMetrics ? { callMetrics } : {}),
|
|
1033
1057
|
...ralphVerdict,
|
|
1058
|
+
...effort,
|
|
1034
1059
|
}
|
|
1035
1060
|
}
|
|
1036
1061
|
|
|
@@ -1100,23 +1125,24 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1100
1125
|
const diff = await conflictDiff(dir, conflicted, signal)
|
|
1101
1126
|
const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt)
|
|
1102
1127
|
|
|
1103
|
-
const { summary, stats, stderrTail, usage, callMetrics } =
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1128
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1129
|
+
await runAgentInWorkspace(
|
|
1130
|
+
{
|
|
1131
|
+
dir,
|
|
1132
|
+
systemPrompt: job.systemPrompt,
|
|
1133
|
+
userPrompt,
|
|
1134
|
+
model: job.model,
|
|
1135
|
+
harness: job.harness,
|
|
1136
|
+
subscriptionToken: job.subscriptionToken,
|
|
1137
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1138
|
+
ambientAuth: job.ambientAuth,
|
|
1139
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
1140
|
+
sessionToken: job.sessionToken,
|
|
1141
|
+
contextFiles: job.contextFiles,
|
|
1142
|
+
guardLimits: job.guardLimits,
|
|
1143
|
+
},
|
|
1144
|
+
opts,
|
|
1145
|
+
)
|
|
1120
1146
|
|
|
1121
1147
|
// Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
|
|
1122
1148
|
// the PR would still be broken. Fail so the engine can retry / notify.
|
|
@@ -1125,30 +1151,36 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1125
1151
|
logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
|
|
1126
1152
|
unresolved: unresolved.length,
|
|
1127
1153
|
})
|
|
1128
|
-
return
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1154
|
+
return mergeEffort(
|
|
1155
|
+
{
|
|
1156
|
+
pushed: false,
|
|
1157
|
+
branch: job.branch,
|
|
1158
|
+
summary,
|
|
1159
|
+
stats,
|
|
1160
|
+
error: unresolvedReason(unresolved, stats, stderrTail),
|
|
1161
|
+
failureCause: 'agent',
|
|
1162
|
+
...(usage ? { usage } : {}),
|
|
1163
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1164
|
+
},
|
|
1165
|
+
effortReport,
|
|
1166
|
+
)
|
|
1138
1167
|
}
|
|
1139
1168
|
// Complete the merge commit with the agent's resolution staged, then push.
|
|
1140
1169
|
await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal)
|
|
1141
1170
|
opts.onPhase?.('push')
|
|
1142
1171
|
logger.info('agent(conflict): pushing resolved branch', { ...stats })
|
|
1143
1172
|
await pushBranch(dir, job.branch, job.ghToken, signal)
|
|
1144
|
-
return
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1173
|
+
return mergeEffort(
|
|
1174
|
+
{
|
|
1175
|
+
pushed: true,
|
|
1176
|
+
branch: job.branch,
|
|
1177
|
+
summary,
|
|
1178
|
+
stats,
|
|
1179
|
+
...(usage ? { usage } : {}),
|
|
1180
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1181
|
+
},
|
|
1182
|
+
effortReport,
|
|
1183
|
+
)
|
|
1152
1184
|
})
|
|
1153
1185
|
}
|
|
1154
1186
|
|
|
@@ -1239,22 +1271,23 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
|
|
|
1239
1271
|
|
|
1240
1272
|
opts.onPhase?.('agent')
|
|
1241
1273
|
logger.info('agent(bootstrap): running agent')
|
|
1242
|
-
const { summary, stats, stderrTail, usage, callMetrics } =
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1274
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1275
|
+
await runAgentInWorkspace(
|
|
1276
|
+
{
|
|
1277
|
+
dir,
|
|
1278
|
+
systemPrompt: job.systemPrompt,
|
|
1279
|
+
userPrompt: job.userPrompt,
|
|
1280
|
+
model: job.model,
|
|
1281
|
+
harness: job.harness,
|
|
1282
|
+
subscriptionToken: job.subscriptionToken,
|
|
1283
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1284
|
+
ambientAuth: job.ambientAuth,
|
|
1285
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
1286
|
+
sessionToken: job.sessionToken,
|
|
1287
|
+
guardLimits: job.guardLimits,
|
|
1288
|
+
},
|
|
1289
|
+
opts,
|
|
1290
|
+
)
|
|
1258
1291
|
|
|
1259
1292
|
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
1260
1293
|
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
@@ -1263,14 +1296,17 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
|
|
|
1263
1296
|
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1264
1297
|
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
1265
1298
|
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
1266
|
-
return
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1299
|
+
return mergeEffort(
|
|
1300
|
+
{
|
|
1301
|
+
summary,
|
|
1302
|
+
stats,
|
|
1303
|
+
error,
|
|
1304
|
+
failureCause: 'agent',
|
|
1305
|
+
...(usage ? { usage } : {}),
|
|
1306
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1307
|
+
},
|
|
1308
|
+
effortReport,
|
|
1309
|
+
)
|
|
1274
1310
|
}
|
|
1275
1311
|
|
|
1276
1312
|
opts.onPhase?.('push')
|
|
@@ -1286,13 +1322,16 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
|
|
|
1286
1322
|
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1287
1323
|
})
|
|
1288
1324
|
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
1289
|
-
return
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1325
|
+
return mergeEffort(
|
|
1326
|
+
{
|
|
1327
|
+
defaultBranch: boot.target.defaultBranch,
|
|
1328
|
+
summary,
|
|
1329
|
+
stats,
|
|
1330
|
+
...(usage ? { usage } : {}),
|
|
1331
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
1332
|
+
},
|
|
1333
|
+
effortReport,
|
|
1334
|
+
)
|
|
1296
1335
|
})
|
|
1297
1336
|
}
|
|
1298
1337
|
|
package/src/coding-agent.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './git.js'
|
|
32
32
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
|
|
33
33
|
import type { HarnessCallMetric, PiRunStats } from './pi.js'
|
|
34
|
+
import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
|
|
34
35
|
import {
|
|
35
36
|
acquireRepoCheckout,
|
|
36
37
|
agentNeverActed,
|
|
@@ -123,6 +124,8 @@ export interface CodingAgentOutcome {
|
|
|
123
124
|
usage?: { inputTokens: number; outputTokens: number }
|
|
124
125
|
/** Per-model-call telemetry from a subscription harness's CLI stream (absent for Pi). */
|
|
125
126
|
callMetrics?: HarnessCallMetric[]
|
|
127
|
+
/** The agent's effort self-assessment, lifted from its sentinel file (absent when it wrote none). */
|
|
128
|
+
effortReport?: EffortReport
|
|
126
129
|
/**
|
|
127
130
|
* Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
|
|
128
131
|
* exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
|
|
@@ -242,6 +245,14 @@ export async function runCodingAgent(
|
|
|
242
245
|
const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir
|
|
243
246
|
if (serviceDirectory) await mkdir(workDir, { recursive: true })
|
|
244
247
|
|
|
248
|
+
// Every container agent is asked to write its effort self-assessment to `.cat-effort.json`
|
|
249
|
+
// in its cwd (the backend appends EFFORT_REPORT_GUIDANCE to every container prompt). Locally
|
|
250
|
+
// exclude it from git — exactly like the follow-ups sentinel below — so the agent's own
|
|
251
|
+
// `git add` can never stage it into the PR. `readEffortReport` also removes it after the run,
|
|
252
|
+
// but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
|
|
253
|
+
// filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
|
|
254
|
+
await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
|
|
255
|
+
|
|
245
256
|
// Follow-up companion: tail the Coder's sentinel file and stream new items out on the
|
|
246
257
|
// job view. Locally exclude it from git first so the agent's own `git add` can never
|
|
247
258
|
// stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
|
|
@@ -462,7 +473,7 @@ async function finalizeCodingRun(args: {
|
|
|
462
473
|
agentRun,
|
|
463
474
|
} = args
|
|
464
475
|
const { signal } = opts
|
|
465
|
-
const { summary, stats, stderrTail, usage, callMetrics } = agentRun
|
|
476
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun
|
|
466
477
|
let outcome: CodingAgentOutcome
|
|
467
478
|
|
|
468
479
|
// Stop tailing the follow-up sentinel and flush any items written after the last
|
|
@@ -521,6 +532,7 @@ async function finalizeCodingRun(args: {
|
|
|
521
532
|
...(stderrTail ? { stderrTail } : {}),
|
|
522
533
|
...(usage ? { usage } : {}),
|
|
523
534
|
...(callMetrics ? { callMetrics } : {}),
|
|
535
|
+
...(effortReport ? { effortReport } : {}),
|
|
524
536
|
}
|
|
525
537
|
} else {
|
|
526
538
|
opts.onPhase?.('push')
|
|
@@ -534,6 +546,7 @@ async function finalizeCodingRun(args: {
|
|
|
534
546
|
...(stderrTail ? { stderrTail } : {}),
|
|
535
547
|
...(usage ? { usage } : {}),
|
|
536
548
|
...(callMetrics ? { callMetrics } : {}),
|
|
549
|
+
...(effortReport ? { effortReport } : {}),
|
|
537
550
|
}
|
|
538
551
|
}
|
|
539
552
|
|
|
@@ -765,26 +778,27 @@ export async function runMultiRepoCoding(
|
|
|
765
778
|
// note + the backend system-prompt section explain the layout.
|
|
766
779
|
opts.onPhase?.('agent')
|
|
767
780
|
logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) })
|
|
768
|
-
const { summary, stats, stderrTail, usage, callMetrics } =
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
781
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
782
|
+
await runAgentInWorkspace(
|
|
783
|
+
{
|
|
784
|
+
dir: root,
|
|
785
|
+
systemPrompt: job.systemPrompt,
|
|
786
|
+
userPrompt: job.userPrompt,
|
|
787
|
+
model: job.model,
|
|
788
|
+
harness: job.harness,
|
|
789
|
+
subscriptionToken: job.subscriptionToken,
|
|
790
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
791
|
+
ambientAuth: job.ambientAuth,
|
|
792
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
793
|
+
sessionToken: job.sessionToken,
|
|
794
|
+
webToolsGuidance: job.webToolsGuidance,
|
|
795
|
+
webSearchProxy: job.webSearch,
|
|
796
|
+
guardLimits: job.guardLimits,
|
|
797
|
+
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
798
|
+
multiRepo: true,
|
|
799
|
+
},
|
|
800
|
+
opts,
|
|
801
|
+
)
|
|
788
802
|
|
|
789
803
|
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|
|
790
804
|
const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(
|
|
@@ -807,6 +821,7 @@ export async function runMultiRepoCoding(
|
|
|
807
821
|
stats,
|
|
808
822
|
...(usage ? { usage } : {}),
|
|
809
823
|
...(callMetrics ? { callMetrics } : {}),
|
|
824
|
+
...(effortReport ? { effortReport } : {}),
|
|
810
825
|
}
|
|
811
826
|
}
|
|
812
827
|
return {
|
|
@@ -822,6 +837,7 @@ export async function runMultiRepoCoding(
|
|
|
822
837
|
failureCause: 'no-changes',
|
|
823
838
|
...(usage ? { usage } : {}),
|
|
824
839
|
...(callMetrics ? { callMetrics } : {}),
|
|
840
|
+
...(effortReport ? { effortReport } : {}),
|
|
825
841
|
}
|
|
826
842
|
}
|
|
827
843
|
logger.info('multi-repo: complete', {
|
|
@@ -838,6 +854,7 @@ export async function runMultiRepoCoding(
|
|
|
838
854
|
stats,
|
|
839
855
|
...(usage ? { usage } : {}),
|
|
840
856
|
...(callMetrics ? { callMetrics } : {}),
|
|
857
|
+
...(effortReport ? { effortReport } : {}),
|
|
841
858
|
}
|
|
842
859
|
})
|
|
843
860
|
}
|
package/src/effort.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// The agent effort self-assessment side channel. Every container agent is asked
|
|
6
|
+
// (via the backend-composed system prompt) to end its run by writing a short JSON
|
|
7
|
+
// self-assessment — how hard the work was, what reduced its effectiveness, the key
|
|
8
|
+
// obstacles — to a sentinel file in its working directory. The harness reads it after
|
|
9
|
+
// the agent finishes, removes it (so it never lands in a commit), and forwards it on
|
|
10
|
+
// the job result; the backend records it on the step and surfaces it in run details.
|
|
11
|
+
//
|
|
12
|
+
// The filename is kept in sync with `EFFORT_REPORT_FILE` in `@cat-factory/agents`
|
|
13
|
+
// (the executor-harness has no dependency on that package), exactly like CONTEXT_DIR
|
|
14
|
+
// and the follow-ups sentinel. The shape mirrors the contracts `AgentEffortReport`.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** The sentinel file the agent writes its effort self-assessment to (relative to its cwd). */
|
|
18
|
+
export const EFFORT_REPORT_FILE = '.cat-effort.json'
|
|
19
|
+
|
|
20
|
+
/** A container agent's self-assessment of the work it just did. */
|
|
21
|
+
export interface EffortReport {
|
|
22
|
+
/** How hard the work was: 1 (trivial) .. 10 (extremely hard). */
|
|
23
|
+
difficulty: number
|
|
24
|
+
/** One or two sentences on how hard/easy the work was and why. */
|
|
25
|
+
summary?: string
|
|
26
|
+
/** What reduced the agent's effectiveness. */
|
|
27
|
+
reducedEffectiveness?: string
|
|
28
|
+
/** The key obstacles the agent hit. */
|
|
29
|
+
obstacles?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read + parse + REMOVE the agent's effort sentinel file from `cwd`. Lenient: returns undefined
|
|
34
|
+
* when the file is absent (the agent wrote none), unreadable, not JSON, or carries nothing
|
|
35
|
+
* meaningful. Never throws — a malformed self-report must never fail an otherwise-good run.
|
|
36
|
+
*/
|
|
37
|
+
export async function readEffortReport(cwd: string): Promise<EffortReport | undefined> {
|
|
38
|
+
const path = join(cwd, EFFORT_REPORT_FILE)
|
|
39
|
+
let raw: string
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(path, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined // no report written — the common case
|
|
44
|
+
}
|
|
45
|
+
// Remove it so it never lands in a commit (defence in depth; the backend also excludes it).
|
|
46
|
+
await rm(path, { force: true }).catch(() => {})
|
|
47
|
+
let parsed: unknown
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(raw)
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
return coerceEffort(parsed)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Coerce arbitrary parsed JSON into a clean {@link EffortReport}, or undefined when it carries nothing. */
|
|
57
|
+
function coerceEffort(value: unknown): EffortReport | undefined {
|
|
58
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
59
|
+
const o = value as Record<string, unknown>
|
|
60
|
+
const report: EffortReport = { difficulty: clampDifficulty(o.difficulty) }
|
|
61
|
+
if (typeof o.summary === 'string' && o.summary.trim()) {
|
|
62
|
+
report.summary = o.summary.trim().slice(0, 2000)
|
|
63
|
+
}
|
|
64
|
+
if (typeof o.reducedEffectiveness === 'string' && o.reducedEffectiveness.trim()) {
|
|
65
|
+
report.reducedEffectiveness = o.reducedEffectiveness.trim().slice(0, 2000)
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(o.obstacles)) {
|
|
68
|
+
const obstacles = o.obstacles
|
|
69
|
+
.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
|
70
|
+
.map((x) => x.trim().slice(0, 500))
|
|
71
|
+
.slice(0, 20)
|
|
72
|
+
if (obstacles.length) report.obstacles = obstacles
|
|
73
|
+
}
|
|
74
|
+
// Nothing beyond a defaulted difficulty ⇒ the agent didn't really report anything; drop it so
|
|
75
|
+
// run details don't show an empty "5/10, no detail" card for a stray/blank file.
|
|
76
|
+
if (
|
|
77
|
+
report.summary === undefined &&
|
|
78
|
+
report.reducedEffectiveness === undefined &&
|
|
79
|
+
report.obstacles === undefined &&
|
|
80
|
+
!isFiniteNumber(o.difficulty)
|
|
81
|
+
) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
return report
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clampDifficulty(v: unknown): number {
|
|
88
|
+
const n = isFiniteNumber(v)
|
|
89
|
+
? v
|
|
90
|
+
: typeof v === 'string' && v.trim() !== ''
|
|
91
|
+
? Number(v)
|
|
92
|
+
: Number.NaN
|
|
93
|
+
if (!Number.isFinite(n)) return 5
|
|
94
|
+
return Math.min(10, Math.max(1, Math.round(n)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isFiniteNumber(v: unknown): v is number {
|
|
98
|
+
return typeof v === 'number' && Number.isFinite(v)
|
|
99
|
+
}
|