ai-foreman 1.7.20 → 1.7.21
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 +12 -2
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +18 -6
- package/dist/adapters/claude.d.ts +16 -0
- package/dist/adapters/claude.js +28 -3
- package/dist/adapters/codex.d.ts +20 -0
- package/dist/adapters/codex.js +99 -5
- package/dist/adapters/recovering.d.ts +9 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +7 -0
- package/dist/agentRun.js +24 -13
- package/dist/branch/currentGuard.d.ts +1 -0
- package/dist/branch/currentGuard.js +1 -0
- package/dist/branch/finalization.js +6 -1
- package/dist/branch/git.d.ts +1 -0
- package/dist/branch/git.js +15 -1
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +90 -4
- package/dist/buildAdmission.js +2 -0
- package/dist/buildAssignment.d.ts +33 -0
- package/dist/buildAssignment.js +215 -0
- package/dist/buildInterventions.d.ts +90 -0
- package/dist/buildInterventions.js +334 -0
- package/dist/buildOwnershipReconciliation.d.ts +19 -0
- package/dist/buildOwnershipReconciliation.js +41 -0
- package/dist/buildOwnershipRepair.d.ts +33 -0
- package/dist/buildOwnershipRepair.js +111 -0
- package/dist/buildRuns.d.ts +6 -0
- package/dist/buildRuns.js +23 -17
- package/dist/buildSyntheticWork.d.ts +2 -0
- package/dist/buildSyntheticWork.js +30 -0
- package/dist/buildWorkAdmission.d.ts +17 -0
- package/dist/buildWorkAdmission.js +57 -0
- package/dist/buildWorkAuthorization.d.ts +2 -0
- package/dist/buildWorkAuthorization.js +23 -0
- package/dist/buildWorkContext.d.ts +3 -0
- package/dist/buildWorkContext.js +6 -0
- package/dist/buildWorkMigration.d.ts +9 -0
- package/dist/buildWorkMigration.js +206 -0
- package/dist/buildWorkReconciliation.d.ts +12 -0
- package/dist/buildWorkReconciliation.js +70 -0
- package/dist/builderGuidanceFollowup.d.ts +15 -0
- package/dist/builderGuidanceFollowup.js +34 -0
- package/dist/cli/doctor.js +3 -2
- package/dist/cli/graph.d.ts +2 -0
- package/dist/cli/graph.js +75 -0
- package/dist/cli/manager.d.ts +8 -0
- package/dist/cli/manager.js +234 -12
- package/dist/cli/recovery.js +3 -2
- package/dist/cli/runtimeAuthPrompt.js +28 -8
- package/dist/cli/start.js +152 -48
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +11 -0
- package/dist/continuity.js +22 -6
- package/dist/foreman.d.ts +10 -2
- package/dist/foreman.js +330 -39
- package/dist/graph/boundary.d.ts +15 -0
- package/dist/graph/boundary.js +50 -0
- package/dist/graph/bridge.d.ts +9 -0
- package/dist/graph/bridge.js +146 -0
- package/dist/graph/bridge.py +205 -0
- package/dist/graph/config.d.ts +11 -0
- package/dist/graph/config.js +40 -0
- package/dist/graph/corpus.d.ts +16 -0
- package/dist/graph/corpus.js +188 -0
- package/dist/graph/derived.d.ts +17 -0
- package/dist/graph/derived.js +73 -0
- package/dist/graph/host.d.ts +4 -0
- package/dist/graph/host.js +160 -0
- package/dist/graph/install.d.ts +3 -0
- package/dist/graph/install.js +50 -0
- package/dist/graph/lifecycle.d.ts +11 -0
- package/dist/graph/lifecycle.js +124 -0
- package/dist/graph/maintenance.d.ts +43 -0
- package/dist/graph/maintenance.js +301 -0
- package/dist/graph/manager.d.ts +9 -0
- package/dist/graph/manager.js +92 -0
- package/dist/graph/preparation.d.ts +41 -0
- package/dist/graph/preparation.js +43 -0
- package/dist/graph/read.d.ts +27 -0
- package/dist/graph/read.js +152 -0
- package/dist/graph/semantic.d.ts +17 -0
- package/dist/graph/semantic.js +27 -0
- package/dist/graph/session.d.ts +8 -0
- package/dist/graph/session.js +92 -0
- package/dist/graph/sources.d.ts +4 -0
- package/dist/graph/sources.js +11 -0
- package/dist/graph/storage.d.ts +38 -0
- package/dist/graph/storage.js +159 -0
- package/dist/graph/turn.d.ts +17 -0
- package/dist/graph/turn.js +360 -0
- package/dist/graph/util.d.ts +5 -0
- package/dist/graph/util.js +51 -0
- package/dist/handoffs.d.ts +1 -0
- package/dist/handoffs.js +5 -1
- package/dist/humanDecision.d.ts +21 -2
- package/dist/humanDecision.js +169 -11
- package/dist/index.js +2 -0
- package/dist/installOwnership.d.ts +46 -0
- package/dist/installOwnership.js +440 -0
- package/dist/linuxProbeInventory.d.ts +12 -0
- package/dist/linuxProbeInventory.js +48 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.js +7 -1
- package/dist/managerEvidence.d.ts +25 -0
- package/dist/managerEvidence.js +326 -0
- package/dist/managerEvidenceArtifacts.d.ts +38 -0
- package/dist/managerEvidenceArtifacts.js +118 -0
- package/dist/managerPacket.d.ts +2 -0
- package/dist/managerPacket.js +23 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +2 -2
- package/dist/observability.js +1 -1
- package/dist/processIdentity.js +7 -0
- package/dist/providerPhase.d.ts +13 -0
- package/dist/providerPhase.js +26 -0
- package/dist/qaAuthorizedDisposition.d.ts +24 -0
- package/dist/qaAuthorizedDisposition.js +26 -0
- package/dist/qaBuildGate.d.ts +4 -0
- package/dist/qaBuildGate.js +216 -0
- package/dist/qaBuilderCoverage.d.ts +5 -0
- package/dist/qaBuilderCoverage.js +78 -0
- package/dist/qaContractCoverage.d.ts +10 -0
- package/dist/qaContractCoverage.js +55 -0
- package/dist/qaContractDelivery.d.ts +23 -0
- package/dist/qaContractDelivery.js +145 -0
- package/dist/qaContractFreshness.d.ts +15 -0
- package/dist/qaContractFreshness.js +36 -0
- package/dist/qaDeliveryJournal.d.ts +10 -0
- package/dist/qaDeliveryJournal.js +16 -1
- package/dist/qaEffectiveConfig.d.ts +20 -0
- package/dist/qaEffectiveConfig.js +40 -0
- package/dist/qaEvidenceReader.d.ts +14 -0
- package/dist/qaEvidenceReader.js +176 -0
- package/dist/qaFailureDelivery.d.ts +2 -0
- package/dist/qaFailureDelivery.js +107 -10
- package/dist/qaHandbackMigration.d.ts +1 -1
- package/dist/qaHandbackMigration.js +3 -3
- package/dist/qaPreparation.d.ts +26 -0
- package/dist/qaPreparation.js +150 -0
- package/dist/qaPreparationInputs.d.ts +8 -0
- package/dist/qaPreparationInputs.js +47 -0
- package/dist/qaPreparationMetrics.d.ts +145 -0
- package/dist/qaPreparationMetrics.js +120 -0
- package/dist/qaPreparationPolicy.d.ts +5 -0
- package/dist/qaPreparationPolicy.js +51 -0
- package/dist/qaPreparationStore.d.ts +157 -0
- package/dist/qaPreparationStore.js +412 -0
- package/dist/qaProtocolV2.d.ts +28 -0
- package/dist/qaProtocolV2.js +12 -0
- package/dist/qaReview.d.ts +6 -0
- package/dist/qaReview.js +234 -24
- package/dist/qaTimeline.d.ts +18 -0
- package/dist/qaTimeline.js +123 -0
- package/dist/qaVerificationContract.d.ts +17 -0
- package/dist/qaVerificationContract.js +236 -0
- package/dist/readinessCleanup.js +12 -5
- package/dist/recoveryGuidance.d.ts +9 -0
- package/dist/recoveryGuidance.js +13 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +1 -0
- package/dist/runtimeReadiness.js +13 -1
- package/dist/sessionIdentity.d.ts +2 -0
- package/dist/sessionIdentity.js +14 -0
- package/dist/sessionLifecycle.js +3 -3
- package/dist/sources/sourceRegistry.js +7 -0
- package/dist/stateTransfer.js +50 -1
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.js +18 -8
- package/dist/terminalStyle.d.ts +21 -0
- package/dist/terminalStyle.js +50 -0
- package/dist/ticketPopulation.d.ts +1 -0
- package/dist/ticketPopulation.js +5 -1
- package/dist/tickets/commands.js +2 -2
- package/dist/tickets/queue.js +1 -1
- package/dist/tickets/ticketSchema.d.ts +198 -0
- package/dist/tickets/ticketSchema.js +2 -0
- package/dist/workflowDb.d.ts +51 -5
- package/dist/workflowDb.js +338 -7
- package/dist/workflowReader.d.ts +109 -2
- package/dist/workflowReader.js +48 -4
- package/package.json +84 -7
- package/scripts/copy-graph-assets.mjs +3 -0
- package/scripts/graph-benchmark.mjs +20 -0
- package/scripts/graph-evaluation.mjs +85 -0
package/dist/foreman.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { withGraphSessionAccess } from "./graph/session.js";
|
|
2
|
+
import { collectBuilderContractCoverage } from "./qaBuilderCoverage.js";
|
|
3
|
+
import { maintainGraphTask } from "./graph/boundary.js";
|
|
4
|
+
import { ensureBuilderContract } from "./qaBuildGate.js";
|
|
5
|
+
import { renderBuildWorkContext } from "./buildWorkContext.js";
|
|
6
|
+
import { loadTicketSetupConfigWithDefaults } from "./tickets/setupConfig.js";
|
|
7
|
+
import { deliverBuilderGuidanceFollowup } from "./builderGuidanceFollowup.js";
|
|
8
|
+
import { graphContext, sendGraphTurn } from "./graph/turn.js";
|
|
9
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
10
|
+
import { BuildControlBoundary } from "./buildInterventions.js";
|
|
1
11
|
import { buildScopeRevision } from "./buildApproval.js";
|
|
2
|
-
import { durableHumanDecision, HumanDecisionRequired } from "./humanDecision.js";
|
|
12
|
+
import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions, decisionResponse } from "./humanDecision.js";
|
|
3
13
|
import { select, text, isCancel } from "@clack/prompts";
|
|
4
14
|
import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
|
|
5
15
|
import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
|
|
@@ -18,6 +28,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
18
28
|
import { join, relative } from "node:path";
|
|
19
29
|
import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
|
|
20
30
|
import { WorkflowDb } from "./workflowDb.js";
|
|
31
|
+
import { assertBuildAssignmentReconciled, beginBuildAssignment, BuildAssignmentRejected, finishBuildAssignment, validateBuildAssignment } from "./buildAssignment.js";
|
|
21
32
|
/** Phrases that suggest the builder ended its turn by asking the human. */
|
|
22
33
|
const QUESTION_HINTS = [
|
|
23
34
|
"should i",
|
|
@@ -201,6 +212,29 @@ export function createPermissionHandler(policy, log, opts = {}) {
|
|
|
201
212
|
}));
|
|
202
213
|
return providerQuestion;
|
|
203
214
|
}
|
|
215
|
+
// A provider sandbox escalation is never covered by routine tool allow-lists.
|
|
216
|
+
if (req.input.providerSandboxEscalation === true) {
|
|
217
|
+
const owner = opts.durable?.();
|
|
218
|
+
if (!owner || req.signal?.aborted)
|
|
219
|
+
return { behavior: "deny", message: "Sandbox escalation requires a current durable build owner" };
|
|
220
|
+
try {
|
|
221
|
+
const action = await durableHumanDecision({ projectDir: owner.projectDir, runId: owner.runId, key: `permission:${req.toolUseID ?? "unknown"}:${createHash("sha256").update(JSON.stringify(req.input)).digest("hex")}`,
|
|
222
|
+
prompt: `Provider requests sandbox escalation for this exact operation: ${JSON.stringify(req.input)}. This may permit host access beyond the workspace and network; ticket approval does not grant it. Approve only if you accept this scope.`,
|
|
223
|
+
choices: [{ id: "approve-once", label: "Approve this operation once" }, { id: "deny", label: "Deny" }],
|
|
224
|
+
operation: async () => {
|
|
225
|
+
if (req.signal?.aborted)
|
|
226
|
+
return undefined;
|
|
227
|
+
const selected = await select({ message: req.title ?? "Approve sandbox escalation?", signal: req.signal, options: [{ value: "deny", label: "Deny (Recommended)" }, { value: "approve-once", label: "Approve this exact operation once" }] });
|
|
228
|
+
return isCancel(selected) || req.signal?.aborted ? undefined : selected;
|
|
229
|
+
} });
|
|
230
|
+
return action === "approve-once" && !req.signal?.aborted ? { behavior: "allow" } : { behavior: "deny", message: "Sandbox escalation denied or paused" };
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (!(error instanceof HumanDecisionRequired))
|
|
234
|
+
throw error;
|
|
235
|
+
return { behavior: "deny", message: error.message };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
204
238
|
const verdict = policy.classify(req);
|
|
205
239
|
log.write("permission", {
|
|
206
240
|
tool: req.toolName,
|
|
@@ -246,19 +280,25 @@ export class Foreman {
|
|
|
246
280
|
qaResumedRecovery;
|
|
247
281
|
qaRunId;
|
|
248
282
|
continueIndependentTickets;
|
|
283
|
+
builderWorktree;
|
|
284
|
+
decisionPrompt;
|
|
285
|
+
graphPurpose = "implementation";
|
|
286
|
+
setGraphPurpose(purpose) { this.graphPurpose = purpose; }
|
|
249
287
|
ticketsEnabled;
|
|
250
288
|
notificationsEnabled;
|
|
251
289
|
terminalBellEnabled;
|
|
252
290
|
qaStream = { reviews: 0, modificationViolations: 0 };
|
|
253
291
|
builderWorkSessions = 0;
|
|
254
292
|
currentTicketId;
|
|
293
|
+
preflightPlan = "";
|
|
294
|
+
approvedPlanText() { return this.preflightPlan; }
|
|
255
295
|
constructor(builder, log, notifications = false, qaEnabled = true, qaMaxCycles = 3, projectDir,
|
|
256
296
|
/** @deprecated QA must be created by qaFactory in a disposable snapshot. */
|
|
257
297
|
_deprecatedSameSessionReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
|
|
258
298
|
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
259
299
|
observeBuilderNativeCompactions, observer, qaRuntimeContext, qaContinuityManaged = false, qaReportRecovery, qaResumedRecovery,
|
|
260
300
|
/** Exact durable build-run scope. Observability is never an identity authority. */
|
|
261
|
-
qaRunId = `qa-${randomUUID()}`, continueIndependentTickets = true, initialTicketId) {
|
|
301
|
+
qaRunId = `qa-${randomUUID()}`, continueIndependentTickets = true, initialTicketId, builderWorktree, decisionPrompt) {
|
|
262
302
|
this.builder = builder;
|
|
263
303
|
this.log = log;
|
|
264
304
|
this.qaEnabled = qaEnabled;
|
|
@@ -281,6 +321,8 @@ export class Foreman {
|
|
|
281
321
|
this.qaResumedRecovery = qaResumedRecovery;
|
|
282
322
|
this.qaRunId = qaRunId;
|
|
283
323
|
this.continueIndependentTickets = continueIndependentTickets;
|
|
324
|
+
this.builderWorktree = builderWorktree;
|
|
325
|
+
this.decisionPrompt = decisionPrompt;
|
|
284
326
|
void _deprecatedSameSessionReviewer;
|
|
285
327
|
this.currentTicketId = initialTicketId;
|
|
286
328
|
this.notificationsEnabled = typeof notifications === "boolean" ? notifications : notifications.desktop;
|
|
@@ -358,11 +400,43 @@ export class Foreman {
|
|
|
358
400
|
this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
|
|
359
401
|
}
|
|
360
402
|
async doTurnWith(adapter, instruction, mode = "builder") {
|
|
403
|
+
return this.projectDir
|
|
404
|
+
? withGraphSessionAccess(this.projectDir, adapter, () => this.doTurnWithOwned(adapter, instruction, mode))
|
|
405
|
+
: this.doTurnWithOwned(adapter, instruction, mode);
|
|
406
|
+
}
|
|
407
|
+
async doTurnWithOwned(adapter, instruction, mode = "builder") {
|
|
361
408
|
const ticket = mode === "builder" ? this.currentTicketId : undefined;
|
|
362
409
|
const scopedInstruction = (text) => !ticket || text.includes(`Ticket scope: ${ticket}.`) ? text
|
|
363
410
|
: `${text}\nTicket scope: ${ticket}. Do not substitute another ticket. Include ticket="${ticket}" in any STEP_STATUS marker.`;
|
|
364
411
|
instruction = scopedInstruction(instruction);
|
|
365
|
-
const send = (text, policy) =>
|
|
412
|
+
const send = async (text, policy) => {
|
|
413
|
+
const context = ticket && this.projectDir ? renderBuildWorkContext(this.ticketForQa(0, ticket), loadTicketSetupConfigWithDefaults(this.projectDir).build.validation_checklist) : "";
|
|
414
|
+
const contractContext = ticket && this.projectDir ? await ensureBuilderContract(this.projectDir, this.qaRunId, ticket, this.builderWorktree ?? this.projectDir, adapter, this.qaFactory) : "";
|
|
415
|
+
const finalInstruction = scopedInstruction([text, context, contractContext].filter(Boolean).join("\n\n"));
|
|
416
|
+
const assignment = ticket && this.projectDir ? beginBuildAssignment(this.projectDir, this.qaRunId, ticket, this.builderWorktree ?? this.projectDir, finalInstruction, undefined, adapter) : undefined;
|
|
417
|
+
const submitted = assignment?.instruction ?? finalInstruction;
|
|
418
|
+
const response = this.projectDir
|
|
419
|
+
? await sendGraphTurn(adapter, submitted, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, this.graphPurpose, submitted, { ...(assignment ? { operationId: `graph:${assignment.operationId}` } : {}), logicalTaskId: assignment?.operationId ?? randomUUID(), runId: this.qaRunId, workId: ticket }), policy)
|
|
420
|
+
: await adapter.sendTurn(submitted, policy);
|
|
421
|
+
const parsed = parseStepStatus(response.text);
|
|
422
|
+
const applicable = parsed.kind === "unknown" && looksLikeQuestion(response.text) ? { kind: "needs_input", question: lastLine(response.text) } : parsed;
|
|
423
|
+
if (assignment && !response.isError && !response.failure && ["done", "plan_complete"].includes(applicable.kind))
|
|
424
|
+
await collectBuilderContractCoverage(this.projectDir, assignment.runId, assignment.ticketId, assignment.worktree, adapter, assignment.operationId, submitted, response);
|
|
425
|
+
const rejection = assignment ? finishBuildAssignment(this.projectDir, assignment, response, applicable) : undefined;
|
|
426
|
+
if (rejection)
|
|
427
|
+
throw new BuildAssignmentRejected(rejection);
|
|
428
|
+
if (assignment && !response.isError && applicable.kind === "done") {
|
|
429
|
+
try {
|
|
430
|
+
const graphOutcome = await maintainGraphTask(this.projectDir, assignment.worktree, assignment.operationId, adapter);
|
|
431
|
+
if (graphOutcome)
|
|
432
|
+
this.log.write("graph-maintenance", { outcome: graphOutcome });
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
this.log.write("graph-maintenance", { state: "deferred", detail: String(error) });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return response;
|
|
439
|
+
};
|
|
366
440
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
367
441
|
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
368
442
|
adapter = this.builder;
|
|
@@ -371,6 +445,8 @@ export class Foreman {
|
|
|
371
445
|
let result = await send(instruction, turnPolicy);
|
|
372
446
|
await this.observeBuilderNative(adapter);
|
|
373
447
|
let status = parseStepStatus(result.text);
|
|
448
|
+
if (ticket && validateBuildAssignment(ticket, status))
|
|
449
|
+
return { result, status };
|
|
374
450
|
if (result.isError || result.failure)
|
|
375
451
|
return { result: { ...result, isError: true }, status };
|
|
376
452
|
if (status.kind === "unknown") {
|
|
@@ -405,15 +481,18 @@ export class Foreman {
|
|
|
405
481
|
}
|
|
406
482
|
let blockerExplanations = 0;
|
|
407
483
|
while (true) {
|
|
484
|
+
if (ticket && validateBuildAssignment(ticket, status))
|
|
485
|
+
return { result, status };
|
|
408
486
|
while (status.kind === "needs_input") {
|
|
409
487
|
const question = status.question ?? "The builder has a question";
|
|
410
488
|
const choices = status.choices?.length ? status.choices : ["Continue"];
|
|
411
489
|
this.log.write("needs_input", { question, choices });
|
|
412
490
|
signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
|
|
413
|
-
const prompt = async () => this.waitForUserInput(async () => {
|
|
491
|
+
const prompt = async (signal) => this.waitForUserInput(async () => {
|
|
414
492
|
console.log();
|
|
415
493
|
const selected = await select({
|
|
416
494
|
message: question,
|
|
495
|
+
signal,
|
|
417
496
|
options: [
|
|
418
497
|
...choices.map((choice) => ({ value: choice, label: choice })),
|
|
419
498
|
{ value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
|
|
@@ -424,7 +503,7 @@ export class Foreman {
|
|
|
424
503
|
return undefined;
|
|
425
504
|
if (selected !== "__rafi_custom__")
|
|
426
505
|
return selected;
|
|
427
|
-
const custom = await text({ message: "Custom response:", validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
|
|
506
|
+
const custom = await text({ message: "Custom response:", signal, validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
|
|
428
507
|
return isCancel(custom) ? undefined : String(custom);
|
|
429
508
|
});
|
|
430
509
|
if (!this.projectDir && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
@@ -434,30 +513,58 @@ export class Foreman {
|
|
|
434
513
|
let answer;
|
|
435
514
|
try {
|
|
436
515
|
answer = this.projectDir ? await durableHumanDecision({ projectDir: this.projectDir, runId: this.qaRunId, key: `builder:${this.currentTicketId ?? "step"}:${buildScopeRevision(this.projectDir)}`, ticketId: this.currentTicketId,
|
|
437
|
-
prompt: question, choices: [...choices.map(choice => ({ id:
|
|
516
|
+
prompt: question, choices: [...choices.map((choice, index) => ({ id: `option-${index + 1}`, label: choice })), { id: "custom", label: "Custom response" }],
|
|
438
517
|
defer: Boolean(this.currentTicketId && this.continueIndependentTickets), operation: prompt }) : await prompt();
|
|
439
518
|
}
|
|
440
519
|
catch (error) {
|
|
441
520
|
if (!(error instanceof HumanDecisionRequired))
|
|
442
521
|
throw error;
|
|
443
|
-
status = { kind: "blocked", reason: error.message };
|
|
522
|
+
status = { kind: "blocked", ticket, reason: error.message };
|
|
444
523
|
break;
|
|
445
524
|
}
|
|
446
525
|
console.log();
|
|
447
526
|
if (answer === undefined) {
|
|
448
527
|
result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
|
|
449
|
-
status = { kind: "blocked", reason: "user chose safe pause at an input prompt" };
|
|
528
|
+
status = { kind: "blocked", ticket, reason: "user chose safe pause at an input prompt" };
|
|
450
529
|
break;
|
|
451
530
|
}
|
|
531
|
+
// Journal inline answers too: a crash after dispatch must not replay them on resume.
|
|
532
|
+
let inlineContinuationKey;
|
|
533
|
+
if (this.projectDir && this.currentTicketId) {
|
|
534
|
+
const db = new WorkflowDb(this.projectDir);
|
|
535
|
+
try {
|
|
536
|
+
const decision = db.answeredTicketDecisions(this.qaRunId, buildScopeRevision(this.projectDir)).find(item => item.interruptionId === `ticket:${this.currentTicketId}` && item.prompt === question);
|
|
537
|
+
if (decision)
|
|
538
|
+
db.atomic(() => {
|
|
539
|
+
inlineContinuationKey = db.nextDecisionContinuationKey(this.qaRunId, decision.decisionId);
|
|
540
|
+
db.planOperation({ runId: this.qaRunId, idempotencyKey: inlineContinuationKey, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: this.currentTicketId } });
|
|
541
|
+
db.updateOperation(inlineContinuationKey, "in_progress");
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
db.close();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
452
548
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
453
549
|
this.builder = await this.beforeBuilderTurn(this.builder, scopedInstruction(answer));
|
|
454
550
|
adapter = this.builder;
|
|
455
551
|
}
|
|
456
552
|
result = await send(answer, turnPolicy);
|
|
553
|
+
if (inlineContinuationKey && this.projectDir) {
|
|
554
|
+
const db = new WorkflowDb(this.projectDir);
|
|
555
|
+
try {
|
|
556
|
+
db.updateOperation(inlineContinuationKey, result.failure?.dispatchState === "not-sent" ? "failed" : result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState ? "uncertain" : "confirmed", { result: { isError: result.isError, dispatchState: result.failure?.dispatchState } });
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
db.close();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
457
562
|
await this.observeBuilderNative(adapter);
|
|
458
563
|
if (result.isError || result.failure)
|
|
459
564
|
return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
|
|
460
565
|
status = parseStepStatus(result.text);
|
|
566
|
+
if (ticket && validateBuildAssignment(ticket, status))
|
|
567
|
+
return { result, status };
|
|
461
568
|
}
|
|
462
569
|
if (status.kind !== "blocked" || (status.reason?.startsWith("user chose safe pause") || status.reason?.includes("Rafi is waiting for input")) || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
463
570
|
break;
|
|
@@ -510,7 +617,7 @@ export class Foreman {
|
|
|
510
617
|
const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId) + (executionTickets ? `\nAuthorized ticket scope: ${executionTickets.join(", ")}. Plan only these tickets. Tickets awaiting answers are not eligible for implementation.` : "");
|
|
511
618
|
if (this.beforeBuilderTurn)
|
|
512
619
|
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
513
|
-
const result = await this.builder.sendTurn(instruction);
|
|
620
|
+
const result = this.projectDir ? await sendGraphTurn(this.builder, instruction, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", instruction), { purpose: "planning" }) : await this.builder.sendTurn(instruction, { purpose: "planning" });
|
|
514
621
|
await this.observeBuilderNative(this.builder);
|
|
515
622
|
this.log.write("preflight", {
|
|
516
623
|
ticketsProvided: ticketsContent !== undefined,
|
|
@@ -519,17 +626,19 @@ export class Foreman {
|
|
|
519
626
|
});
|
|
520
627
|
if (result.isError)
|
|
521
628
|
throw new Error(result.text);
|
|
629
|
+
this.preflightPlan = result.text;
|
|
522
630
|
return result.text;
|
|
523
631
|
}
|
|
524
632
|
/** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
|
|
525
633
|
async sendPreflightFeedback(feedback) {
|
|
526
634
|
if (this.beforeBuilderTurn)
|
|
527
635
|
this.builder = await this.beforeBuilderTurn(this.builder, feedback);
|
|
528
|
-
const result = await this.builder.sendTurn(feedback);
|
|
636
|
+
const result = this.projectDir ? await sendGraphTurn(this.builder, feedback, graphContext(this.projectDir, this.builderWorktree ?? this.projectDir, "build-preflight", feedback), { purpose: "planning" }) : await this.builder.sendTurn(feedback, { purpose: "planning" });
|
|
529
637
|
await this.observeBuilderNative(this.builder);
|
|
530
638
|
this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
|
|
531
639
|
if (result.isError)
|
|
532
640
|
throw new Error(result.text);
|
|
641
|
+
this.preflightPlan = result.text;
|
|
533
642
|
}
|
|
534
643
|
/** Send one custom instruction through the same needs_input loop as a batch turn. */
|
|
535
644
|
async runInstruction(instruction) {
|
|
@@ -548,11 +657,12 @@ export class Foreman {
|
|
|
548
657
|
* Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
|
|
549
658
|
* QA turns are free — they do not advance the step counter.
|
|
550
659
|
*/
|
|
551
|
-
async runQa(stepIndex, ticketId, builderResult) {
|
|
660
|
+
async runQa(stepIndex, ticketId, builderResult, explicitResume = false) {
|
|
552
661
|
if (this.projectDir && this.qaFactory) {
|
|
553
662
|
const resumedRecovery = this.qaResumedRecovery;
|
|
663
|
+
const scopedTicket = this.ticketForQa(stepIndex, ticketId);
|
|
554
664
|
const review = await runIsolatedQa({
|
|
555
|
-
ticket:
|
|
665
|
+
ticket: scopedTicket,
|
|
556
666
|
builderWorktree: this.projectDir,
|
|
557
667
|
builderSummary: builderResult ?? "Builder result unavailable at this explicit QA-only API boundary",
|
|
558
668
|
qaStrategy: this.qaSessionStrategy,
|
|
@@ -567,6 +677,24 @@ export class Foreman {
|
|
|
567
677
|
continuityManaged: this.qaContinuityManaged,
|
|
568
678
|
onReportRecovery: this.qaReportRecovery,
|
|
569
679
|
resumedRecovery,
|
|
680
|
+
resumeBuilderGuidance: explicitResume || Boolean(resumedRecovery),
|
|
681
|
+
deliverBuilderFollowup: (instruction, followup) => deliverBuilderGuidanceFollowup({ projectDir: this.projectDir, runId: this.qaRunId, ticketId: scopedTicket.id, worktree: this.builderWorktree ?? this.projectDir }, instruction, followup, {
|
|
682
|
+
prepare: async (instruction) => {
|
|
683
|
+
await this.prepareBuilderBoundary(instruction);
|
|
684
|
+
if (this.beforeBuilderTurn)
|
|
685
|
+
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
686
|
+
return this.builder;
|
|
687
|
+
},
|
|
688
|
+
validateRequirements: () => {
|
|
689
|
+
if (qaDigest("admitted-requirements", this.ticketForQa(stepIndex, scopedTicket.id)) !== followup.requirementsDigest)
|
|
690
|
+
throw new BuildAssignmentRejected("Ticket requirements changed during session preparation; renewed scope approval is required");
|
|
691
|
+
},
|
|
692
|
+
completed: async (operationId, adapter) => {
|
|
693
|
+
this.builderWorkSessions += 1;
|
|
694
|
+
await this.observeBuilderNative(adapter);
|
|
695
|
+
this.log.write("qa-fix", { kind: "builder-guidance-followup", ticketId: scopedTicket.id, operationId });
|
|
696
|
+
},
|
|
697
|
+
}),
|
|
570
698
|
deliverFailure: async (request) => {
|
|
571
699
|
this.builderWorkSessions += 1;
|
|
572
700
|
const delivery = new QaFailureDeliveryService();
|
|
@@ -614,9 +742,9 @@ export class Foreman {
|
|
|
614
742
|
async runPendingQaRecovery(ticketId, builderResult) {
|
|
615
743
|
if (this.qaResumedRecovery && this.qaResumedRecovery.manifest.ticketId !== ticketId)
|
|
616
744
|
throw new Error(`pending QA recovery belongs to ${this.qaResumedRecovery.manifest.ticketId}, not ${ticketId}`);
|
|
617
|
-
return this.runQa(1, ticketId, builderResult);
|
|
745
|
+
return this.runQa(1, ticketId, builderResult, true);
|
|
618
746
|
}
|
|
619
|
-
/**
|
|
747
|
+
/** Resume the pending boundary, including authorized waiting guidance, then complete its tracker transition. */
|
|
620
748
|
async completePendingQaRecovery(ticketId) {
|
|
621
749
|
if (!this.projectDir || !this.ticketsEnabled)
|
|
622
750
|
throw new Error("pending QA recovery requires an initialized ticket project");
|
|
@@ -679,12 +807,21 @@ export class Foreman {
|
|
|
679
807
|
const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
|
|
680
808
|
if (ticket)
|
|
681
809
|
return ticket;
|
|
810
|
+
throw new Error(`Assigned ticket ${ticketId ?? "(missing)"} cannot be resolved for QA; ownership reconciliation is required`);
|
|
811
|
+
}
|
|
812
|
+
if (!this.projectDir || !ticketId)
|
|
813
|
+
throw new Error("QA requires a durable admitted work identity");
|
|
814
|
+
const db = new WorkflowDb(this.projectDir);
|
|
815
|
+
try {
|
|
816
|
+
db.assertAdmittedWork(this.qaRunId, ticketId);
|
|
817
|
+
const synthetic = db.workDefinitions(this.qaRunId).find(work => work.workId === ticketId && work.kind === "synthetic");
|
|
818
|
+
if (!synthetic)
|
|
819
|
+
throw new Error("Synthetic QA identity cannot be resolved; failed ticket lookup never enables unticketed work");
|
|
820
|
+
return synthetic.definition;
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
db.close();
|
|
682
824
|
}
|
|
683
|
-
return {
|
|
684
|
-
id: `STEP-${stepIndex}`, order: stepIndex, title: `Implementation step ${stepIndex}`, area: "project",
|
|
685
|
-
priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Review implementation step ${stepIndex}`,
|
|
686
|
-
acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
|
|
687
|
-
};
|
|
688
825
|
}
|
|
689
826
|
async runBatch(n, trackerPath, onTicketStart, preferredTicketId, recoveryTickets) {
|
|
690
827
|
this.log.write("batch-start", { requested: n, agent: this.builder.agent });
|
|
@@ -692,12 +829,49 @@ export class Foreman {
|
|
|
692
829
|
let outcome = "all-done";
|
|
693
830
|
let detail;
|
|
694
831
|
const deferred = new Set();
|
|
832
|
+
const service = async () => {
|
|
833
|
+
if (!this.projectDir)
|
|
834
|
+
return false;
|
|
835
|
+
assertBuildAssignmentReconciled(this.projectDir, this.qaRunId);
|
|
836
|
+
try {
|
|
837
|
+
return await servicePendingHumanDecisions({ projectDir: this.projectDir, runId: this.qaRunId, tickets: recoveryTickets, observer: this.observer, prompt: this.decisionPrompt });
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (!(error instanceof HumanDecisionRequired))
|
|
841
|
+
throw error;
|
|
842
|
+
detail = error.message;
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
695
846
|
for (let i = 1; completed < n; i++) {
|
|
847
|
+
if (this.projectDir) {
|
|
848
|
+
try {
|
|
849
|
+
assertBuildAssignmentReconciled(this.projectDir, this.qaRunId);
|
|
850
|
+
const db = new WorkflowDb(this.projectDir);
|
|
851
|
+
try {
|
|
852
|
+
if (db.operations(this.qaRunId).some(op => op.kind === "decision-continuation" && ["in_progress", "uncertain"].includes(op.status)))
|
|
853
|
+
throw new BuildAssignmentRejected("Uncertain decision continuation requires reconciliation before further provider work");
|
|
854
|
+
}
|
|
855
|
+
finally {
|
|
856
|
+
db.close();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
if (!(error instanceof BuildAssignmentRejected || error instanceof BuildControlBoundary))
|
|
861
|
+
throw error;
|
|
862
|
+
outcome = "needs-human";
|
|
863
|
+
detail = error.message;
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
696
867
|
const decisionRevision = this.projectDir ? buildScopeRevision(this.projectDir) : "";
|
|
697
868
|
const decisions = this.projectDir ? (() => {
|
|
698
869
|
const db = new WorkflowDb(this.projectDir);
|
|
699
870
|
try {
|
|
700
871
|
const revision = decisionRevision;
|
|
872
|
+
for (const work of db.workDefinitions(this.qaRunId))
|
|
873
|
+
if (!recoveryTickets || recoveryTickets.includes(work.workId))
|
|
874
|
+
db.consumeInstructionControls(this.qaRunId, work.workId);
|
|
701
875
|
db.refreshStaleTicketDecisions(this.qaRunId, revision, recoveryTickets);
|
|
702
876
|
return { answered: db.answeredTicketDecisions(this.qaRunId, revision), pending: db.pendingHumanDecisions(this.qaRunId) };
|
|
703
877
|
}
|
|
@@ -707,6 +881,10 @@ export class Foreman {
|
|
|
707
881
|
})() : { answered: [], pending: [] };
|
|
708
882
|
const answered = decisions.answered.filter(decision => !recoveryTickets || recoveryTickets.includes(decision.interruptionId.slice(7)));
|
|
709
883
|
if (decisions.pending.some(decision => !decision.interruptionId.startsWith("ticket:"))) {
|
|
884
|
+
if (await service()) {
|
|
885
|
+
i--;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
710
888
|
outcome = "needs-human";
|
|
711
889
|
detail = "Rafi is waiting for an answer to a build-wide question";
|
|
712
890
|
break;
|
|
@@ -716,6 +894,10 @@ export class Foreman {
|
|
|
716
894
|
if (!recoveryTickets || recoveryTickets.includes(ticket))
|
|
717
895
|
deferred.add(ticket);
|
|
718
896
|
if (waiting.size && !this.continueIndependentTickets) {
|
|
897
|
+
if (await service()) {
|
|
898
|
+
i--;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
719
901
|
outcome = "needs-human";
|
|
720
902
|
detail = "Rafi is waiting for ticket answers";
|
|
721
903
|
break;
|
|
@@ -730,20 +912,34 @@ export class Foreman {
|
|
|
730
912
|
if (preferred && preferred.blockedBy !== "None")
|
|
731
913
|
deferred.add(preferred.ticket);
|
|
732
914
|
const next = preferred && preferred.blockedBy === "None" && !waiting.has(preferred.ticket) ? preferred
|
|
733
|
-
:
|
|
734
|
-
|
|
915
|
+
: i === 1 && preferredTicketId && (!recoveryTickets || recoveryTickets.length <= 1) ? undefined
|
|
916
|
+
: queue.find(row => row.blockedBy === "None" && !waiting.has(row.ticket) && (!deferred.has(row.ticket) || canAnswer(row.ticket)) && (row.status === "next" || (row.status === "blocked" && canAnswer(row.ticket))));
|
|
917
|
+
if (!next && [...waiting].some(ticket => !recoveryTickets || recoveryTickets.includes(ticket))) {
|
|
918
|
+
if (await service()) {
|
|
919
|
+
i--;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (i === 1 && preferredTicketId && !next) {
|
|
735
924
|
outcome = "needs-human";
|
|
736
|
-
detail = `
|
|
925
|
+
detail = `requested ticket ${preferredTicketId} is unavailable or blocked in the implementation queue`;
|
|
737
926
|
break;
|
|
738
927
|
}
|
|
739
|
-
if (!next
|
|
928
|
+
if (!next) {
|
|
929
|
+
if (queue.some(row => row.status !== "done") && !detail) {
|
|
930
|
+
outcome = "needs-human";
|
|
931
|
+
detail = "No eligible ticket assignment; unfinished work requires prerequisite or question recovery";
|
|
932
|
+
}
|
|
740
933
|
break;
|
|
934
|
+
}
|
|
741
935
|
if (next && next.status !== "next" && next.status !== "in_progress" && next.status !== "blocked") {
|
|
742
936
|
outcome = "needs-human";
|
|
743
937
|
detail = `recovery ticket ${next.ticket} cannot resume while its status is ${next.status}`;
|
|
744
938
|
break;
|
|
745
939
|
}
|
|
746
940
|
if (next) {
|
|
941
|
+
outcome = "all-done";
|
|
942
|
+
detail = undefined;
|
|
747
943
|
pendingTicketId = next.ticket;
|
|
748
944
|
await onTicketStart?.(next.ticket);
|
|
749
945
|
// Approval/feedback can change definitions while selection is awaiting it.
|
|
@@ -753,6 +949,7 @@ export class Foreman {
|
|
|
753
949
|
}
|
|
754
950
|
const boundaryDb = new WorkflowDb(this.projectDir);
|
|
755
951
|
try {
|
|
952
|
+
boundaryDb.consumeInstructionControls(this.qaRunId, next.ticket);
|
|
756
953
|
const pending = boundaryDb.pendingHumanDecisions(this.qaRunId);
|
|
757
954
|
if (pending.some(decision => !decision.interruptionId.startsWith("ticket:") || decision.interruptionId === `ticket:${next.ticket}`)) {
|
|
758
955
|
i--;
|
|
@@ -769,6 +966,13 @@ export class Foreman {
|
|
|
769
966
|
}
|
|
770
967
|
if (next.status === "blocked")
|
|
771
968
|
cmdUnblock(this.projectDir, next.ticket, { actor: "foreman", summary: canAnswer(next.ticket) ? "Scoped question answered" : "Reopened by build recovery" });
|
|
969
|
+
const admittedDb = new WorkflowDb(this.projectDir);
|
|
970
|
+
try {
|
|
971
|
+
admittedDb.assertAdmittedWork(this.qaRunId, next.ticket);
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
admittedDb.close();
|
|
975
|
+
}
|
|
772
976
|
deferred.delete(next.ticket);
|
|
773
977
|
cmdUpdate(this.projectDir, next.ticket, {
|
|
774
978
|
status: "in_progress",
|
|
@@ -778,11 +982,29 @@ export class Foreman {
|
|
|
778
982
|
}
|
|
779
983
|
}
|
|
780
984
|
catch (err) {
|
|
985
|
+
if (err instanceof HumanDecisionCancelled)
|
|
986
|
+
throw err;
|
|
781
987
|
outcome = "needs-human";
|
|
782
988
|
detail = `failed to update ticket tracker before step ${i}: ${err instanceof Error ? err.message : String(err)}`;
|
|
783
989
|
break;
|
|
784
990
|
}
|
|
785
991
|
}
|
|
992
|
+
if (!this.ticketsEnabled && this.projectDir) {
|
|
993
|
+
const db = new WorkflowDb(this.projectDir);
|
|
994
|
+
try {
|
|
995
|
+
const completedSynthetic = new Set(db.operations(this.qaRunId).filter(operation => operation.kind === "synthetic-completion" && operation.status === "confirmed").map(operation => operation.intent.workId));
|
|
996
|
+
const selected = db.workDefinitions(this.qaRunId).find(work => work.kind === "synthetic" && !completedSynthetic.has(work.workId) && (i !== 1 || !preferredTicketId || work.workId === preferredTicketId));
|
|
997
|
+
if (!selected) {
|
|
998
|
+
outcome = completedSynthetic.size ? "all-done" : "needs-human";
|
|
999
|
+
detail = completedSynthetic.size ? undefined : "No explicitly admitted unticketed work is available";
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
pendingTicketId = selected.workId;
|
|
1003
|
+
}
|
|
1004
|
+
finally {
|
|
1005
|
+
db.close();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
786
1008
|
this.currentTicketId = pendingTicketId;
|
|
787
1009
|
let instruction = i === 1
|
|
788
1010
|
? buildPrimer(n, trackerPath, this.ticketsEnabled, pendingTicketId)
|
|
@@ -790,13 +1012,15 @@ export class Foreman {
|
|
|
790
1012
|
if (pendingTicketId)
|
|
791
1013
|
instruction += `\n\nAssigned ticket: ${pendingTicketId}. Implement exactly this ticket. Do not substitute another ticket. End with ticket="${pendingTicketId}" in the STEP_STATUS marker.`;
|
|
792
1014
|
const continuations = answered.filter(decision => decision.interruptionId === `ticket:${pendingTicketId}`);
|
|
1015
|
+
const continuationKeys = [];
|
|
793
1016
|
if (continuations.length) {
|
|
794
|
-
instruction += "\n\nScoped answers authorizing this ticket continuation:\n" + continuations.map(decision => `${decision.prompt}\nAnswer: ${decision
|
|
1017
|
+
instruction += "\n\nScoped answers authorizing this ticket continuation:\n" + continuations.map(decision => `${decision.prompt}\nAnswer: ${decisionResponse(decision)}`).join("\n");
|
|
795
1018
|
const db = new WorkflowDb(this.projectDir);
|
|
796
1019
|
try {
|
|
797
1020
|
db.atomic(() => {
|
|
798
1021
|
for (const decision of continuations) {
|
|
799
|
-
const key =
|
|
1022
|
+
const key = db.nextDecisionContinuationKey(this.qaRunId, decision.decisionId);
|
|
1023
|
+
continuationKeys.push(key);
|
|
800
1024
|
db.planOperation({ runId: this.qaRunId, idempotencyKey: key, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: pendingTicketId } });
|
|
801
1025
|
db.updateOperation(key, "in_progress");
|
|
802
1026
|
}
|
|
@@ -810,14 +1034,23 @@ export class Foreman {
|
|
|
810
1034
|
await this.prepareBuilderBoundary(instruction);
|
|
811
1035
|
this.builderWorkSessions += 1;
|
|
812
1036
|
const turn = () => this.doTurn(instruction);
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
: await turn();
|
|
1037
|
+
let response;
|
|
1038
|
+
try {
|
|
1039
|
+
response = this.observer ? await this.observer.withContext({ role: "builder", stream: "builder", ticketId: pendingTicketId }, turn) : await turn();
|
|
1040
|
+
}
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
if (!(error instanceof BuildAssignmentRejected || error instanceof BuildControlBoundary))
|
|
1043
|
+
throw error;
|
|
1044
|
+
outcome = "needs-human";
|
|
1045
|
+
detail = error.message;
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
const { result, status } = response;
|
|
816
1049
|
if (continuations.length) {
|
|
817
1050
|
const db = new WorkflowDb(this.projectDir);
|
|
818
1051
|
try {
|
|
819
|
-
for (const
|
|
820
|
-
db.updateOperation(
|
|
1052
|
+
for (const key of continuationKeys)
|
|
1053
|
+
db.updateOperation(key, result.failure?.dispatchState === "not-sent" ? "failed" : result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState ? "uncertain" : "confirmed", { result: { isError: result.isError, dispatchState: result.failure?.dispatchState } });
|
|
821
1054
|
}
|
|
822
1055
|
finally {
|
|
823
1056
|
db.close();
|
|
@@ -838,23 +1071,18 @@ export class Foreman {
|
|
|
838
1071
|
detail = `builder turn errored: ${result.text.slice(0, 200)}`;
|
|
839
1072
|
break;
|
|
840
1073
|
}
|
|
841
|
-
if (recoveryTickets && pendingTicketId && status.ticket !== pendingTicketId && (status.kind === "done" || status.kind === "plan_complete" || Boolean(status.ticket))) {
|
|
842
|
-
outcome = "needs-human";
|
|
843
|
-
detail = `Builder response named ${status.ticket ?? "no ticket"}, but this recovery turn was scoped to ${pendingTicketId}`;
|
|
844
|
-
break;
|
|
845
|
-
}
|
|
846
1074
|
if (status.kind === "done" || status.kind === "plan_complete") {
|
|
847
1075
|
let qaSummary;
|
|
848
1076
|
let qaWaived = false;
|
|
849
1077
|
let qaPassCertificateId;
|
|
850
1078
|
let qaSourceStateDigest;
|
|
851
1079
|
if (this.qaEnabled) {
|
|
852
|
-
const qa = await this.runQa(i,
|
|
1080
|
+
const qa = await this.runQa(i, pendingTicketId, result.text);
|
|
853
1081
|
if (qa.outcome === "blocked" || qa.outcome === "needs-human") {
|
|
854
1082
|
outcome = qa.outcome;
|
|
855
1083
|
detail = qa.detail;
|
|
856
1084
|
if (pendingTicketId && this.continueIndependentTickets && this.ticketsEnabled && this.projectDir) {
|
|
857
|
-
cmdBlock(this.projectDir, pendingTicketId, { summary: qa.detail ?? "QA requires recovery", actor: "foreman" });
|
|
1085
|
+
cmdBlock(this.projectDir, pendingTicketId, { summary: qa.detail ?? "QA requires recovery", unblockCriteria: qa.detail ?? "Reconcile QA recovery", blockerType: "qa", actor: "foreman" });
|
|
858
1086
|
deferred.add(pendingTicketId);
|
|
859
1087
|
continue;
|
|
860
1088
|
}
|
|
@@ -868,7 +1096,7 @@ export class Foreman {
|
|
|
868
1096
|
// Update ticket state only after QA has passed, so generated tracker
|
|
869
1097
|
// state does not claim done before verification has completed.
|
|
870
1098
|
if (this.ticketsEnabled && this.projectDir) {
|
|
871
|
-
const ticketId =
|
|
1099
|
+
const ticketId = pendingTicketId;
|
|
872
1100
|
if (ticketId) {
|
|
873
1101
|
try {
|
|
874
1102
|
if (this.qaEnabled && !qaWaived) {
|
|
@@ -876,6 +1104,13 @@ export class Foreman {
|
|
|
876
1104
|
throw new Error("QA passed without a durable pass certificate");
|
|
877
1105
|
await beginQaFinalization(this.projectDir, this.projectDir, this.qaRunId, ticketId, qaPassCertificateId, qaSourceStateDigest, `ticket-complete:${ticketId}`, generatedTrackerDirtyPaths(loadTicketsConfig(this.projectDir).paths));
|
|
878
1106
|
}
|
|
1107
|
+
const controls = new WorkflowDb(this.projectDir);
|
|
1108
|
+
try {
|
|
1109
|
+
controls.assertFinalizationControls(this.qaRunId, ticketId);
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
controls.close();
|
|
1113
|
+
}
|
|
879
1114
|
cmdComplete(this.projectDir, ticketId, {
|
|
880
1115
|
actor: "foreman",
|
|
881
1116
|
summary: status.summary ?? `Step ${i} complete`,
|
|
@@ -897,6 +1132,46 @@ export class Foreman {
|
|
|
897
1132
|
}
|
|
898
1133
|
}
|
|
899
1134
|
}
|
|
1135
|
+
if (!this.ticketsEnabled && this.projectDir && pendingTicketId) {
|
|
1136
|
+
const controls = new WorkflowDb(this.projectDir);
|
|
1137
|
+
try {
|
|
1138
|
+
controls.assertFinalizationControls(this.qaRunId, pendingTicketId);
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
if (!(error instanceof BuildControlBoundary))
|
|
1142
|
+
throw error;
|
|
1143
|
+
outcome = "needs-human";
|
|
1144
|
+
detail = error.message;
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
controls.close();
|
|
1149
|
+
}
|
|
1150
|
+
if (this.qaEnabled && !qaWaived) {
|
|
1151
|
+
if (!qaPassCertificateId || !qaSourceStateDigest)
|
|
1152
|
+
throw new Error("Synthetic work requires a durable independent QA pass");
|
|
1153
|
+
await beginQaFinalization(this.projectDir, this.builderWorktree ?? this.projectDir, this.qaRunId, pendingTicketId, qaPassCertificateId, qaSourceStateDigest, `synthetic-complete:${pendingTicketId}`);
|
|
1154
|
+
}
|
|
1155
|
+
const db = new WorkflowDb(this.projectDir);
|
|
1156
|
+
try {
|
|
1157
|
+
db.atomic(() => {
|
|
1158
|
+
const key = `synthetic-completion:${this.qaRunId}:${pendingTicketId}`;
|
|
1159
|
+
db.planOperation({ runId: this.qaRunId, idempotencyKey: key, kind: "synthetic-completion", intent: { workId: pendingTicketId } });
|
|
1160
|
+
if (db.operation(key)?.status === "planned")
|
|
1161
|
+
db.updateOperation(key, "in_progress");
|
|
1162
|
+
db.updateOperation(key, "confirmed");
|
|
1163
|
+
const state = db.getRun(this.qaRunId).state;
|
|
1164
|
+
const progress = state.progress;
|
|
1165
|
+
if (progress)
|
|
1166
|
+
db.transition(this.qaRunId, { checkpoint: "synthetic-completed", state: { ...state, progress: { ...progress, completedTickets: [...new Set([...(progress.completedTickets ?? []), pendingTicketId])] } } });
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
finally {
|
|
1170
|
+
db.close();
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (!this.ticketsEnabled && this.projectDir && pendingTicketId && this.qaEnabled && !qaWaived)
|
|
1174
|
+
completeQaFinalization(this.projectDir, this.qaRunId, pendingTicketId);
|
|
900
1175
|
completed++;
|
|
901
1176
|
if (status.kind === "plan_complete") {
|
|
902
1177
|
outcome = "plan-complete";
|
|
@@ -908,11 +1183,13 @@ export class Foreman {
|
|
|
908
1183
|
if (status.kind === "blocked") {
|
|
909
1184
|
// Mark ticket blocked in the tracker
|
|
910
1185
|
if (this.ticketsEnabled && this.projectDir) {
|
|
911
|
-
const ticketId =
|
|
1186
|
+
const ticketId = pendingTicketId;
|
|
912
1187
|
if (ticketId) {
|
|
913
1188
|
try {
|
|
914
1189
|
cmdBlock(this.projectDir, ticketId, {
|
|
915
1190
|
summary: status.reason ?? "builder reported blocked",
|
|
1191
|
+
unblockCriteria: status.reason ?? "Resolve the external blocker before continuing",
|
|
1192
|
+
blockerType: status.reason?.includes("Rafi is waiting for input") ? "input" : "external",
|
|
916
1193
|
actor: "foreman",
|
|
917
1194
|
});
|
|
918
1195
|
}
|
|
@@ -940,6 +1217,20 @@ export class Foreman {
|
|
|
940
1217
|
: "builder did not emit a STEP_STATUS marker";
|
|
941
1218
|
break;
|
|
942
1219
|
}
|
|
1220
|
+
if (completed >= n && this.projectDir) {
|
|
1221
|
+
const db = new WorkflowDb(this.projectDir);
|
|
1222
|
+
let pending;
|
|
1223
|
+
try {
|
|
1224
|
+
pending = db.pendingHumanDecisions(this.qaRunId).some(decision => !decision.interruptionId.startsWith("ticket:") || !recoveryTickets || recoveryTickets.includes(decision.interruptionId.slice(7)));
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
db.close();
|
|
1228
|
+
}
|
|
1229
|
+
if (pending && !await service()) {
|
|
1230
|
+
outcome = "needs-human";
|
|
1231
|
+
detail = "Paused with pending questions at the authorized step limit";
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
943
1234
|
if (deferred.size) {
|
|
944
1235
|
outcome = "blocked";
|
|
945
1236
|
detail = `Deferred tickets: ${[...deferred].join(", ")}. ${detail ?? "Input or prerequisite recovery is required."}`;
|