ai-foreman 1.7.11 → 1.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/activity.d.ts +5 -1
- package/dist/activity.js +1 -1
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +133 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +121 -11
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +11 -1
- package/dist/adapters/types.d.ts +47 -1
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +8 -2
- package/dist/branch/resume.js +11 -1
- package/dist/branch/runner.d.ts +12 -3
- package/dist/branch/runner.js +131 -67
- package/dist/buildRuns.d.ts +6 -2
- package/dist/buildRuns.js +76 -32
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +84 -1
- package/dist/cli/manager.d.ts +17 -0
- package/dist/cli/manager.js +135 -0
- package/dist/cli/recovery.d.ts +4 -0
- package/dist/cli/recovery.js +80 -0
- package/dist/cli/start.js +446 -142
- package/dist/cli/status.js +13 -2
- package/dist/cli/tickets.js +27 -9
- package/dist/config.d.ts +4 -0
- package/dist/config.js +28 -1
- package/dist/continuity.js +14 -9
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +428 -0
- package/dist/foreman.d.ts +26 -6
- package/dist/foreman.js +147 -104
- package/dist/handoffs.d.ts +35 -3
- package/dist/handoffs.js +155 -17
- package/dist/index.js +6 -0
- package/dist/log.d.ts +3 -3
- package/dist/log.js +4 -1
- package/dist/managerPacket.d.ts +25 -0
- package/dist/managerPacket.js +83 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +4 -1
- package/dist/notify.d.ts +4 -0
- package/dist/notify.js +15 -0
- package/dist/observability.d.ts +248 -0
- package/dist/observability.js +1018 -0
- package/dist/planChanges.d.ts +4 -0
- package/dist/planChanges.js +16 -0
- package/dist/projectDiagnostics.d.ts +27 -0
- package/dist/projectDiagnostics.js +407 -0
- package/dist/qaRecovery.d.ts +140 -0
- package/dist/qaRecovery.js +595 -0
- package/dist/qaReview.d.ts +133 -14
- package/dist/qaReview.js +984 -59
- package/dist/qaSnapshot.d.ts +31 -5
- package/dist/qaSnapshot.js +209 -134
- package/dist/recoveryPolicy.d.ts +37 -0
- package/dist/recoveryPolicy.js +251 -0
- package/dist/roles.js +1 -0
- package/dist/supervisor.d.ts +40 -0
- package/dist/supervisor.js +102 -0
- package/dist/ticketHistoryReader.d.ts +19 -0
- package/dist/ticketHistoryReader.js +58 -0
- package/dist/tickets/details.d.ts +8 -0
- package/dist/tickets/details.js +59 -11
- package/dist/tickets/output.d.ts +5 -0
- package/dist/tickets/output.js +73 -0
- package/dist/tickets/stateDb.d.ts +1 -0
- package/dist/tickets/stateDb.js +5 -0
- package/dist/workflowDb.d.ts +31 -2
- package/dist/workflowDb.js +192 -23
- package/dist/workflowReader.d.ts +42 -0
- package/dist/workflowReader.js +171 -0
- package/foreman.yaml +13 -0
- package/package.json +51 -3
package/dist/foreman.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { select, isCancel } from "@clack/prompts";
|
|
1
|
+
import { select, text, isCancel } from "@clack/prompts";
|
|
2
2
|
import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
|
|
3
3
|
import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
|
|
4
|
-
import {
|
|
4
|
+
import { signalAttention } from "./notify.js";
|
|
5
|
+
import { pauseActivityForInput } from "./activity.js";
|
|
5
6
|
import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
|
|
6
|
-
import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
|
|
7
|
+
import { cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdImplementationQueue } from "./tickets/commands.js";
|
|
7
8
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
8
|
-
import { runIsolatedQa } from "./qaReview.js";
|
|
9
|
-
import {
|
|
9
|
+
import { buildDurableQaFixHandoff, runIsolatedQa } from "./qaReview.js";
|
|
10
|
+
import { changeManifestAsync, deterministicChangeSummaryAsync } from "./qaSnapshot.js";
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
12
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
11
13
|
import { join, relative } from "node:path";
|
|
12
14
|
import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
|
|
@@ -25,7 +27,8 @@ const QUESTION_HINTS = [
|
|
|
25
27
|
* Format: `STEP_STATUS: <kind> | key="value" key="value"`.
|
|
26
28
|
*/
|
|
27
29
|
export function parseStepStatus(text) {
|
|
28
|
-
const
|
|
30
|
+
const markerLines = text.split(/\r?\n/).filter((line) => /^\s*STEP_STATUS:/.test(line));
|
|
31
|
+
const markerCount = markerLines.length;
|
|
29
32
|
if (markerCount > 1) {
|
|
30
33
|
return { kind: "unknown", error: "builder emitted multiple STEP_STATUS markers" };
|
|
31
34
|
}
|
|
@@ -151,7 +154,7 @@ export function buildQaInstruction() {
|
|
|
151
154
|
Triple-check. Do not rubber-stamp your own work. Be skeptical.
|
|
152
155
|
|
|
153
156
|
If everything is solid, end with STEP_STATUS: qa_pass.
|
|
154
|
-
If anything is off,
|
|
157
|
+
If anything is off, return the required RAFI_QA_FAILURE_REPORT_START/RAFI_QA_FAILURE_REPORT_END JSON envelope immediately before STEP_STATUS: qa_fail. The report must contain every check, every blocking finding, and all nonblocking observations. Do NOT fix issues on this turn — just report them. Foreman will instruct the Builder to fix them next.
|
|
155
158
|
|
|
156
159
|
${QA_MARKER_SPEC}`;
|
|
157
160
|
}
|
|
@@ -216,11 +219,9 @@ export function createPermissionHandler(policy, log, opts = {}) {
|
|
|
216
219
|
export class Foreman {
|
|
217
220
|
builder;
|
|
218
221
|
log;
|
|
219
|
-
notificationsEnabled;
|
|
220
222
|
qaEnabled;
|
|
221
223
|
qaMaxCycles;
|
|
222
224
|
projectDir;
|
|
223
|
-
qaReviewer;
|
|
224
225
|
qaFactory;
|
|
225
226
|
qaSessionStrategy;
|
|
226
227
|
builderFactory;
|
|
@@ -231,19 +232,27 @@ export class Foreman {
|
|
|
231
232
|
qaSessionBoundary;
|
|
232
233
|
observeQaNativeCompactions;
|
|
233
234
|
observeBuilderNativeCompactions;
|
|
235
|
+
observer;
|
|
236
|
+
qaRuntimeContext;
|
|
237
|
+
qaContinuityManaged;
|
|
238
|
+
qaReportRecovery;
|
|
239
|
+
qaResumedRecovery;
|
|
234
240
|
ticketsEnabled;
|
|
241
|
+
notificationsEnabled;
|
|
242
|
+
terminalBellEnabled;
|
|
235
243
|
qaStream = { reviews: 0, modificationViolations: 0 };
|
|
236
244
|
builderWorkSessions = 0;
|
|
237
|
-
|
|
245
|
+
fallbackQaRunId = `qa-${randomUUID()}`;
|
|
246
|
+
constructor(builder, log, notifications = false, qaEnabled = true, qaMaxCycles = 3, projectDir,
|
|
247
|
+
/** @deprecated QA must be created by qaFactory in a disposable snapshot. */
|
|
248
|
+
_deprecatedSameSessionReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
|
|
238
249
|
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
239
|
-
observeBuilderNativeCompactions) {
|
|
250
|
+
observeBuilderNativeCompactions, observer, qaRuntimeContext, qaContinuityManaged = false, qaReportRecovery, qaResumedRecovery) {
|
|
240
251
|
this.builder = builder;
|
|
241
252
|
this.log = log;
|
|
242
|
-
this.notificationsEnabled = notificationsEnabled;
|
|
243
253
|
this.qaEnabled = qaEnabled;
|
|
244
254
|
this.qaMaxCycles = qaMaxCycles;
|
|
245
255
|
this.projectDir = projectDir;
|
|
246
|
-
this.qaReviewer = qaReviewer;
|
|
247
256
|
this.qaFactory = qaFactory;
|
|
248
257
|
this.qaSessionStrategy = qaSessionStrategy;
|
|
249
258
|
this.builderFactory = builderFactory;
|
|
@@ -254,8 +263,33 @@ export class Foreman {
|
|
|
254
263
|
this.qaSessionBoundary = qaSessionBoundary;
|
|
255
264
|
this.observeQaNativeCompactions = observeQaNativeCompactions;
|
|
256
265
|
this.observeBuilderNativeCompactions = observeBuilderNativeCompactions;
|
|
266
|
+
this.observer = observer;
|
|
267
|
+
this.qaRuntimeContext = qaRuntimeContext;
|
|
268
|
+
this.qaContinuityManaged = qaContinuityManaged;
|
|
269
|
+
this.qaReportRecovery = qaReportRecovery;
|
|
270
|
+
this.qaResumedRecovery = qaResumedRecovery;
|
|
271
|
+
void _deprecatedSameSessionReviewer;
|
|
272
|
+
this.notificationsEnabled = typeof notifications === "boolean" ? notifications : notifications.desktop;
|
|
273
|
+
this.terminalBellEnabled = typeof notifications === "boolean" ? true : notifications.terminalBell;
|
|
257
274
|
this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
|
|
258
275
|
}
|
|
276
|
+
async waitForUserInput(operation) {
|
|
277
|
+
const paused = () => pauseActivityForInput(operation);
|
|
278
|
+
if (!this.observer)
|
|
279
|
+
return paused();
|
|
280
|
+
return this.observer.withContext({ role: "builder", stream: "user-input" }, async () => {
|
|
281
|
+
const spanId = this.observer.store.startSpan(this.observer.context(), { kind: "user_wait", name: "Builder input prompt" });
|
|
282
|
+
try {
|
|
283
|
+
const value = await paused();
|
|
284
|
+
this.observer.store.finishSpan(spanId, { outcome: value === undefined ? "paused" : "answered" });
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
this.observer.store.finishSpan(spanId, { outcome: "error" });
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
259
293
|
/**
|
|
260
294
|
* Send one instruction and resolve any needs_input exchanges before returning.
|
|
261
295
|
* The returned result and status always reflect the final (non-needs_input) turn.
|
|
@@ -309,7 +343,7 @@ export class Foreman {
|
|
|
309
343
|
this.builder = await this.builderFactory(this.projectDir);
|
|
310
344
|
this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
|
|
311
345
|
}
|
|
312
|
-
async doTurnWith(adapter, instruction) {
|
|
346
|
+
async doTurnWith(adapter, instruction, mode = "builder") {
|
|
313
347
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
314
348
|
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
315
349
|
adapter = this.builder;
|
|
@@ -324,12 +358,11 @@ export class Foreman {
|
|
|
324
358
|
status = { kind: "needs_input", question: lastLine(result.text), choices: ["Continue", "Cancel"] };
|
|
325
359
|
}
|
|
326
360
|
else {
|
|
327
|
-
const qa = /\bQA\b|qa_pass|qa_fail/i.test(instruction);
|
|
328
361
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
329
362
|
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
330
363
|
adapter = this.builder;
|
|
331
364
|
}
|
|
332
|
-
result = await adapter.sendTurn(qa
|
|
365
|
+
result = await adapter.sendTurn(mode === "qa"
|
|
333
366
|
? "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."
|
|
334
367
|
: "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.");
|
|
335
368
|
await this.observeBuilderNative(adapter);
|
|
@@ -338,33 +371,67 @@ export class Foreman {
|
|
|
338
371
|
status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
|
|
339
372
|
}
|
|
340
373
|
}
|
|
341
|
-
while (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
? status.choices
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
374
|
+
while (true) {
|
|
375
|
+
while (status.kind === "needs_input") {
|
|
376
|
+
const question = status.question ?? "The builder has a question";
|
|
377
|
+
const choices = status.choices?.length ? status.choices : ["Continue"];
|
|
378
|
+
this.log.write("needs_input", { question, choices });
|
|
379
|
+
signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
|
|
380
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
381
|
+
console.error(`foreman: input required: ${question}`);
|
|
382
|
+
console.error("foreman: resume this run in an interactive terminal, or provide explicit guidance through the printed resume command");
|
|
383
|
+
result = { text: result.text, isError: false, numTurns: result.numTurns, costUsd: result.costUsd };
|
|
384
|
+
status = { kind: "blocked", reason: `input required in an interactive terminal: ${question}` };
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const answer = await this.waitForUserInput(async () => {
|
|
388
|
+
console.log();
|
|
389
|
+
const selected = await select({
|
|
390
|
+
message: question,
|
|
391
|
+
options: [
|
|
392
|
+
...choices.map((choice) => ({ value: choice, label: choice })),
|
|
393
|
+
{ value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
|
|
394
|
+
{ value: "__rafi_pause__", label: "Pause safely", hint: "Keep the run recoverable and return to the terminal" },
|
|
395
|
+
],
|
|
396
|
+
});
|
|
397
|
+
if (isCancel(selected) || selected === "__rafi_pause__")
|
|
398
|
+
return undefined;
|
|
399
|
+
if (selected !== "__rafi_custom__")
|
|
400
|
+
return selected;
|
|
401
|
+
const custom = await text({ message: "Custom response:", validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
|
|
402
|
+
return isCancel(custom) ? undefined : String(custom);
|
|
403
|
+
});
|
|
404
|
+
console.log();
|
|
405
|
+
if (answer === undefined) {
|
|
406
|
+
result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
|
|
407
|
+
status = { kind: "blocked", reason: "user chose safe pause at an input prompt" };
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
411
|
+
this.builder = await this.beforeBuilderTurn(this.builder, answer);
|
|
412
|
+
adapter = this.builder;
|
|
413
|
+
}
|
|
414
|
+
result = await adapter.sendTurn(answer);
|
|
415
|
+
await this.observeBuilderNative(adapter);
|
|
416
|
+
status = parseStepStatus(result.text);
|
|
349
417
|
}
|
|
350
|
-
|
|
351
|
-
const answer = await select({
|
|
352
|
-
message: question,
|
|
353
|
-
options: choices.map((c) => ({ value: c, label: c })),
|
|
354
|
-
});
|
|
355
|
-
console.log();
|
|
356
|
-
if (isCancel(answer)) {
|
|
357
|
-
result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
|
|
358
|
-
status = { kind: "blocked", reason: "user cancelled at input prompt" };
|
|
418
|
+
if (status.kind !== "blocked" || status.reason?.startsWith("user chose safe pause") || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
359
419
|
break;
|
|
360
|
-
|
|
420
|
+
const reason = status.reason ?? "the agent reported an unspecified blocker";
|
|
421
|
+
this.log.write("blocked-recovery", { reason, role: adapter === this.builder ? "builder" : "qa" });
|
|
422
|
+
const recoveryInstruction = buildBlockerRecoveryInstruction(reason);
|
|
361
423
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
362
|
-
this.builder = await this.beforeBuilderTurn(this.builder,
|
|
424
|
+
this.builder = await this.beforeBuilderTurn(this.builder, recoveryInstruction);
|
|
363
425
|
adapter = this.builder;
|
|
364
426
|
}
|
|
365
|
-
result = await adapter.sendTurn(
|
|
427
|
+
result = await adapter.sendTurn(recoveryInstruction);
|
|
366
428
|
await this.observeBuilderNative(adapter);
|
|
367
429
|
status = parseStepStatus(result.text);
|
|
430
|
+
if (status.kind === "unknown") {
|
|
431
|
+
result = await adapter.sendTurn('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.');
|
|
432
|
+
await this.observeBuilderNative(adapter);
|
|
433
|
+
status = parseStepStatus(result.text);
|
|
434
|
+
}
|
|
368
435
|
}
|
|
369
436
|
return { result, status };
|
|
370
437
|
}
|
|
@@ -398,6 +465,10 @@ export class Foreman {
|
|
|
398
465
|
async runInstruction(instruction) {
|
|
399
466
|
return this.doTurn(instruction);
|
|
400
467
|
}
|
|
468
|
+
/** Ask an already-active role session to propose choices for its reported blocker. */
|
|
469
|
+
async resolveBlocker(adapter, reason, mode = "builder") {
|
|
470
|
+
return this.doTurnWith(adapter, buildBlockerRecoveryInstruction(reason), mode);
|
|
471
|
+
}
|
|
401
472
|
async observeBuilderNative(adapter) {
|
|
402
473
|
if (adapter === this.builder)
|
|
403
474
|
await this.observeBuilderNativeCompactions?.(adapter);
|
|
@@ -407,100 +478,56 @@ export class Foreman {
|
|
|
407
478
|
* Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
|
|
408
479
|
* QA turns are free — they do not advance the step counter.
|
|
409
480
|
*/
|
|
410
|
-
async runQa(stepIndex, ticketId) {
|
|
481
|
+
async runQa(stepIndex, ticketId, builderResult) {
|
|
411
482
|
if (this.projectDir && this.qaFactory) {
|
|
483
|
+
const resumedRecovery = this.qaResumedRecovery;
|
|
484
|
+
this.qaResumedRecovery = undefined;
|
|
412
485
|
const review = await runIsolatedQa({
|
|
413
486
|
ticket: this.ticketForQa(stepIndex, ticketId),
|
|
414
487
|
builderWorktree: this.projectDir,
|
|
415
|
-
builderSummary:
|
|
488
|
+
builderSummary: builderResult ?? "Builder result unavailable at this explicit QA-only API boundary",
|
|
416
489
|
qaStrategy: this.qaSessionStrategy,
|
|
417
490
|
state: this.qaStream,
|
|
418
491
|
createQa: this.qaFactory,
|
|
419
492
|
sessionBoundary: this.qaSessionBoundary,
|
|
420
493
|
observeNativeCompactions: this.observeQaNativeCompactions,
|
|
421
494
|
maxCycles: this.qaMaxCycles,
|
|
422
|
-
|
|
423
|
-
|
|
495
|
+
recovery: { projectDir: this.projectDir, runId: this.observer?.runId ?? this.fallbackQaRunId },
|
|
496
|
+
observer: this.observer,
|
|
497
|
+
qaRuntimeContext: this.qaRuntimeContext,
|
|
498
|
+
continuityManaged: this.qaContinuityManaged,
|
|
499
|
+
onReportRecovery: this.qaReportRecovery,
|
|
500
|
+
resumedRecovery,
|
|
501
|
+
fix: async (request) => {
|
|
502
|
+
const manifest = await changeManifestAsync(this.projectDir);
|
|
503
|
+
const changeSummary = await deterministicChangeSummaryAsync(this.projectDir);
|
|
504
|
+
const instruction = buildDurableQaFixHandoff(this.ticketForQa(stepIndex, ticketId), request, this.projectDir, request.latestBuilderResult, manifest.diffDigest, changeSummary);
|
|
424
505
|
await this.prepareBuilderBoundary(instruction);
|
|
425
506
|
this.builderWorkSessions += 1;
|
|
426
507
|
const fix = await this.doTurn(instruction);
|
|
427
508
|
this.log.write("qa-fix", { stepIndex, statusKind: fix.status.kind, costUsd: fix.result.costUsd, isError: fix.result.isError });
|
|
428
509
|
return fix.result.isError || fix.status.kind !== "done"
|
|
429
510
|
? { ok: false, detail: fix.status.reason ?? fix.status.error ?? fix.result.text.slice(0, 200) }
|
|
430
|
-
: { ok: true };
|
|
511
|
+
: { ok: true, response: fix.result.text, summary: fix.status.summary ?? fix.result.text };
|
|
431
512
|
},
|
|
432
513
|
evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
|
|
433
514
|
onNonconvergence: this.qaNonconvergence,
|
|
515
|
+
resolveBlocked: (adapter, reason) => this.resolveBlocker(adapter, reason, "qa"),
|
|
434
516
|
});
|
|
435
517
|
if (review.outcome === "nonconverged")
|
|
436
518
|
return { outcome: "needs-human", detail: review.detail };
|
|
437
519
|
return { outcome: review.outcome, detail: review.detail, summary: review.summary };
|
|
438
520
|
}
|
|
439
|
-
for (let cycle = 1; cycle <= this.qaMaxCycles; cycle++) {
|
|
440
|
-
const before = this.qaReviewer && this.projectDir ? fingerprintProtectedTree(this.projectDir) : undefined;
|
|
441
|
-
const qa = await this.doTurnWith(this.qaReviewer ?? this.builder, buildQaInstruction());
|
|
442
|
-
if (before && this.projectDir) {
|
|
443
|
-
const changes = changedProtectedFiles(before, fingerprintProtectedTree(this.projectDir));
|
|
444
|
-
if (changes.length > 0) {
|
|
445
|
-
this.log.write("qa-protected-files-changed", { stepIndex, cycle, paths: changes });
|
|
446
|
-
return { outcome: "needs-human", detail: `independent QA changed protected files: ${changes.join(", ")}` };
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
this.log.write("qa", {
|
|
450
|
-
stepIndex,
|
|
451
|
-
cycle,
|
|
452
|
-
statusKind: qa.status.kind,
|
|
453
|
-
issues: qa.status.issues,
|
|
454
|
-
costUsd: qa.result.costUsd,
|
|
455
|
-
isError: qa.result.isError,
|
|
456
|
-
});
|
|
457
|
-
if (qa.result.isError) {
|
|
458
|
-
return { outcome: "blocked", detail: `QA turn errored: ${qa.result.text.slice(0, 200)}` };
|
|
459
|
-
}
|
|
460
|
-
if (qa.status.kind === "qa_pass") {
|
|
461
|
-
return { outcome: "passed", summary: qa.status.summary };
|
|
462
|
-
}
|
|
463
|
-
if (qa.status.kind === "blocked") {
|
|
464
|
-
return { outcome: "blocked", detail: qa.status.reason ?? "QA reported blocked" };
|
|
465
|
-
}
|
|
466
|
-
if (qa.status.kind !== "qa_fail") {
|
|
467
|
-
return {
|
|
468
|
-
outcome: "needs-human",
|
|
469
|
-
detail: qa.status.error ?? `QA turn did not emit a valid marker (got ${qa.status.kind})`,
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
const fix = await this.doTurn(buildQaFixInstruction(qa.status.issues ?? "(no issues listed)"));
|
|
473
|
-
this.log.write("qa-fix", {
|
|
474
|
-
stepIndex,
|
|
475
|
-
cycle,
|
|
476
|
-
statusKind: fix.status.kind,
|
|
477
|
-
costUsd: fix.result.costUsd,
|
|
478
|
-
isError: fix.result.isError,
|
|
479
|
-
});
|
|
480
|
-
if (fix.result.isError) {
|
|
481
|
-
return { outcome: "blocked", detail: `QA fix turn errored: ${fix.result.text.slice(0, 200)}` };
|
|
482
|
-
}
|
|
483
|
-
if (fix.status.kind === "blocked") {
|
|
484
|
-
return { outcome: "blocked", detail: fix.status.reason ?? "QA fix reported blocked" };
|
|
485
|
-
}
|
|
486
|
-
if (fix.status.kind !== "done") {
|
|
487
|
-
return {
|
|
488
|
-
outcome: "needs-human",
|
|
489
|
-
detail: fix.status.error ?? `QA fix turn did not emit done (got ${fix.status.kind})`,
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
// loop back into QA
|
|
493
|
-
}
|
|
494
521
|
return {
|
|
495
522
|
outcome: "needs-human",
|
|
496
|
-
detail:
|
|
523
|
+
detail: "QA is enabled but no fresh disposable QA factory and durable session boundary were configured",
|
|
497
524
|
};
|
|
498
525
|
}
|
|
499
|
-
async runQaReview(stepIndex) {
|
|
500
|
-
return this.runQa(stepIndex);
|
|
526
|
+
async runQaReview(stepIndex, builderResult) {
|
|
527
|
+
return this.runQa(stepIndex, undefined, builderResult);
|
|
501
528
|
}
|
|
502
|
-
qaSessionId() { return this.qaStream.sessionId
|
|
503
|
-
qaSessionRef() { return this.qaStream.sessionRef
|
|
529
|
+
qaSessionId() { return this.qaStream.sessionId; }
|
|
530
|
+
qaSessionRef() { return this.qaStream.sessionRef; }
|
|
504
531
|
builderSessionId() { return this.builder.sessionId(); }
|
|
505
532
|
builderAdapter() { return this.builder; }
|
|
506
533
|
async close() { await this.builder.close(); }
|
|
@@ -539,7 +566,10 @@ export class Foreman {
|
|
|
539
566
|
detail = `recovery ticket ${preferredTicketId} is no longer available in the implementation queue`;
|
|
540
567
|
break;
|
|
541
568
|
}
|
|
542
|
-
if (i === 1 && preferredTicketId && next
|
|
569
|
+
if (i === 1 && preferredTicketId && next?.status === "blocked") {
|
|
570
|
+
cmdUnblock(this.projectDir, preferredTicketId, { actor: "foreman", summary: "Reopened by explicit build recovery" });
|
|
571
|
+
}
|
|
572
|
+
else if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
|
|
543
573
|
outcome = "needs-human";
|
|
544
574
|
detail = `recovery ticket ${preferredTicketId} cannot resume while its status is ${next.status}`;
|
|
545
575
|
break;
|
|
@@ -566,7 +596,10 @@ export class Foreman {
|
|
|
566
596
|
if (i > 1)
|
|
567
597
|
await this.prepareBuilderBoundary(instruction);
|
|
568
598
|
this.builderWorkSessions += 1;
|
|
569
|
-
const
|
|
599
|
+
const turn = () => this.doTurn(instruction);
|
|
600
|
+
const { result, status } = this.observer
|
|
601
|
+
? await this.observer.withContext({ role: "builder", stream: "builder", ticketId: pendingTicketId }, turn)
|
|
602
|
+
: await turn();
|
|
570
603
|
this.log.write("step", {
|
|
571
604
|
index: i,
|
|
572
605
|
statusKind: status.kind,
|
|
@@ -586,7 +619,7 @@ export class Foreman {
|
|
|
586
619
|
let qaSummary;
|
|
587
620
|
let qaWaived = false;
|
|
588
621
|
if (this.qaEnabled) {
|
|
589
|
-
const qa = await this.runQa(i, status.ticket ?? pendingTicketId);
|
|
622
|
+
const qa = await this.runQa(i, status.ticket ?? pendingTicketId, result.text);
|
|
590
623
|
if (qa.outcome === "blocked") {
|
|
591
624
|
outcome = "blocked";
|
|
592
625
|
detail = qa.detail;
|
|
@@ -672,6 +705,16 @@ function lastLine(text) {
|
|
|
672
705
|
const lines = text.trim().split("\n");
|
|
673
706
|
return (lines[lines.length - 1] ?? "").slice(0, 200);
|
|
674
707
|
}
|
|
708
|
+
function buildBlockerRecoveryInstruction(reason) {
|
|
709
|
+
return [
|
|
710
|
+
`You reported this blocker: ${reason}`,
|
|
711
|
+
"Do not end the run. Analyze the durable state and propose two or three safe, materially different approaches the user can choose from.",
|
|
712
|
+
"Put your recommended approach first and end its label with (Recommended). Include the important consequence or tradeoff in every choice label.",
|
|
713
|
+
"Do not perform more implementation or QA work until the user chooses. End exactly with:",
|
|
714
|
+
'STEP_STATUS: needs_input | question="How should Rafi unblock this work?" choices="recommended approach and consequence (Recommended)|alternative and consequence|another alternative and consequence"',
|
|
715
|
+
"After the user answers, apply that guidance in this same session. If still blocked, report blocked again so Rafi can offer a new set of approaches.",
|
|
716
|
+
].join("\n\n");
|
|
717
|
+
}
|
|
675
718
|
function fingerprintProtectedTree(root) {
|
|
676
719
|
const out = new Map();
|
|
677
720
|
const ignored = new Set([".git", "node_modules", "dist", "coverage"]);
|
package/dist/handoffs.d.ts
CHANGED
|
@@ -23,8 +23,12 @@ export interface CreateHandoffInput {
|
|
|
23
23
|
compactMaximum: number;
|
|
24
24
|
resources?: Array<{
|
|
25
25
|
label: string;
|
|
26
|
-
content
|
|
26
|
+
content?: string | Buffer;
|
|
27
|
+
digest?: string;
|
|
27
28
|
authoritative: boolean;
|
|
29
|
+
requiredForRecovery?: boolean;
|
|
30
|
+
mediaType?: string;
|
|
31
|
+
path?: string;
|
|
28
32
|
}>;
|
|
29
33
|
requestedByBuilder?: boolean;
|
|
30
34
|
/** Internal recovery path: use the last valid checkpoint while its head is marked degraded/invalid. */
|
|
@@ -45,14 +49,42 @@ export declare class HandoffLoopError extends Error {
|
|
|
45
49
|
readonly runId: string;
|
|
46
50
|
constructor(runId: string);
|
|
47
51
|
}
|
|
52
|
+
export type HandoffAcceptanceFailureCode = "missing-acknowledgement" | "invalid-continuity-delta" | "missing-successor-session" | "missing-scoped-successor-session" | "reused-predecessor-session";
|
|
53
|
+
export declare class HandoffAcceptanceError extends Error {
|
|
54
|
+
readonly code: HandoffAcceptanceFailureCode;
|
|
55
|
+
readonly runId: string;
|
|
56
|
+
readonly generation: number;
|
|
57
|
+
constructor(code: HandoffAcceptanceFailureCode, runId: string, generation: number, detail: string);
|
|
58
|
+
}
|
|
59
|
+
export declare class HandoffRecoveryPausedError extends Error {
|
|
60
|
+
readonly runId: string;
|
|
61
|
+
readonly generation: number;
|
|
62
|
+
constructor(runId: string, generation: number, detail: string);
|
|
63
|
+
}
|
|
64
|
+
export type HandoffRecoveryChoice = "retry" | "switch" | "custom" | "pause";
|
|
65
|
+
export interface HandoffRecoveryOptions {
|
|
66
|
+
/** Enables the provider-switch choice. The callback must honor the requested runtime. */
|
|
67
|
+
allowProviderSwitch?: boolean;
|
|
68
|
+
choose?: (error: HandoffAcceptanceError, currentRuntime: "claude" | "codex") => Promise<HandoffRecoveryChoice>;
|
|
69
|
+
customGuidance?: () => Promise<string | undefined>;
|
|
70
|
+
desktopNotifications?: boolean;
|
|
71
|
+
terminalBell?: boolean;
|
|
72
|
+
/** Persist run-local provider selection only after validated acceptance. */
|
|
73
|
+
onAccepted?: (result: HandoffTransferResult) => void | Promise<void>;
|
|
74
|
+
}
|
|
48
75
|
/** Durable, validated ownership transfer; cache copies are never authoritative. */
|
|
49
76
|
export declare class HandoffService {
|
|
50
77
|
readonly projectDir: string;
|
|
51
78
|
readonly cacheRoot: string;
|
|
52
79
|
constructor(projectDir: string);
|
|
53
80
|
stage(input: CreateHandoffInput, now?: Date): StagedHandoff;
|
|
54
|
-
transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff) => Promise<BuilderAdapter
|
|
55
|
-
|
|
81
|
+
transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
|
|
82
|
+
acceptStagedWithRecovery(staged: StagedHandoff, initialSuccessor: BuilderAdapter, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
|
|
83
|
+
acceptStaged(staged: StagedHandoff, successor: BuilderAdapter, options?: {
|
|
84
|
+
finalizeFailure?: boolean;
|
|
85
|
+
guidance?: string;
|
|
86
|
+
}): Promise<HandoffTransferResult>;
|
|
87
|
+
private failStaged;
|
|
56
88
|
inspect(runId: string, generation?: number): {
|
|
57
89
|
manifest: HandoffManifestV1;
|
|
58
90
|
markdown: string;
|