ai-foreman 1.7.12 → 1.7.13
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 +15 -0
- package/dist/activity.d.ts +5 -1
- package/dist/activity.js +1 -1
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +133 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +121 -11
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +11 -1
- package/dist/adapters/types.d.ts +47 -1
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +7 -1
- package/dist/branch/resume.js +11 -1
- package/dist/branch/runner.d.ts +11 -3
- package/dist/branch/runner.js +114 -60
- package/dist/buildRuns.d.ts +6 -2
- package/dist/buildRuns.js +76 -32
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +84 -1
- package/dist/cli/manager.d.ts +17 -0
- package/dist/cli/manager.js +135 -0
- package/dist/cli/recovery.d.ts +4 -0
- package/dist/cli/recovery.js +80 -0
- package/dist/cli/start.js +289 -53
- package/dist/cli/status.js +13 -2
- package/dist/cli/tickets.js +26 -8
- package/dist/config.d.ts +2 -0
- package/dist/config.js +23 -0
- package/dist/continuity.js +14 -9
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +428 -0
- package/dist/foreman.d.ts +16 -6
- package/dist/foreman.js +70 -82
- package/dist/handoffs.d.ts +5 -1
- package/dist/handoffs.js +4 -1
- package/dist/index.js +6 -0
- package/dist/log.d.ts +2 -2
- package/dist/log.js +4 -1
- package/dist/managerPacket.d.ts +25 -0
- package/dist/managerPacket.js +83 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +4 -1
- package/dist/observability.d.ts +248 -0
- package/dist/observability.js +1018 -0
- package/dist/planChanges.d.ts +4 -0
- package/dist/planChanges.js +16 -0
- package/dist/projectDiagnostics.d.ts +27 -0
- package/dist/projectDiagnostics.js +407 -0
- package/dist/qaRecovery.d.ts +140 -0
- package/dist/qaRecovery.js +595 -0
- package/dist/qaReview.d.ts +128 -14
- package/dist/qaReview.js +974 -57
- package/dist/qaSnapshot.d.ts +31 -5
- package/dist/qaSnapshot.js +209 -134
- package/dist/recoveryPolicy.d.ts +37 -0
- package/dist/recoveryPolicy.js +251 -0
- package/dist/roles.js +1 -0
- package/dist/supervisor.d.ts +40 -0
- package/dist/supervisor.js +102 -0
- package/dist/ticketHistoryReader.d.ts +19 -0
- package/dist/ticketHistoryReader.js +58 -0
- package/dist/tickets/details.d.ts +8 -0
- package/dist/tickets/details.js +59 -11
- package/dist/tickets/output.d.ts +5 -0
- package/dist/tickets/output.js +73 -0
- package/dist/tickets/stateDb.d.ts +1 -0
- package/dist/tickets/stateDb.js +5 -0
- package/dist/workflowDb.d.ts +31 -2
- package/dist/workflowDb.js +192 -23
- package/dist/workflowReader.d.ts +42 -0
- package/dist/workflowReader.js +171 -0
- package/foreman.yaml +9 -0
- package/package.json +51 -3
package/dist/cli/start.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import { resolve, join, relative } from "node:path";
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { select, text, isCancel } from "@clack/prompts";
|
|
7
7
|
import { loadConfig } from "../config.js";
|
|
8
8
|
import { Log } from "../log.js";
|
|
@@ -43,10 +43,33 @@ import { HandoffAcceptanceError, HandoffLoopError, HandoffRecoveryPausedError, H
|
|
|
43
43
|
import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
|
|
44
44
|
import { SessionUnavailableError } from "../adapters/sessionFailure.js";
|
|
45
45
|
import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
|
|
46
|
+
import { ObservabilityStore, RunObserver } from "../observability.js";
|
|
47
|
+
import { loadProjectAutonomyConfig, resolveAutonomyPolicy, resolveQaEnablement } from "../recoveryPolicy.js";
|
|
48
|
+
import { loadQaRecoveryPacket } from "../qaRecovery.js";
|
|
49
|
+
import { loadSkill } from "special-agents";
|
|
50
|
+
const FOREMAN_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
46
51
|
function fail(message) {
|
|
47
52
|
console.error(`foreman: ${message}`);
|
|
48
53
|
process.exit(1);
|
|
49
54
|
}
|
|
55
|
+
function pendingQaRecoveryPacket(projectDir, runId) {
|
|
56
|
+
const db = new WorkflowDb(projectDir);
|
|
57
|
+
try {
|
|
58
|
+
const pending = db.getRun(runId)?.state.qaReportRecovery;
|
|
59
|
+
if (!pending || typeof pending !== "object")
|
|
60
|
+
return undefined;
|
|
61
|
+
const state = pending;
|
|
62
|
+
if (state.pendingAction !== "operator-menu" || typeof state.packetPath !== "string" || typeof state.packetDigest !== "string")
|
|
63
|
+
return undefined;
|
|
64
|
+
const packet = loadQaRecoveryPacket(state.packetPath);
|
|
65
|
+
if (packet.manifest.packetDigest !== state.packetDigest || packet.manifest.runId !== runId)
|
|
66
|
+
throw new Error("saved QA recovery packet does not match its durable pending-decision state");
|
|
67
|
+
return packet;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
db.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
50
73
|
function findLastSessionId(dir) {
|
|
51
74
|
if (!existsSync(dir))
|
|
52
75
|
return undefined;
|
|
@@ -311,6 +334,9 @@ export function buildStartCommand() {
|
|
|
311
334
|
.option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
|
|
312
335
|
.option("--fast", "fast mode — lower latency (maps to effort=low for codex)")
|
|
313
336
|
.option("--no-qa", "disable per-ticket QA review (enabled by default)")
|
|
337
|
+
.option("--autonomy <profile>", "autonomy profile (supervised | balanced | unattended)")
|
|
338
|
+
.option("--detach", "return after launching the durable supervisor")
|
|
339
|
+
.option("--no-supervisor", "run the compatibility worker directly without supervision")
|
|
314
340
|
.option("--branch-per-ticket", "run each selected structured ticket in an isolated git worktree and branch")
|
|
315
341
|
.option("--no-branch-per-ticket", "disable saved branch-per-ticket defaults for this run")
|
|
316
342
|
.option("--create-pr", "push each successful ticket branch and create a GitHub PR (implies --branch-per-ticket)")
|
|
@@ -375,6 +401,12 @@ export function buildStartCommand() {
|
|
|
375
401
|
const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
|
|
376
402
|
if (opts.recoverRun && !recoveryRecord)
|
|
377
403
|
fail(`recoverable build run not found: ${opts.recoverRun}`);
|
|
404
|
+
const autonomyProfile = opts.autonomy;
|
|
405
|
+
if (autonomyProfile && !["supervised", "balanced", "unattended"].includes(autonomyProfile))
|
|
406
|
+
fail("--autonomy must be supervised, balanced, or unattended");
|
|
407
|
+
const frozenPolicy = recoveryRecord && "frozenPolicy" in recoveryRecord
|
|
408
|
+
? recoveryRecord.frozenPolicy
|
|
409
|
+
: resolveAutonomyPolicy(loadProjectAutonomyConfig(cwd), autonomyProfile);
|
|
378
410
|
const autoApprovePlanUpdates = recoveryRecord
|
|
379
411
|
? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
|
|
380
412
|
: Boolean(opts.yes);
|
|
@@ -607,7 +639,43 @@ export function buildStartCommand() {
|
|
|
607
639
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
608
640
|
const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
|
|
609
641
|
const log = new Log(logPath);
|
|
610
|
-
const
|
|
642
|
+
const qaResolution = resolveQaEnablement({
|
|
643
|
+
cli: command.getOptionValueSource("qa") === "cli" ? Boolean(opts.qa) : undefined,
|
|
644
|
+
frozen: recoveryRecord && "qaEnabled" in recoveryRecord ? Boolean(recoveryRecord.qaEnabled) : undefined,
|
|
645
|
+
projectDir: cwd,
|
|
646
|
+
legacyForeman: config.qa.enabled,
|
|
647
|
+
});
|
|
648
|
+
const qaEnabled = qaResolution.enabled;
|
|
649
|
+
if (qaResolution.deprecationWarning)
|
|
650
|
+
console.error(`rafi: warning: ${qaResolution.deprecationWarning}`);
|
|
651
|
+
const invocationRunId = recoveryRecord?.runId ?? randomUUID();
|
|
652
|
+
let observabilityStore;
|
|
653
|
+
if (config.observability.enabled) {
|
|
654
|
+
try {
|
|
655
|
+
observabilityStore = new ObservabilityStore(cwd, { config: config.observability });
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
console.error(`rafi: observability unavailable; build will continue: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
let activeObserver = observabilityStore ? new RunObserver(observabilityStore, invocationRunId) : undefined;
|
|
662
|
+
const finishObservationOnExit = () => {
|
|
663
|
+
if (!activeObserver)
|
|
664
|
+
return;
|
|
665
|
+
activeObserver.finish("interrupted");
|
|
666
|
+
observabilityStore?.closeLogFile(logPath, "interrupted");
|
|
667
|
+
observabilityStore?.close();
|
|
668
|
+
activeObserver = undefined;
|
|
669
|
+
};
|
|
670
|
+
process.once("exit", finishObservationOnExit);
|
|
671
|
+
activeObserver?.startResourceSampling();
|
|
672
|
+
observabilityStore?.recordCapabilities(invocationRunId, {
|
|
673
|
+
provider_turns: { source: "provider_turns", state: "available", observedAt: new Date().toISOString() },
|
|
674
|
+
tools: { source: "tools", state: "available", observedAt: new Date().toISOString() },
|
|
675
|
+
process_resources: { source: "process_resources", state: "available", observedAt: new Date().toISOString() },
|
|
676
|
+
child_process_resources: { source: "child_process_resources", state: "unavailable", observedAt: new Date().toISOString(), detail: "provider child-process attribution is unsupported" },
|
|
677
|
+
}, FOREMAN_VERSION);
|
|
678
|
+
observabilityStore?.indexLogFile(logPath, invocationRunId);
|
|
611
679
|
const createRawBuilder = async (builderCwd, sessionId, sessionRef) => {
|
|
612
680
|
const builderPolicy = new PermissionPolicy(config.permissions, builderCwd, { currentBranchWorkflow: !branchMode });
|
|
613
681
|
const roleBundle = loadRoleBundle("builder", { projectDir: builderCwd });
|
|
@@ -630,6 +698,7 @@ export function buildStartCommand() {
|
|
|
630
698
|
systemPromptAppend: roleBundle.system || undefined,
|
|
631
699
|
skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
|
|
632
700
|
autoCompactThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
|
|
701
|
+
observer: activeObserver,
|
|
633
702
|
};
|
|
634
703
|
const adapter = agent === "codex"
|
|
635
704
|
? new CodexAdapter(adapterOpts)
|
|
@@ -732,6 +801,7 @@ export function buildStartCommand() {
|
|
|
732
801
|
systemPromptAppend: roleBundle.system || undefined,
|
|
733
802
|
skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
|
|
734
803
|
autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
|
|
804
|
+
observer: activeObserver,
|
|
735
805
|
};
|
|
736
806
|
const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
|
|
737
807
|
await prepareNativeAutoCompaction(created);
|
|
@@ -760,6 +830,7 @@ export function buildStartCommand() {
|
|
|
760
830
|
systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
|
|
761
831
|
skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
|
|
762
832
|
autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
|
|
833
|
+
observer: activeObserver,
|
|
763
834
|
};
|
|
764
835
|
const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
|
|
765
836
|
await prepareNativeAutoCompaction(created);
|
|
@@ -789,6 +860,7 @@ export function buildStartCommand() {
|
|
|
789
860
|
initial,
|
|
790
861
|
runtime: agent,
|
|
791
862
|
label: "builder turn",
|
|
863
|
+
observer: activeObserver,
|
|
792
864
|
enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
793
865
|
allowSwitch: !(opts.resume || opts.continue),
|
|
794
866
|
recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
|
|
@@ -980,6 +1052,7 @@ export function buildStartCommand() {
|
|
|
980
1052
|
systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
|
|
981
1053
|
skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
|
|
982
1054
|
autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
|
|
1055
|
+
observer: activeObserver,
|
|
983
1056
|
};
|
|
984
1057
|
const created = qaSettings.make === "codex"
|
|
985
1058
|
? new CodexAdapter(adapterOpts)
|
|
@@ -993,6 +1066,7 @@ export function buildStartCommand() {
|
|
|
993
1066
|
initial,
|
|
994
1067
|
runtime: qaAgent,
|
|
995
1068
|
label: "QA turn",
|
|
1069
|
+
observer: activeObserver,
|
|
996
1070
|
enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
997
1071
|
allowSwitch: !sessionRef,
|
|
998
1072
|
recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
|
|
@@ -1113,6 +1187,7 @@ export function buildStartCommand() {
|
|
|
1113
1187
|
void sessionId;
|
|
1114
1188
|
let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
|
|
1115
1189
|
let explicitRecoveryAccepted = false;
|
|
1190
|
+
let handoffReceipt = { unavailable: "no predecessor handoff was required" };
|
|
1116
1191
|
if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
|
|
1117
1192
|
const service = new HandoffService(cwd);
|
|
1118
1193
|
const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
|
|
@@ -1120,6 +1195,7 @@ export function buildStartCommand() {
|
|
|
1120
1195
|
adapter = accepted.successor;
|
|
1121
1196
|
pendingHandoffGeneration = undefined;
|
|
1122
1197
|
explicitRecoveryAccepted = true;
|
|
1198
|
+
handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
|
|
1123
1199
|
log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
|
|
1124
1200
|
}
|
|
1125
1201
|
// A new disposable QA worktree always means a new provider session.
|
|
@@ -1152,6 +1228,7 @@ export function buildStartCommand() {
|
|
|
1152
1228
|
});
|
|
1153
1229
|
const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
|
|
1154
1230
|
adapter = accepted.successor;
|
|
1231
|
+
handoffReceipt = { generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest };
|
|
1155
1232
|
log.write("handoff-transfer", {
|
|
1156
1233
|
role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
|
|
1157
1234
|
predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
|
|
@@ -1159,7 +1236,35 @@ export function buildStartCommand() {
|
|
|
1159
1236
|
});
|
|
1160
1237
|
}
|
|
1161
1238
|
}
|
|
1162
|
-
|
|
1239
|
+
const decorated = decorateQa(adapter, qaCwd, effectiveSessionId);
|
|
1240
|
+
const settings = resolvedContinuitySettings("qa");
|
|
1241
|
+
const bundle = loadRoleBundle("qa", { projectDir: qaCwd });
|
|
1242
|
+
const effectiveRoleInstructions = `${bundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`;
|
|
1243
|
+
return {
|
|
1244
|
+
adapter: decorated,
|
|
1245
|
+
sessionIdentity: decorated.sessionRef?.() ?? decorated.sessionId(),
|
|
1246
|
+
effectiveRoleInstructions,
|
|
1247
|
+
runtimeContext: settings,
|
|
1248
|
+
skills: bundle.skills.map((name) => {
|
|
1249
|
+
const path = [join(qaCwd, ".agents", "skills", name, "SKILL.md"), join(qaCwd, ".claude", "skills", name, "SKILL.md"), join(qaCwd, ".codex", "skills", name, "SKILL.md")].find(existsSync);
|
|
1250
|
+
if (path) {
|
|
1251
|
+
const dispatchedContent = `## ${name}\n${readFileSync(path, "utf8").trim()}`;
|
|
1252
|
+
return { name, path, digest: createHash("sha256").update(dispatchedContent).digest("hex") };
|
|
1253
|
+
}
|
|
1254
|
+
if (settings.make === "codex") {
|
|
1255
|
+
try {
|
|
1256
|
+
const bundled = loadSkill(name);
|
|
1257
|
+
if (bundled.body?.trim()) {
|
|
1258
|
+
const dispatchedContent = `## ${bundled.name}\n${bundled.body.trim()}`;
|
|
1259
|
+
return { name, path: `special-agents:${name}`, digest: createHash("sha256").update(dispatchedContent).digest("hex") };
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
catch { /* explicit unavailable result below */ }
|
|
1263
|
+
}
|
|
1264
|
+
return { name, digest: "unavailable", reason: "runtime skill name had no resolvable loaded skill content" };
|
|
1265
|
+
}),
|
|
1266
|
+
handoffReceipt,
|
|
1267
|
+
};
|
|
1163
1268
|
};
|
|
1164
1269
|
const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
|
|
1165
1270
|
const context = await input.adapter.contextUsage?.().catch(() => undefined);
|
|
@@ -1198,7 +1303,7 @@ export function buildStartCommand() {
|
|
|
1198
1303
|
await input.adapter.close().catch(() => { });
|
|
1199
1304
|
return decorateQa(transfer.successor, qaCwd, undefined, input.next);
|
|
1200
1305
|
};
|
|
1201
|
-
const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd) => {
|
|
1306
|
+
const qaSessionBoundary = async (adapter, frozenAction, strategy, qaCwd, recovery) => {
|
|
1202
1307
|
if (!activeContinuityRunId)
|
|
1203
1308
|
throw new Error("QA session boundary requires an active durable run");
|
|
1204
1309
|
const qaSettings = resolvedContinuitySettings("qa");
|
|
@@ -1217,13 +1322,13 @@ export function buildStartCommand() {
|
|
|
1217
1322
|
}),
|
|
1218
1323
|
report: (event) => {
|
|
1219
1324
|
currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
|
|
1220
|
-
log.write("context-lifecycle", { role: "qa", ...event, sample: event.sample });
|
|
1221
1325
|
},
|
|
1222
1326
|
handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
|
|
1223
1327
|
const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
|
|
1224
1328
|
const transfer = await new HandoffService(cwd).transfer({
|
|
1225
1329
|
runId: activeContinuityRunId, role: "qa", reason, predecessorSessionId: predecessor.sessionId(), predecessorSessionRef: predecessor.sessionRef?.(),
|
|
1226
|
-
roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample
|
|
1330
|
+
roleState: { frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample,
|
|
1331
|
+
...(recovery ? { recoveryPacketDigest: recovery.packetDigest, reviewedStateDigest: recovery.reviewedStateDigest } : {}) },
|
|
1227
1332
|
...(nativeUsage ? { sessionUsage: {
|
|
1228
1333
|
version: 1, runId: activeContinuityRunId, role: "qa", provider: predecessor.agent,
|
|
1229
1334
|
providerSessionId: predecessor.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
|
|
@@ -1231,7 +1336,10 @@ export function buildStartCommand() {
|
|
|
1231
1336
|
cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
|
|
1232
1337
|
} } : {}),
|
|
1233
1338
|
compactionCount, compactMaximum: settings.compact_maximum ?? 10,
|
|
1234
|
-
resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }
|
|
1339
|
+
resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }, ...(recovery ? [
|
|
1340
|
+
{ label: "qa-recovery-packet", digest: recovery.packetDigest, authoritative: true, requiredForRecovery: true, mediaType: "application/vnd.rafi.qa-recovery+json", path: recovery.packetPath },
|
|
1341
|
+
{ label: "qa-reviewed-state", digest: recovery.reviewedStateDigest, authoritative: true, requiredForRecovery: true, mediaType: "application/vnd.rafi.reviewed-state", path: "reviewed-state/" },
|
|
1342
|
+
] : [])],
|
|
1235
1343
|
}, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
|
|
1236
1344
|
log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
|
|
1237
1345
|
await predecessor.close().catch(() => { });
|
|
@@ -1253,7 +1361,8 @@ export function buildStartCommand() {
|
|
|
1253
1361
|
});
|
|
1254
1362
|
await controller.observeNativeCompactions(adapter);
|
|
1255
1363
|
};
|
|
1256
|
-
const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner);
|
|
1364
|
+
const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner, () => activeObserver);
|
|
1365
|
+
const qaReportRecovery = createQaReportRecoveryHandler(!process.stdin.isTTY || !process.stdout.isTTY, () => activeObserver);
|
|
1257
1366
|
if (branchMode) {
|
|
1258
1367
|
const ticketsConfig = loadTicketsConfig(cwd);
|
|
1259
1368
|
const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
|
|
@@ -1443,13 +1552,13 @@ export function buildStartCommand() {
|
|
|
1443
1552
|
}
|
|
1444
1553
|
console.log();
|
|
1445
1554
|
if (!autoApprovePlanUpdates && plan.issues.every((issue) => !issue.blocking)) {
|
|
1446
|
-
const action = await select({
|
|
1555
|
+
const action = await withObservedUserWait(activeObserver, "branch plan approval", () => select({
|
|
1447
1556
|
message: branchPresentation.prompt,
|
|
1448
1557
|
options: [
|
|
1449
1558
|
{ value: "proceed", label: "Proceed" },
|
|
1450
1559
|
{ value: "cancel", label: "Cancel" },
|
|
1451
1560
|
],
|
|
1452
|
-
});
|
|
1561
|
+
}));
|
|
1453
1562
|
if (isCancel(action) || action === "cancel") {
|
|
1454
1563
|
console.log("ai-foreman: cancelled");
|
|
1455
1564
|
process.exit(0);
|
|
@@ -1489,11 +1598,15 @@ export function buildStartCommand() {
|
|
|
1489
1598
|
};
|
|
1490
1599
|
const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
|
|
1491
1600
|
let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
|
|
1601
|
+
runId: invocationRunId,
|
|
1492
1602
|
tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
|
|
1493
1603
|
repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
|
|
1604
|
+
frozenPolicy, autonomyProfile, qaEnabled,
|
|
1494
1605
|
runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
|
|
1495
1606
|
});
|
|
1496
1607
|
activeContinuityRunId = masterRun.runId;
|
|
1608
|
+
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId) : undefined;
|
|
1609
|
+
activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
|
|
1497
1610
|
const branchContextControllers = new Map();
|
|
1498
1611
|
const branchContextController = (worktreePath) => {
|
|
1499
1612
|
const existing = branchContextControllers.get(worktreePath);
|
|
@@ -1509,7 +1622,7 @@ export function buildStartCommand() {
|
|
|
1509
1622
|
adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
|
|
1510
1623
|
sessionTransition: "adopted live settings",
|
|
1511
1624
|
}),
|
|
1512
|
-
report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model });
|
|
1625
|
+
report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); },
|
|
1513
1626
|
handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
|
|
1514
1627
|
const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
|
|
1515
1628
|
const transfer = await new HandoffService(cwd).transfer({
|
|
@@ -1548,6 +1661,12 @@ export function buildStartCommand() {
|
|
|
1548
1661
|
notificationsEnabled: config.notifications.enabled,
|
|
1549
1662
|
terminalBellEnabled: config.notifications.terminal_bell,
|
|
1550
1663
|
qaEnabled,
|
|
1664
|
+
qaRuntimeContext: capturedBranchQa,
|
|
1665
|
+
qaContinuityManaged: true,
|
|
1666
|
+
qaReportRecovery,
|
|
1667
|
+
qaResumedRecovery,
|
|
1668
|
+
qaMaxFixAttempts: frozenPolicy.limits.builderQaFixesPerTicket,
|
|
1669
|
+
observer: activeObserver,
|
|
1551
1670
|
createPr: branchDefaults.createReview,
|
|
1552
1671
|
completionMode: branchDefaults.completionMode,
|
|
1553
1672
|
reviewProvider: branchDefaults.reviewProvider,
|
|
@@ -1597,17 +1716,9 @@ export function buildStartCommand() {
|
|
|
1597
1716
|
activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
|
|
1598
1717
|
else
|
|
1599
1718
|
console.log(line);
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
db.recordTelemetry(masterRun.runId, snapshot);
|
|
1604
|
-
db.recordContextSample(snapshot.contextSample);
|
|
1605
|
-
if (snapshot.sessionUsage)
|
|
1606
|
-
db.recordSessionUsage(snapshot.sessionUsage);
|
|
1607
|
-
}
|
|
1608
|
-
finally {
|
|
1609
|
-
db.close();
|
|
1610
|
-
}
|
|
1719
|
+
activeObserver?.store.updateCurrentState({ runId: masterRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: masterRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: "builder ticket session", lastSignalAt: snapshot.contextSample.observedAt });
|
|
1720
|
+
if (snapshot.contextSample.used !== undefined)
|
|
1721
|
+
activeObserver?.store.recordMetric({ runId: masterRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
|
|
1611
1722
|
});
|
|
1612
1723
|
reporter.start();
|
|
1613
1724
|
try {
|
|
@@ -1680,6 +1791,14 @@ export function buildStartCommand() {
|
|
|
1680
1791
|
masterRun = summaries.every((row) => row.buildStatus === "done")
|
|
1681
1792
|
? completeBuildRun(cwd, masterRun)
|
|
1682
1793
|
: releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
|
|
1794
|
+
activeObserver?.finish(masterRun.status, observationSummaryMetadata(cwd, masterRun, qaEnabled, capturedBranchBuilder.make, summaries.find(row => row.pr?.url)));
|
|
1795
|
+
observabilityStore?.closeLogFile(logPath, masterRun.status);
|
|
1796
|
+
observabilityStore?.enforceLimits();
|
|
1797
|
+
observabilityStore?.maintainLogs(cwd);
|
|
1798
|
+
observabilityStore?.close();
|
|
1799
|
+
pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
|
|
1800
|
+
activeObserver = undefined;
|
|
1801
|
+
process.off("exit", finishObservationOnExit);
|
|
1683
1802
|
console.log("foreman: branch run summary");
|
|
1684
1803
|
console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
|
|
1685
1804
|
for (const row of summaries) {
|
|
@@ -1746,10 +1865,14 @@ export function buildStartCommand() {
|
|
|
1746
1865
|
};
|
|
1747
1866
|
const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false, auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50, compact_maximum: roleDefaults.qa?.compact_maximum ?? 10, settings_revision: settingsRevision };
|
|
1748
1867
|
let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
|
|
1868
|
+
runId: invocationRunId,
|
|
1749
1869
|
tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
|
|
1870
|
+
frozenPolicy, autonomyProfile, qaEnabled,
|
|
1750
1871
|
runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
|
|
1751
1872
|
});
|
|
1873
|
+
activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
|
|
1752
1874
|
activeContinuityRunId = buildRun.runId;
|
|
1875
|
+
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId) : undefined;
|
|
1753
1876
|
const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
|
|
1754
1877
|
heartbeat.unref();
|
|
1755
1878
|
const checkpointSignal = () => {
|
|
@@ -1793,7 +1916,6 @@ export function buildStartCommand() {
|
|
|
1793
1916
|
}),
|
|
1794
1917
|
report: (event) => {
|
|
1795
1918
|
currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
|
|
1796
|
-
log.write("context-lifecycle", { ...event, sample: event.sample });
|
|
1797
1919
|
},
|
|
1798
1920
|
handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
|
|
1799
1921
|
const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
|
|
@@ -1823,7 +1945,7 @@ export function buildStartCommand() {
|
|
|
1823
1945
|
return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
|
|
1824
1946
|
},
|
|
1825
1947
|
});
|
|
1826
|
-
const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, qaEnabled,
|
|
1948
|
+
const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, qaEnabled, frozenPolicy.limits.builderQaFixesPerTicket, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); }, activeObserver, capturedQa, true, qaReportRecovery, qaResumedRecovery);
|
|
1827
1949
|
activeBuilderForStatus = () => foreman.builderAdapter();
|
|
1828
1950
|
const statusReporter = liveStatusReporter = new AgentStatusReporter({
|
|
1829
1951
|
runId: buildRun.runId,
|
|
@@ -1850,17 +1972,9 @@ export function buildStartCommand() {
|
|
|
1850
1972
|
activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
|
|
1851
1973
|
else
|
|
1852
1974
|
console.log(line);
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
db.recordTelemetry(buildRun.runId, snapshot);
|
|
1857
|
-
db.recordContextSample(snapshot.contextSample);
|
|
1858
|
-
if (snapshot.sessionUsage)
|
|
1859
|
-
db.recordSessionUsage(snapshot.sessionUsage);
|
|
1860
|
-
}
|
|
1861
|
-
finally {
|
|
1862
|
-
db.close();
|
|
1863
|
-
}
|
|
1975
|
+
activeObserver?.store.updateCurrentState({ runId: buildRun.runId, role: "builder", stream: "builder", executionId: activeObserver.executionId, ticketId: buildRun.currentTicket, providerSessionId: snapshot.contextSample.providerSessionId, phase: "builder work session", lastSignalAt: snapshot.contextSample.observedAt });
|
|
1976
|
+
if (snapshot.contextSample.used !== undefined)
|
|
1977
|
+
activeObserver?.store.recordMetric({ runId: buildRun.runId, executionId: activeObserver.executionId, role: "builder", stream: "builder", providerSessionId: snapshot.contextSample.providerSessionId }, "context_used_tokens", snapshot.contextSample.used, { unit: "tokens" });
|
|
1864
1978
|
});
|
|
1865
1979
|
statusReporter.start();
|
|
1866
1980
|
const modifiers = [
|
|
@@ -1878,7 +1992,7 @@ export function buildStartCommand() {
|
|
|
1878
1992
|
try {
|
|
1879
1993
|
console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
|
|
1880
1994
|
buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
|
|
1881
|
-
await foreman.runPreflight(steps, ticketsContent, preferredTicket);
|
|
1995
|
+
await (activeObserver ? activeObserver.span("preflight", "Builder preflight", () => foreman.runPreflight(steps, ticketsContent, preferredTicket)) : foreman.runPreflight(steps, ticketsContent, preferredTicket));
|
|
1882
1996
|
buildRun = withRunLocalAcceptedRuntimes(buildRun);
|
|
1883
1997
|
if (foreman.builderSessionId())
|
|
1884
1998
|
buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
|
|
@@ -1888,14 +2002,14 @@ export function buildStartCommand() {
|
|
|
1888
2002
|
if (!autoApprovePlanUpdates) {
|
|
1889
2003
|
while (true) {
|
|
1890
2004
|
console.log();
|
|
1891
|
-
const action = await select({
|
|
2005
|
+
const action = await withObservedUserWait(activeObserver, "build plan decision", () => select({
|
|
1892
2006
|
message: "How does this plan look?",
|
|
1893
2007
|
options: [
|
|
1894
2008
|
{ value: "proceed", label: "Proceed — start implementing" },
|
|
1895
2009
|
{ value: "feedback", label: "Give feedback — revise the plan" },
|
|
1896
2010
|
{ value: "cancel", label: "Cancel" },
|
|
1897
2011
|
],
|
|
1898
|
-
});
|
|
2012
|
+
}));
|
|
1899
2013
|
if (isCancel(action) || action === "cancel") {
|
|
1900
2014
|
console.log("ai-foreman: cancelled");
|
|
1901
2015
|
statusReporter.stop();
|
|
@@ -1907,10 +2021,10 @@ export function buildStartCommand() {
|
|
|
1907
2021
|
console.log();
|
|
1908
2022
|
break;
|
|
1909
2023
|
}
|
|
1910
|
-
const fb = await text({
|
|
2024
|
+
const fb = await withObservedUserWait(activeObserver, "build plan feedback", () => text({
|
|
1911
2025
|
message: "Your feedback:",
|
|
1912
2026
|
validate: (v) => (v?.trim() ? undefined : "Please enter some feedback"),
|
|
1913
|
-
});
|
|
2027
|
+
}));
|
|
1914
2028
|
if (isCancel(fb)) {
|
|
1915
2029
|
console.log("ai-foreman: cancelled");
|
|
1916
2030
|
statusReporter.stop();
|
|
@@ -1922,7 +2036,7 @@ export function buildStartCommand() {
|
|
|
1922
2036
|
await foreman.sendPreflightFeedback(String(fb));
|
|
1923
2037
|
}
|
|
1924
2038
|
}
|
|
1925
|
-
const result = await foreman.runBatch(steps, trackerRelPath, (ticketId) => {
|
|
2039
|
+
const result = await (activeObserver ? activeObserver.span("builder_work", "Builder and QA batch", () => foreman.runBatch(steps, trackerRelPath, (ticketId) => {
|
|
1926
2040
|
const tickets = buildRun.tickets.includes(ticketId)
|
|
1927
2041
|
? buildRun.tickets
|
|
1928
2042
|
: [...buildRun.tickets, ticketId];
|
|
@@ -1930,7 +2044,10 @@ export function buildStartCommand() {
|
|
|
1930
2044
|
tickets,
|
|
1931
2045
|
currentTicket: ticketId,
|
|
1932
2046
|
});
|
|
1933
|
-
}, preferredTicket)
|
|
2047
|
+
}, preferredTicket)) : foreman.runBatch(steps, trackerRelPath, (ticketId) => {
|
|
2048
|
+
const tickets = buildRun.tickets.includes(ticketId) ? buildRun.tickets : [...buildRun.tickets, ticketId];
|
|
2049
|
+
buildRun = checkpointBuildRun(cwd, buildRun, "ticket-selected", { tickets, currentTicket: ticketId });
|
|
2050
|
+
}, preferredTicket));
|
|
1934
2051
|
if (pendingHandoffGeneration !== undefined)
|
|
1935
2052
|
throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted; recovery mode was not substituted`);
|
|
1936
2053
|
if (foreman.builderSessionId())
|
|
@@ -1944,6 +2061,14 @@ export function buildStartCommand() {
|
|
|
1944
2061
|
statusReporter.stop();
|
|
1945
2062
|
await foreman.close();
|
|
1946
2063
|
await viewer;
|
|
2064
|
+
activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
|
|
2065
|
+
observabilityStore?.closeLogFile(logPath, buildRun.status);
|
|
2066
|
+
observabilityStore?.enforceLimits();
|
|
2067
|
+
observabilityStore?.maintainLogs(cwd);
|
|
2068
|
+
observabilityStore?.close();
|
|
2069
|
+
pruneRecoveryTelemetryAtCompletion(cwd, config.observability.detail_retention_days);
|
|
2070
|
+
activeObserver = undefined;
|
|
2071
|
+
process.off("exit", finishObservationOnExit);
|
|
1947
2072
|
console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
|
|
1948
2073
|
console.log(`foreman: outcome — ${result.outcome}`);
|
|
1949
2074
|
if (result.detail)
|
|
@@ -2021,6 +2146,74 @@ export function buildStartCommand() {
|
|
|
2021
2146
|
}
|
|
2022
2147
|
});
|
|
2023
2148
|
}
|
|
2149
|
+
function withObservedUserWait(observer, label, operation) {
|
|
2150
|
+
return observer ? observer.span("user_wait", label, operation) : operation();
|
|
2151
|
+
}
|
|
2152
|
+
function ticketSizeBucket(projectDir, ticketIds) {
|
|
2153
|
+
if (!ticketIds.length || !isTicketsInitialized(projectDir))
|
|
2154
|
+
return undefined;
|
|
2155
|
+
try {
|
|
2156
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
2157
|
+
const ranks = new Map([["XS", 0], ["S", 1], ["M", 2], ["L", 3], ["XL", 4]]);
|
|
2158
|
+
return loadTickets(paths.tickets).filter(ticket => ticketIds.includes(ticket.id))
|
|
2159
|
+
.sort((left, right) => (ranks.get(right.size) ?? -1) - (ranks.get(left.size) ?? -1))[0]?.size;
|
|
2160
|
+
}
|
|
2161
|
+
catch {
|
|
2162
|
+
return undefined;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
function observationSummaryMetadata(projectDir, run, qaEnabled, provider, reviewRun) {
|
|
2166
|
+
const worktree = run.repository.worktree;
|
|
2167
|
+
const terminalHead = reviewRun?.commit ?? diagnosticGit(worktree, ["rev-parse", "HEAD"]);
|
|
2168
|
+
const base = run.repository.git.baselineHead ?? run.repository.baseHead;
|
|
2169
|
+
const commitCount = base ? Number(diagnosticGit(worktree, ["rev-list", "--count", `${base}..HEAD`])) : undefined;
|
|
2170
|
+
const status = diagnosticGit(worktree, ["status", "--porcelain=v1"]);
|
|
2171
|
+
const numstat = base ? diagnosticGit(worktree, ["diff", "--numstat", `${base}..HEAD`]) : undefined;
|
|
2172
|
+
let additions = 0;
|
|
2173
|
+
let deletions = 0;
|
|
2174
|
+
for (const line of numstat?.split("\n").filter(Boolean) ?? []) {
|
|
2175
|
+
const [added, deleted] = line.split("\t");
|
|
2176
|
+
if (/^\d+$/.test(added ?? ""))
|
|
2177
|
+
additions += Number(added);
|
|
2178
|
+
if (/^\d+$/.test(deleted ?? ""))
|
|
2179
|
+
deletions += Number(deleted);
|
|
2180
|
+
}
|
|
2181
|
+
const receipt = Object.entries(run.receipts).find(([key, value]) => /(?:pull|merge|pr|mr)/i.test(`${key} ${value.externalId ?? ""}`));
|
|
2182
|
+
const review = reviewRun?.pr;
|
|
2183
|
+
const reviewId = review?.url?.match(/\/(?:pull|merge_requests)\/(\d+)(?:\D|$)/)?.[1] ?? receipt?.[1].externalId;
|
|
2184
|
+
return {
|
|
2185
|
+
branchMode: run.branchMode, qaEnabled, primaryProvider: provider, model: run.builder?.settings.model,
|
|
2186
|
+
ticketCount: run.tickets.length, ticketIds: run.tickets, deliveryUnit: run.deliveryUnit, checkpoint: run.checkpoint,
|
|
2187
|
+
failureCategory: run.failure?.category ?? run.interruption?.category, ticketSizeBucket: ticketSizeBucket(projectDir, run.tickets),
|
|
2188
|
+
createdAt: run.createdAt, updatedAt: run.updatedAt, completedAt: run.completedAt,
|
|
2189
|
+
git: { branch: reviewRun?.branch ?? run.repository.git.branch ?? run.repository.branch, terminalHead, ...(Number.isFinite(commitCount) ? { commitCount } : {}),
|
|
2190
|
+
changedPathCount: status?.split("\n").filter(Boolean).length, ...(numstat !== undefined ? { additions, deletions } : {}),
|
|
2191
|
+
...(reviewId ? { pullRequest: { provider: /merge_requests\//.test(review?.url ?? "") || /mr|gitlab/i.test(receipt?.[0] ?? "") ? "gitlab" : "github", id: reviewId, state: review?.status, url: review?.url } } : {}),
|
|
2192
|
+
observedAt: new Date().toISOString(), historical: true },
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
function diagnosticGit(cwd, args) {
|
|
2196
|
+
try {
|
|
2197
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 1024 * 1024 }).trim();
|
|
2198
|
+
}
|
|
2199
|
+
catch {
|
|
2200
|
+
return undefined;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
function pruneRecoveryTelemetryAtCompletion(projectDir, retentionDays) {
|
|
2204
|
+
try {
|
|
2205
|
+
const db = new WorkflowDb(projectDir);
|
|
2206
|
+
try {
|
|
2207
|
+
db.pruneTerminalTelemetry(retentionDays);
|
|
2208
|
+
}
|
|
2209
|
+
finally {
|
|
2210
|
+
db.close();
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
catch (error) {
|
|
2214
|
+
console.error(`rafi: recovery telemetry maintenance skipped: ${String(error).replace(/\s+/g, " ").slice(0, 300)}`);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2024
2217
|
async function prepareNativeAutoCompaction(adapter) {
|
|
2025
2218
|
if (!adapter.prepareAutoCompaction) {
|
|
2026
2219
|
await adapter.close().catch(() => { });
|
|
@@ -2039,25 +2232,25 @@ async function prepareNativeAutoCompaction(adapter) {
|
|
|
2039
2232
|
throw error;
|
|
2040
2233
|
}
|
|
2041
2234
|
}
|
|
2042
|
-
function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
|
|
2235
|
+
function createQaNonconvergenceHandler(projectDir, noninteractive, planner, observer) {
|
|
2043
2236
|
return async (context) => {
|
|
2044
2237
|
if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
2045
2238
|
return { action: "pause" };
|
|
2046
2239
|
while (true) {
|
|
2047
|
-
const choice = await select({ message: `QA did not converge for ${context.ticket.id}. What next?`, options: [
|
|
2240
|
+
const choice = await withObservedUserWait(observer?.(), "QA nonconvergence decision", () => select({ message: `QA did not converge for ${context.ticket.id}. What next?`, options: [
|
|
2048
2241
|
{ value: "retry", label: "Retry another Builder fix" },
|
|
2049
2242
|
{ value: "pause", label: "Pause at this checkpoint" },
|
|
2050
2243
|
{ value: "waive", label: "Explicitly waive QA" },
|
|
2051
2244
|
{ value: "planner", label: "Discuss with read-only Planner" },
|
|
2052
|
-
] });
|
|
2245
|
+
] }));
|
|
2053
2246
|
if (isCancel(choice) || choice === "pause")
|
|
2054
2247
|
return { action: "pause" };
|
|
2055
2248
|
if (choice === "retry")
|
|
2056
2249
|
return { action: "retry" };
|
|
2057
2250
|
if (choice === "waive") {
|
|
2058
|
-
const approved = await select({ message: "Waive QA and record validation_result=failed with all unresolved issues?", options: [
|
|
2251
|
+
const approved = await withObservedUserWait(observer?.(), "QA waiver confirmation", () => select({ message: "Waive QA and record validation_result=failed with all unresolved issues?", options: [
|
|
2059
2252
|
{ value: "no", label: "No (Recommended)" }, { value: "yes", label: "Yes, waive and continue" },
|
|
2060
|
-
] });
|
|
2253
|
+
] }));
|
|
2061
2254
|
if (approved === "yes") {
|
|
2062
2255
|
const db = new WorkflowDb(projectDir);
|
|
2063
2256
|
try {
|
|
@@ -2076,18 +2269,26 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
|
|
|
2076
2269
|
}
|
|
2077
2270
|
continue;
|
|
2078
2271
|
}
|
|
2079
|
-
const discussion = await text({ message: "What should the Planner focus on?", defaultValue: "Explain the QA failures and propose the safest concrete remediation." });
|
|
2272
|
+
const discussion = await withObservedUserWait(observer?.(), "QA remediation guidance", () => text({ message: "What should the Planner focus on?", defaultValue: "Explain the QA failures and propose the safest concrete remediation." }));
|
|
2080
2273
|
if (isCancel(discussion))
|
|
2081
2274
|
continue;
|
|
2082
|
-
const
|
|
2275
|
+
const rawDiff = execFileSync("git", ["-C", context.builderWorktree, "diff", "--binary", "HEAD"], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
|
|
2276
|
+
const diffLimit = 128 * 1024;
|
|
2277
|
+
const diffTruncated = rawDiff.length > diffLimit;
|
|
2278
|
+
const diff = rawDiff.subarray(0, diffLimit).toString("utf8");
|
|
2279
|
+
const changeSummary = execFileSync("git", ["-C", context.builderWorktree, "status", "--short", "--untracked-files=all"], { encoding: "utf8" }).split("\n").filter(Boolean).sort().join("\n");
|
|
2280
|
+
const untracked = execFileSync("git", ["-C", context.builderWorktree, "ls-files", "--others", "--exclude-standard"], { encoding: "utf8" }).split("\n").filter(Boolean).sort();
|
|
2083
2281
|
let instruction = [
|
|
2084
2282
|
"You are the read-only Planner mediating QA nonconvergence. Do not edit files or run mutating tools.",
|
|
2085
2283
|
`Ticket: ${context.ticket.id} — ${context.ticket.title}`,
|
|
2086
2284
|
`Requirements: ${context.ticket.acceptance.join("; ")}`,
|
|
2087
2285
|
`Required tests: ${context.ticket.required_tests.join("; ")}`,
|
|
2286
|
+
`Builder worktree: ${context.builderWorktree}`,
|
|
2287
|
+
`Deterministic change summary:\n${changeSummary || "(clean)"}`,
|
|
2288
|
+
`Untracked-file inventory:\n${untracked.join("\n") || "(none)"}`,
|
|
2088
2289
|
`User discussion: ${String(discussion)}`,
|
|
2089
2290
|
`Complete QA history:\n${JSON.stringify(context.history, null, 2)}`,
|
|
2090
|
-
|
|
2291
|
+
`${diffTruncated ? "Builder diff (TRUNCATED at 128 KiB; inspect the actual worktree before relying on it)" : "Full Builder diff"}:\n${diff || "(no tracked diff)"}`,
|
|
2091
2292
|
"Return RAFI_QA_REMEDIATION_START, then one JSON object {\"summary\":\"...\",\"fix_instructions\":[\"...\"]}, then RAFI_QA_REMEDIATION_END.",
|
|
2092
2293
|
"End with STEP_STATUS: plan_complete | summary=\"QA remediation proposed\"",
|
|
2093
2294
|
].join("\n\n");
|
|
@@ -2108,14 +2309,14 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
|
|
|
2108
2309
|
if (!proposal.summary || !Array.isArray(proposal.fix_instructions) || !proposal.fix_instructions.every((item) => typeof item === "string"))
|
|
2109
2310
|
throw new Error("Planner remediation proposal is malformed");
|
|
2110
2311
|
console.log(`\nPlanner remediation: ${proposal.summary}\n${proposal.fix_instructions.map((item) => `- ${item}`).join("\n")}`);
|
|
2111
|
-
const decision = await select({ message: "Use this remediation for a new Builder fix session?", options: [
|
|
2312
|
+
const decision = await withObservedUserWait(observer?.(), "QA remediation approval", () => select({ message: "Use this remediation for a new Builder fix session?", options: [
|
|
2112
2313
|
{ value: "approve", label: "Approve (Recommended)" }, { value: "revise", label: "Discuss/revise" }, { value: "cancel", label: "Cancel and return to choices" },
|
|
2113
|
-
] });
|
|
2314
|
+
] }));
|
|
2114
2315
|
if (decision === "approve")
|
|
2115
2316
|
return { action: "remediate", remediation: [proposal.summary, ...proposal.fix_instructions].join("\n") };
|
|
2116
2317
|
if (isCancel(decision) || decision === "cancel")
|
|
2117
2318
|
break;
|
|
2118
|
-
const feedback = await text({ message: "Planner revision feedback:" });
|
|
2319
|
+
const feedback = await withObservedUserWait(observer?.(), "QA Planner revision feedback", () => text({ message: "Planner revision feedback:" }));
|
|
2119
2320
|
if (isCancel(feedback))
|
|
2120
2321
|
break;
|
|
2121
2322
|
resumeSessionId = run.sessionId;
|
|
@@ -2124,6 +2325,41 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
|
|
|
2124
2325
|
}
|
|
2125
2326
|
};
|
|
2126
2327
|
}
|
|
2328
|
+
function createQaReportRecoveryHandler(noninteractive, observer) {
|
|
2329
|
+
return async ({ packet, originalIssues, liveSession, contextUsage }) => {
|
|
2330
|
+
if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2331
|
+
console.error(`foreman: QA report recovery requires input; saved packet ${packet.directory}`);
|
|
2332
|
+
console.error(`foreman: resume with the run ID recorded in ${join(packet.directory, "manifest.json")}`);
|
|
2333
|
+
return { action: "pause" };
|
|
2334
|
+
}
|
|
2335
|
+
const choice = await withObservedUserWait(observer?.(), "QA report recovery decision", () => select({
|
|
2336
|
+
message: "QA report recovery:",
|
|
2337
|
+
options: [
|
|
2338
|
+
{ value: "fresh", label: "Try another fresh QA." },
|
|
2339
|
+
{ value: "plain", label: "Use plain issues fallback.", hint: originalIssues ? originalIssues.slice(0, 120) : "Unavailable: original issues synopsis was empty" },
|
|
2340
|
+
{ value: "manual", label: "Manually fix saved JSON.", hint: join(packet.directory, "report.json") },
|
|
2341
|
+
{ value: "guidance", label: "Give QA specific instructions." },
|
|
2342
|
+
{ value: "pause", label: "Pause." },
|
|
2343
|
+
],
|
|
2344
|
+
}));
|
|
2345
|
+
if (isCancel(choice) || choice === "pause")
|
|
2346
|
+
return { action: "pause" };
|
|
2347
|
+
if (choice === "fresh")
|
|
2348
|
+
return { action: "fresh" };
|
|
2349
|
+
if (choice === "plain")
|
|
2350
|
+
return { action: "plain" };
|
|
2351
|
+
if (choice === "manual")
|
|
2352
|
+
return { action: "manual" };
|
|
2353
|
+
console.log(`foreman: current QA context usage: ${JSON.stringify(contextUsage).slice(0, 500)}`);
|
|
2354
|
+
const guidance = await withObservedUserWait(observer?.(), "QA recovery guidance", () => text({ message: "Specific QA report-recovery instructions:" }));
|
|
2355
|
+
if (isCancel(guidance) || !String(guidance).trim())
|
|
2356
|
+
return { action: "pause" };
|
|
2357
|
+
const route = await withObservedUserWait(observer?.(), "QA recovery session choice", () => select({ message: "Use which QA session route?", options: liveSession ? [
|
|
2358
|
+
{ value: "current", label: "Current session" }, { value: "compact", label: "Compact current session" }, { value: "fresh", label: "Fresh validated handoff" },
|
|
2359
|
+
] : [{ value: "fresh", label: "Fresh validated handoff (resumed recovery)" }] }));
|
|
2360
|
+
return isCancel(route) ? { action: "pause" } : { action: "guidance", instructions: String(guidance), route };
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2127
2363
|
function readAgentDefaults(projectDir) {
|
|
2128
2364
|
const path = join(projectDir, "rafi-config.yaml");
|
|
2129
2365
|
if (!existsSync(path))
|