ai-foreman 1.7.13 → 1.7.14
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 +33 -3
- package/dist/adapters/claude.d.ts +5 -0
- package/dist/adapters/claude.js +122 -16
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +30 -8
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +6 -0
- package/dist/adapters/types.d.ts +22 -0
- package/dist/agentRun.js +21 -3
- package/dist/branch/currentGuard.d.ts +2 -0
- package/dist/branch/currentGuard.js +9 -0
- package/dist/branch/finalization.d.ts +22 -0
- package/dist/branch/finalization.js +120 -0
- package/dist/branch/git.js +7 -2
- package/dist/branch/runner.d.ts +3 -2
- package/dist/branch/runner.js +277 -75
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +33 -3
- package/dist/cli/start.js +314 -86
- package/dist/cli/state.d.ts +4 -0
- package/dist/cli/state.js +69 -0
- package/dist/continuity.d.ts +4 -0
- package/dist/continuity.js +26 -3
- package/dist/foreman.d.ts +22 -4
- package/dist/foreman.js +105 -19
- package/dist/handoffs.d.ts +5 -2
- package/dist/handoffs.js +93 -12
- package/dist/index.js +2 -0
- package/dist/qaFailureDelivery.d.ts +100 -0
- package/dist/qaFailureDelivery.js +570 -0
- package/dist/qaProtocolV2.d.ts +233 -0
- package/dist/qaProtocolV2.js +114 -0
- package/dist/qaRecovery.d.ts +15 -3
- package/dist/qaRecovery.js +477 -83
- package/dist/qaReview.d.ts +42 -17
- package/dist/qaReview.js +1216 -457
- package/dist/qaRuntime.d.ts +14 -0
- package/dist/qaRuntime.js +44 -0
- package/dist/qaSnapshot.d.ts +34 -0
- package/dist/qaSnapshot.js +302 -34
- package/dist/stateTransfer.d.ts +57 -0
- package/dist/stateTransfer.js +644 -0
- package/dist/statusReporter.d.ts +2 -0
- package/dist/statusReporter.js +6 -0
- package/dist/workflowDb.d.ts +245 -2
- package/dist/workflowDb.js +647 -6
- package/package.json +15 -3
package/dist/cli/start.js
CHANGED
|
@@ -45,25 +45,47 @@ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
|
|
|
45
45
|
import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
|
|
46
46
|
import { ObservabilityStore, RunObserver } from "../observability.js";
|
|
47
47
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy, resolveQaEnablement } from "../recoveryPolicy.js";
|
|
48
|
-
import { loadQaRecoveryPacket } from "../qaRecovery.js";
|
|
48
|
+
import { loadQaRecoveryPacket, recoverPendingQaRecoveryPublications } from "../qaRecovery.js";
|
|
49
|
+
import { qaDigest } from "../qaProtocolV2.js";
|
|
49
50
|
import { loadSkill } from "special-agents";
|
|
51
|
+
import { acceptQaRuntimeHandoff, describeQaRuntimeHandle, frozenQaRuntimeSettings } from "../qaRuntime.js";
|
|
50
52
|
const FOREMAN_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
51
53
|
function fail(message) {
|
|
52
54
|
console.error(`foreman: ${message}`);
|
|
53
55
|
process.exit(1);
|
|
54
56
|
}
|
|
55
|
-
function pendingQaRecoveryPacket(projectDir, runId) {
|
|
57
|
+
function pendingQaRecoveryPacket(projectDir, runId, ticketId) {
|
|
58
|
+
recoverPendingQaRecoveryPublications(projectDir, runId);
|
|
56
59
|
const db = new WorkflowDb(projectDir);
|
|
57
60
|
try {
|
|
58
|
-
const pending = db.
|
|
59
|
-
if (
|
|
61
|
+
const pending = db.pendingQaRecoveryHeads(runId);
|
|
62
|
+
if (pending.length > 1 && !ticketId)
|
|
63
|
+
throw new Error(`run ${runId} has multiple pending QA recovery packets; select one with --ticket`);
|
|
64
|
+
const selected = ticketId ? pending.find((item) => item.ticketId === ticketId) : pending[0];
|
|
65
|
+
if (!selected || selected.pendingAction === "resolved")
|
|
60
66
|
return undefined;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
const packet = loadQaRecoveryPacket(selected.packetPath);
|
|
68
|
+
const state = db.qaRecoveryHead(runId, packet.manifest.ticketId);
|
|
69
|
+
if (!state || packet.manifest.packetDigest !== state.packetDigest || packet.manifest.runId !== runId
|
|
70
|
+
|| packet.manifest.ticketId !== state.ticketId || packet.manifest.packetId !== state.packetId
|
|
71
|
+
|| packet.manifest.revision !== state.revision || packet.manifest.reviewedStateDigest !== state.reviewedStateDigest
|
|
72
|
+
|| packet.manifest.correctionTurns !== state.correctionTurns || packet.manifest.pendingAction !== state.pendingAction) {
|
|
73
|
+
throw new Error("saved QA recovery packet does not match its durable recovery head");
|
|
74
|
+
}
|
|
75
|
+
const qaHead = db.qaTicketHead(runId, packet.manifest.ticketId);
|
|
76
|
+
const attempts = db.qaReviewAttempts(runId, packet.manifest.ticketId);
|
|
77
|
+
const attempt = attempts.find((item) => item.attemptId === packet.manifest.reviewAttemptId);
|
|
78
|
+
const packetMayLeadSource = qaHead.sourceStateDigest !== packet.manifest.reviewedStateDigest
|
|
79
|
+
&& ["automatic-recovery", "successor-acknowledgement", "qa-correction", "qa-full-review", "operator-menu"].includes(packet.manifest.pendingAction);
|
|
80
|
+
const exactBinding = qaHead.reviewNumber === packet.manifest.reviewAttempt && attempt?.reviewNumber === packet.manifest.reviewAttempt
|
|
81
|
+
&& attempt.sourceDigest === qaHead.sourceStateDigest && (qaHead.sourceStateDigest === packet.manifest.reviewedStateDigest || packetMayLeadSource);
|
|
82
|
+
const undispatchedSuccessor = attempts.find((item) => item.reviewNumber === qaHead.reviewNumber);
|
|
83
|
+
const packetMayLagOneUndispatchedReview = qaHead.state === "review-ready" && qaHead.reviewNumber === packet.manifest.reviewAttempt + 1
|
|
84
|
+
&& attempt?.reviewNumber === packet.manifest.reviewAttempt && attempt.status === "interrupted" && undispatchedSuccessor?.status === "started"
|
|
85
|
+
&& undispatchedSuccessor.sourceDigest === qaHead.sourceStateDigest && qaHead.sourceStateDigest === packet.manifest.reviewedStateDigest;
|
|
86
|
+
if (!exactBinding && !packetMayLagOneUndispatchedReview) {
|
|
87
|
+
throw new Error("saved QA recovery packet does not match its durable review/source binding");
|
|
88
|
+
}
|
|
67
89
|
return packet;
|
|
68
90
|
}
|
|
69
91
|
finally {
|
|
@@ -326,6 +348,8 @@ export function buildStartCommand() {
|
|
|
326
348
|
.option("-r, --resume <sessionId>", "resume a prior builder session")
|
|
327
349
|
.option("--recover-run <id>", "continue an existing master recovery run ID")
|
|
328
350
|
.option("--recovery-mode <mode>", "frozen build:resume mode (internal recovery receipt)")
|
|
351
|
+
.option("--recovery-decision-digest <digest>", "exact frozen build:resume decision digest (internal)")
|
|
352
|
+
.option("--qa-revision <number>", "exact durable QA protocol revision (internal recovery receipt)")
|
|
329
353
|
.option("--accept-handoff <generation>", "accept a pre-staged cumulative handoff generation")
|
|
330
354
|
.option("--accept-handoff-role <role>", "role owning the pre-staged handoff (builder | qa)", "builder")
|
|
331
355
|
.option("--continue", "resume the most recent logged session for this project")
|
|
@@ -416,6 +440,8 @@ export function buildStartCommand() {
|
|
|
416
440
|
fail(`unknown frozen recovery mode ${String(opts.recoveryMode)}`);
|
|
417
441
|
if (!recoveryRecord?.recoveryDecision || recoveryRecord.recoveryDecision.mode !== opts.recoveryMode)
|
|
418
442
|
fail("recovery mode no longer matches the persisted decision receipt; Rafi will not substitute a path");
|
|
443
|
+
if (opts.recoveryDecisionDigest && createHash("sha256").update(JSON.stringify(recoveryRecord.recoveryDecision)).digest("hex") !== opts.recoveryDecisionDigest)
|
|
444
|
+
fail("recovery decision digest no longer matches the persisted decision receipt");
|
|
419
445
|
}
|
|
420
446
|
if (opts.acceptHandoff && !recoveryRecord)
|
|
421
447
|
fail("--accept-handoff requires --recover-run");
|
|
@@ -777,6 +803,25 @@ export function buildStartCommand() {
|
|
|
777
803
|
...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
|
|
778
804
|
...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
|
|
779
805
|
});
|
|
806
|
+
const qaRuntimeMetadata = new WeakMap();
|
|
807
|
+
const resolveQaRuntimeMetadata = (qaCwd, settings) => {
|
|
808
|
+
const bundle = loadRoleBundle("qa", { projectDir: qaCwd });
|
|
809
|
+
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.`;
|
|
810
|
+
const skills = bundle.skills.map((name) => {
|
|
811
|
+
const runtimeRoot = settings.make === "codex" ? ".codex" : ".claude";
|
|
812
|
+
const path = [join(qaCwd, runtimeRoot, "skills", name, "SKILL.md"), join(qaCwd, ".agents", "skills", name, "SKILL.md")].find(existsSync);
|
|
813
|
+
if (path) {
|
|
814
|
+
const content = `## ${name}\n${readFileSync(path, "utf8").trim()}`;
|
|
815
|
+
return { name, path, content, digest: createHash("sha256").update(content).digest("hex") };
|
|
816
|
+
}
|
|
817
|
+
const bundled = loadSkill(name);
|
|
818
|
+
if (!bundled.body?.trim())
|
|
819
|
+
throw new Error(`QA runtime ${settings.make} skill ${name} has no exact dispatchable content`);
|
|
820
|
+
const content = `## ${bundled.name}\n${bundled.body.trim()}`;
|
|
821
|
+
return { name, path: `special-agents:${name}`, content, digest: createHash("sha256").update(content).digest("hex") };
|
|
822
|
+
});
|
|
823
|
+
return { settings: structuredClone(settings), effectiveRoleInstructions, skills };
|
|
824
|
+
};
|
|
780
825
|
const createBuilderForSettings = async (builderCwd, settings) => {
|
|
781
826
|
const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
|
|
782
827
|
label: "live Builder settings",
|
|
@@ -814,8 +859,8 @@ export function buildStartCommand() {
|
|
|
814
859
|
allowSwitch: false,
|
|
815
860
|
model: settings.model === "default" ? undefined : settings.model,
|
|
816
861
|
});
|
|
817
|
-
const policy = new PermissionPolicy(
|
|
818
|
-
const
|
|
862
|
+
const policy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
|
|
863
|
+
const metadata = resolveQaRuntimeMetadata(qaCwd, settings);
|
|
819
864
|
const adapterOptions = {
|
|
820
865
|
cwd: qaCwd,
|
|
821
866
|
configRoot: cwd,
|
|
@@ -827,13 +872,19 @@ export function buildStartCommand() {
|
|
|
827
872
|
permission: createPermissionHandler(policy, log),
|
|
828
873
|
effort: explicitEffort(settings.reasoning),
|
|
829
874
|
fast: settings.fast,
|
|
830
|
-
|
|
831
|
-
|
|
875
|
+
sandboxMode: "read-only",
|
|
876
|
+
systemPromptAppend: metadata.effectiveRoleInstructions,
|
|
877
|
+
preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
|
|
832
878
|
autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
|
|
879
|
+
allowAutoCompactionSetupTurn: false,
|
|
833
880
|
observer: activeObserver,
|
|
834
881
|
};
|
|
835
882
|
const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
|
|
883
|
+
if (!created.prepareSession)
|
|
884
|
+
throw new Error(`QA runtime ${settings.make} cannot establish a durable session identity before review`);
|
|
885
|
+
await created.prepareSession();
|
|
836
886
|
await prepareNativeAutoCompaction(created);
|
|
887
|
+
qaRuntimeMetadata.set(created, metadata);
|
|
837
888
|
return created;
|
|
838
889
|
};
|
|
839
890
|
const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
|
|
@@ -1033,8 +1084,8 @@ export function buildStartCommand() {
|
|
|
1033
1084
|
allowSwitch: false,
|
|
1034
1085
|
model: qaSettings.model === "default" ? undefined : qaSettings.model,
|
|
1035
1086
|
});
|
|
1036
|
-
const qaPolicy = new PermissionPolicy(
|
|
1037
|
-
const
|
|
1087
|
+
const qaPolicy = new PermissionPolicy(readOnlyPermissionConfig(), qaCwd);
|
|
1088
|
+
const metadata = resolveQaRuntimeMetadata(qaCwd, qaSettings);
|
|
1038
1089
|
const adapterOpts = {
|
|
1039
1090
|
cwd: qaCwd,
|
|
1040
1091
|
configRoot: cwd,
|
|
@@ -1049,26 +1100,35 @@ export function buildStartCommand() {
|
|
|
1049
1100
|
permission: createPermissionHandler(qaPolicy, log),
|
|
1050
1101
|
effort: explicitEffort(qaSettings.reasoning),
|
|
1051
1102
|
fast: qaSettings.fast,
|
|
1052
|
-
|
|
1053
|
-
|
|
1103
|
+
sandboxMode: "read-only",
|
|
1104
|
+
systemPromptAppend: metadata.effectiveRoleInstructions,
|
|
1105
|
+
preloadedSkillContent: metadata.skills.map(({ name, content }) => ({ name, content })),
|
|
1054
1106
|
autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
|
|
1107
|
+
allowAutoCompactionSetupTurn: false,
|
|
1055
1108
|
observer: activeObserver,
|
|
1056
1109
|
};
|
|
1057
1110
|
const created = qaSettings.make === "codex"
|
|
1058
1111
|
? new CodexAdapter(adapterOpts)
|
|
1059
1112
|
: await ClaudeAdapter.create(adapterOpts);
|
|
1113
|
+
if (!created.prepareSession)
|
|
1114
|
+
throw new Error(`QA runtime ${qaSettings.make} cannot establish a durable session identity before review`);
|
|
1115
|
+
await created.prepareSession();
|
|
1060
1116
|
await prepareNativeAutoCompaction(created);
|
|
1117
|
+
qaRuntimeMetadata.set(created, metadata);
|
|
1061
1118
|
return created;
|
|
1062
1119
|
};
|
|
1063
1120
|
const createRecoveringQa = async (qaCwd, sessionRef) => {
|
|
1064
1121
|
const initial = await createRawQa(qaCwd, sessionRef);
|
|
1065
|
-
|
|
1122
|
+
const recovering = new RecoveringAdapter({
|
|
1066
1123
|
initial,
|
|
1067
1124
|
runtime: qaAgent,
|
|
1068
1125
|
label: "QA turn",
|
|
1069
1126
|
observer: activeObserver,
|
|
1070
|
-
|
|
1071
|
-
|
|
1127
|
+
// QA retries and provider switches must cross the explicit durable
|
|
1128
|
+
// QA recovery boundary; an adapter-local replay would reuse one turn
|
|
1129
|
+
// intent for two different provider executions.
|
|
1130
|
+
enabled: false,
|
|
1131
|
+
allowSwitch: false,
|
|
1072
1132
|
recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
|
|
1073
1133
|
const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
|
|
1074
1134
|
label: "QA recovery",
|
|
@@ -1099,11 +1159,17 @@ export function buildStartCommand() {
|
|
|
1099
1159
|
return createRawQa(qaCwd, resumeRef);
|
|
1100
1160
|
},
|
|
1101
1161
|
});
|
|
1162
|
+
const metadata = qaRuntimeMetadata.get(initial);
|
|
1163
|
+
if (metadata)
|
|
1164
|
+
qaRuntimeMetadata.set(recovering, metadata);
|
|
1165
|
+
return recovering;
|
|
1102
1166
|
};
|
|
1103
|
-
const decorateQa = (adapter, qaCwd, sessionId
|
|
1104
|
-
const
|
|
1167
|
+
const decorateQa = (adapter, qaCwd, sessionId) => {
|
|
1168
|
+
const metadata = qaRuntimeMetadata.get(adapter);
|
|
1169
|
+
const qaSettings = frozenQaRuntimeSettings(adapter, metadata);
|
|
1105
1170
|
const continuous = activeContinuityRunId ? new ContinuityAdapter({
|
|
1106
1171
|
adapter, projectDir: cwd, runId: activeContinuityRunId, role: "qa", settings: qaSettings,
|
|
1172
|
+
durableSingleTurn: true,
|
|
1107
1173
|
authoritativeStateRevision: () => readAgentDefaults(cwd).revision ?? settingsRevision,
|
|
1108
1174
|
createSuccessor: () => createRecoveringQa(qaCwd),
|
|
1109
1175
|
replaceRecoveryLeaseAfterCheckpoint: String(opts.recoveryMode ?? "") === "fresh-recovery-only" && recoveryRecord?.recoveryDecision?.role === "qa",
|
|
@@ -1141,7 +1207,7 @@ export function buildStartCommand() {
|
|
|
1141
1207
|
return transfer.successor;
|
|
1142
1208
|
},
|
|
1143
1209
|
}) : adapter;
|
|
1144
|
-
|
|
1210
|
+
const decorated = new RoleStatusAdapter(continuous, (active) => liveStatusReporter?.updateState({
|
|
1145
1211
|
role: "qa", provider: active.agent, model: qaSettings.model, reasoning: qaSettings.reasoning, fast: qaSettings.fast,
|
|
1146
1212
|
phase: "QA review session", sessionTransition: sessionId ? "resumed QA session" : "QA session", adapter: active,
|
|
1147
1213
|
settingsRevision: qaSettings.settings_revision, displaySessionCost: qaSettings.display_session_cost,
|
|
@@ -1178,6 +1244,52 @@ export function buildStartCommand() {
|
|
|
1178
1244
|
} },
|
|
1179
1245
|
});
|
|
1180
1246
|
});
|
|
1247
|
+
qaRuntimeMetadata.set(continuous, metadata);
|
|
1248
|
+
qaRuntimeMetadata.set(decorated, metadata);
|
|
1249
|
+
return decorated;
|
|
1250
|
+
};
|
|
1251
|
+
const describeQaHandle = (adapter, _qaCwd, handoffReceipt) => describeQaRuntimeHandle(adapter, qaRuntimeMetadata.get(adapter), handoffReceipt);
|
|
1252
|
+
const qaV2SessionRef = (ref) => {
|
|
1253
|
+
if (ref.role !== "qa" || ref.stream !== "qa" || !ref.sessionId || !ref.cwd || !ref.configRoot
|
|
1254
|
+
|| /^(?:unavailable|unknown)$/i.test(ref.sessionId.trim()))
|
|
1255
|
+
throw new Error("QA handoff receipt has an invalid scoped session identity");
|
|
1256
|
+
return {
|
|
1257
|
+
version: 2, provider: ref.provider, sessionId: ref.sessionId, role: "qa", stream: "qa",
|
|
1258
|
+
generation: ref.generation, cwd: ref.cwd, configRoot: ref.configRoot,
|
|
1259
|
+
createdAt: ref.createdAt, validatedAt: ref.validatedAt ?? new Date().toISOString(),
|
|
1260
|
+
};
|
|
1261
|
+
};
|
|
1262
|
+
const qaV2HandoffReceipt = (receipt, recovery, confinementDigest) => {
|
|
1263
|
+
if (!receipt.predecessorSessionRef)
|
|
1264
|
+
throw new Error("QA recovery handoff has no scoped predecessor identity");
|
|
1265
|
+
const receiptDb = new WorkflowDb(cwd);
|
|
1266
|
+
try {
|
|
1267
|
+
const head = receiptDb.qaTicketHead(recovery.runId, recovery.ticketId);
|
|
1268
|
+
if (!head.reviewBasisDigest || !head.sourceStateDigest)
|
|
1269
|
+
throw new Error("QA recovery handoff has no durable predecessor review basis/source state");
|
|
1270
|
+
const resources = receipt.resources.map((resource) => {
|
|
1271
|
+
if (!resource.path || !resource.purpose || resource.bytes === undefined || !resource.mediaType)
|
|
1272
|
+
throw new Error(`QA recovery handoff resource ${resource.label} lacks complete inventory metadata`);
|
|
1273
|
+
return { label: resource.label, digest: resource.digest, authoritative: resource.authoritative,
|
|
1274
|
+
requiredForRecovery: resource.requiredForRecovery ?? false, mediaType: resource.mediaType,
|
|
1275
|
+
path: resource.path, purpose: resource.purpose, bytes: resource.bytes };
|
|
1276
|
+
});
|
|
1277
|
+
const base = {
|
|
1278
|
+
version: 2, runId: recovery.runId, ticketId: recovery.ticketId, qaRevision: head.revision,
|
|
1279
|
+
sourceStateDigest: recovery.reviewedStateDigest, predecessorSourceStateDigest: head.sourceStateDigest,
|
|
1280
|
+
reviewBasisDigest: head.reviewBasisDigest, requiresFullReview: true, confinementDigest,
|
|
1281
|
+
predecessor: qaV2SessionRef(receipt.predecessorSessionRef), successor: qaV2SessionRef(receipt.successorSessionRef),
|
|
1282
|
+
manifestDigest: receipt.manifestDigest, continuityCheckpointDigest: receipt.continuityCheckpointDigest,
|
|
1283
|
+
acceptanceCheckpointDigest: receipt.acceptanceCheckpointDigest, packetDigest: recovery.packetDigest,
|
|
1284
|
+
inventoryDigest: qaDigest("handoff-inventory", resources), resources, acceptedAt: receipt.acceptedAt,
|
|
1285
|
+
};
|
|
1286
|
+
const completed = { ...base, operationId: qaDigest("qa-handoff-operation", base) };
|
|
1287
|
+
receiptDb.recordQaHandoffReceipt(completed);
|
|
1288
|
+
return completed;
|
|
1289
|
+
}
|
|
1290
|
+
finally {
|
|
1291
|
+
receiptDb.close();
|
|
1292
|
+
}
|
|
1181
1293
|
};
|
|
1182
1294
|
const createQa = async (qaCwd, sessionId) => {
|
|
1183
1295
|
const recoveryMode = String(opts.recoveryMode ?? "");
|
|
@@ -1187,15 +1299,24 @@ export function buildStartCommand() {
|
|
|
1187
1299
|
void sessionId;
|
|
1188
1300
|
let adapter = await createRecoveringQa(qaCwd, effectiveSessionId);
|
|
1189
1301
|
let explicitRecoveryAccepted = false;
|
|
1190
|
-
let handoffReceipt = {
|
|
1302
|
+
let handoffReceipt = { kind: "initial" };
|
|
1191
1303
|
if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
|
|
1192
1304
|
const service = new HandoffService(cwd);
|
|
1193
1305
|
const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
|
|
1194
1306
|
const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
|
|
1307
|
+
if (!accepted.acceptanceReceipt)
|
|
1308
|
+
throw new Error("QA recovery handoff did not produce a scoped acceptance receipt");
|
|
1195
1309
|
adapter = accepted.successor;
|
|
1196
1310
|
pendingHandoffGeneration = undefined;
|
|
1197
1311
|
explicitRecoveryAccepted = true;
|
|
1198
|
-
|
|
1312
|
+
const packet = pendingQaRecoveryPacket(cwd, recoveryRecord.runId, typeof opts.ticket === "string" ? opts.ticket : undefined);
|
|
1313
|
+
if (!packet)
|
|
1314
|
+
throw new Error("accepted QA recovery handoff has no current durable packet");
|
|
1315
|
+
const confinement = describeQaHandle(adapter, qaCwd, { kind: "initial" }).confinement;
|
|
1316
|
+
handoffReceipt = { kind: "accepted", receipt: qaV2HandoffReceipt(accepted.acceptanceReceipt, {
|
|
1317
|
+
runId: packet.manifest.runId, ticketId: packet.manifest.ticketId, packetDigest: packet.manifest.packetDigest,
|
|
1318
|
+
reviewedStateDigest: packet.manifest.reviewedStateDigest,
|
|
1319
|
+
}, confinement.digest) };
|
|
1199
1320
|
log.write("recovery-handoff-accepted", { role: "qa", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
|
|
1200
1321
|
}
|
|
1201
1322
|
// A new disposable QA worktree always means a new provider session.
|
|
@@ -1227,8 +1348,12 @@ export function buildStartCommand() {
|
|
|
1227
1348
|
compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
|
|
1228
1349
|
});
|
|
1229
1350
|
const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
|
|
1351
|
+
if (!accepted.acceptanceReceipt)
|
|
1352
|
+
throw new Error("QA snapshot handoff did not produce a scoped acceptance receipt");
|
|
1230
1353
|
adapter = accepted.successor;
|
|
1231
|
-
|
|
1354
|
+
// This continuity transfer precedes construction of a ticket/source
|
|
1355
|
+
// review basis, so it is not a QA-report recovery receipt.
|
|
1356
|
+
handoffReceipt = { kind: "initial" };
|
|
1232
1357
|
log.write("handoff-transfer", {
|
|
1233
1358
|
role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
|
|
1234
1359
|
predecessorSessionId: staged.manifest.predecessorSessionId, successorSessionId: accepted.successorSessionId,
|
|
@@ -1237,34 +1362,7 @@ export function buildStartCommand() {
|
|
|
1237
1362
|
}
|
|
1238
1363
|
}
|
|
1239
1364
|
const decorated = decorateQa(adapter, qaCwd, effectiveSessionId);
|
|
1240
|
-
|
|
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
|
-
};
|
|
1365
|
+
return describeQaHandle(decorated, qaCwd, handoffReceipt);
|
|
1268
1366
|
};
|
|
1269
1367
|
const applyQaSettingsBoundary = async (runId, qaCwd, input) => {
|
|
1270
1368
|
const context = await input.adapter.contextUsage?.().catch(() => undefined);
|
|
@@ -1301,12 +1399,14 @@ export function buildStartCommand() {
|
|
|
1301
1399
|
acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
|
|
1302
1400
|
});
|
|
1303
1401
|
await input.adapter.close().catch(() => { });
|
|
1304
|
-
return decorateQa(transfer.successor, qaCwd
|
|
1402
|
+
return decorateQa(transfer.successor, qaCwd);
|
|
1305
1403
|
};
|
|
1306
|
-
const qaSessionBoundary = async (
|
|
1404
|
+
const qaSessionBoundary = async (handle, frozenAction, strategy, qaCwd, recovery) => {
|
|
1405
|
+
const adapter = handle.adapter;
|
|
1307
1406
|
if (!activeContinuityRunId)
|
|
1308
1407
|
throw new Error("QA session boundary requires an active durable run");
|
|
1309
|
-
const qaSettings =
|
|
1408
|
+
const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
|
|
1409
|
+
let acceptedBoundary;
|
|
1310
1410
|
const controller = RoleSessionController.managed({
|
|
1311
1411
|
projectDir: cwd,
|
|
1312
1412
|
runId: activeContinuityRunId,
|
|
@@ -1315,11 +1415,14 @@ export function buildStartCommand() {
|
|
|
1315
1415
|
historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, activeContinuityRunId, "qa"),
|
|
1316
1416
|
readSettings: () => liveRoleSettings("qa", qaSettings),
|
|
1317
1417
|
settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
|
|
1318
|
-
settingsAdopted: (
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1418
|
+
settingsAdopted: (_settings, active) => {
|
|
1419
|
+
const actual = frozenQaRuntimeSettings(active, qaRuntimeMetadata.get(active));
|
|
1420
|
+
liveStatusReporter?.updateState({
|
|
1421
|
+
role: "qa", provider: active.agent, model: actual.model, reasoning: actual.reasoning, fast: actual.fast,
|
|
1422
|
+
adapter: active, settingsRevision: actual.settings_revision, displaySessionCost: actual.display_session_cost,
|
|
1423
|
+
sessionTransition: "adopted live settings",
|
|
1424
|
+
});
|
|
1425
|
+
},
|
|
1323
1426
|
report: (event) => {
|
|
1324
1427
|
currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
|
|
1325
1428
|
},
|
|
@@ -1336,22 +1439,29 @@ export function buildStartCommand() {
|
|
|
1336
1439
|
cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
|
|
1337
1440
|
} } : {}),
|
|
1338
1441
|
compactionCount, compactMaximum: settings.compact_maximum ?? 10,
|
|
1339
|
-
resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }, ...(recovery ?
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1442
|
+
resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true, purpose: "Frozen QA action across the session boundary" }, ...(recovery ? recovery.resources.map((resource) => ({
|
|
1443
|
+
label: resource.label, digest: resource.digest, authoritative: true, requiredForRecovery: resource.requiredForRecovery,
|
|
1444
|
+
mediaType: resource.mediaType, path: resource.path, purpose: resource.purpose, bytes: resource.bytes,
|
|
1445
|
+
})) : [])],
|
|
1343
1446
|
}, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
|
|
1447
|
+
if (!transfer.acceptanceReceipt || !recovery)
|
|
1448
|
+
throw new Error("QA recovery boundary did not produce a complete receipt context");
|
|
1449
|
+
const successor = decorateQa(transfer.successor, qaCwd);
|
|
1450
|
+
acceptedBoundary = acceptQaRuntimeHandoff(describeQaHandle(successor, qaCwd, { kind: "initial" }), (confinementDigest) => qaV2HandoffReceipt(transfer.acceptanceReceipt, recovery, confinementDigest));
|
|
1344
1451
|
log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
|
|
1345
1452
|
await predecessor.close().catch(() => { });
|
|
1346
|
-
return
|
|
1453
|
+
return successor;
|
|
1347
1454
|
},
|
|
1348
1455
|
});
|
|
1349
|
-
|
|
1456
|
+
const next = (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
|
|
1457
|
+
if (!acceptedBoundary || acceptedBoundary.handle.adapter !== next)
|
|
1458
|
+
throw new Error("QA recovery boundary did not produce a validated acceptance receipt for its active successor");
|
|
1459
|
+
return acceptedBoundary;
|
|
1350
1460
|
};
|
|
1351
1461
|
const observeQaNativeCompactions = async (adapter) => {
|
|
1352
1462
|
if (!activeContinuityRunId)
|
|
1353
1463
|
return;
|
|
1354
|
-
const qaSettings =
|
|
1464
|
+
const qaSettings = frozenQaRuntimeSettings(adapter, qaRuntimeMetadata.get(adapter));
|
|
1355
1465
|
const controller = new ThresholdCompactionController({
|
|
1356
1466
|
projectDir: cwd,
|
|
1357
1467
|
runId: activeContinuityRunId,
|
|
@@ -1597,7 +1707,7 @@ export function buildStartCommand() {
|
|
|
1597
1707
|
compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
|
|
1598
1708
|
};
|
|
1599
1709
|
const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
|
|
1600
|
-
let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
|
|
1710
|
+
let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
|
|
1601
1711
|
runId: invocationRunId,
|
|
1602
1712
|
tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
|
|
1603
1713
|
repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
|
|
@@ -1605,7 +1715,41 @@ export function buildStartCommand() {
|
|
|
1605
1715
|
runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
|
|
1606
1716
|
});
|
|
1607
1717
|
activeContinuityRunId = masterRun.runId;
|
|
1608
|
-
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId) : undefined;
|
|
1718
|
+
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, typeof opts.ticket === "string" ? opts.ticket : undefined) : undefined;
|
|
1719
|
+
let qaProtocolResumeTicket;
|
|
1720
|
+
if (recoveryRecord && !qaResumedRecovery) {
|
|
1721
|
+
const protocolDb = new WorkflowDb(cwd);
|
|
1722
|
+
try {
|
|
1723
|
+
const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
|
|
1724
|
+
if (pendingHeads.length > 1 && !opts.ticket)
|
|
1725
|
+
fail(`run ${masterRun.runId} has multiple pending QA tickets; select one with --ticket`);
|
|
1726
|
+
const requested = typeof opts.ticket === "string" ? opts.ticket : undefined;
|
|
1727
|
+
const head = requested ? pendingHeads.find((item) => item.ticketId === requested) : pendingHeads[0];
|
|
1728
|
+
if (pendingHeads.length && requested && !head)
|
|
1729
|
+
fail(`--ticket ${requested} conflicts with the pending QA protocol ticket${pendingHeads.length === 1 ? ` ${pendingHeads[0].ticketId}` : "s"}`);
|
|
1730
|
+
if (head && !["completed", "waived"].includes(head.state)) {
|
|
1731
|
+
if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)
|
|
1732
|
+
fail(`pending QA protocol requires exact --qa-revision ${head.revision}`);
|
|
1733
|
+
qaProtocolResumeTicket = head.ticketId;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
finally {
|
|
1737
|
+
protocolDb.close();
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
if (qaResumedRecovery && String(opts.ticket ?? qaResumedRecovery.manifest.ticketId) !== qaResumedRecovery.manifest.ticketId)
|
|
1741
|
+
fail(`--ticket ${String(opts.ticket)} conflicts with pending QA recovery ticket ${qaResumedRecovery.manifest.ticketId}`);
|
|
1742
|
+
if (qaResumedRecovery) {
|
|
1743
|
+
const protocolDb = new WorkflowDb(cwd);
|
|
1744
|
+
try {
|
|
1745
|
+
const revision = protocolDb.qaTicketHead(masterRun.runId, qaResumedRecovery.manifest.ticketId).revision;
|
|
1746
|
+
if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== revision)
|
|
1747
|
+
fail(`pending QA protocol requires exact --qa-revision ${revision}`);
|
|
1748
|
+
}
|
|
1749
|
+
finally {
|
|
1750
|
+
protocolDb.close();
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1609
1753
|
activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
|
|
1610
1754
|
const branchContextControllers = new Map();
|
|
1611
1755
|
const branchContextController = (worktreePath) => {
|
|
@@ -1665,6 +1809,7 @@ export function buildStartCommand() {
|
|
|
1665
1809
|
qaContinuityManaged: true,
|
|
1666
1810
|
qaReportRecovery,
|
|
1667
1811
|
qaResumedRecovery,
|
|
1812
|
+
qaProtocolResumeTicket,
|
|
1668
1813
|
qaMaxFixAttempts: frozenPolicy.limits.builderQaFixesPerTicket,
|
|
1669
1814
|
observer: activeObserver,
|
|
1670
1815
|
createPr: branchDefaults.createReview,
|
|
@@ -1864,7 +2009,7 @@ export function buildStartCommand() {
|
|
|
1864
2009
|
compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
|
|
1865
2010
|
};
|
|
1866
2011
|
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 };
|
|
1867
|
-
let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
|
|
2012
|
+
let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
|
|
1868
2013
|
runId: invocationRunId,
|
|
1869
2014
|
tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
|
|
1870
2015
|
frozenPolicy, autonomyProfile, qaEnabled,
|
|
@@ -1872,7 +2017,47 @@ export function buildStartCommand() {
|
|
|
1872
2017
|
});
|
|
1873
2018
|
activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
|
|
1874
2019
|
activeContinuityRunId = buildRun.runId;
|
|
1875
|
-
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId) : undefined;
|
|
2020
|
+
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, typeof opts.ticket === "string" ? opts.ticket : undefined) : undefined;
|
|
2021
|
+
let qaFinalizationTicket;
|
|
2022
|
+
let qaProtocolResumeTicket;
|
|
2023
|
+
if (recoveryRecord && !qaResumedRecovery) {
|
|
2024
|
+
const finalizationDb = new WorkflowDb(cwd);
|
|
2025
|
+
try {
|
|
2026
|
+
const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
|
|
2027
|
+
if (pendingHeads.length > 1 && !opts.ticket)
|
|
2028
|
+
fail(`run ${buildRun.runId} has multiple pending QA tickets; select one with --ticket`);
|
|
2029
|
+
const requested = typeof opts.ticket === "string" ? opts.ticket : undefined;
|
|
2030
|
+
const head = requested ? pendingHeads.find((item) => item.ticketId === requested) : pendingHeads[0];
|
|
2031
|
+
if (pendingHeads.length && requested && !head)
|
|
2032
|
+
fail(`--ticket ${requested} conflicts with the pending QA protocol ticket${pendingHeads.length === 1 ? ` ${pendingHeads[0].ticketId}` : "s"}`);
|
|
2033
|
+
if (head) {
|
|
2034
|
+
if (head.state === "finalizing")
|
|
2035
|
+
qaFinalizationTicket = head.ticketId;
|
|
2036
|
+
else if (!["idle", "completed", "waived"].includes(head.state))
|
|
2037
|
+
qaProtocolResumeTicket = head.ticketId;
|
|
2038
|
+
if ((qaFinalizationTicket || qaProtocolResumeTicket)
|
|
2039
|
+
&& (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== head.revision)) {
|
|
2040
|
+
fail(`pending QA protocol requires exact --qa-revision ${head.revision}`);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
finally {
|
|
2045
|
+
finalizationDb.close();
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
if (qaResumedRecovery && String(opts.ticket ?? qaResumedRecovery.manifest.ticketId) !== qaResumedRecovery.manifest.ticketId)
|
|
2049
|
+
fail(`--ticket ${String(opts.ticket)} conflicts with pending QA recovery ticket ${qaResumedRecovery.manifest.ticketId}`);
|
|
2050
|
+
if (qaResumedRecovery) {
|
|
2051
|
+
const qaDb = new WorkflowDb(cwd);
|
|
2052
|
+
try {
|
|
2053
|
+
const currentRevision = qaDb.qaTicketHead(buildRun.runId, qaResumedRecovery.manifest.ticketId).revision;
|
|
2054
|
+
if (opts.qaRevision === undefined || !/^\d+$/.test(String(opts.qaRevision)) || Number(opts.qaRevision) !== currentRevision)
|
|
2055
|
+
fail(`pending QA recovery requires exact --qa-revision ${currentRevision}`);
|
|
2056
|
+
}
|
|
2057
|
+
finally {
|
|
2058
|
+
qaDb.close();
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
1876
2061
|
const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
|
|
1877
2062
|
heartbeat.unref();
|
|
1878
2063
|
const checkpointSignal = () => {
|
|
@@ -1945,7 +2130,7 @@ export function buildStartCommand() {
|
|
|
1945
2130
|
return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
|
|
1946
2131
|
},
|
|
1947
2132
|
});
|
|
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);
|
|
2133
|
+
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, buildRun.runId);
|
|
1949
2134
|
activeBuilderForStatus = () => foreman.builderAdapter();
|
|
1950
2135
|
const statusReporter = liveStatusReporter = new AgentStatusReporter({
|
|
1951
2136
|
runId: buildRun.runId,
|
|
@@ -1990,6 +2175,54 @@ export function buildStartCommand() {
|
|
|
1990
2175
|
console.log(`foreman: log ${logPath}\n`);
|
|
1991
2176
|
const viewer = printEvents(builder.events());
|
|
1992
2177
|
try {
|
|
2178
|
+
if (qaFinalizationTicket) {
|
|
2179
|
+
console.log(`ai-foreman: reconciling the interrupted QA finalization for ${qaFinalizationTicket}; Builder dispatch is disabled.\n`);
|
|
2180
|
+
await foreman.completePendingQaFinalization(qaFinalizationTicket);
|
|
2181
|
+
buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "qa-finalization-complete", { status: "recoverable", currentTicket: qaFinalizationTicket }), "recoverable");
|
|
2182
|
+
clearInterval(heartbeat);
|
|
2183
|
+
statusReporter.stop();
|
|
2184
|
+
await foreman.close();
|
|
2185
|
+
await viewer;
|
|
2186
|
+
activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
|
|
2187
|
+
observabilityStore?.closeLogFile(logPath, buildRun.status);
|
|
2188
|
+
observabilityStore?.close();
|
|
2189
|
+
activeObserver = undefined;
|
|
2190
|
+
process.off("exit", finishObservationOnExit);
|
|
2191
|
+
console.log(`foreman: reconciled QA finalization for ${qaFinalizationTicket}`);
|
|
2192
|
+
process.exit(0);
|
|
2193
|
+
}
|
|
2194
|
+
if (qaResumedRecovery || qaProtocolResumeTicket) {
|
|
2195
|
+
const ticketId = qaResumedRecovery?.manifest.ticketId ?? qaProtocolResumeTicket;
|
|
2196
|
+
console.log(`ai-foreman: resuming the exact QA boundary for ${ticketId}; Builder preflight and work dispatch are disabled.\n`);
|
|
2197
|
+
buildRun = checkpointBuildRun(cwd, buildRun, "qa-recovery-before-dispatch", { currentTicket: ticketId });
|
|
2198
|
+
const qa = await (activeObserver
|
|
2199
|
+
? activeObserver.span("qa_recovery", "Exact QA-only recovery", () => foreman.completePendingQaRecovery(ticketId))
|
|
2200
|
+
: foreman.completePendingQaRecovery(ticketId));
|
|
2201
|
+
if (pendingHandoffGeneration !== undefined)
|
|
2202
|
+
throw new Error(`recovery handoff generation ${pendingHandoffGeneration} for ${pendingHandoffRole} was not accepted`);
|
|
2203
|
+
if (foreman.builderSessionId())
|
|
2204
|
+
buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
|
|
2205
|
+
if (foreman.qaSessionId())
|
|
2206
|
+
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
|
|
2207
|
+
buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, qa.outcome === "passed" || qa.outcome === "waived" ? "qa-recovery-complete" : "qa-recovery-paused", {
|
|
2208
|
+
status: "recoverable", currentTicket: ticketId,
|
|
2209
|
+
}), "recoverable");
|
|
2210
|
+
clearInterval(heartbeat);
|
|
2211
|
+
statusReporter.stop();
|
|
2212
|
+
await foreman.close();
|
|
2213
|
+
await viewer;
|
|
2214
|
+
activeObserver?.finish(buildRun.status, observationSummaryMetadata(cwd, buildRun, qaEnabled, capturedBuilder.make));
|
|
2215
|
+
observabilityStore?.closeLogFile(logPath, buildRun.status);
|
|
2216
|
+
observabilityStore?.enforceLimits();
|
|
2217
|
+
observabilityStore?.maintainLogs(cwd);
|
|
2218
|
+
observabilityStore?.close();
|
|
2219
|
+
activeObserver = undefined;
|
|
2220
|
+
process.off("exit", finishObservationOnExit);
|
|
2221
|
+
console.log(`foreman: exact QA recovery for ${ticketId} — ${qa.outcome}`);
|
|
2222
|
+
if (qa.detail)
|
|
2223
|
+
console.log(`foreman: ${qa.detail}`);
|
|
2224
|
+
process.exit(qa.outcome === "passed" || qa.outcome === "waived" ? 0 : 2);
|
|
2225
|
+
}
|
|
1993
2226
|
console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
|
|
1994
2227
|
buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
|
|
1995
2228
|
await (activeObserver ? activeObserver.span("preflight", "Builder preflight", () => foreman.runPreflight(steps, ticketsContent, preferredTicket)) : foreman.runPreflight(steps, ticketsContent, preferredTicket));
|
|
@@ -2326,7 +2559,7 @@ function createQaNonconvergenceHandler(projectDir, noninteractive, planner, obse
|
|
|
2326
2559
|
};
|
|
2327
2560
|
}
|
|
2328
2561
|
function createQaReportRecoveryHandler(noninteractive, observer) {
|
|
2329
|
-
return async ({ packet,
|
|
2562
|
+
return async ({ packet, liveSession, contextUsage }) => {
|
|
2330
2563
|
if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2331
2564
|
console.error(`foreman: QA report recovery requires input; saved packet ${packet.directory}`);
|
|
2332
2565
|
console.error(`foreman: resume with the run ID recorded in ${join(packet.directory, "manifest.json")}`);
|
|
@@ -2335,10 +2568,9 @@ function createQaReportRecoveryHandler(noninteractive, observer) {
|
|
|
2335
2568
|
const choice = await withObservedUserWait(observer?.(), "QA report recovery decision", () => select({
|
|
2336
2569
|
message: "QA report recovery:",
|
|
2337
2570
|
options: [
|
|
2338
|
-
{ value: "fresh", label: "
|
|
2339
|
-
{ value: "
|
|
2340
|
-
{ value: "
|
|
2341
|
-
{ value: "guidance", label: "Give QA specific instructions." },
|
|
2571
|
+
{ value: "fresh", label: "Run complete fresh QA." },
|
|
2572
|
+
{ value: "manual", label: "Inspect saved JSON.", hint: join(packet.directory, "report.json") },
|
|
2573
|
+
{ value: "guidance", label: "Give QA fresh-review instructions." },
|
|
2342
2574
|
{ value: "pause", label: "Pause." },
|
|
2343
2575
|
],
|
|
2344
2576
|
}));
|
|
@@ -2346,18 +2578,14 @@ function createQaReportRecoveryHandler(noninteractive, observer) {
|
|
|
2346
2578
|
return { action: "pause" };
|
|
2347
2579
|
if (choice === "fresh")
|
|
2348
2580
|
return { action: "fresh" };
|
|
2349
|
-
if (choice === "plain")
|
|
2350
|
-
return { action: "plain" };
|
|
2351
2581
|
if (choice === "manual")
|
|
2352
2582
|
return { action: "manual" };
|
|
2353
2583
|
console.log(`foreman: current QA context usage: ${JSON.stringify(contextUsage).slice(0, 500)}`);
|
|
2354
2584
|
const guidance = await withObservedUserWait(observer?.(), "QA recovery guidance", () => text({ message: "Specific QA report-recovery instructions:" }));
|
|
2355
2585
|
if (isCancel(guidance) || !String(guidance).trim())
|
|
2356
2586
|
return { action: "pause" };
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
] : [{ value: "fresh", label: "Fresh validated handoff (resumed recovery)" }] }));
|
|
2360
|
-
return isCancel(route) ? { action: "pause" } : { action: "guidance", instructions: String(guidance), route };
|
|
2587
|
+
void liveSession;
|
|
2588
|
+
return { action: "guidance", instructions: String(guidance), route: "fresh" };
|
|
2361
2589
|
};
|
|
2362
2590
|
}
|
|
2363
2591
|
function readAgentDefaults(projectDir) {
|