ai-foreman 1.7.19 → 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 +29 -5
- 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 +92 -5
- package/dist/buildAdmission.d.ts +61 -0
- package/dist/buildAdmission.js +286 -0
- package/dist/buildApproval.d.ts +2 -0
- package/dist/buildApproval.js +17 -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/buildLaunchGate.d.ts +1 -0
- package/dist/buildLaunchGate.js +36 -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 +24 -1
- package/dist/buildRuns.js +216 -70
- 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.d.ts +5 -0
- package/dist/cli/runtimeAuthPrompt.js +41 -7
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +2091 -1849
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +16 -1
- package/dist/continuity.js +68 -12
- package/dist/foreman.d.ts +12 -4
- package/dist/foreman.js +397 -60
- 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 -10
- package/dist/index.js +4 -1
- 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.d.ts +21 -1
- package/dist/processIdentity.js +87 -5
- 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 +293 -41
- 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.d.ts +19 -0
- package/dist/readinessCleanup.js +122 -0
- package/dist/readinessGate.d.ts +1 -0
- package/dist/readinessGate.js +82 -0
- package/dist/recoveryGuidance.d.ts +17 -0
- package/dist/recoveryGuidance.js +38 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +13 -0
- package/dist/runtimeReadiness.js +206 -10
- 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 +85 -8
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.d.ts +6 -0
- package/dist/supervisedStart.js +178 -62
- package/dist/supervisor.d.ts +2 -0
- package/dist/supervisor.js +26 -2
- 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/windowsProbeJob.d.ts +12 -0
- package/dist/windowsProbeJob.js +144 -0
- package/dist/workflowDb.d.ts +121 -6
- package/dist/workflowDb.js +874 -30
- package/dist/workflowReader.d.ts +116 -2
- package/dist/workflowReader.js +131 -4
- package/package.json +89 -8
- 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,14 +400,53 @@ 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") {
|
|
408
|
+
const ticket = mode === "builder" ? this.currentTicketId : undefined;
|
|
409
|
+
const scopedInstruction = (text) => !ticket || text.includes(`Ticket scope: ${ticket}.`) ? text
|
|
410
|
+
: `${text}\nTicket scope: ${ticket}. Do not substitute another ticket. Include ticket="${ticket}" in any STEP_STATUS marker.`;
|
|
411
|
+
instruction = scopedInstruction(instruction);
|
|
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
|
+
};
|
|
361
440
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
362
441
|
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
363
442
|
adapter = this.builder;
|
|
364
443
|
}
|
|
365
444
|
const turnPolicy = { logicalActionId: this.currentTicketId ?? createHash("sha256").update(instruction).digest("hex") };
|
|
366
|
-
let result = await
|
|
445
|
+
let result = await send(instruction, turnPolicy);
|
|
367
446
|
await this.observeBuilderNative(adapter);
|
|
368
447
|
let status = parseStepStatus(result.text);
|
|
448
|
+
if (ticket && validateBuildAssignment(ticket, status))
|
|
449
|
+
return { result, status };
|
|
369
450
|
if (result.isError || result.failure)
|
|
370
451
|
return { result: { ...result, isError: true }, status };
|
|
371
452
|
if (status.kind === "unknown") {
|
|
@@ -387,7 +468,7 @@ export class Foreman {
|
|
|
387
468
|
budget.close();
|
|
388
469
|
}
|
|
389
470
|
}
|
|
390
|
-
result = await
|
|
471
|
+
result = await send(mode === "qa"
|
|
391
472
|
? "Protocol correction only: based on the review already completed, return exactly one final STEP_STATUS: qa_pass or STEP_STATUS: qa_fail marker. Do not repeat QA, tests, tools, or compaction."
|
|
392
473
|
: "Protocol correction only: based on the work already completed, return exactly one final STEP_STATUS: done, plan_complete, blocked, or needs_input marker. Do not repeat implementation, tools, or compaction.", { ...turnPolicy, responseOnly: true });
|
|
393
474
|
await this.observeBuilderNative(adapter);
|
|
@@ -400,15 +481,18 @@ export class Foreman {
|
|
|
400
481
|
}
|
|
401
482
|
let blockerExplanations = 0;
|
|
402
483
|
while (true) {
|
|
484
|
+
if (ticket && validateBuildAssignment(ticket, status))
|
|
485
|
+
return { result, status };
|
|
403
486
|
while (status.kind === "needs_input") {
|
|
404
487
|
const question = status.question ?? "The builder has a question";
|
|
405
488
|
const choices = status.choices?.length ? status.choices : ["Continue"];
|
|
406
489
|
this.log.write("needs_input", { question, choices });
|
|
407
490
|
signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
|
|
408
|
-
const prompt = async () => this.waitForUserInput(async () => {
|
|
491
|
+
const prompt = async (signal) => this.waitForUserInput(async () => {
|
|
409
492
|
console.log();
|
|
410
493
|
const selected = await select({
|
|
411
494
|
message: question,
|
|
495
|
+
signal,
|
|
412
496
|
options: [
|
|
413
497
|
...choices.map((choice) => ({ value: choice, label: choice })),
|
|
414
498
|
{ value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
|
|
@@ -419,7 +503,7 @@ export class Foreman {
|
|
|
419
503
|
return undefined;
|
|
420
504
|
if (selected !== "__rafi_custom__")
|
|
421
505
|
return selected;
|
|
422
|
-
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" });
|
|
423
507
|
return isCancel(custom) ? undefined : String(custom);
|
|
424
508
|
});
|
|
425
509
|
if (!this.projectDir && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
@@ -429,30 +513,58 @@ export class Foreman {
|
|
|
429
513
|
let answer;
|
|
430
514
|
try {
|
|
431
515
|
answer = this.projectDir ? await durableHumanDecision({ projectDir: this.projectDir, runId: this.qaRunId, key: `builder:${this.currentTicketId ?? "step"}:${buildScopeRevision(this.projectDir)}`, ticketId: this.currentTicketId,
|
|
432
|
-
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" }],
|
|
433
517
|
defer: Boolean(this.currentTicketId && this.continueIndependentTickets), operation: prompt }) : await prompt();
|
|
434
518
|
}
|
|
435
519
|
catch (error) {
|
|
436
520
|
if (!(error instanceof HumanDecisionRequired))
|
|
437
521
|
throw error;
|
|
438
|
-
status = { kind: "blocked", reason: error.message };
|
|
522
|
+
status = { kind: "blocked", ticket, reason: error.message };
|
|
439
523
|
break;
|
|
440
524
|
}
|
|
441
525
|
console.log();
|
|
442
526
|
if (answer === undefined) {
|
|
443
527
|
result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
|
|
444
|
-
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" };
|
|
445
529
|
break;
|
|
446
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
|
+
}
|
|
447
548
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
448
|
-
this.builder = await this.beforeBuilderTurn(this.builder, answer);
|
|
549
|
+
this.builder = await this.beforeBuilderTurn(this.builder, scopedInstruction(answer));
|
|
449
550
|
adapter = this.builder;
|
|
450
551
|
}
|
|
451
|
-
result = await
|
|
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
|
+
}
|
|
452
562
|
await this.observeBuilderNative(adapter);
|
|
453
563
|
if (result.isError || result.failure)
|
|
454
564
|
return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
|
|
455
565
|
status = parseStepStatus(result.text);
|
|
566
|
+
if (ticket && validateBuildAssignment(ticket, status))
|
|
567
|
+
return { result, status };
|
|
456
568
|
}
|
|
457
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)
|
|
458
570
|
break;
|
|
@@ -472,10 +584,10 @@ export class Foreman {
|
|
|
472
584
|
this.log.write("blocked-recovery", { reason, role: adapter === this.builder ? "builder" : "qa" });
|
|
473
585
|
const recoveryInstruction = buildBlockerRecoveryInstruction(reason);
|
|
474
586
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
475
|
-
this.builder = await this.beforeBuilderTurn(this.builder, recoveryInstruction);
|
|
587
|
+
this.builder = await this.beforeBuilderTurn(this.builder, scopedInstruction(recoveryInstruction));
|
|
476
588
|
adapter = this.builder;
|
|
477
589
|
}
|
|
478
|
-
result = await
|
|
590
|
+
result = await send(recoveryInstruction, turnPolicy);
|
|
479
591
|
await this.observeBuilderNative(adapter);
|
|
480
592
|
if (result.isError || result.failure)
|
|
481
593
|
return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
|
|
@@ -491,7 +603,7 @@ export class Foreman {
|
|
|
491
603
|
budget.close();
|
|
492
604
|
}
|
|
493
605
|
}
|
|
494
|
-
result = await
|
|
606
|
+
result = await send('Protocol correction only: return the blocker approaches now using exactly one final STEP_STATUS: needs_input marker with question="..." and choices="recommended (Recommended)|alternative|alternative". Do not repeat tools or implementation.', { ...turnPolicy, responseOnly: true });
|
|
495
607
|
await this.observeBuilderNative(adapter);
|
|
496
608
|
if (result.isError || result.failure)
|
|
497
609
|
return { result: { ...result, isError: true }, status: { kind: "blocked", reason: result.text } };
|
|
@@ -501,11 +613,11 @@ export class Foreman {
|
|
|
501
613
|
return { result, status };
|
|
502
614
|
}
|
|
503
615
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
504
|
-
async runPreflight(n, ticketsContent, preferredTicketId) {
|
|
505
|
-
const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId);
|
|
616
|
+
async runPreflight(n, ticketsContent, preferredTicketId, executionTickets) {
|
|
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.` : "");
|
|
506
618
|
if (this.beforeBuilderTurn)
|
|
507
619
|
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
508
|
-
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" });
|
|
509
621
|
await this.observeBuilderNative(this.builder);
|
|
510
622
|
this.log.write("preflight", {
|
|
511
623
|
ticketsProvided: ticketsContent !== undefined,
|
|
@@ -514,17 +626,19 @@ export class Foreman {
|
|
|
514
626
|
});
|
|
515
627
|
if (result.isError)
|
|
516
628
|
throw new Error(result.text);
|
|
629
|
+
this.preflightPlan = result.text;
|
|
517
630
|
return result.text;
|
|
518
631
|
}
|
|
519
632
|
/** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
|
|
520
633
|
async sendPreflightFeedback(feedback) {
|
|
521
634
|
if (this.beforeBuilderTurn)
|
|
522
635
|
this.builder = await this.beforeBuilderTurn(this.builder, feedback);
|
|
523
|
-
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" });
|
|
524
637
|
await this.observeBuilderNative(this.builder);
|
|
525
638
|
this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
|
|
526
639
|
if (result.isError)
|
|
527
640
|
throw new Error(result.text);
|
|
641
|
+
this.preflightPlan = result.text;
|
|
528
642
|
}
|
|
529
643
|
/** Send one custom instruction through the same needs_input loop as a batch turn. */
|
|
530
644
|
async runInstruction(instruction) {
|
|
@@ -543,11 +657,12 @@ export class Foreman {
|
|
|
543
657
|
* Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
|
|
544
658
|
* QA turns are free — they do not advance the step counter.
|
|
545
659
|
*/
|
|
546
|
-
async runQa(stepIndex, ticketId, builderResult) {
|
|
660
|
+
async runQa(stepIndex, ticketId, builderResult, explicitResume = false) {
|
|
547
661
|
if (this.projectDir && this.qaFactory) {
|
|
548
662
|
const resumedRecovery = this.qaResumedRecovery;
|
|
663
|
+
const scopedTicket = this.ticketForQa(stepIndex, ticketId);
|
|
549
664
|
const review = await runIsolatedQa({
|
|
550
|
-
ticket:
|
|
665
|
+
ticket: scopedTicket,
|
|
551
666
|
builderWorktree: this.projectDir,
|
|
552
667
|
builderSummary: builderResult ?? "Builder result unavailable at this explicit QA-only API boundary",
|
|
553
668
|
qaStrategy: this.qaSessionStrategy,
|
|
@@ -562,6 +677,24 @@ export class Foreman {
|
|
|
562
677
|
continuityManaged: this.qaContinuityManaged,
|
|
563
678
|
onReportRecovery: this.qaReportRecovery,
|
|
564
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
|
+
}),
|
|
565
698
|
deliverFailure: async (request) => {
|
|
566
699
|
this.builderWorkSessions += 1;
|
|
567
700
|
const delivery = new QaFailureDeliveryService();
|
|
@@ -609,9 +742,9 @@ export class Foreman {
|
|
|
609
742
|
async runPendingQaRecovery(ticketId, builderResult) {
|
|
610
743
|
if (this.qaResumedRecovery && this.qaResumedRecovery.manifest.ticketId !== ticketId)
|
|
611
744
|
throw new Error(`pending QA recovery belongs to ${this.qaResumedRecovery.manifest.ticketId}, not ${ticketId}`);
|
|
612
|
-
return this.runQa(1, ticketId, builderResult);
|
|
745
|
+
return this.runQa(1, ticketId, builderResult, true);
|
|
613
746
|
}
|
|
614
|
-
/**
|
|
747
|
+
/** Resume the pending boundary, including authorized waiting guidance, then complete its tracker transition. */
|
|
615
748
|
async completePendingQaRecovery(ticketId) {
|
|
616
749
|
if (!this.projectDir || !this.ticketsEnabled)
|
|
617
750
|
throw new Error("pending QA recovery requires an initialized ticket project");
|
|
@@ -674,63 +807,173 @@ export class Foreman {
|
|
|
674
807
|
const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
|
|
675
808
|
if (ticket)
|
|
676
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();
|
|
677
824
|
}
|
|
678
|
-
return {
|
|
679
|
-
id: `STEP-${stepIndex}`, order: stepIndex, title: `Implementation step ${stepIndex}`, area: "project",
|
|
680
|
-
priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Review implementation step ${stepIndex}`,
|
|
681
|
-
acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
|
|
682
|
-
};
|
|
683
825
|
}
|
|
684
|
-
async runBatch(n, trackerPath, onTicketStart, preferredTicketId) {
|
|
826
|
+
async runBatch(n, trackerPath, onTicketStart, preferredTicketId, recoveryTickets) {
|
|
685
827
|
this.log.write("batch-start", { requested: n, agent: this.builder.agent });
|
|
686
828
|
let completed = 0;
|
|
687
829
|
let outcome = "all-done";
|
|
688
830
|
let detail;
|
|
689
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
|
+
};
|
|
690
846
|
for (let i = 1; completed < n; i++) {
|
|
691
|
-
|
|
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
|
+
}
|
|
867
|
+
const decisionRevision = this.projectDir ? buildScopeRevision(this.projectDir) : "";
|
|
868
|
+
const decisions = this.projectDir ? (() => {
|
|
692
869
|
const db = new WorkflowDb(this.projectDir);
|
|
693
870
|
try {
|
|
694
|
-
|
|
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);
|
|
875
|
+
db.refreshStaleTicketDecisions(this.qaRunId, revision, recoveryTickets);
|
|
876
|
+
return { answered: db.answeredTicketDecisions(this.qaRunId, revision), pending: db.pendingHumanDecisions(this.qaRunId) };
|
|
695
877
|
}
|
|
696
878
|
finally {
|
|
697
879
|
db.close();
|
|
698
880
|
}
|
|
699
|
-
})() : [];
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
881
|
+
})() : { answered: [], pending: [] };
|
|
882
|
+
const answered = decisions.answered.filter(decision => !recoveryTickets || recoveryTickets.includes(decision.interruptionId.slice(7)));
|
|
883
|
+
if (decisions.pending.some(decision => !decision.interruptionId.startsWith("ticket:"))) {
|
|
884
|
+
if (await service()) {
|
|
885
|
+
i--;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
outcome = "needs-human";
|
|
889
|
+
detail = "Rafi is waiting for an answer to a build-wide question";
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
const waiting = new Set(decisions.pending.map(decision => decision.interruptionId.slice(7)));
|
|
893
|
+
for (const ticket of waiting)
|
|
894
|
+
if (!recoveryTickets || recoveryTickets.includes(ticket))
|
|
895
|
+
deferred.add(ticket);
|
|
896
|
+
if (waiting.size && !this.continueIndependentTickets) {
|
|
897
|
+
if (await service()) {
|
|
898
|
+
i--;
|
|
899
|
+
continue;
|
|
706
900
|
}
|
|
901
|
+
outcome = "needs-human";
|
|
902
|
+
detail = "Rafi is waiting for ticket answers";
|
|
903
|
+
break;
|
|
707
904
|
}
|
|
708
905
|
// Determine which ticket we're about to work on (for in_progress marking)
|
|
709
906
|
let pendingTicketId;
|
|
710
907
|
if (this.ticketsEnabled && this.projectDir) {
|
|
711
908
|
try {
|
|
712
|
-
const queue = cmdImplementationQueue(this.projectDir);
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
909
|
+
const queue = cmdImplementationQueue(this.projectDir).filter(row => !recoveryTickets || recoveryTickets.includes(row.ticket));
|
|
910
|
+
const preferred = i === 1 && preferredTicketId ? queue.find(row => row.ticket === preferredTicketId) : undefined;
|
|
911
|
+
const canAnswer = (ticket) => answered.some(decision => decision.interruptionId === `ticket:${ticket}`);
|
|
912
|
+
if (preferred && preferred.blockedBy !== "None")
|
|
913
|
+
deferred.add(preferred.ticket);
|
|
914
|
+
const next = preferred && preferred.blockedBy === "None" && !waiting.has(preferred.ticket) ? preferred
|
|
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
|
+
}
|
|
716
923
|
if (i === 1 && preferredTicketId && !next) {
|
|
717
924
|
outcome = "needs-human";
|
|
718
|
-
detail = `
|
|
925
|
+
detail = `requested ticket ${preferredTicketId} is unavailable or blocked in the implementation queue`;
|
|
719
926
|
break;
|
|
720
927
|
}
|
|
721
|
-
if (
|
|
722
|
-
|
|
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
|
+
}
|
|
933
|
+
break;
|
|
723
934
|
}
|
|
724
|
-
|
|
935
|
+
if (next && next.status !== "next" && next.status !== "in_progress" && next.status !== "blocked") {
|
|
725
936
|
outcome = "needs-human";
|
|
726
|
-
detail = `recovery ticket ${
|
|
937
|
+
detail = `recovery ticket ${next.ticket} cannot resume while its status is ${next.status}`;
|
|
727
938
|
break;
|
|
728
939
|
}
|
|
729
|
-
if ((!next || deferred.has(next.ticket)) && deferred.size)
|
|
730
|
-
break;
|
|
731
940
|
if (next) {
|
|
941
|
+
outcome = "all-done";
|
|
942
|
+
detail = undefined;
|
|
732
943
|
pendingTicketId = next.ticket;
|
|
733
944
|
await onTicketStart?.(next.ticket);
|
|
945
|
+
// Approval/feedback can change definitions while selection is awaiting it.
|
|
946
|
+
if (decisionRevision !== buildScopeRevision(this.projectDir)) {
|
|
947
|
+
i--;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const boundaryDb = new WorkflowDb(this.projectDir);
|
|
951
|
+
try {
|
|
952
|
+
boundaryDb.consumeInstructionControls(this.qaRunId, next.ticket);
|
|
953
|
+
const pending = boundaryDb.pendingHumanDecisions(this.qaRunId);
|
|
954
|
+
if (pending.some(decision => !decision.interruptionId.startsWith("ticket:") || decision.interruptionId === `ticket:${next.ticket}`)) {
|
|
955
|
+
i--;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
finally {
|
|
960
|
+
boundaryDb.close();
|
|
961
|
+
}
|
|
962
|
+
const latest = cmdImplementationQueue(this.projectDir).find(row => row.ticket === next.ticket);
|
|
963
|
+
if (!latest || latest.status !== next.status || latest.blockedBy !== "None") {
|
|
964
|
+
i--;
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (next.status === "blocked")
|
|
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
|
+
}
|
|
976
|
+
deferred.delete(next.ticket);
|
|
734
977
|
cmdUpdate(this.projectDir, next.ticket, {
|
|
735
978
|
status: "in_progress",
|
|
736
979
|
actor: "foreman",
|
|
@@ -739,23 +982,45 @@ export class Foreman {
|
|
|
739
982
|
}
|
|
740
983
|
}
|
|
741
984
|
catch (err) {
|
|
985
|
+
if (err instanceof HumanDecisionCancelled)
|
|
986
|
+
throw err;
|
|
742
987
|
outcome = "needs-human";
|
|
743
988
|
detail = `failed to update ticket tracker before step ${i}: ${err instanceof Error ? err.message : String(err)}`;
|
|
744
989
|
break;
|
|
745
990
|
}
|
|
746
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
|
+
}
|
|
747
1008
|
this.currentTicketId = pendingTicketId;
|
|
748
1009
|
let instruction = i === 1
|
|
749
|
-
? buildPrimer(n, trackerPath, this.ticketsEnabled,
|
|
1010
|
+
? buildPrimer(n, trackerPath, this.ticketsEnabled, pendingTicketId)
|
|
750
1011
|
: buildNextStepInstruction(i, n);
|
|
1012
|
+
if (pendingTicketId)
|
|
1013
|
+
instruction += `\n\nAssigned ticket: ${pendingTicketId}. Implement exactly this ticket. Do not substitute another ticket. End with ticket="${pendingTicketId}" in the STEP_STATUS marker.`;
|
|
751
1014
|
const continuations = answered.filter(decision => decision.interruptionId === `ticket:${pendingTicketId}`);
|
|
1015
|
+
const continuationKeys = [];
|
|
752
1016
|
if (continuations.length) {
|
|
753
|
-
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");
|
|
754
1018
|
const db = new WorkflowDb(this.projectDir);
|
|
755
1019
|
try {
|
|
756
1020
|
db.atomic(() => {
|
|
757
1021
|
for (const decision of continuations) {
|
|
758
|
-
const key =
|
|
1022
|
+
const key = db.nextDecisionContinuationKey(this.qaRunId, decision.decisionId);
|
|
1023
|
+
continuationKeys.push(key);
|
|
759
1024
|
db.planOperation({ runId: this.qaRunId, idempotencyKey: key, kind: "decision-continuation", intent: { decisionId: decision.decisionId, ticketId: pendingTicketId } });
|
|
760
1025
|
db.updateOperation(key, "in_progress");
|
|
761
1026
|
}
|
|
@@ -769,14 +1034,23 @@ export class Foreman {
|
|
|
769
1034
|
await this.prepareBuilderBoundary(instruction);
|
|
770
1035
|
this.builderWorkSessions += 1;
|
|
771
1036
|
const turn = () => this.doTurn(instruction);
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
: 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;
|
|
775
1049
|
if (continuations.length) {
|
|
776
1050
|
const db = new WorkflowDb(this.projectDir);
|
|
777
1051
|
try {
|
|
778
|
-
for (const
|
|
779
|
-
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 } });
|
|
780
1054
|
}
|
|
781
1055
|
finally {
|
|
782
1056
|
db.close();
|
|
@@ -803,12 +1077,12 @@ export class Foreman {
|
|
|
803
1077
|
let qaPassCertificateId;
|
|
804
1078
|
let qaSourceStateDigest;
|
|
805
1079
|
if (this.qaEnabled) {
|
|
806
|
-
const qa = await this.runQa(i,
|
|
1080
|
+
const qa = await this.runQa(i, pendingTicketId, result.text);
|
|
807
1081
|
if (qa.outcome === "blocked" || qa.outcome === "needs-human") {
|
|
808
1082
|
outcome = qa.outcome;
|
|
809
1083
|
detail = qa.detail;
|
|
810
1084
|
if (pendingTicketId && this.continueIndependentTickets && this.ticketsEnabled && this.projectDir) {
|
|
811
|
-
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" });
|
|
812
1086
|
deferred.add(pendingTicketId);
|
|
813
1087
|
continue;
|
|
814
1088
|
}
|
|
@@ -822,7 +1096,7 @@ export class Foreman {
|
|
|
822
1096
|
// Update ticket state only after QA has passed, so generated tracker
|
|
823
1097
|
// state does not claim done before verification has completed.
|
|
824
1098
|
if (this.ticketsEnabled && this.projectDir) {
|
|
825
|
-
const ticketId =
|
|
1099
|
+
const ticketId = pendingTicketId;
|
|
826
1100
|
if (ticketId) {
|
|
827
1101
|
try {
|
|
828
1102
|
if (this.qaEnabled && !qaWaived) {
|
|
@@ -830,6 +1104,13 @@ export class Foreman {
|
|
|
830
1104
|
throw new Error("QA passed without a durable pass certificate");
|
|
831
1105
|
await beginQaFinalization(this.projectDir, this.projectDir, this.qaRunId, ticketId, qaPassCertificateId, qaSourceStateDigest, `ticket-complete:${ticketId}`, generatedTrackerDirtyPaths(loadTicketsConfig(this.projectDir).paths));
|
|
832
1106
|
}
|
|
1107
|
+
const controls = new WorkflowDb(this.projectDir);
|
|
1108
|
+
try {
|
|
1109
|
+
controls.assertFinalizationControls(this.qaRunId, ticketId);
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
controls.close();
|
|
1113
|
+
}
|
|
833
1114
|
cmdComplete(this.projectDir, ticketId, {
|
|
834
1115
|
actor: "foreman",
|
|
835
1116
|
summary: status.summary ?? `Step ${i} complete`,
|
|
@@ -851,6 +1132,46 @@ export class Foreman {
|
|
|
851
1132
|
}
|
|
852
1133
|
}
|
|
853
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);
|
|
854
1175
|
completed++;
|
|
855
1176
|
if (status.kind === "plan_complete") {
|
|
856
1177
|
outcome = "plan-complete";
|
|
@@ -862,11 +1183,13 @@ export class Foreman {
|
|
|
862
1183
|
if (status.kind === "blocked") {
|
|
863
1184
|
// Mark ticket blocked in the tracker
|
|
864
1185
|
if (this.ticketsEnabled && this.projectDir) {
|
|
865
|
-
const ticketId =
|
|
1186
|
+
const ticketId = pendingTicketId;
|
|
866
1187
|
if (ticketId) {
|
|
867
1188
|
try {
|
|
868
1189
|
cmdBlock(this.projectDir, ticketId, {
|
|
869
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",
|
|
870
1193
|
actor: "foreman",
|
|
871
1194
|
});
|
|
872
1195
|
}
|
|
@@ -894,6 +1217,20 @@ export class Foreman {
|
|
|
894
1217
|
: "builder did not emit a STEP_STATUS marker";
|
|
895
1218
|
break;
|
|
896
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
|
+
}
|
|
897
1234
|
if (deferred.size) {
|
|
898
1235
|
outcome = "blocked";
|
|
899
1236
|
detail = `Deferred tickets: ${[...deferred].join(", ")}. ${detail ?? "Input or prerequisite recovery is required."}`;
|