ai-foreman 1.7.11 → 1.7.12
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/dist/agentRun.js +1 -1
- package/dist/branch/runner.d.ts +1 -0
- package/dist/branch/runner.js +18 -8
- package/dist/cli/start.js +170 -102
- package/dist/cli/tickets.js +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -1
- package/dist/foreman.d.ts +12 -2
- package/dist/foreman.js +82 -27
- package/dist/handoffs.d.ts +30 -2
- package/dist/handoffs.js +151 -16
- package/dist/log.d.ts +1 -1
- package/dist/notify.d.ts +4 -0
- package/dist/notify.js +15 -0
- package/dist/qaReview.d.ts +5 -0
- package/dist/qaReview.js +10 -2
- package/foreman.yaml +4 -0
- package/package.json +2 -2
package/dist/agentRun.js
CHANGED
|
@@ -293,7 +293,7 @@ export async function runRoleInstruction(opts) {
|
|
|
293
293
|
const { builder, effort, roleBundle, skills } = roleBuilder;
|
|
294
294
|
const viewer = printEvents(builder.events());
|
|
295
295
|
const config = loadConfig(join(opts.projectDir, "foreman.yaml"));
|
|
296
|
-
const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, opts.projectDir);
|
|
296
|
+
const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, opts.projectDir);
|
|
297
297
|
try {
|
|
298
298
|
const turn = await foreman.runInstruction(opts.instruction);
|
|
299
299
|
await builder.close();
|
package/dist/branch/runner.d.ts
CHANGED
package/dist/branch/runner.js
CHANGED
|
@@ -2,7 +2,7 @@ import { buildDurableQaFixHandoff, compactWithRetry, runIsolatedQa } from "../qa
|
|
|
2
2
|
import { changeManifestAsync } from "../qaSnapshot.js";
|
|
3
3
|
import { Foreman, MARKER_SPEC, parseStepStatus } from "../foreman.js";
|
|
4
4
|
import { fireNotification } from "../notify.js";
|
|
5
|
-
import { cmdBlock, cmdComplete, cmdUpdate } from "../tickets/commands.js";
|
|
5
|
+
import { cmdBlock, cmdComplete, cmdUnblock, cmdUpdate } from "../tickets/commands.js";
|
|
6
6
|
import { commitAll, createTicketWorktree, findWorktreeForBranch, currentWorktreeBranch, deleteLocalBranch, ensureCleanBaseWorktree, ensureForemanExcluded, hasTrackerChanges, hasWorktreeChanges, headCommitIfAhead, removeTicketWorktree, mergeBranchToLocalBase, } from "./git.js";
|
|
7
7
|
import { checkGitHubPrMerged, createOrReusePr, enableGitHubAutoMerge, pushBranchForPr } from "./github.js";
|
|
8
8
|
import { checkGitLabMrMerged, createOrReuseMr, enableGitLabAutoMerge, pushBranchForMr } from "./gitlab.js";
|
|
@@ -143,10 +143,14 @@ export async function runBranchPlan(opts) {
|
|
|
143
143
|
sessionRef: resumeSession.sessionRef,
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
|
-
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:in-progress`, { ticket: node.ticket.id, status: "in_progress" }, () => {
|
|
147
|
+
if (resumeSession)
|
|
148
|
+
cmdUnblock(opts.projectDir, node.ticket.id, { actor: "foreman", summary: `Reopened by explicit branch recovery for ${node.branch}` });
|
|
149
|
+
cmdUpdate(opts.projectDir, node.ticket.id, {
|
|
150
|
+
status: "in_progress", actor: "foreman",
|
|
151
|
+
summary: resumeSession ? `Resuming branch ${node.branch}` : `Starting branch ${node.branch}`,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
150
154
|
const ticketInstruction = resumeSession ? buildBranchTicketResumeInstruction(node, opts.trackerPaths) : buildBranchTicketInstruction(node, opts.trackerPaths);
|
|
151
155
|
// Reattach the predecessor even for a `fresh` strategy so the host can
|
|
152
156
|
// publish and validate a cumulative handoff before creating its successor.
|
|
@@ -173,7 +177,7 @@ export async function runBranchPlan(opts) {
|
|
|
173
177
|
}
|
|
174
178
|
builderWorkSessions += 1;
|
|
175
179
|
viewer = opts.observeBuilder?.(builder);
|
|
176
|
-
const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
|
|
180
|
+
const foreman = new Foreman(builder, opts.log, { desktop: opts.notificationsEnabled, terminalBell: opts.terminalBellEnabled ?? true }, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
|
|
177
181
|
const { result, status } = await foreman.runInstruction(ticketInstruction);
|
|
178
182
|
builder = foreman.builderAdapter();
|
|
179
183
|
workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
|
|
@@ -243,6 +247,7 @@ export async function runBranchPlan(opts) {
|
|
|
243
247
|
qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
|
|
244
248
|
sessionBoundary: opts.qaSessionBoundary,
|
|
245
249
|
observeNativeCompactions: opts.observeQaNativeCompactions,
|
|
250
|
+
resolveBlocked: (adapter, reason) => foreman.resolveBlocker(adapter, reason),
|
|
246
251
|
evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
|
|
247
252
|
fix: async (issues) => {
|
|
248
253
|
if (!builder)
|
|
@@ -267,8 +272,13 @@ export async function runBranchPlan(opts) {
|
|
|
267
272
|
}
|
|
268
273
|
if (opts.beforeBuilderTurn)
|
|
269
274
|
builder = await opts.beforeBuilderTurn(builder, fixInstruction, worktreePath);
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
let fixed = await builder.sendTurn(fixInstruction);
|
|
276
|
+
let fixedStatus = parseStepStatus(fixed.text);
|
|
277
|
+
if (!fixed.isError && fixedStatus.kind === "blocked") {
|
|
278
|
+
const resolved = await foreman.resolveBlocker(builder, fixedStatus.reason ?? "Builder QA fix reported an unspecified blocker");
|
|
279
|
+
fixed = resolved.result;
|
|
280
|
+
fixedStatus = resolved.status;
|
|
281
|
+
}
|
|
272
282
|
const fixedSessionId = builder.sessionId();
|
|
273
283
|
const fixedSessionRef = builder.sessionRef?.();
|
|
274
284
|
if (fixedSessionId)
|
package/dist/cli/start.js
CHANGED
|
@@ -31,6 +31,7 @@ import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig
|
|
|
31
31
|
import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
|
|
32
32
|
import { AgentStatusReporter, RoleStatusAdapter } from "../statusReporter.js";
|
|
33
33
|
import { currentActivity, withActivityPhase } from "../activity.js";
|
|
34
|
+
import { fireTerminalBell } from "../notify.js";
|
|
34
35
|
import { WorkflowDb, readCurrentWorkflowLease } from "../workflowDb.js";
|
|
35
36
|
import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
|
|
36
37
|
import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibility.js";
|
|
@@ -38,7 +39,7 @@ import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js
|
|
|
38
39
|
import { captureCurrentWorkflowSessionIdentity, CurrentWorkflowChangedError, CurrentWorkflowGuardAdapter } from "../branch/currentGuard.js";
|
|
39
40
|
import { ContinuityAdapter, ContinuityRecoveryRequiredError, SessionUnavailableContinuityError } from "../continuity.js";
|
|
40
41
|
import { ContextCapabilityError, RoleSessionController, ThresholdCompactionController } from "../sessionLifecycle.js";
|
|
41
|
-
import { HandoffLoopError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
|
|
42
|
+
import { HandoffAcceptanceError, HandoffLoopError, HandoffRecoveryPausedError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
|
|
42
43
|
import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
|
|
43
44
|
import { SessionUnavailableError } from "../adapters/sessionFailure.js";
|
|
44
45
|
import { resolveProviderSessionAvailability } from "../sessionAvailability.js";
|
|
@@ -374,6 +375,9 @@ export function buildStartCommand() {
|
|
|
374
375
|
const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
|
|
375
376
|
if (opts.recoverRun && !recoveryRecord)
|
|
376
377
|
fail(`recoverable build run not found: ${opts.recoverRun}`);
|
|
378
|
+
const autoApprovePlanUpdates = recoveryRecord
|
|
379
|
+
? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
|
|
380
|
+
: Boolean(opts.yes);
|
|
377
381
|
if (opts.recoveryMode) {
|
|
378
382
|
const allowed = ["exact-session", "fresh-with-handoff", "fresh-recovery-only", "guided-recovery"];
|
|
379
383
|
if (!allowed.includes(String(opts.recoveryMode)))
|
|
@@ -676,6 +680,34 @@ export function buildStartCommand() {
|
|
|
676
680
|
settings_revision: revision,
|
|
677
681
|
};
|
|
678
682
|
};
|
|
683
|
+
const settingsForRequestedRuntime = (base, requestedRuntime) => requestedRuntime && requestedRuntime !== base.make
|
|
684
|
+
? { ...base, make: requestedRuntime, model: "default", source: "cli" }
|
|
685
|
+
: base;
|
|
686
|
+
const handoffRecoveryOptions = (role) => ({
|
|
687
|
+
allowProviderSwitch: true,
|
|
688
|
+
desktopNotifications: config.notifications.enabled,
|
|
689
|
+
terminalBell: config.notifications.terminal_bell,
|
|
690
|
+
onAccepted: (accepted) => {
|
|
691
|
+
if (role === "builder" && accepted.successor.agent !== agent) {
|
|
692
|
+
agent = accepted.successor.agent;
|
|
693
|
+
model = undefined;
|
|
694
|
+
agentExecutable = undefined;
|
|
695
|
+
}
|
|
696
|
+
if (role === "qa" && accepted.successor.agent !== qaAgent) {
|
|
697
|
+
qaAgent = accepted.successor.agent;
|
|
698
|
+
qaModel = undefined;
|
|
699
|
+
qaExecutable = undefined;
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
const acceptedRuntimeSettings = (current, make, acceptedModel) => current.make === make && current.model === (acceptedModel ?? "default")
|
|
704
|
+
? current
|
|
705
|
+
: { ...current, make, model: acceptedModel ?? "default", source: "cli" };
|
|
706
|
+
const withRunLocalAcceptedRuntimes = (run) => ({
|
|
707
|
+
...run,
|
|
708
|
+
...(run.builder ? { builder: { ...run.builder, settings: acceptedRuntimeSettings(run.builder.settings, agent, model) } } : {}),
|
|
709
|
+
...(run.qa ? { qa: { ...run.qa, settings: acceptedRuntimeSettings(run.qa.settings, qaAgent, qaModel) } } : {}),
|
|
710
|
+
});
|
|
679
711
|
const createBuilderForSettings = async (builderCwd, settings) => {
|
|
680
712
|
const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
|
|
681
713
|
label: "live Builder settings",
|
|
@@ -757,7 +789,7 @@ export function buildStartCommand() {
|
|
|
757
789
|
initial,
|
|
758
790
|
runtime: agent,
|
|
759
791
|
label: "builder turn",
|
|
760
|
-
enabled:
|
|
792
|
+
enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
761
793
|
allowSwitch: !(opts.resume || opts.continue),
|
|
762
794
|
recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
|
|
763
795
|
const nextReady = await ensureRuntimeReadyForCommand(builderCwd, nextRuntime, {
|
|
@@ -806,7 +838,7 @@ export function buildStartCommand() {
|
|
|
806
838
|
} } : {}),
|
|
807
839
|
compactionCount, compactMaximum: effectiveSettings.compact_maximum,
|
|
808
840
|
resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
|
|
809
|
-
}, () => createBuilderForSettings(builderCwd, effectiveSettings));
|
|
841
|
+
}, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
|
|
810
842
|
log.write("handoff-transfer", {
|
|
811
843
|
role: "builder", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
|
|
812
844
|
predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
|
|
@@ -854,7 +886,7 @@ export function buildStartCommand() {
|
|
|
854
886
|
compactionCount,
|
|
855
887
|
compactMaximum: effectiveSettings.compact_maximum,
|
|
856
888
|
resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
|
|
857
|
-
}, () => createBuilderForSettings(builderCwd, effectiveSettings));
|
|
889
|
+
}, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("builder"));
|
|
858
890
|
log.write("handoff-transfer", {
|
|
859
891
|
requestedByBuilder: true, reason: request.reason, generation: transfer.manifest.generation,
|
|
860
892
|
predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
|
|
@@ -869,7 +901,8 @@ export function buildStartCommand() {
|
|
|
869
901
|
let adapter = await createRecoveringBuilder(builderCwd, effectiveSessionId, effectiveSessionId ? sessionRef : undefined);
|
|
870
902
|
if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "builder" && activeContinuityRunId === recoveryRecord?.runId) {
|
|
871
903
|
const service = new HandoffService(cwd);
|
|
872
|
-
const
|
|
904
|
+
const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
|
|
905
|
+
const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(resolvedContinuitySettings("builder"), runtime)), handoffRecoveryOptions("builder"));
|
|
873
906
|
adapter = accepted.successor;
|
|
874
907
|
pendingHandoffGeneration = undefined;
|
|
875
908
|
log.write("recovery-handoff-accepted", { role: "builder", generation: accepted.manifest.generation, successorSessionId: accepted.successorSessionId, acceptanceCheckpointDigest: accepted.acceptanceCheckpointDigest });
|
|
@@ -904,7 +937,7 @@ export function buildStartCommand() {
|
|
|
904
937
|
} } : {}),
|
|
905
938
|
compactionCount, compactMaximum: input.next.compact_maximum,
|
|
906
939
|
resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true }],
|
|
907
|
-
}, () => createBuilderForSettings(builderCwd, input.next));
|
|
940
|
+
}, (_handoff, runtime) => createBuilderForSettings(builderCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("builder"));
|
|
908
941
|
log.write("handoff-transfer", {
|
|
909
942
|
role: "builder", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
|
|
910
943
|
predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
|
|
@@ -960,7 +993,7 @@ export function buildStartCommand() {
|
|
|
960
993
|
initial,
|
|
961
994
|
runtime: qaAgent,
|
|
962
995
|
label: "QA turn",
|
|
963
|
-
enabled:
|
|
996
|
+
enabled: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
964
997
|
allowSwitch: !sessionRef,
|
|
965
998
|
recreate: async (nextRuntime, resumeSessionId, resumeRef) => {
|
|
966
999
|
const nextReady = await ensureRuntimeReadyForCommand(qaCwd, nextRuntime, {
|
|
@@ -1025,7 +1058,7 @@ export function buildStartCommand() {
|
|
|
1025
1058
|
} } : {}),
|
|
1026
1059
|
compactionCount, compactMaximum: effectiveSettings.compact_maximum,
|
|
1027
1060
|
resources: [{ label: "continuity-reconstruction", content: reconstruction, authoritative: true }],
|
|
1028
|
-
}, () => createQaForSettings(qaCwd, effectiveSettings));
|
|
1061
|
+
}, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(effectiveSettings, runtime)), handoffRecoveryOptions("qa"));
|
|
1029
1062
|
log.write("handoff-transfer", {
|
|
1030
1063
|
role: "qa", reason, recovery: "double-invalid-continuity", generation: transfer.manifest.generation,
|
|
1031
1064
|
predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
|
|
@@ -1082,7 +1115,8 @@ export function buildStartCommand() {
|
|
|
1082
1115
|
let explicitRecoveryAccepted = false;
|
|
1083
1116
|
if (pendingHandoffGeneration !== undefined && pendingHandoffRole === "qa" && activeContinuityRunId === recoveryRecord?.runId) {
|
|
1084
1117
|
const service = new HandoffService(cwd);
|
|
1085
|
-
const
|
|
1118
|
+
const staged = service.loadStaged(recoveryRecord.runId, pendingHandoffGeneration);
|
|
1119
|
+
const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
|
|
1086
1120
|
adapter = accepted.successor;
|
|
1087
1121
|
pendingHandoffGeneration = undefined;
|
|
1088
1122
|
explicitRecoveryAccepted = true;
|
|
@@ -1116,7 +1150,7 @@ export function buildStartCommand() {
|
|
|
1116
1150
|
compactionCount,
|
|
1117
1151
|
compactMaximum: resolvedContinuitySettings("qa").compact_maximum,
|
|
1118
1152
|
});
|
|
1119
|
-
const accepted = await service.
|
|
1153
|
+
const accepted = await service.acceptStagedWithRecovery(staged, adapter, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(resolvedContinuitySettings("qa"), runtime)), handoffRecoveryOptions("qa"));
|
|
1120
1154
|
adapter = accepted.successor;
|
|
1121
1155
|
log.write("handoff-transfer", {
|
|
1122
1156
|
role: "qa", reason: staged.manifest.reason, generation: accepted.manifest.generation,
|
|
@@ -1155,7 +1189,7 @@ export function buildStartCommand() {
|
|
|
1155
1189
|
} } : {}),
|
|
1156
1190
|
compactionCount, compactMaximum: input.next.compact_maximum,
|
|
1157
1191
|
resources: [{ label: "frozen-qa-action", content: input.frozenAction, authoritative: true }],
|
|
1158
|
-
}, () => createQaForSettings(qaCwd, input.next));
|
|
1192
|
+
}, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(input.next, runtime)), handoffRecoveryOptions("qa"));
|
|
1159
1193
|
log.write("handoff-transfer", {
|
|
1160
1194
|
role: "qa", reason, settingsRevision: input.next.settings_revision, generation: transfer.manifest.generation,
|
|
1161
1195
|
predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
|
|
@@ -1198,7 +1232,7 @@ export function buildStartCommand() {
|
|
|
1198
1232
|
} } : {}),
|
|
1199
1233
|
compactionCount, compactMaximum: settings.compact_maximum ?? 10,
|
|
1200
1234
|
resources: [{ label: "frozen-qa-action", content: frozenAction, authoritative: true }],
|
|
1201
|
-
}, () => createQaForSettings(qaCwd, settings));
|
|
1235
|
+
}, (_handoff, runtime) => createQaForSettings(qaCwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("qa"));
|
|
1202
1236
|
log.write("handoff-transfer", { role: "qa", generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
|
|
1203
1237
|
await predecessor.close().catch(() => { });
|
|
1204
1238
|
return decorateQa(transfer.successor, qaCwd, undefined, settings);
|
|
@@ -1219,7 +1253,7 @@ export function buildStartCommand() {
|
|
|
1219
1253
|
});
|
|
1220
1254
|
await controller.observeNativeCompactions(adapter);
|
|
1221
1255
|
};
|
|
1222
|
-
const qaNonconvergence = createQaNonconvergenceHandler(cwd,
|
|
1256
|
+
const qaNonconvergence = createQaNonconvergenceHandler(cwd, !process.stdin.isTTY || !process.stdout.isTTY, roleDefaults.planner);
|
|
1223
1257
|
if (branchMode) {
|
|
1224
1258
|
const ticketsConfig = loadTicketsConfig(cwd);
|
|
1225
1259
|
const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
|
|
@@ -1408,7 +1442,7 @@ export function buildStartCommand() {
|
|
|
1408
1442
|
console.log(` ! ${issue.ticket ?? "plan"}: ${issue.message}`);
|
|
1409
1443
|
}
|
|
1410
1444
|
console.log();
|
|
1411
|
-
if (!
|
|
1445
|
+
if (!autoApprovePlanUpdates && plan.issues.every((issue) => !issue.blocking)) {
|
|
1412
1446
|
const action = await select({
|
|
1413
1447
|
message: branchPresentation.prompt,
|
|
1414
1448
|
options: [
|
|
@@ -1484,7 +1518,7 @@ export function buildStartCommand() {
|
|
|
1484
1518
|
...(nativeUsage ? { sessionUsage: { version: 1, runId: masterRun.runId, role: "builder", provider: adapter.agent, providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source, cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens, cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd } } : {}),
|
|
1485
1519
|
compactionCount, compactMaximum: settings.compact_maximum ?? 10,
|
|
1486
1520
|
resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
|
|
1487
|
-
}, () => createBuilderForSettings(worktreePath, settings));
|
|
1521
|
+
}, (_handoff, runtime) => createBuilderForSettings(worktreePath, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
|
|
1488
1522
|
log.write("handoff-transfer", { worktreePath, generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
|
|
1489
1523
|
if (adapter instanceof ContinuityAdapter) {
|
|
1490
1524
|
await adapter.adoptValidatedSuccessor(transfer.successor);
|
|
@@ -1500,93 +1534,111 @@ export function buildStartCommand() {
|
|
|
1500
1534
|
const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
|
|
1501
1535
|
branchHeartbeat.unref();
|
|
1502
1536
|
let branchSessionFailure;
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1537
|
+
let summaries;
|
|
1538
|
+
try {
|
|
1539
|
+
summaries = await runBranchPlan({
|
|
1540
|
+
projectDir: cwd,
|
|
1541
|
+
runId: masterRun.runId,
|
|
1542
|
+
plan,
|
|
1543
|
+
log,
|
|
1544
|
+
agent,
|
|
1545
|
+
model,
|
|
1546
|
+
effort: builderEffort,
|
|
1547
|
+
fast: builderFast,
|
|
1548
|
+
notificationsEnabled: config.notifications.enabled,
|
|
1549
|
+
terminalBellEnabled: config.notifications.terminal_bell,
|
|
1550
|
+
qaEnabled,
|
|
1551
|
+
createPr: branchDefaults.createReview,
|
|
1552
|
+
completionMode: branchDefaults.completionMode,
|
|
1553
|
+
reviewProvider: branchDefaults.reviewProvider,
|
|
1554
|
+
prReady: branchDefaults.prReady,
|
|
1555
|
+
keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
|
|
1556
|
+
cleanupBranches: branchDefaults.cleanupBranches,
|
|
1557
|
+
autoMergeWait: branchDefaults.autoMergeWait,
|
|
1558
|
+
autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
|
|
1559
|
+
mergeMethod: branchDefaults.mergeMethod,
|
|
1560
|
+
allowedBaseDirtyPaths,
|
|
1561
|
+
baseWorktreePolicy,
|
|
1562
|
+
trackerPaths: {
|
|
1563
|
+
progressDoc: ticketsConfig.paths.progressDoc,
|
|
1564
|
+
archiveDoc: ticketsConfig.paths.archiveDoc,
|
|
1565
|
+
},
|
|
1566
|
+
resumeSessions: resumeSessionByTicket,
|
|
1567
|
+
createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
|
|
1568
|
+
recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
|
|
1569
|
+
recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
|
|
1570
|
+
onSessionUnavailable: (error) => { branchSessionFailure = error; },
|
|
1571
|
+
createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
|
|
1572
|
+
builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
|
|
1573
|
+
qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
|
|
1574
|
+
observeBuilder: async (builder) => {
|
|
1575
|
+
liveStatusReporter?.stop();
|
|
1576
|
+
activeBuilderForStatus = () => builder;
|
|
1577
|
+
const reporter = liveStatusReporter = new AgentStatusReporter({
|
|
1578
|
+
runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
|
|
1579
|
+
reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
|
|
1580
|
+
phase: "builder ticket session", sessionTransition: builder.sessionId() ? "resumed session" : "initial session", adapter: builder,
|
|
1581
|
+
settingsRevision: capturedBranchBuilder.settings_revision, displaySessionCost: capturedBranchBuilder.display_session_cost ?? false,
|
|
1582
|
+
compactionCount: () => { const db = new WorkflowDb(cwd); try {
|
|
1583
|
+
return db.successfulCompactionCount(masterRun.runId, "builder", builder.sessionRef?.() ?? builder.sessionId());
|
|
1584
|
+
}
|
|
1585
|
+
finally {
|
|
1586
|
+
db.close();
|
|
1587
|
+
} },
|
|
1588
|
+
handoffGeneration: () => { const db = new WorkflowDb(cwd); try {
|
|
1589
|
+
return db.handoffs(masterRun.runId).at(-1)?.generation ?? 0;
|
|
1590
|
+
}
|
|
1591
|
+
finally {
|
|
1592
|
+
db.close();
|
|
1593
|
+
} },
|
|
1594
|
+
}, (line, snapshot) => {
|
|
1595
|
+
const activity = currentActivity();
|
|
1596
|
+
if (activity && process.stdout.isTTY)
|
|
1597
|
+
activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
|
|
1598
|
+
else
|
|
1599
|
+
console.log(line);
|
|
1600
|
+
log.write("agent-status", { ...snapshot });
|
|
1601
|
+
const db = new WorkflowDb(cwd);
|
|
1602
|
+
try {
|
|
1603
|
+
db.recordTelemetry(masterRun.runId, snapshot);
|
|
1604
|
+
db.recordContextSample(snapshot.contextSample);
|
|
1605
|
+
if (snapshot.sessionUsage)
|
|
1606
|
+
db.recordSessionUsage(snapshot.sessionUsage);
|
|
1607
|
+
}
|
|
1608
|
+
finally {
|
|
1609
|
+
db.close();
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
reporter.start();
|
|
1565
1613
|
try {
|
|
1566
|
-
|
|
1567
|
-
db.recordContextSample(snapshot.contextSample);
|
|
1568
|
-
if (snapshot.sessionUsage)
|
|
1569
|
-
db.recordSessionUsage(snapshot.sessionUsage);
|
|
1614
|
+
await printEvents(builder.events());
|
|
1570
1615
|
}
|
|
1571
1616
|
finally {
|
|
1572
|
-
|
|
1617
|
+
reporter.stop();
|
|
1573
1618
|
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1619
|
+
},
|
|
1620
|
+
qaNonconvergence,
|
|
1621
|
+
beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
|
|
1622
|
+
observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
|
|
1623
|
+
builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
|
|
1624
|
+
qaSessionBoundary,
|
|
1625
|
+
observeQaNativeCompactions,
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
catch (error) {
|
|
1629
|
+
if (!(error instanceof HandoffAcceptanceError) && !(error instanceof HandoffRecoveryPausedError))
|
|
1630
|
+
throw error;
|
|
1631
|
+
const summary = error.message.slice(0, 1000);
|
|
1632
|
+
masterRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "handoff-recovery-required", {
|
|
1633
|
+
status: "recoverable",
|
|
1634
|
+
failure: { category: "unknown", summary, at: new Date().toISOString() },
|
|
1635
|
+
}), "recoverable");
|
|
1636
|
+
clearInterval(branchHeartbeat);
|
|
1637
|
+
log.write("safe-boundary-paused", { runId: masterRun.runId, message: summary, handoff: true });
|
|
1638
|
+
console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
|
|
1639
|
+
console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(masterRun.runId)}`);
|
|
1640
|
+
process.exit(2);
|
|
1641
|
+
}
|
|
1590
1642
|
if (branchSessionFailure) {
|
|
1591
1643
|
const failure = branchSessionFailure;
|
|
1592
1644
|
const guided = failure instanceof SessionUnavailableContinuityError ? failure.guidedRecovery : failure.failure.dispatchState === "unknown";
|
|
@@ -1624,6 +1676,7 @@ export function buildStartCommand() {
|
|
|
1624
1676
|
}
|
|
1625
1677
|
console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
|
|
1626
1678
|
}
|
|
1679
|
+
masterRun = withRunLocalAcceptedRuntimes(masterRun);
|
|
1627
1680
|
masterRun = summaries.every((row) => row.buildStatus === "done")
|
|
1628
1681
|
? completeBuildRun(cwd, masterRun)
|
|
1629
1682
|
: releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
|
|
@@ -1651,6 +1704,7 @@ export function buildStartCommand() {
|
|
|
1651
1704
|
if (masterRun.status === "completed")
|
|
1652
1705
|
printHandoffPruneCommand(masterRun.runId);
|
|
1653
1706
|
const failed = summaries.some((row) => row.buildStatus === "blocked" || row.buildStatus === "needs-human");
|
|
1707
|
+
fireTerminalBell(config.notifications.terminal_bell);
|
|
1654
1708
|
process.exit(failed ? 2 : 0);
|
|
1655
1709
|
}
|
|
1656
1710
|
const ready = await ensureRuntimeReadyForCommand(cwd, agent, {
|
|
@@ -1759,7 +1813,7 @@ export function buildStartCommand() {
|
|
|
1759
1813
|
compactionCount,
|
|
1760
1814
|
compactMaximum: settings.compact_maximum ?? 10,
|
|
1761
1815
|
resources: [{ label: "frozen-action", content: frozenAction, authoritative: true }],
|
|
1762
|
-
}, () => createBuilderForSettings(cwd, settings));
|
|
1816
|
+
}, (_handoff, runtime) => createBuilderForSettings(cwd, settingsForRequestedRuntime(settings, runtime)), handoffRecoveryOptions("builder"));
|
|
1763
1817
|
log.write("handoff-transfer", { generation: transfer.manifest.generation, predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId, acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest });
|
|
1764
1818
|
if (adapter instanceof ContinuityAdapter) {
|
|
1765
1819
|
await adapter.adoptValidatedSuccessor(transfer.successor);
|
|
@@ -1769,7 +1823,7 @@ export function buildStartCommand() {
|
|
|
1769
1823
|
return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
|
|
1770
1824
|
},
|
|
1771
1825
|
});
|
|
1772
|
-
const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); });
|
|
1826
|
+
const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); });
|
|
1773
1827
|
activeBuilderForStatus = () => foreman.builderAdapter();
|
|
1774
1828
|
const statusReporter = liveStatusReporter = new AgentStatusReporter({
|
|
1775
1829
|
runId: buildRun.runId,
|
|
@@ -1825,12 +1879,13 @@ export function buildStartCommand() {
|
|
|
1825
1879
|
console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
|
|
1826
1880
|
buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
|
|
1827
1881
|
await foreman.runPreflight(steps, ticketsContent, preferredTicket);
|
|
1882
|
+
buildRun = withRunLocalAcceptedRuntimes(buildRun);
|
|
1828
1883
|
if (foreman.builderSessionId())
|
|
1829
1884
|
buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderAdapter().sessionRef?.() ?? foreman.builderSessionId());
|
|
1830
1885
|
if (foreman.qaSessionId())
|
|
1831
1886
|
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionRef() ?? foreman.qaSessionId());
|
|
1832
1887
|
buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
|
|
1833
|
-
if (!
|
|
1888
|
+
if (!autoApprovePlanUpdates) {
|
|
1834
1889
|
while (true) {
|
|
1835
1890
|
console.log();
|
|
1836
1891
|
const action = await select({
|
|
@@ -1902,7 +1957,8 @@ export function buildStartCommand() {
|
|
|
1902
1957
|
console.log(line);
|
|
1903
1958
|
}
|
|
1904
1959
|
}
|
|
1905
|
-
|
|
1960
|
+
fireTerminalBell(config.notifications.terminal_bell);
|
|
1961
|
+
process.exit(result.outcome === "needs-human" || result.outcome === "blocked" ? 2 : 0);
|
|
1906
1962
|
}
|
|
1907
1963
|
catch (err) {
|
|
1908
1964
|
clearInterval(heartbeat);
|
|
@@ -1934,6 +1990,18 @@ export function buildStartCommand() {
|
|
|
1934
1990
|
: `foreman: inspect recovery with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)} --inspect`);
|
|
1935
1991
|
process.exit(2);
|
|
1936
1992
|
}
|
|
1993
|
+
if (err instanceof HandoffAcceptanceError || err instanceof HandoffRecoveryPausedError) {
|
|
1994
|
+
const summary = err.message.slice(0, 1000);
|
|
1995
|
+
buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "handoff-recovery-required", {
|
|
1996
|
+
status: "recoverable",
|
|
1997
|
+
failure: { category: "unknown", summary, at: new Date().toISOString() },
|
|
1998
|
+
}), "recoverable");
|
|
1999
|
+
await foreman.close().catch(() => { });
|
|
2000
|
+
log.write("safe-boundary-paused", { runId: buildRun.runId, message: summary, handoff: true });
|
|
2001
|
+
console.error(`foreman: handoff paused at a recoverable checkpoint: ${summary}`);
|
|
2002
|
+
console.error(`foreman: resume this run with: rafi build:resume ${shellQuote(cwd)} --run ${shellQuote(buildRun.runId)}`);
|
|
2003
|
+
process.exit(2);
|
|
2004
|
+
}
|
|
1937
2005
|
if (err instanceof HandoffLoopError || err instanceof ContinuityRecoveryRequiredError) {
|
|
1938
2006
|
const summary = err.message.slice(0, 1000);
|
|
1939
2007
|
buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "guided-recovery-required", {
|
package/dist/cli/tickets.js
CHANGED
|
@@ -1026,7 +1026,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
1026
1026
|
}));
|
|
1027
1027
|
builder = roleBuilder.builder;
|
|
1028
1028
|
viewer = printEvents(builder.events());
|
|
1029
|
-
const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
|
|
1029
|
+
const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, dir);
|
|
1030
1030
|
console.log(`foreman tickets: populating tickets with ${roleBuilder.runtime}`);
|
|
1031
1031
|
console.log(`foreman tickets: project ${dir}`);
|
|
1032
1032
|
console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
|
package/dist/config.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface PermissionConfig {
|
|
|
13
13
|
export interface NotificationsConfig {
|
|
14
14
|
/** Fire a desktop notification when the builder needs user input. Default: false. */
|
|
15
15
|
enabled: boolean;
|
|
16
|
+
/** Ring the terminal bell when user attention is required or a run finishes. Default: true. */
|
|
17
|
+
terminal_bell: boolean;
|
|
16
18
|
}
|
|
17
19
|
export interface QaConfig {
|
|
18
20
|
/** Run a QA review pass after each completed ticket or step. Default: true. */
|
package/dist/config.js
CHANGED
|
@@ -65,7 +65,7 @@ export const DEFAULT_CONFIG = {
|
|
|
65
65
|
],
|
|
66
66
|
escalateTools: ["WebFetch", "WebSearch"],
|
|
67
67
|
},
|
|
68
|
-
notifications: { enabled: false },
|
|
68
|
+
notifications: { enabled: false, terminal_bell: true },
|
|
69
69
|
qa: { enabled: true },
|
|
70
70
|
};
|
|
71
71
|
/** Load foreman.yaml if present and deep-merge it over the defaults. */
|
|
@@ -104,6 +104,10 @@ function validateConfig(raw, path) {
|
|
|
104
104
|
}
|
|
105
105
|
if (cfg.notifications !== undefined) {
|
|
106
106
|
validateBooleanObject(cfg.notifications, "notifications", path);
|
|
107
|
+
const terminalBell = cfg.notifications.terminal_bell;
|
|
108
|
+
if (terminalBell !== undefined && typeof terminalBell !== "boolean") {
|
|
109
|
+
throw new Error(`${path}: notifications.terminal_bell must be a boolean`);
|
|
110
|
+
}
|
|
107
111
|
}
|
|
108
112
|
if (cfg.qa !== undefined) {
|
|
109
113
|
validateBooleanObject(cfg.qa, "qa", path);
|
package/dist/foreman.d.ts
CHANGED
|
@@ -48,11 +48,14 @@ export interface BatchResult {
|
|
|
48
48
|
outcome: "all-done" | "plan-complete" | "blocked" | "needs-human";
|
|
49
49
|
detail?: string;
|
|
50
50
|
}
|
|
51
|
+
export interface ForemanNotificationOptions {
|
|
52
|
+
desktop: boolean;
|
|
53
|
+
terminalBell: boolean;
|
|
54
|
+
}
|
|
51
55
|
/** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
|
|
52
56
|
export declare class Foreman {
|
|
53
57
|
private builder;
|
|
54
58
|
private readonly log;
|
|
55
|
-
private readonly notificationsEnabled;
|
|
56
59
|
private readonly qaEnabled;
|
|
57
60
|
private readonly qaMaxCycles;
|
|
58
61
|
private readonly projectDir?;
|
|
@@ -69,9 +72,11 @@ export declare class Foreman {
|
|
|
69
72
|
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
70
73
|
private readonly observeBuilderNativeCompactions?;
|
|
71
74
|
private readonly ticketsEnabled;
|
|
75
|
+
private readonly notificationsEnabled;
|
|
76
|
+
private readonly terminalBellEnabled;
|
|
72
77
|
private readonly qaStream;
|
|
73
78
|
private builderWorkSessions;
|
|
74
|
-
constructor(builder: BuilderAdapter, log: Log,
|
|
79
|
+
constructor(builder: BuilderAdapter, log: Log, notifications?: boolean | ForemanNotificationOptions, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
|
|
75
80
|
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
76
81
|
observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined);
|
|
77
82
|
/**
|
|
@@ -90,6 +95,11 @@ export declare class Foreman {
|
|
|
90
95
|
result: TurnResult;
|
|
91
96
|
status: StepStatus;
|
|
92
97
|
}>;
|
|
98
|
+
/** Ask an already-active role session to propose choices for its reported blocker. */
|
|
99
|
+
resolveBlocker(adapter: BuilderAdapter, reason: string): Promise<{
|
|
100
|
+
result: TurnResult;
|
|
101
|
+
status: StepStatus;
|
|
102
|
+
}>;
|
|
93
103
|
private observeBuilderNative;
|
|
94
104
|
/**
|
|
95
105
|
* Run a QA review pass on the ticket the builder just completed.
|
package/dist/foreman.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
9
|
import { runIsolatedQa } from "./qaReview.js";
|
|
9
10
|
import { createHash } from "node:crypto";
|
|
@@ -216,7 +217,6 @@ export function createPermissionHandler(policy, log, opts = {}) {
|
|
|
216
217
|
export class Foreman {
|
|
217
218
|
builder;
|
|
218
219
|
log;
|
|
219
|
-
notificationsEnabled;
|
|
220
220
|
qaEnabled;
|
|
221
221
|
qaMaxCycles;
|
|
222
222
|
projectDir;
|
|
@@ -232,14 +232,15 @@ export class Foreman {
|
|
|
232
232
|
observeQaNativeCompactions;
|
|
233
233
|
observeBuilderNativeCompactions;
|
|
234
234
|
ticketsEnabled;
|
|
235
|
+
notificationsEnabled;
|
|
236
|
+
terminalBellEnabled;
|
|
235
237
|
qaStream = { reviews: 0, modificationViolations: 0 };
|
|
236
238
|
builderWorkSessions = 0;
|
|
237
|
-
constructor(builder, log,
|
|
239
|
+
constructor(builder, log, notifications = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
|
|
238
240
|
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
239
241
|
observeBuilderNativeCompactions) {
|
|
240
242
|
this.builder = builder;
|
|
241
243
|
this.log = log;
|
|
242
|
-
this.notificationsEnabled = notificationsEnabled;
|
|
243
244
|
this.qaEnabled = qaEnabled;
|
|
244
245
|
this.qaMaxCycles = qaMaxCycles;
|
|
245
246
|
this.projectDir = projectDir;
|
|
@@ -254,6 +255,8 @@ export class Foreman {
|
|
|
254
255
|
this.qaSessionBoundary = qaSessionBoundary;
|
|
255
256
|
this.observeQaNativeCompactions = observeQaNativeCompactions;
|
|
256
257
|
this.observeBuilderNativeCompactions = observeBuilderNativeCompactions;
|
|
258
|
+
this.notificationsEnabled = typeof notifications === "boolean" ? notifications : notifications.desktop;
|
|
259
|
+
this.terminalBellEnabled = typeof notifications === "boolean" ? true : notifications.terminalBell;
|
|
257
260
|
this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
|
|
258
261
|
}
|
|
259
262
|
/**
|
|
@@ -338,33 +341,67 @@ export class Foreman {
|
|
|
338
341
|
status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
|
-
while (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
? status.choices
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
344
|
+
while (true) {
|
|
345
|
+
while (status.kind === "needs_input") {
|
|
346
|
+
const question = status.question ?? "The builder has a question";
|
|
347
|
+
const choices = status.choices?.length ? status.choices : ["Continue"];
|
|
348
|
+
this.log.write("needs_input", { question, choices });
|
|
349
|
+
signalAttention("Foreman needs your input", question, this.notificationsEnabled, this.terminalBellEnabled);
|
|
350
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
351
|
+
console.error(`foreman: input required: ${question}`);
|
|
352
|
+
console.error("foreman: resume this run in an interactive terminal, or provide explicit guidance through the printed resume command");
|
|
353
|
+
result = { text: result.text, isError: false, numTurns: result.numTurns, costUsd: result.costUsd };
|
|
354
|
+
status = { kind: "blocked", reason: `input required in an interactive terminal: ${question}` };
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
const answer = await pauseActivityForInput(async () => {
|
|
358
|
+
console.log();
|
|
359
|
+
const selected = await select({
|
|
360
|
+
message: question,
|
|
361
|
+
options: [
|
|
362
|
+
...choices.map((choice) => ({ value: choice, label: choice })),
|
|
363
|
+
{ value: "__rafi_custom__", label: "Custom response", hint: "Type a different answer" },
|
|
364
|
+
{ value: "__rafi_pause__", label: "Pause safely", hint: "Keep the run recoverable and return to the terminal" },
|
|
365
|
+
],
|
|
366
|
+
});
|
|
367
|
+
if (isCancel(selected) || selected === "__rafi_pause__")
|
|
368
|
+
return undefined;
|
|
369
|
+
if (selected !== "__rafi_custom__")
|
|
370
|
+
return selected;
|
|
371
|
+
const custom = await text({ message: "Custom response:", validate: (value) => String(value ?? "").trim() ? undefined : "Enter a response" });
|
|
372
|
+
return isCancel(custom) ? undefined : String(custom);
|
|
373
|
+
});
|
|
374
|
+
console.log();
|
|
375
|
+
if (answer === undefined) {
|
|
376
|
+
result = { text: "", isError: false, numTurns: 0, costUsd: 0 };
|
|
377
|
+
status = { kind: "blocked", reason: "user chose safe pause at an input prompt" };
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
381
|
+
this.builder = await this.beforeBuilderTurn(this.builder, answer);
|
|
382
|
+
adapter = this.builder;
|
|
383
|
+
}
|
|
384
|
+
result = await adapter.sendTurn(answer);
|
|
385
|
+
await this.observeBuilderNative(adapter);
|
|
386
|
+
status = parseStepStatus(result.text);
|
|
349
387
|
}
|
|
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" };
|
|
388
|
+
if (status.kind !== "blocked" || status.reason?.startsWith("user chose safe pause") || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
359
389
|
break;
|
|
360
|
-
|
|
390
|
+
const reason = status.reason ?? "the agent reported an unspecified blocker";
|
|
391
|
+
this.log.write("blocked-recovery", { reason, role: adapter === this.builder ? "builder" : "qa" });
|
|
392
|
+
const recoveryInstruction = buildBlockerRecoveryInstruction(reason);
|
|
361
393
|
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
362
|
-
this.builder = await this.beforeBuilderTurn(this.builder,
|
|
394
|
+
this.builder = await this.beforeBuilderTurn(this.builder, recoveryInstruction);
|
|
363
395
|
adapter = this.builder;
|
|
364
396
|
}
|
|
365
|
-
result = await adapter.sendTurn(
|
|
397
|
+
result = await adapter.sendTurn(recoveryInstruction);
|
|
366
398
|
await this.observeBuilderNative(adapter);
|
|
367
399
|
status = parseStepStatus(result.text);
|
|
400
|
+
if (status.kind === "unknown") {
|
|
401
|
+
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.');
|
|
402
|
+
await this.observeBuilderNative(adapter);
|
|
403
|
+
status = parseStepStatus(result.text);
|
|
404
|
+
}
|
|
368
405
|
}
|
|
369
406
|
return { result, status };
|
|
370
407
|
}
|
|
@@ -398,6 +435,10 @@ export class Foreman {
|
|
|
398
435
|
async runInstruction(instruction) {
|
|
399
436
|
return this.doTurn(instruction);
|
|
400
437
|
}
|
|
438
|
+
/** Ask an already-active role session to propose choices for its reported blocker. */
|
|
439
|
+
async resolveBlocker(adapter, reason) {
|
|
440
|
+
return this.doTurnWith(adapter, buildBlockerRecoveryInstruction(reason));
|
|
441
|
+
}
|
|
401
442
|
async observeBuilderNative(adapter) {
|
|
402
443
|
if (adapter === this.builder)
|
|
403
444
|
await this.observeBuilderNativeCompactions?.(adapter);
|
|
@@ -431,6 +472,7 @@ export class Foreman {
|
|
|
431
472
|
},
|
|
432
473
|
evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
|
|
433
474
|
onNonconvergence: this.qaNonconvergence,
|
|
475
|
+
resolveBlocked: (adapter, reason) => this.resolveBlocker(adapter, reason),
|
|
434
476
|
});
|
|
435
477
|
if (review.outcome === "nonconverged")
|
|
436
478
|
return { outcome: "needs-human", detail: review.detail };
|
|
@@ -539,7 +581,10 @@ export class Foreman {
|
|
|
539
581
|
detail = `recovery ticket ${preferredTicketId} is no longer available in the implementation queue`;
|
|
540
582
|
break;
|
|
541
583
|
}
|
|
542
|
-
if (i === 1 && preferredTicketId && next
|
|
584
|
+
if (i === 1 && preferredTicketId && next?.status === "blocked") {
|
|
585
|
+
cmdUnblock(this.projectDir, preferredTicketId, { actor: "foreman", summary: "Reopened by explicit build recovery" });
|
|
586
|
+
}
|
|
587
|
+
else if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
|
|
543
588
|
outcome = "needs-human";
|
|
544
589
|
detail = `recovery ticket ${preferredTicketId} cannot resume while its status is ${next.status}`;
|
|
545
590
|
break;
|
|
@@ -672,6 +717,16 @@ function lastLine(text) {
|
|
|
672
717
|
const lines = text.trim().split("\n");
|
|
673
718
|
return (lines[lines.length - 1] ?? "").slice(0, 200);
|
|
674
719
|
}
|
|
720
|
+
function buildBlockerRecoveryInstruction(reason) {
|
|
721
|
+
return [
|
|
722
|
+
`You reported this blocker: ${reason}`,
|
|
723
|
+
"Do not end the run. Analyze the durable state and propose two or three safe, materially different approaches the user can choose from.",
|
|
724
|
+
"Put your recommended approach first and end its label with (Recommended). Include the important consequence or tradeoff in every choice label.",
|
|
725
|
+
"Do not perform more implementation or QA work until the user chooses. End exactly with:",
|
|
726
|
+
'STEP_STATUS: needs_input | question="How should Rafi unblock this work?" choices="recommended approach and consequence (Recommended)|alternative and consequence|another alternative and consequence"',
|
|
727
|
+
"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.",
|
|
728
|
+
].join("\n\n");
|
|
729
|
+
}
|
|
675
730
|
function fingerprintProtectedTree(root) {
|
|
676
731
|
const out = new Map();
|
|
677
732
|
const ignored = new Set([".git", "node_modules", "dist", "coverage"]);
|
package/dist/handoffs.d.ts
CHANGED
|
@@ -45,14 +45,42 @@ export declare class HandoffLoopError extends Error {
|
|
|
45
45
|
readonly runId: string;
|
|
46
46
|
constructor(runId: string);
|
|
47
47
|
}
|
|
48
|
+
export type HandoffAcceptanceFailureCode = "missing-acknowledgement" | "invalid-continuity-delta" | "missing-successor-session" | "missing-scoped-successor-session" | "reused-predecessor-session";
|
|
49
|
+
export declare class HandoffAcceptanceError extends Error {
|
|
50
|
+
readonly code: HandoffAcceptanceFailureCode;
|
|
51
|
+
readonly runId: string;
|
|
52
|
+
readonly generation: number;
|
|
53
|
+
constructor(code: HandoffAcceptanceFailureCode, runId: string, generation: number, detail: string);
|
|
54
|
+
}
|
|
55
|
+
export declare class HandoffRecoveryPausedError extends Error {
|
|
56
|
+
readonly runId: string;
|
|
57
|
+
readonly generation: number;
|
|
58
|
+
constructor(runId: string, generation: number, detail: string);
|
|
59
|
+
}
|
|
60
|
+
export type HandoffRecoveryChoice = "retry" | "switch" | "custom" | "pause";
|
|
61
|
+
export interface HandoffRecoveryOptions {
|
|
62
|
+
/** Enables the provider-switch choice. The callback must honor the requested runtime. */
|
|
63
|
+
allowProviderSwitch?: boolean;
|
|
64
|
+
choose?: (error: HandoffAcceptanceError, currentRuntime: "claude" | "codex") => Promise<HandoffRecoveryChoice>;
|
|
65
|
+
customGuidance?: () => Promise<string | undefined>;
|
|
66
|
+
desktopNotifications?: boolean;
|
|
67
|
+
terminalBell?: boolean;
|
|
68
|
+
/** Persist run-local provider selection only after validated acceptance. */
|
|
69
|
+
onAccepted?: (result: HandoffTransferResult) => void | Promise<void>;
|
|
70
|
+
}
|
|
48
71
|
/** Durable, validated ownership transfer; cache copies are never authoritative. */
|
|
49
72
|
export declare class HandoffService {
|
|
50
73
|
readonly projectDir: string;
|
|
51
74
|
readonly cacheRoot: string;
|
|
52
75
|
constructor(projectDir: string);
|
|
53
76
|
stage(input: CreateHandoffInput, now?: Date): StagedHandoff;
|
|
54
|
-
transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff) => Promise<BuilderAdapter
|
|
55
|
-
|
|
77
|
+
transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
|
|
78
|
+
acceptStagedWithRecovery(staged: StagedHandoff, initialSuccessor: BuilderAdapter, createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>, recovery?: HandoffRecoveryOptions): Promise<HandoffTransferResult>;
|
|
79
|
+
acceptStaged(staged: StagedHandoff, successor: BuilderAdapter, options?: {
|
|
80
|
+
finalizeFailure?: boolean;
|
|
81
|
+
guidance?: string;
|
|
82
|
+
}): Promise<HandoffTransferResult>;
|
|
83
|
+
private failStaged;
|
|
56
84
|
inspect(runId: string, generation?: number): {
|
|
57
85
|
manifest: HandoffManifestV1;
|
|
58
86
|
markdown: string;
|
package/dist/handoffs.js
CHANGED
|
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, st
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { continuityInstruction, mergeContinuityDeltas, parseContinuityDelta } from "./continuity.js";
|
|
5
5
|
import { WorkflowDb } from "./workflowDb.js";
|
|
6
|
+
import { pauseActivityForInput } from "./activity.js";
|
|
7
|
+
import { signalAttention } from "./notify.js";
|
|
6
8
|
export const HANDOFF_CACHE_RETENTION_DAYS = 30;
|
|
7
9
|
export const HANDOFF_ACCEPTED = "HANDOFF_ACCEPTED";
|
|
8
10
|
export const HANDOFF_REQUEST_START = "RAFI_HANDOFF_REQUEST_START";
|
|
@@ -55,6 +57,28 @@ export class HandoffLoopError extends Error {
|
|
|
55
57
|
this.runId = runId;
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
export class HandoffAcceptanceError extends Error {
|
|
61
|
+
code;
|
|
62
|
+
runId;
|
|
63
|
+
generation;
|
|
64
|
+
constructor(code, runId, generation, detail) {
|
|
65
|
+
super(`handoff acceptance rejected (${code}): ${detail}; predecessor retains the lease`);
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.runId = runId;
|
|
68
|
+
this.generation = generation;
|
|
69
|
+
this.name = "HandoffAcceptanceError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export class HandoffRecoveryPausedError extends Error {
|
|
73
|
+
runId;
|
|
74
|
+
generation;
|
|
75
|
+
constructor(runId, generation, detail) {
|
|
76
|
+
super(`handoff recovery paused safely: ${detail}`);
|
|
77
|
+
this.runId = runId;
|
|
78
|
+
this.generation = generation;
|
|
79
|
+
this.name = "HandoffRecoveryPausedError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
58
82
|
/** Durable, validated ownership transfer; cache copies are never authoritative. */
|
|
59
83
|
export class HandoffService {
|
|
60
84
|
projectDir;
|
|
@@ -130,34 +154,86 @@ export class HandoffService {
|
|
|
130
154
|
db.close();
|
|
131
155
|
}
|
|
132
156
|
}
|
|
133
|
-
async transfer(input, createSuccessor) {
|
|
157
|
+
async transfer(input, createSuccessor, recovery = {}) {
|
|
134
158
|
const staged = this.stage(input);
|
|
135
159
|
const successor = await createSuccessor(staged);
|
|
136
|
-
return this.
|
|
160
|
+
return this.acceptStagedWithRecovery(staged, successor, createSuccessor, recovery);
|
|
137
161
|
}
|
|
138
|
-
async
|
|
162
|
+
async acceptStagedWithRecovery(staged, initialSuccessor, createSuccessor, recovery = {}) {
|
|
163
|
+
let successor = initialSuccessor;
|
|
164
|
+
let guidance;
|
|
165
|
+
while (true) {
|
|
166
|
+
try {
|
|
167
|
+
const accepted = await this.acceptStaged(staged, successor, { finalizeFailure: false, guidance });
|
|
168
|
+
await recovery.onAccepted?.(accepted);
|
|
169
|
+
return accepted;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (!(error instanceof HandoffAcceptanceError)) {
|
|
173
|
+
this.failStaged(staged, error instanceof Error ? error.message : String(error));
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
if ((!process.stdin.isTTY || !process.stdout.isTTY) && !recovery.choose) {
|
|
177
|
+
this.failStaged(staged, error.message);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
signalAttention("Rafi handoff needs input", error.message, recovery.desktopNotifications, recovery.terminalBell);
|
|
181
|
+
const currentRuntime = successor.agent;
|
|
182
|
+
const choice = recovery.choose
|
|
183
|
+
? await recovery.choose(error, currentRuntime)
|
|
184
|
+
: await promptHandoffRecovery(error, currentRuntime, Boolean(recovery.allowProviderSwitch));
|
|
185
|
+
if (choice === "pause") {
|
|
186
|
+
this.failStaged(staged, `user paused after ${error.code}`);
|
|
187
|
+
throw new HandoffRecoveryPausedError(staged.manifest.runId, staged.manifest.generation, error.message);
|
|
188
|
+
}
|
|
189
|
+
guidance = undefined;
|
|
190
|
+
let requestedRuntime;
|
|
191
|
+
if (choice === "switch")
|
|
192
|
+
requestedRuntime = currentRuntime === "claude" ? "codex" : "claude";
|
|
193
|
+
if (choice === "custom") {
|
|
194
|
+
guidance = recovery.customGuidance ? await recovery.customGuidance() : await promptCustomHandoffGuidance();
|
|
195
|
+
if (!guidance) {
|
|
196
|
+
successor = await createSuccessor(staged);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
successor = await createSuccessor(staged, requestedRuntime);
|
|
201
|
+
if (requestedRuntime && successor.agent !== requestedRuntime) {
|
|
202
|
+
await successor.close().catch(() => { });
|
|
203
|
+
console.warn(`foreman: ${requestedRuntime} was not available from the successor factory; choose another handoff recovery option`);
|
|
204
|
+
successor = await createSuccessor(staged);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async acceptStaged(staged, successor, options = {}) {
|
|
139
210
|
const db = new WorkflowDb(this.projectDir);
|
|
140
211
|
try {
|
|
141
|
-
const
|
|
212
|
+
const acceptancePrompt = [
|
|
142
213
|
"Accept this validated Rafi handoff. Do not repeat completed work and reconcile host receipts before side effects.",
|
|
143
214
|
staged.markdown,
|
|
144
215
|
`Manifest JSON: ${JSON.stringify(staged.manifest)}`,
|
|
216
|
+
...(options.guidance ? [`Human recovery guidance: ${options.guidance}`] : []),
|
|
145
217
|
`Reply with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
|
|
146
|
-
].join("\n\n")
|
|
218
|
+
].join("\n\n");
|
|
219
|
+
let response = await successor.sendTurn(acceptancePrompt);
|
|
220
|
+
let validation = validateHandoffAcceptance(staged, successor, response.text);
|
|
221
|
+
if (validation && (validation.code === "missing-acknowledgement" || validation.code === "invalid-continuity-delta")) {
|
|
222
|
+
response = await successor.sendTurn([
|
|
223
|
+
`Your handoff acknowledgement was rejected: ${validation.message}.`,
|
|
224
|
+
"Correction only: do not use tools, repeat completed work, or perform implementation.",
|
|
225
|
+
`Reply with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
|
|
226
|
+
].join("\n\n"));
|
|
227
|
+
validation = validateHandoffAcceptance(staged, successor, response.text);
|
|
228
|
+
}
|
|
229
|
+
if (validation)
|
|
230
|
+
throw new HandoffAcceptanceError(validation.code, staged.manifest.runId, staged.manifest.generation, validation.message);
|
|
147
231
|
const parsed = parseContinuityDelta(response.text);
|
|
148
232
|
const sessionId = successor.sessionId();
|
|
149
233
|
const observedSuccessorRef = successor.sessionRef?.();
|
|
150
234
|
const successorRef = observedSuccessorRef ? { ...observedSuccessorRef, generation: staged.manifest.generation, validatedAt: new Date().toISOString() } : undefined;
|
|
151
|
-
if (!
|
|
152
|
-
|
|
153
|
-
await successor.close().catch(() => { });
|
|
154
|
-
throw new Error("fresh successor failed validated handoff acceptance; predecessor retains the lease");
|
|
155
|
-
}
|
|
156
|
-
if (staged.manifest.predecessorSessionId && sessionId === staged.manifest.predecessorSessionId) {
|
|
157
|
-
db.failHandoff(staged.manifest.runId, staged.manifest.generation, "successor reused predecessor session ID");
|
|
158
|
-
await successor.close().catch(() => { });
|
|
159
|
-
throw new Error("handoff successor must be a genuinely fresh provider session");
|
|
160
|
-
}
|
|
235
|
+
if (!parsed.delta || !sessionId)
|
|
236
|
+
throw new Error("validated handoff acceptance lost its parsed continuity state");
|
|
161
237
|
if (successorRef)
|
|
162
238
|
successor.adoptSessionRef?.(successorRef);
|
|
163
239
|
db.appendContinuityEvent({ runId: staged.manifest.runId, role: staged.manifest.role, kind: "handoff_successor_accepted", payload: { generation: staged.manifest.generation, sessionId, sessionRef: successorRef, delta: parsed.delta }, authoritativeStateRevision: db.continuityHead(staged.manifest.runId, staged.manifest.role)?.authoritativeStateRevision ?? 0, sessionRef: successorRef });
|
|
@@ -166,8 +242,10 @@ export class HandoffService {
|
|
|
166
242
|
return { ...staged, lineage, successor, successorSessionId: sessionId, acceptanceCheckpointDigest: checkpoint.digest };
|
|
167
243
|
}
|
|
168
244
|
catch (error) {
|
|
245
|
+
if (error instanceof HandoffAcceptanceError)
|
|
246
|
+
await successor.close().catch(() => { });
|
|
169
247
|
const current = db.handoff(staged.manifest.runId, staged.manifest.generation);
|
|
170
|
-
if (current?.state === "staged")
|
|
248
|
+
if (options.finalizeFailure !== false && current?.state === "staged")
|
|
171
249
|
db.failHandoff(staged.manifest.runId, staged.manifest.generation, error instanceof Error ? error.message : String(error));
|
|
172
250
|
throw error;
|
|
173
251
|
}
|
|
@@ -175,6 +253,17 @@ export class HandoffService {
|
|
|
175
253
|
db.close();
|
|
176
254
|
}
|
|
177
255
|
}
|
|
256
|
+
failStaged(staged, detail) {
|
|
257
|
+
const db = new WorkflowDb(this.projectDir);
|
|
258
|
+
try {
|
|
259
|
+
if (db.handoff(staged.manifest.runId, staged.manifest.generation)?.state === "staged") {
|
|
260
|
+
db.failHandoff(staged.manifest.runId, staged.manifest.generation, detail);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
db.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
178
267
|
inspect(runId, generation) {
|
|
179
268
|
const db = new WorkflowDb(this.projectDir);
|
|
180
269
|
try {
|
|
@@ -275,6 +364,52 @@ export class HandoffService {
|
|
|
275
364
|
return count;
|
|
276
365
|
}
|
|
277
366
|
}
|
|
367
|
+
function validateHandoffAcceptance(staged, successor, text) {
|
|
368
|
+
if (!text.trimStart().startsWith(HANDOFF_ACCEPTED)) {
|
|
369
|
+
return { code: "missing-acknowledgement", message: `the first non-whitespace line must begin with ${HANDOFF_ACCEPTED}` };
|
|
370
|
+
}
|
|
371
|
+
const parsed = parseContinuityDelta(text);
|
|
372
|
+
if (!parsed.delta) {
|
|
373
|
+
return { code: "invalid-continuity-delta", message: parsed.error?.problems.join("; ") ?? "the continuity delta is missing or malformed" };
|
|
374
|
+
}
|
|
375
|
+
const sessionId = successor.sessionId();
|
|
376
|
+
if (!sessionId)
|
|
377
|
+
return { code: "missing-successor-session", message: "the provider did not expose a successor session ID" };
|
|
378
|
+
if (staged.manifest.predecessorSessionRef && !successor.sessionRef?.()) {
|
|
379
|
+
return { code: "missing-scoped-successor-session", message: "the provider did not expose a location-scoped successor session reference" };
|
|
380
|
+
}
|
|
381
|
+
if (staged.manifest.predecessorSessionId && sessionId === staged.manifest.predecessorSessionId) {
|
|
382
|
+
return { code: "reused-predecessor-session", message: `successor reused predecessor session ${sessionId}; a genuinely fresh session is required` };
|
|
383
|
+
}
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
async function promptHandoffRecovery(error, runtime, allowProviderSwitch) {
|
|
387
|
+
const { select, isCancel } = await import("@clack/prompts");
|
|
388
|
+
return pauseActivityForInput(async () => {
|
|
389
|
+
console.error(`foreman: ${error.message}`);
|
|
390
|
+
const other = runtime === "claude" ? "codex" : "claude";
|
|
391
|
+
const answer = await select({
|
|
392
|
+
message: "How should Rafi recover the rejected successor handoff?",
|
|
393
|
+
options: [
|
|
394
|
+
{ value: "retry", label: `Retry ${runtime} with a fresh successor (Recommended)`, hint: "The rejected successor is closed; the predecessor keeps its lease" },
|
|
395
|
+
...(allowProviderSwitch ? [{ value: "switch", label: `Switch to verified ${other}`, hint: "Use a fresh provider session without changing project defaults" }] : []),
|
|
396
|
+
{ value: "custom", label: "Add custom guidance and retry", hint: "Give the next fresh successor an explicit correction" },
|
|
397
|
+
{ value: "pause", label: "Pause safely", hint: "Keep the predecessor lease and return recovery instructions" },
|
|
398
|
+
],
|
|
399
|
+
});
|
|
400
|
+
return isCancel(answer) ? "pause" : answer;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async function promptCustomHandoffGuidance() {
|
|
404
|
+
const { text, isCancel } = await import("@clack/prompts");
|
|
405
|
+
return pauseActivityForInput(async () => {
|
|
406
|
+
const answer = await text({
|
|
407
|
+
message: "Guidance for the next fresh successor:",
|
|
408
|
+
validate: (value) => String(value ?? "").trim() ? undefined : "Enter guidance",
|
|
409
|
+
});
|
|
410
|
+
return isCancel(answer) ? undefined : String(answer);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
278
413
|
export function renderHandoffMarkdown(manifest) {
|
|
279
414
|
const section = (title, values) => [`## ${title}`, "", ...(values.length ? values.map((value) => `- ${value}`) : ["- None recorded."]), ""];
|
|
280
415
|
return [
|
package/dist/log.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** A single structured log record. */
|
|
2
2
|
export interface LogRecord {
|
|
3
3
|
ts: string;
|
|
4
|
-
event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "qa-evidence" | "agent-status" | "context-lifecycle" | "handoff-transfer" | "recovery-handoff-accepted" | "guided-recovery-required" | "safe-boundary-paused" | "session-unavailable" | "error";
|
|
4
|
+
event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "blocked-recovery" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "qa-evidence" | "agent-status" | "context-lifecycle" | "handoff-transfer" | "recovery-handoff-accepted" | "guided-recovery-required" | "safe-boundary-paused" | "session-unavailable" | "error";
|
|
5
5
|
[key: string]: unknown;
|
|
6
6
|
}
|
|
7
7
|
/** Append-only JSONL logger. One line per record, plus an echo to the console. */
|
package/dist/notify.d.ts
CHANGED
|
@@ -7,3 +7,7 @@
|
|
|
7
7
|
* WSL : powershell.exe Windows toast (no extra install required)
|
|
8
8
|
*/
|
|
9
9
|
export declare function fireNotification(title: string, body: string): void;
|
|
10
|
+
/** Ring one best-effort terminal bell for a user-attention boundary. */
|
|
11
|
+
export declare function fireTerminalBell(enabled?: boolean): void;
|
|
12
|
+
/** Notify through the terminal by default and through the desktop when opted in. */
|
|
13
|
+
export declare function signalAttention(title: string, body: string, desktop?: boolean, terminalBell?: boolean): void;
|
package/dist/notify.js
CHANGED
|
@@ -47,3 +47,18 @@ export function fireNotification(title, body) {
|
|
|
47
47
|
// best-effort — never crash the run
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/** Ring one best-effort terminal bell for a user-attention boundary. */
|
|
51
|
+
export function fireTerminalBell(enabled = true) {
|
|
52
|
+
if (!enabled || !process.stderr.isTTY)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
process.stderr.write("\u0007");
|
|
56
|
+
}
|
|
57
|
+
catch { /* best-effort */ }
|
|
58
|
+
}
|
|
59
|
+
/** Notify through the terminal by default and through the desktop when opted in. */
|
|
60
|
+
export function signalAttention(title, body, desktop = false, terminalBell = true) {
|
|
61
|
+
fireTerminalBell(terminalBell);
|
|
62
|
+
if (desktop)
|
|
63
|
+
fireNotification(title, body);
|
|
64
|
+
}
|
package/dist/qaReview.d.ts
CHANGED
|
@@ -43,6 +43,11 @@ export interface IsolatedQaOptions {
|
|
|
43
43
|
qaDiff?: string[];
|
|
44
44
|
}) => void;
|
|
45
45
|
onNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
|
|
46
|
+
/** Resolve a QA blocker with the same still-open QA session before disposal. */
|
|
47
|
+
resolveBlocked?: (adapter: BuilderAdapter, reason: string) => Promise<{
|
|
48
|
+
result: import("./adapters/types.js").TurnResult;
|
|
49
|
+
status: import("./foreman.js").StepStatus;
|
|
50
|
+
}>;
|
|
46
51
|
}
|
|
47
52
|
export interface IsolatedQaResult {
|
|
48
53
|
outcome: "passed" | "blocked" | "needs-human" | "nonconverged" | "waived";
|
package/dist/qaReview.js
CHANGED
|
@@ -49,9 +49,15 @@ async function oneReview(opts, cycle) {
|
|
|
49
49
|
// continuity/checkpoint stream; an old provider session is never moved
|
|
50
50
|
// into a newly-created /tmp/rafi-qa-* directory.
|
|
51
51
|
qa = await opts.createQa(snapshot.path);
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
let turn = await qa.sendTurn(handoff);
|
|
53
|
+
let status = parseStepStatus(turn.text);
|
|
54
54
|
await opts.observeNativeCompactions?.(qa);
|
|
55
|
+
if (!turn.isError && status.kind === "blocked" && opts.resolveBlocked) {
|
|
56
|
+
const resolved = await opts.resolveBlocked(qa, status.reason ?? "QA reported an unspecified blocker");
|
|
57
|
+
turn = resolved.result;
|
|
58
|
+
status = resolved.status;
|
|
59
|
+
await opts.observeNativeCompactions?.(qa);
|
|
60
|
+
}
|
|
55
61
|
opts.state.reviews += 1;
|
|
56
62
|
opts.state.sessionId = qa.sessionId();
|
|
57
63
|
opts.state.sessionRef = qa.sessionRef?.();
|
|
@@ -66,6 +72,8 @@ async function oneReview(opts, cycle) {
|
|
|
66
72
|
opts.state.modificationViolations = 0;
|
|
67
73
|
if (turn.isError)
|
|
68
74
|
return { outcome: "blocked", detail: turn.text.slice(0, 500) };
|
|
75
|
+
if (status.kind === "blocked")
|
|
76
|
+
return { outcome: "blocked", detail: status.reason ?? "QA reported blocked" };
|
|
69
77
|
if (status.kind === "qa_pass") {
|
|
70
78
|
opts.evidence?.({ cycle, outcome: "passed", detail: status.summary ?? "qa_pass" });
|
|
71
79
|
return { outcome: "passed", summary: status.summary };
|
package/foreman.yaml
CHANGED
|
@@ -75,6 +75,10 @@ permissions:
|
|
|
75
75
|
- WebSearch
|
|
76
76
|
|
|
77
77
|
notifications:
|
|
78
|
+
# Ring once when input is required and once when a master run finishes.
|
|
79
|
+
# Default: true.
|
|
80
|
+
terminal_bell: true
|
|
81
|
+
|
|
78
82
|
# When enabled, a desktop notification fires whenever the builder emits
|
|
79
83
|
# STEP_STATUS: needs_input — useful if you've walked away and need a nudge
|
|
80
84
|
# to come back to the terminal to answer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.12",
|
|
4
4
|
"description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
},
|
|
160
160
|
"dependencies": {
|
|
161
161
|
"special-agents": "0.8.7",
|
|
162
|
-
"rafi-spec": "0.8.
|
|
162
|
+
"rafi-spec": "0.8.8",
|
|
163
163
|
"@clack/prompts": "^1.4.0",
|
|
164
164
|
"ajv": "^8.20.0",
|
|
165
165
|
"ajv-formats": "^3.0.1",
|