ai-foreman 1.7.23 → 1.7.25
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/activity.d.ts +3 -0
- package/dist/activity.js +12 -1
- package/dist/adapters/claude.js +17 -1
- package/dist/adapters/types.d.ts +9 -0
- package/dist/branch/runner.js +17 -6
- package/dist/buildAssignment.d.ts +18 -0
- package/dist/buildAssignment.js +139 -0
- package/dist/buildOwnershipRepair.d.ts +2 -0
- package/dist/buildOwnershipRepair.js +23 -1
- package/dist/cli/start.d.ts +3 -1
- package/dist/cli/start.js +26 -12
- package/dist/cli/status.js +23 -1
- package/dist/cli/tickets.d.ts +1 -0
- package/dist/cli/tickets.js +20 -2
- package/dist/foreman.js +3 -1
- package/dist/handoffs.js +17 -5
- package/dist/tickets/delivery.d.ts +8 -0
- package/dist/tickets/delivery.js +27 -0
- package/package.json +2 -2
package/dist/activity.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ export declare class ActivityReporter {
|
|
|
48
48
|
private lastSemanticKey?;
|
|
49
49
|
private paused;
|
|
50
50
|
private agentStatusLine?;
|
|
51
|
+
private readonly reportedToolIssues;
|
|
51
52
|
constructor(command: string, options?: ActivityReporterOptions);
|
|
52
53
|
begin(label: string): () => void;
|
|
53
54
|
update(state: string, detail?: string, metadata?: {
|
|
@@ -58,6 +59,8 @@ export declare class ActivityReporter {
|
|
|
58
59
|
pulse(detail?: string): void;
|
|
59
60
|
/** A completed tool or turn is distinct from transport/status activity. */
|
|
60
61
|
completedWork(): void;
|
|
62
|
+
/** One tool may fail recoverably many times while the agent continues. */
|
|
63
|
+
reportToolIssue(name: string): void;
|
|
61
64
|
note(message: string): void;
|
|
62
65
|
writePersistent(text: string): void;
|
|
63
66
|
/** Keep role/provider/context state attached to the current activity status. */
|
package/dist/activity.js
CHANGED
|
@@ -32,6 +32,7 @@ export class ActivityReporter {
|
|
|
32
32
|
lastSemanticKey;
|
|
33
33
|
paused = 0;
|
|
34
34
|
agentStatusLine;
|
|
35
|
+
reportedToolIssues = new Set();
|
|
35
36
|
constructor(command, options = {}) {
|
|
36
37
|
this.command = command;
|
|
37
38
|
this.output = options.output ?? {
|
|
@@ -95,6 +96,16 @@ export class ActivityReporter {
|
|
|
95
96
|
this.lastUsefulProgressAt = this.now();
|
|
96
97
|
this.usefulProgressWarningPrinted = false;
|
|
97
98
|
}
|
|
99
|
+
/** One tool may fail recoverably many times while the agent continues. */
|
|
100
|
+
reportToolIssue(name) {
|
|
101
|
+
const key = clean(name, 100).toLowerCase();
|
|
102
|
+
if (this.reportedToolIssues.has(key)) {
|
|
103
|
+
this.pulse();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.reportedToolIssues.add(key);
|
|
107
|
+
this.update("tool reported a failure; agent continuing", name);
|
|
108
|
+
}
|
|
98
109
|
note(message) {
|
|
99
110
|
this.clearLine();
|
|
100
111
|
this.output.write(`${sanitizeTerminalText(message).replace(/[\r\t]+/g, " ").slice(0, 500)}\n`);
|
|
@@ -307,7 +318,7 @@ export function reportBuilderEvent(event) {
|
|
|
307
318
|
else if (event.status === "cancelled")
|
|
308
319
|
reporter.update("tool cancelled", event.name);
|
|
309
320
|
else if (isFailedToolCompletion(event))
|
|
310
|
-
reporter.
|
|
321
|
+
reporter.reportToolIssue(event.name);
|
|
311
322
|
else
|
|
312
323
|
reporter.pulse();
|
|
313
324
|
break;
|
package/dist/adapters/claude.js
CHANGED
|
@@ -637,7 +637,20 @@ export class ClaudeAdapter {
|
|
|
637
637
|
const failed = block.is_error === true;
|
|
638
638
|
// TIME_ELAPSED alone does not establish success or failure. Preserve it as unknown.
|
|
639
639
|
const elapsedOnly = /^\s*TIME_ELAPSED\s*$/.test(text);
|
|
640
|
-
|
|
640
|
+
// An SDK `is_error` bit without a terminal reason is not enough to call
|
|
641
|
+
// the tool failed: enterprise gateways can emit provisional errors and
|
|
642
|
+
// continue the same turn. Preserve that state as unknown until a
|
|
643
|
+
// correlated, explicit terminal result arrives.
|
|
644
|
+
const resultReason = elapsedOnly ? "elapsed_placeholder"
|
|
645
|
+
: failed && /timed?\s*out|timeout/i.test(text) ? "timeout"
|
|
646
|
+
: failed && /cancel(?:led|ed)/i.test(text) ? "cancelled"
|
|
647
|
+
: failed && /permission denied/i.test(text) ? "permission_denied"
|
|
648
|
+
: failed && /not found/i.test(text) ? "not_found"
|
|
649
|
+
: failed && /\b(?:exit(?:ed)?|status)\s+[1-9]\d*\b/i.test(text) ? "nonzero_exit"
|
|
650
|
+
: failed && /\b(?:failed|error|exception)\b/i.test(text) ? "explicit_error"
|
|
651
|
+
: failed ? "unexplained_error"
|
|
652
|
+
: "completed";
|
|
653
|
+
const status = resultReason === "timeout" ? "timed_out" : resultReason === "cancelled" ? "cancelled" : ["permission_denied", "not_found", "nonzero_exit", "explicit_error"].includes(resultReason) ? "failed" : resultReason === "completed" ? "completed" : "unknown";
|
|
641
654
|
if (status !== "unknown") {
|
|
642
655
|
this.completedToolCalls.add(callId);
|
|
643
656
|
if (this.completedToolCalls.size > 1000)
|
|
@@ -645,6 +658,9 @@ export class ClaudeAdapter {
|
|
|
645
658
|
this.toolCalls.delete(callId);
|
|
646
659
|
}
|
|
647
660
|
const outputDigest = createHash("sha256").update(text).digest("hex");
|
|
661
|
+
// Persist only correlation metadata. The result text can contain source
|
|
662
|
+
// paths, command output, or credentials and must not enter lifecycle logs.
|
|
663
|
+
this.opts.onLifecycleTrace?.({ phase: "provider-tool-result", at: new Date().toISOString(), sessionId: this._sessionId, providerTurnId: this.activeProviderTurnId, toolCallId: callId, toolName: call?.name ?? "unidentified tool", classification: status, resultReason, isError: failed, outputDigest, rawOutputBytes: Buffer.byteLength(text) });
|
|
648
664
|
if (status === "unknown") {
|
|
649
665
|
if (this.unknownToolResults.get(callId) === outputDigest)
|
|
650
666
|
continue;
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -269,6 +269,7 @@ export interface BuilderAdapterOptions {
|
|
|
269
269
|
turnDeadlineMs?: number;
|
|
270
270
|
shutdownTimeoutMs?: number;
|
|
271
271
|
/** Metadata-only lifecycle trace; no prompts, responses, credentials, or tool inputs. */
|
|
272
|
+
/** Bounded, redacted provider lifecycle diagnostics; never include raw tool output. */
|
|
272
273
|
onLifecycleTrace?: (event: {
|
|
273
274
|
phase: string;
|
|
274
275
|
at: string;
|
|
@@ -276,6 +277,14 @@ export interface BuilderAdapterOptions {
|
|
|
276
277
|
sessionId?: string;
|
|
277
278
|
method?: string;
|
|
278
279
|
timeoutMs?: number;
|
|
280
|
+
providerTurnId?: string;
|
|
281
|
+
toolCallId?: string;
|
|
282
|
+
toolName?: string;
|
|
283
|
+
classification?: string;
|
|
284
|
+
resultReason?: string;
|
|
285
|
+
isError?: boolean;
|
|
286
|
+
outputDigest?: string;
|
|
287
|
+
rawOutputBytes?: number;
|
|
279
288
|
}) => void;
|
|
280
289
|
}
|
|
281
290
|
export interface BuilderAdapter {
|
package/dist/branch/runner.js
CHANGED
|
@@ -96,7 +96,7 @@ export async function runBranchPlan(opts) {
|
|
|
96
96
|
recoveryDb.close();
|
|
97
97
|
}
|
|
98
98
|
if (exactInterruptedMerge) {
|
|
99
|
-
opts.log.write("branch-resume", { detail: "Base contains exactly the staged tree of its durable interrupted merge" });
|
|
99
|
+
opts.log.write("branch-resume", { runId: opts.runId, detail: "Base contains exactly the staged tree of its durable interrupted merge" });
|
|
100
100
|
}
|
|
101
101
|
else if (baseWorktreePolicy === "warn") {
|
|
102
102
|
console.warn(`foreman: warning: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -117,7 +117,7 @@ export async function runBranchPlan(opts) {
|
|
|
117
117
|
let builderWorkSessions = 0;
|
|
118
118
|
const qaStream = { reviews: 0, modificationViolations: 0 };
|
|
119
119
|
for (const issue of opts.plan.issues) {
|
|
120
|
-
opts.log.write("branch-issue", { ...issue });
|
|
120
|
+
opts.log.write("branch-issue", { runId: opts.runId, ...issue });
|
|
121
121
|
notifyIssue(opts.notificationsEnabled, issue.message);
|
|
122
122
|
if (issue.blocking) {
|
|
123
123
|
summaries.push({
|
|
@@ -244,6 +244,7 @@ export async function runBranchPlan(opts) {
|
|
|
244
244
|
const detail = `dependency ${missingDependency} did not complete`;
|
|
245
245
|
summaries.push(summaryFor(node, "skipped", detail));
|
|
246
246
|
opts.log.write("branch-issue", {
|
|
247
|
+
runId: opts.runId,
|
|
247
248
|
ticket: node.ticket.id,
|
|
248
249
|
code: "dependency_unavailable",
|
|
249
250
|
message: detail,
|
|
@@ -261,6 +262,7 @@ export async function runBranchPlan(opts) {
|
|
|
261
262
|
const detail = `dependency ${missingPush} branch was not pushed`;
|
|
262
263
|
summaries.push(summaryFor(node, "skipped", detail, "skipped"));
|
|
263
264
|
opts.log.write("branch-issue", {
|
|
265
|
+
runId: opts.runId,
|
|
264
266
|
ticket: node.ticket.id,
|
|
265
267
|
code: "dependency_unavailable",
|
|
266
268
|
message: detail,
|
|
@@ -275,6 +277,7 @@ export async function runBranchPlan(opts) {
|
|
|
275
277
|
if (!mergeReadiness.ok) {
|
|
276
278
|
summaries.push(summaryFor(node, "skipped", mergeReadiness.message, "skipped"));
|
|
277
279
|
opts.log.write("branch-issue", {
|
|
280
|
+
runId: opts.runId,
|
|
278
281
|
ticket: node.ticket.id,
|
|
279
282
|
code: mergeReadiness.code,
|
|
280
283
|
message: mergeReadiness.message,
|
|
@@ -295,6 +298,7 @@ export async function runBranchPlan(opts) {
|
|
|
295
298
|
admissionDb.close();
|
|
296
299
|
}
|
|
297
300
|
opts.log.write("branch-start", {
|
|
301
|
+
runId: opts.runId,
|
|
298
302
|
ticket: node.ticket.id,
|
|
299
303
|
branch: node.branch,
|
|
300
304
|
base: node.baseBranch,
|
|
@@ -314,6 +318,7 @@ export async function runBranchPlan(opts) {
|
|
|
314
318
|
try {
|
|
315
319
|
if (resumeSession) {
|
|
316
320
|
opts.log.write("branch-resume", {
|
|
321
|
+
runId: opts.runId,
|
|
317
322
|
ticket: node.ticket.id,
|
|
318
323
|
branch: node.branch,
|
|
319
324
|
base: node.baseBranch,
|
|
@@ -660,7 +665,7 @@ export async function runBranchPlan(opts) {
|
|
|
660
665
|
}));
|
|
661
666
|
if (push.ok) {
|
|
662
667
|
confirmJournal(opts.projectDir, pushOperation, node.branch, { sha: commit });
|
|
663
|
-
opts.log.write("branch-push", { ticket: node.ticket.id, branch: node.branch, status: "pushed" });
|
|
668
|
+
opts.log.write("branch-push", { runId: opts.runId, ticket: node.ticket.id, branch: node.branch, status: "pushed" });
|
|
664
669
|
summary.pushStatus = "pushed";
|
|
665
670
|
workflowCheckpoint(opts.projectDir, opts.runId, "push-after", node.ticket.id, { branch: node.branch, sha: commit });
|
|
666
671
|
}
|
|
@@ -677,6 +682,7 @@ export async function runBranchPlan(opts) {
|
|
|
677
682
|
output: push.output,
|
|
678
683
|
};
|
|
679
684
|
opts.log.write("branch-push", {
|
|
685
|
+
runId: opts.runId,
|
|
680
686
|
ticket: node.ticket.id,
|
|
681
687
|
branch: node.branch,
|
|
682
688
|
status: "failed",
|
|
@@ -786,6 +792,7 @@ export async function runBranchPlan(opts) {
|
|
|
786
792
|
}
|
|
787
793
|
summary.pr = { status: "merged", url: mergeCommit };
|
|
788
794
|
opts.log.write("branch-direct-merge", {
|
|
795
|
+
runId: opts.runId,
|
|
789
796
|
ticket: node.ticket.id,
|
|
790
797
|
branch: node.branch,
|
|
791
798
|
base: node.baseBranch,
|
|
@@ -819,6 +826,7 @@ export async function runBranchPlan(opts) {
|
|
|
819
826
|
successfulBranches.add(node.ticket.id);
|
|
820
827
|
summaries.push(summary);
|
|
821
828
|
opts.log.write("branch-complete", {
|
|
829
|
+
runId: opts.runId,
|
|
822
830
|
ticket: node.ticket.id,
|
|
823
831
|
branch: node.branch,
|
|
824
832
|
commit,
|
|
@@ -844,7 +852,7 @@ export async function runBranchPlan(opts) {
|
|
|
844
852
|
}
|
|
845
853
|
if (err instanceof SessionUnavailableError || err instanceof SessionUnavailableContinuityError) {
|
|
846
854
|
const message = err.message;
|
|
847
|
-
opts.log.write("branch-issue", { ticket: node.ticket.id, code: "session_unavailable", message, blocking: true });
|
|
855
|
+
opts.log.write("branch-issue", { runId: opts.runId, ticket: node.ticket.id, code: "session_unavailable", message, blocking: true });
|
|
848
856
|
notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
|
|
849
857
|
summaries.push(summaryFor(node, "blocked", message));
|
|
850
858
|
opts.onSessionUnavailable?.(err);
|
|
@@ -856,7 +864,7 @@ export async function runBranchPlan(opts) {
|
|
|
856
864
|
: message.includes("tracker/control")
|
|
857
865
|
? "tracker_touched"
|
|
858
866
|
: "builder_error";
|
|
859
|
-
opts.log.write("branch-issue", { ticket: node.ticket.id, code, message, blocking: false });
|
|
867
|
+
opts.log.write("branch-issue", { runId: opts.runId, ticket: node.ticket.id, code, message, blocking: false });
|
|
860
868
|
notifyIssue(opts.notificationsEnabled, `${node.ticket.id}: ${message}`);
|
|
861
869
|
journalTracker(opts.projectDir, opts.runId, `${opts.runId}:tracker-update:${node.ticket.id}:runtime-block${continuationSuffix}`, { ticket: node.ticket.id, status: "blocked", detail: message }, () => cmdBlock(opts.projectDir, node.ticket.id, { summary: message, actor: "foreman" }));
|
|
862
870
|
summaries.push(summaryFor(node, "blocked", message));
|
|
@@ -1025,6 +1033,7 @@ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
|
|
|
1025
1033
|
}
|
|
1026
1034
|
if (!waitLogged) {
|
|
1027
1035
|
opts.log.write("branch-auto-merge-wait", {
|
|
1036
|
+
runId: opts.runId,
|
|
1028
1037
|
ticket: node.ticket.id,
|
|
1029
1038
|
pending,
|
|
1030
1039
|
timeoutMinutes: opts.autoMergeTimeoutMinutes ?? null,
|
|
@@ -1046,7 +1055,7 @@ async function waitForAutoMergeDependenciesInternal(opts, node, provider) {
|
|
|
1046
1055
|
if (waitSpanId && opts.observer)
|
|
1047
1056
|
opts.observer.store.finishSpan(waitSpanId, { outcome: waitOutcome });
|
|
1048
1057
|
if (waitLogged)
|
|
1049
|
-
opts.log.write("branch-auto-merge-wait", { ticket: node.ticket.id, outcome: waitOutcome, terminal: true });
|
|
1058
|
+
opts.log.write("branch-auto-merge-wait", { runId: opts.runId, ticket: node.ticket.id, outcome: waitOutcome, terminal: true });
|
|
1050
1059
|
}
|
|
1051
1060
|
}
|
|
1052
1061
|
function autoMergePollMs() {
|
|
@@ -1080,6 +1089,7 @@ function blockBranchIssue(opts, node, summary, code, message, details) {
|
|
|
1080
1089
|
summary.buildStatus = "blocked";
|
|
1081
1090
|
summary.detail = message;
|
|
1082
1091
|
opts.log.write("branch-issue", {
|
|
1092
|
+
runId: opts.runId,
|
|
1083
1093
|
ticket: node.ticket.id,
|
|
1084
1094
|
code,
|
|
1085
1095
|
message,
|
|
@@ -1128,6 +1138,7 @@ Branch-mode rules:
|
|
|
1128
1138
|
- Do not switch branches.
|
|
1129
1139
|
- Do not push, create PRs, or run git commit.
|
|
1130
1140
|
- Do not edit .tickets/, ${progressDoc}, or other tracker state.
|
|
1141
|
+
- If a required test is denied only because you added a pipe, redirection, substitution, or wrapper, retry the exact required-test command without that extra shell syntax. Do not retry other denied actions.
|
|
1131
1142
|
- If another selected ticket is required before this one can be completed, stop and end with STEP_STATUS: blocked | ticket="${node.ticket.id}" branch_dependency="<ticket-id>" reason="<why>".
|
|
1132
1143
|
- End with STEP_STATUS: done | ticket="${node.ticket.id}" summary="<what changed>" when the ticket is implemented.
|
|
1133
1144
|
|
|
@@ -2,6 +2,7 @@ import type { BuilderAdapter } from "./adapters/types.js";
|
|
|
2
2
|
import type { StepStatus } from "./foreman.js";
|
|
3
3
|
import type { TurnResult } from "./adapters/types.js";
|
|
4
4
|
import { type FrozenQaSourceState } from "./qaSnapshot.js";
|
|
5
|
+
import type { BuildRunRecordV2, ProviderSessionRefV1 } from "rafi-spec";
|
|
5
6
|
/** Work responses confirm an assignment; they never select the next ticket. */
|
|
6
7
|
export declare function validateBuildAssignment(ticketId: string, status: StepStatus, responseText?: string): string | undefined;
|
|
7
8
|
export interface BuildAssignmentCapture {
|
|
@@ -19,6 +20,23 @@ type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDi
|
|
|
19
20
|
};
|
|
20
21
|
export declare class BuildAssignmentRejected extends Error {
|
|
21
22
|
}
|
|
23
|
+
/** A frozen exact-session Builder decision may defer unrelated pending QA. */
|
|
24
|
+
export declare function isInterruptedBuilderRecovery(run: BuildRunRecordV2 | undefined): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Retire only the host-side records for a Builder turn that was interrupted
|
|
27
|
+
* before Rafi received a durable response. The next turn is sent to the
|
|
28
|
+
* *same validated provider session*, so it continues the outstanding work
|
|
29
|
+
* rather than treating unknown work as complete or replaying it in a new
|
|
30
|
+
* conversation.
|
|
31
|
+
*/
|
|
32
|
+
export declare function prepareExactSessionBuilderContinuation(projectDir: string, runId: string, session: ProviderSessionRefV1): {
|
|
33
|
+
ticketIds: string[];
|
|
34
|
+
dispatchIds: string[];
|
|
35
|
+
assignmentIds: string[];
|
|
36
|
+
};
|
|
37
|
+
/** Retire uncertain execution for an explicitly selected start-over, never claim
|
|
38
|
+
* that a provider did not run or that its preserved implementation passed QA. */
|
|
39
|
+
export declare function retireBuildExecutionForStartOver(projectDir: string, runId: string, operationId: string): void;
|
|
22
40
|
export interface BuilderGuidanceFollowup {
|
|
23
41
|
reviewAttemptId: string;
|
|
24
42
|
requirementsDigest: string;
|
package/dist/buildAssignment.js
CHANGED
|
@@ -26,13 +26,152 @@ export function validateBuildAssignment(ticketId, status, responseText = "") {
|
|
|
26
26
|
}
|
|
27
27
|
export class BuildAssignmentRejected extends Error {
|
|
28
28
|
}
|
|
29
|
+
/** A frozen exact-session Builder decision may defer unrelated pending QA. */
|
|
30
|
+
export function isInterruptedBuilderRecovery(run) {
|
|
31
|
+
if (!run)
|
|
32
|
+
return false;
|
|
33
|
+
const decision = run.recoveryDecision;
|
|
34
|
+
const continuation = run.interruptedBuilderContinuation;
|
|
35
|
+
return decision?.runId === run.runId && decision.role === "builder" && decision.mode === "exact-session"
|
|
36
|
+
&& decision.executionTickets?.length === 1
|
|
37
|
+
&& run.tickets.includes(decision.executionTickets[0])
|
|
38
|
+
&& Boolean(continuation?.ticketIds?.includes(decision.executionTickets[0]))
|
|
39
|
+
&& Boolean(continuation?.sessionRef && decision.predecessorSessionRef && sameSession(continuation.sessionRef, decision.predecessorSessionRef));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Retire only the host-side records for a Builder turn that was interrupted
|
|
43
|
+
* before Rafi received a durable response. The next turn is sent to the
|
|
44
|
+
* *same validated provider session*, so it continues the outstanding work
|
|
45
|
+
* rather than treating unknown work as complete or replaying it in a new
|
|
46
|
+
* conversation.
|
|
47
|
+
*/
|
|
48
|
+
export function prepareExactSessionBuilderContinuation(projectDir, runId, session) {
|
|
49
|
+
const db = new WorkflowDb(projectDir);
|
|
50
|
+
try {
|
|
51
|
+
const dispatches = db.unresolvedRoleDispatches(runId, "builder");
|
|
52
|
+
if (!dispatches.length)
|
|
53
|
+
throw new BuildAssignmentRejected("No interrupted Builder dispatch is awaiting exact-session continuation");
|
|
54
|
+
if (dispatches.some(dispatch => !sameSession(dispatch.intent.sessionRef, session))) {
|
|
55
|
+
throw new BuildAssignmentRejected("Interrupted Builder dispatch is not bound to the validated exact provider session");
|
|
56
|
+
}
|
|
57
|
+
const assignments = db.operations(runId).filter(operation => operation.kind === "build-assignment" && ["in_progress", "uncertain"].includes(operation.status));
|
|
58
|
+
if (!assignments.length)
|
|
59
|
+
throw new BuildAssignmentRejected("Interrupted Builder dispatch has no durable in-progress assignment");
|
|
60
|
+
const ticketIds = assignments.map(operation => operation.intent.ticketId)
|
|
61
|
+
.filter((ticketId) => typeof ticketId === "string");
|
|
62
|
+
if (ticketIds.length !== assignments.length)
|
|
63
|
+
throw new BuildAssignmentRejected("Interrupted Builder assignment is missing its ticket identity");
|
|
64
|
+
db.atomic(() => {
|
|
65
|
+
const resumedAt = new Date().toISOString();
|
|
66
|
+
for (const dispatch of dispatches) {
|
|
67
|
+
db.updateOperation(dispatch.idempotencyKey, "failed", {
|
|
68
|
+
result: { recovery: "exact-session-continuation", resumedAt, sessionRef: session },
|
|
69
|
+
error: "Host interruption: the same validated Builder session will reconcile and continue this turn",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
for (const assignment of assignments) {
|
|
73
|
+
db.updateOperation(assignment.idempotencyKey, "failed", {
|
|
74
|
+
result: { recovery: "exact-session-continuation", resumedAt, sessionRef: session },
|
|
75
|
+
error: "Host interruption: work remains assigned to the validated Builder session; do not send it to QA yet",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const run = db.getRun(runId);
|
|
79
|
+
if (run)
|
|
80
|
+
db.transition(runId, {
|
|
81
|
+
checkpoint: "builder-exact-session-continuation",
|
|
82
|
+
state: { ...run.state, interruptedBuilderContinuation: { ticketIds, sessionRef: session, resumedAt } },
|
|
83
|
+
event: "builder_exact_session_continuation_authorized",
|
|
84
|
+
payload: { ticketIds, sessionRef: session },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
return { ticketIds: [...new Set(ticketIds)], dispatchIds: dispatches.map(item => item.idempotencyKey), assignmentIds: assignments.map(item => item.idempotencyKey) };
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
db.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Retire uncertain execution for an explicitly selected start-over, never claim
|
|
94
|
+
* that a provider did not run or that its preserved implementation passed QA. */
|
|
95
|
+
export function retireBuildExecutionForStartOver(projectDir, runId, operationId) {
|
|
96
|
+
const db = new WorkflowDb(projectDir);
|
|
97
|
+
let admission;
|
|
98
|
+
let lease;
|
|
99
|
+
try {
|
|
100
|
+
const operation = db.getRun(operationId);
|
|
101
|
+
const original = operation?.originalWork;
|
|
102
|
+
if (operation?.kind !== "recovery" || operation.state.operation !== "build-start-over" || original?.run?.runId !== runId)
|
|
103
|
+
throw new Error("Execution retirement requires a recorded start-over intent for this run");
|
|
104
|
+
admission = db.acquireBuildRecoveryAdmission(runId);
|
|
105
|
+
lease = db.acquireLease(runId);
|
|
106
|
+
if (db.readinessProcesses().length)
|
|
107
|
+
throw new Error("Owned process cleanup must be verified before starting over");
|
|
108
|
+
if (db.incompletePublications().some(item => {
|
|
109
|
+
if (item.runId !== runId)
|
|
110
|
+
return false;
|
|
111
|
+
const intent = item.intent;
|
|
112
|
+
return intent.operation !== "build-run-projection" || intent.target !== join(resolve(projectDir), ".foreman", "runs", `${runId}.json`);
|
|
113
|
+
}))
|
|
114
|
+
throw new Error("Finish pending publication recovery before starting over; preserved files may still be awaiting publication");
|
|
115
|
+
db.atomic(() => {
|
|
116
|
+
for (const dispatch of [...db.unresolvedRoleDispatches(runId, "builder"), ...db.unresolvedRoleDispatches(runId, "qa")]) {
|
|
117
|
+
db.updateOperation(dispatch.idempotencyKey, "failed", { result: { recovery: "start-over", operationId, outcomeUnknown: true, providerReplayAuthorized: false }, error: "Operator chose a fresh run from preserved source; historical execution outcome remains unknown" });
|
|
118
|
+
}
|
|
119
|
+
const run = db.getRun(runId);
|
|
120
|
+
if (!run)
|
|
121
|
+
throw new Error("Original run disappeared before start-over");
|
|
122
|
+
db.transition(runId, { checkpoint: "execution-retired-for-start-over", state: { ...run.state, executionRetiredForStartOver: operationId }, event: "execution_retired_for_start_over", payload: { operationId, providerReplayAuthorized: false } });
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
try {
|
|
127
|
+
if (lease)
|
|
128
|
+
db.releaseLease(lease);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
try {
|
|
132
|
+
if (admission)
|
|
133
|
+
db.releaseBuildAdmission(admission);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
db.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function sameSession(value, session) {
|
|
142
|
+
if (!value || typeof value !== "object")
|
|
143
|
+
return false;
|
|
144
|
+
const candidate = value;
|
|
145
|
+
return candidate.provider === session.provider
|
|
146
|
+
&& candidate.sessionId === session.sessionId
|
|
147
|
+
&& candidate.generation === session.generation
|
|
148
|
+
&& session.role === "builder" && session.stream === "builder"
|
|
149
|
+
&& candidate.role === "builder"
|
|
150
|
+
&& candidate.stream === "builder"
|
|
151
|
+
&& candidate.cwd === session.cwd
|
|
152
|
+
&& candidate.configRoot === session.configRoot;
|
|
153
|
+
}
|
|
29
154
|
export function assertBuildAssignmentReconciled(projectDir, runId, worktree = projectDir) {
|
|
30
155
|
const reader = new WorkflowReader(projectDir);
|
|
31
156
|
try {
|
|
32
157
|
const runs = [...new Set([runId, ...reader.buildRuns(true).map(run => run.runId)])];
|
|
158
|
+
const retiredRuns = new Set();
|
|
33
159
|
for (const operation of runs.flatMap(id => reader.operations(id, true))) {
|
|
34
160
|
if (operation.kind !== "build-assignment")
|
|
35
161
|
continue;
|
|
162
|
+
if (retiredRuns.has(operation.runId))
|
|
163
|
+
continue;
|
|
164
|
+
const owner = reader.getRun(operation.runId);
|
|
165
|
+
// An explicit start-over retires execution, while retaining assignments
|
|
166
|
+
// as historical evidence. It never authorizes their provider replay.
|
|
167
|
+
const retirement = typeof owner?.state.supersededBy === "string" ? reader.getRun(owner.state.supersededBy) : undefined;
|
|
168
|
+
if (owner?.status === "superseded" && retirement?.kind === "recovery" && retirement.state.operation === "build-start-over"
|
|
169
|
+
&& retirement.state.gitPreserved === true && typeof retirement.state.resetId === "string"
|
|
170
|
+
&& retirement.originalWork.run?.runId === operation.runId
|
|
171
|
+
&& reader.events(operation.runId).some(event => event.type === "execution_retired_for_start_over" && event.payload.operationId === retirement.runId)) {
|
|
172
|
+
retiredRuns.add(operation.runId);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
36
175
|
const result = operation.result;
|
|
37
176
|
if (result?.reconciliationId)
|
|
38
177
|
continue;
|
|
@@ -31,3 +31,5 @@ export declare function repairOwnership(projectDir: string, value: unknown, user
|
|
|
31
31
|
afterLocalCommit?(): void;
|
|
32
32
|
afterPublication?(): void;
|
|
33
33
|
}): Record<string, unknown>;
|
|
34
|
+
/** Resume only already-admitted work interrupted while recording its response. */
|
|
35
|
+
export declare function reconcileInterruptedInScopeWork(projectDir: string, runId: string, workId: string): Record<string, unknown>;
|
|
@@ -9,6 +9,7 @@ import { loadTickets } from "./tickets/ticketLoader.js";
|
|
|
9
9
|
import { bindBuildAuthority, checkpointBuildRun } from "./buildRuns.js";
|
|
10
10
|
import { classifyProcess } from "./processIdentity.js";
|
|
11
11
|
import { validateOwnershipRepair } from "./buildWorkReconciliation.js";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
12
13
|
export function prepareOwnershipRepair(projectDir, runId, workId) {
|
|
13
14
|
const snapshot = readQaEvidenceSnapshot(projectDir, runId);
|
|
14
15
|
if (snapshot.availability === "corrupt" || snapshot.availability === "unreadable" || snapshot.availability === "missing")
|
|
@@ -24,7 +25,11 @@ export function prepareOwnershipRepair(projectDir, runId, workId) {
|
|
|
24
25
|
export function repairOwnership(projectDir, value, userTurn, faults) {
|
|
25
26
|
validateOwnershipRepair(value);
|
|
26
27
|
const request = value;
|
|
27
|
-
|
|
28
|
+
const explicitHostRequest = userTurn.trim().startsWith("/qa-repair ") && JSON.stringify(JSON.parse(userTurn.trim().slice("/qa-repair ".length))) === JSON.stringify(request);
|
|
29
|
+
const resumeRequest = userTurn.trim() === "rafi resume reconciliation " + JSON.stringify(request)
|
|
30
|
+
&& request.choice === "authorize_mapped_work"
|
|
31
|
+
&& request.authorization === "Resume reconciled already-approved in-scope work after an interrupted assignment response";
|
|
32
|
+
if (!explicitHostRequest && !resumeRequest)
|
|
28
33
|
throw new Error("Repair requires the original explicit host request");
|
|
29
34
|
const reader = new WorkflowReader(projectDir);
|
|
30
35
|
try {
|
|
@@ -109,3 +114,20 @@ export function repairOwnership(projectDir, value, userTurn, faults) {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
}
|
|
117
|
+
/** Resume only already-admitted work interrupted while recording its response. */
|
|
118
|
+
export function reconcileInterruptedInScopeWork(projectDir, runId, workId) {
|
|
119
|
+
const plan = prepareOwnershipRepair(projectDir, runId, workId);
|
|
120
|
+
const conflicts = plan.ownership.conflicts;
|
|
121
|
+
if (!conflicts.length || conflicts.some(conflict => conflict.workId !== workId || conflict.classification !== "assignment_response_conflict")) {
|
|
122
|
+
throw new Error("Interrupted work is not a single in-scope assignment response conflict");
|
|
123
|
+
}
|
|
124
|
+
const request = {
|
|
125
|
+
version: 1, requestId: `resume-${randomUUID()}`, runId, workId,
|
|
126
|
+
expectedRevision: plan.expectedRevision, choice: "authorize_mapped_work",
|
|
127
|
+
inspectedSourceDigest: plan.inspectedSourceDigest, scopeRevision: plan.scopeRevision,
|
|
128
|
+
authorization: "Resume reconciled already-approved in-scope work after an interrupted assignment response",
|
|
129
|
+
attestation: "Rafi retained the interrupted response and inspected the current preserved source; do not replay the provider turn and require fresh QA",
|
|
130
|
+
mapping: plan.requiredPaths.map(path => ({ path, workId })),
|
|
131
|
+
};
|
|
132
|
+
return repairOwnership(projectDir, request, `rafi resume reconciliation ${JSON.stringify(request)}`);
|
|
133
|
+
}
|
package/dist/cli/start.d.ts
CHANGED
|
@@ -24,7 +24,9 @@ export declare function validateStartOptions(opts: Record<string, any>, requireC
|
|
|
24
24
|
export declare function validateRecoveryArguments(args: readonly string[], projectDir: string): void;
|
|
25
25
|
/** Validate persisted preparation argv without executing a command or provider. */
|
|
26
26
|
export declare function validatePreparationArguments(args: readonly string[], projectDir: string): string[];
|
|
27
|
-
export declare function buildStartCommand(
|
|
27
|
+
export declare function buildStartCommand(options?: {
|
|
28
|
+
beforeFreshStart?: (projectDir: string) => Promise<boolean>;
|
|
29
|
+
}): Command;
|
|
28
30
|
export declare function createQaNonconvergenceHandler(projectDir: string, noninteractive: boolean, planner?: AgentRoleDefaultsV1, observer?: () => RunObserver | undefined, activeRunId?: () => string): (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
|
|
29
31
|
export declare function createQaReportRecoveryHandler(projectDir: string, noninteractive: boolean, observer?: () => RunObserver | undefined): QaReportRecoveryHandler;
|
|
30
32
|
/** Make the executable branch plan exactly match each explicit root-to-tip stack. */
|
package/dist/cli/start.js
CHANGED
|
@@ -8,7 +8,7 @@ import { formatRecoveryCommand, formatDecisionCommands, formatExactRunRecovery }
|
|
|
8
8
|
import { durableReadOnlyProposal } from "../readOnlyProposal.js";
|
|
9
9
|
import { captureFrozenQaSource } from "../qaSnapshot.js";
|
|
10
10
|
import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
|
|
11
|
-
import { assertBuildAssignmentReconciled } from "../buildAssignment.js";
|
|
11
|
+
import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery } from "../buildAssignment.js";
|
|
12
12
|
import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
|
|
13
13
|
import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate } from "../buildApproval.js";
|
|
14
14
|
import { Command, Option } from "commander";
|
|
@@ -43,7 +43,7 @@ import { buildRunSessionBinding, checkpointBuildRun, completeBuildRun, createBui
|
|
|
43
43
|
import { parse as parseYaml } from "yaml";
|
|
44
44
|
import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
|
|
45
45
|
import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
|
|
46
|
-
import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
|
|
46
|
+
import { loadDeliveryConfig, selectDeliveryTicketsForRun, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
|
|
47
47
|
import { AgentStatusReporter, RoleStatusAdapter } from "../statusReporter.js";
|
|
48
48
|
import { currentActivity, withActivityPhase } from "../activity.js";
|
|
49
49
|
import { fireTerminalBell } from "../notify.js";
|
|
@@ -450,7 +450,7 @@ export function validatePreparationArguments(args, projectDir) {
|
|
|
450
450
|
throw new Error("Saved preparation contains stale internal recovery authority");
|
|
451
451
|
return savedStartArguments(command, projectDir, options);
|
|
452
452
|
}
|
|
453
|
-
export function buildStartCommand() {
|
|
453
|
+
export function buildStartCommand(options = {}) {
|
|
454
454
|
return new Command("start")
|
|
455
455
|
.description("Enlist a builder and drive it through a batch of N steps.")
|
|
456
456
|
.argument("<project>", "path to the project directory the builder works in")
|
|
@@ -557,6 +557,10 @@ export function buildStartCommand() {
|
|
|
557
557
|
const cwd = resolve(project);
|
|
558
558
|
if (!existsSync(cwd))
|
|
559
559
|
fail(`project directory not found: ${cwd}`);
|
|
560
|
+
// Recovery commands launch a frozen continuation and must not recursively
|
|
561
|
+
// offer a different recovery path. Fresh launches may be redirected first.
|
|
562
|
+
if (!recoveryIntent && options.beforeFreshStart && !await options.beforeFreshStart(cwd))
|
|
563
|
+
return;
|
|
560
564
|
const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
|
|
561
565
|
let dirtyWork = recoveryRecord?.runDecisions?.dirtyWork;
|
|
562
566
|
if (opts.recoverRun && !recoveryRecord)
|
|
@@ -628,6 +632,7 @@ export function buildStartCommand() {
|
|
|
628
632
|
let deliveryConfig;
|
|
629
633
|
let selectedStacks = [];
|
|
630
634
|
let selectedStackTickets;
|
|
635
|
+
let selectedDeliveryTickets;
|
|
631
636
|
if (isTicketsInitialized(cwd)) {
|
|
632
637
|
const delivery = loadDeliveryConfig(cwd);
|
|
633
638
|
deliveryConfig = delivery;
|
|
@@ -637,6 +642,7 @@ export function buildStartCommand() {
|
|
|
637
642
|
const earlyDb = new StateDb(earlyPaths.stateDb);
|
|
638
643
|
const earlyStates = earlyDb.getAllStates();
|
|
639
644
|
earlyDb.close();
|
|
645
|
+
let skippedDeliveryUnits = opts.skipDeliveryUnit ?? [];
|
|
640
646
|
if (stackCount !== undefined) {
|
|
641
647
|
const earlyTickets = loadTickets(earlyPaths.tickets);
|
|
642
648
|
const selection = selectStacksForRun(delivery, earlyTickets, earlyStates, stackCount);
|
|
@@ -653,15 +659,23 @@ export function buildStartCommand() {
|
|
|
653
659
|
opts.provider = [...providers][0];
|
|
654
660
|
}
|
|
655
661
|
else
|
|
656
|
-
deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates,
|
|
662
|
+
deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, skippedDeliveryUnits);
|
|
657
663
|
if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
|
|
658
664
|
const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
|
|
659
665
|
{ value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
|
|
660
666
|
] });
|
|
661
667
|
if (isCancel(answer))
|
|
662
668
|
process.exit(0);
|
|
663
|
-
if (answer === "skip")
|
|
664
|
-
|
|
669
|
+
if (answer === "skip") {
|
|
670
|
+
skippedDeliveryUnits = [deliveryRun.unit.id, ...skippedDeliveryUnits];
|
|
671
|
+
deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, skippedDeliveryUnits);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (stackCount === undefined) {
|
|
675
|
+
selectedDeliveryTickets = selectDeliveryTicketsForRun(delivery, earlyStates, steps, skippedDeliveryUnits);
|
|
676
|
+
if (selectedDeliveryTickets.length < steps) {
|
|
677
|
+
console.log(`foreman: requested --steps ${steps}; admitting ${selectedDeliveryTickets.length} ticket(s). The next delivery unit uses shared/current branch semantics or is not yet eligible, so it will run separately.`);
|
|
678
|
+
}
|
|
665
679
|
}
|
|
666
680
|
}
|
|
667
681
|
}
|
|
@@ -1814,7 +1828,7 @@ export function buildStartCommand() {
|
|
|
1814
1828
|
branchPrefix,
|
|
1815
1829
|
maxBranchDepth,
|
|
1816
1830
|
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
1817
|
-
ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
1831
|
+
ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? selectedDeliveryTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
1818
1832
|
});
|
|
1819
1833
|
let auditBuilder;
|
|
1820
1834
|
let auditViewer;
|
|
@@ -1834,7 +1848,7 @@ export function buildStartCommand() {
|
|
|
1834
1848
|
maxBranchDepth,
|
|
1835
1849
|
auditDependencies,
|
|
1836
1850
|
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
1837
|
-
ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
1851
|
+
ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? selectedDeliveryTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
1838
1852
|
});
|
|
1839
1853
|
auditDependencyCount = auditDependencies.length;
|
|
1840
1854
|
}
|
|
@@ -1975,9 +1989,9 @@ export function buildStartCommand() {
|
|
|
1975
1989
|
for (const node of plan.nodes)
|
|
1976
1990
|
admitApprovedTicket(cwd, masterRun.runId, node.ticket.id, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved branch plan`);
|
|
1977
1991
|
masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities });
|
|
1978
|
-
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
|
|
1992
|
+
const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
|
|
1979
1993
|
let qaProtocolResumeTicket;
|
|
1980
|
-
if (recoveryRecord && !qaResumedRecovery) {
|
|
1994
|
+
if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
|
|
1981
1995
|
const protocolDb = new WorkflowDb(cwd);
|
|
1982
1996
|
try {
|
|
1983
1997
|
const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
|
|
@@ -2290,10 +2304,10 @@ export function buildStartCommand() {
|
|
|
2290
2304
|
buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
|
|
2291
2305
|
activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
|
|
2292
2306
|
activeContinuityRunId = buildRun.runId;
|
|
2293
|
-
const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
|
|
2307
|
+
const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
|
|
2294
2308
|
let qaFinalizationTicket;
|
|
2295
2309
|
let qaProtocolResumeTicket;
|
|
2296
|
-
if (recoveryRecord && !qaResumedRecovery) {
|
|
2310
|
+
if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
|
|
2297
2311
|
const finalizationDb = new WorkflowDb(cwd);
|
|
2298
2312
|
try {
|
|
2299
2313
|
const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
|
package/dist/cli/status.js
CHANGED
|
@@ -65,7 +65,22 @@ export function runStatus(project) {
|
|
|
65
65
|
const batchEnd = [...records].reverse().find((r) => r.event === "batch-end");
|
|
66
66
|
const retainedRuns = readBuildRuns(projectDir).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
67
67
|
const byId = new Map(retainedRuns.map(run => [run.runId, run]));
|
|
68
|
-
|
|
68
|
+
// Branch completion is the terminal fact for branch-per-ticket runs. Prefer
|
|
69
|
+
// its durable run identity over later bookkeeping/audit records in the same
|
|
70
|
+
// daily log, which may not themselves have a retained build record.
|
|
71
|
+
const lastProgressIndex = records.reduce((last, record, index) => (record.event === "step" || record.event === "batch-end" || String(record.event).startsWith("branch-") ? index : last), -1);
|
|
72
|
+
const completedBranchRunId = lastProgressIndex >= 0
|
|
73
|
+
&& records[lastProgressIndex]?.event === "branch-complete"
|
|
74
|
+
&& typeof records[lastProgressIndex]?.runId === "string"
|
|
75
|
+
&& byId.has(records[lastProgressIndex].runId)
|
|
76
|
+
? records[lastProgressIndex].runId
|
|
77
|
+
: undefined;
|
|
78
|
+
// Older branch logs omitted runId. Infer only when the last meaningful
|
|
79
|
+
// record is a completion and exactly one durable completed run owns that
|
|
80
|
+
// ticket; ambiguity remains deliberately unresolved rather than borrowing
|
|
81
|
+
// an unrelated historical result.
|
|
82
|
+
const legacyCompletedBranchRunId = completedBranchRunId ? undefined : legacyCompletedRunId(records, lastProgressIndex, retainedRuns);
|
|
83
|
+
const recordRunId = completedBranchRunId ?? legacyCompletedBranchRunId ?? [...records].reverse().find(record => typeof record.runId === "string" && (byId.has(record.runId) || record.event === "step" || record.event === "batch-end" || String(record.event).startsWith("branch-")))?.runId;
|
|
69
84
|
const retainedRun = typeof recordRunId === "string" ? byId.get(recordRunId) : undefined;
|
|
70
85
|
console.log(`${accent("foreman:")} latest run ${sanitizeTerminalText(logs[logs.length - 1] ?? "")}`);
|
|
71
86
|
console.log(`${accent("foreman:")} ${steps.length} step record(s), ${escalations.length} escalation(s)`);
|
|
@@ -116,6 +131,13 @@ export function runStatus(project) {
|
|
|
116
131
|
printWorkflowIssues(workflow, activeWorkflows);
|
|
117
132
|
workflow?.close();
|
|
118
133
|
}
|
|
134
|
+
function legacyCompletedRunId(records, lastProgressIndex, retainedRuns) {
|
|
135
|
+
const terminal = lastProgressIndex >= 0 ? records[lastProgressIndex] : undefined;
|
|
136
|
+
if (terminal?.event !== "branch-complete" || typeof terminal.runId === "string" || typeof terminal.ticket !== "string")
|
|
137
|
+
return undefined;
|
|
138
|
+
const candidates = retainedRuns.filter(run => run.status === "completed" && run.tickets.includes(terminal.ticket));
|
|
139
|
+
return candidates.length === 1 ? candidates[0].runId : undefined;
|
|
140
|
+
}
|
|
119
141
|
function printWorkflowIssues(workflow, runs) {
|
|
120
142
|
if (!workflow || runs.length === 0)
|
|
121
143
|
return;
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -61,5 +61,6 @@ export declare function ticketOwnershipReceipts(projectDir: string, commandName:
|
|
|
61
61
|
export declare function buildTicketsCommand(options?: {
|
|
62
62
|
prepareOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
|
|
63
63
|
recordOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
|
|
64
|
+
restartBuild?: (projectDir: string, runId: string, tickets: string[]) => Promise<void>;
|
|
64
65
|
}): Command;
|
|
65
66
|
export {};
|
package/dist/cli/tickets.js
CHANGED
|
@@ -1843,7 +1843,7 @@ export function buildTicketsCommand(options = {}) {
|
|
|
1843
1843
|
});
|
|
1844
1844
|
tickets
|
|
1845
1845
|
.command("reset [ticketId]")
|
|
1846
|
-
.description("Reset
|
|
1846
|
+
.description("Reset tracker state while retaining history; use build:start-over to restart interrupted execution.")
|
|
1847
1847
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1848
1848
|
.option("--all", "open the interactive bulk reset scope chooser")
|
|
1849
1849
|
.option("--scope <scope>", "bulk scope (all | completed-and-unfinished | unfinished)")
|
|
@@ -1898,10 +1898,28 @@ export function buildTicketsCommand(options = {}) {
|
|
|
1898
1898
|
for (const row of preview.tickets)
|
|
1899
1899
|
console.log(` ${row.id}: ${row.title} [${row.status}] — clears ${row.cleared.join(", ")}`);
|
|
1900
1900
|
if (preview.relatedRuns.length) {
|
|
1901
|
-
console.log(" !
|
|
1901
|
+
console.log(" ! Ticket reset clears tracker state only. It cannot resume a Builder session or resolve pending QA/process recovery.");
|
|
1902
1902
|
for (const run of preview.relatedRuns)
|
|
1903
1903
|
console.log(` ${run.runId.slice(0, 8)} [${run.status}] ${run.tickets.join(", ")}`);
|
|
1904
1904
|
}
|
|
1905
|
+
if (preview.relatedRuns.length) {
|
|
1906
|
+
for (const run of preview.relatedRuns)
|
|
1907
|
+
console.log(` Keep files and restart execution: rafi build:start-over --run ${run.runId} --action restart-current`);
|
|
1908
|
+
if (options.restartBuild && process.stdin.isTTY && process.stdout.isTTY && !opts.yes) {
|
|
1909
|
+
const answer = await select({ message: "These tickets belong to interrupted builds. What should Rafi do?", options: [
|
|
1910
|
+
...preview.relatedRuns.map(run => ({ value: run.runId, label: `Restart execution from current files for build ${run.runId.slice(0, 8)} (keep implementation)` })),
|
|
1911
|
+
{ value: "tracker-only", label: "Reset tracker state only; leave build recovery unchanged" },
|
|
1912
|
+
{ value: "cancel", label: "Cancel" },
|
|
1913
|
+
] });
|
|
1914
|
+
if (isCancel(answer) || answer === "cancel")
|
|
1915
|
+
return;
|
|
1916
|
+
if (answer !== "tracker-only") {
|
|
1917
|
+
const run = preview.relatedRuns.find(item => item.runId === answer);
|
|
1918
|
+
await options.restartBuild(cwd(opts), run.runId, preview.tickets.map(item => item.id).filter(id => run.tickets.includes(id)));
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1905
1923
|
if (!preview.tickets.length)
|
|
1906
1924
|
return;
|
|
1907
1925
|
const explicitlyConfirmed = Boolean(opts.yes && (ticketId || opts.scope));
|
package/dist/foreman.js
CHANGED
|
@@ -151,7 +151,7 @@ export function buildPrimer(n, trackerPath, ticketsEnabled = false, preferredTic
|
|
|
151
151
|
Rules:
|
|
152
152
|
- Do exactly ONE ticket or step this turn, then stop.
|
|
153
153
|
- ${MARKER_SPEC}
|
|
154
|
-
- If a tool action is denied by foreman policy, do not retry
|
|
154
|
+
- If a tool action is denied by foreman policy, do not retry the denied form; report it via the blocked marker. The one exception is a required test that you changed by adding a pipe, redirection, substitution, or wrapper: retry the exact required-test command without that extra shell syntax before reporting it blocked.${trackerRule}${preferredTicketRule}
|
|
155
155
|
|
|
156
156
|
This is step 1 of ${n}. Implement the next ticket or step now.`;
|
|
157
157
|
}
|
|
@@ -170,6 +170,8 @@ export function buildQaInstruction() {
|
|
|
170
170
|
|
|
171
171
|
Triple-check. Do not rubber-stamp your own work. Be skeptical.
|
|
172
172
|
|
|
173
|
+
If an otherwise required test is denied only because you added a pipe, redirection, substitution, or wrapper, retry the exact required-test command without that extra shell syntax. Do not retry other denied actions.
|
|
174
|
+
|
|
173
175
|
If everything is solid, end with STEP_STATUS: qa_pass.
|
|
174
176
|
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.
|
|
175
177
|
|
package/dist/handoffs.js
CHANGED
|
@@ -326,26 +326,29 @@ export class HandoffService {
|
|
|
326
326
|
return response;
|
|
327
327
|
};
|
|
328
328
|
const acceptancePrompt = [
|
|
329
|
+
handoffAcceptanceInstruction(),
|
|
329
330
|
"Accept this validated Rafi handoff. Do not repeat completed work and reconcile host receipts before side effects.",
|
|
330
331
|
staged.markdown,
|
|
331
332
|
`Manifest JSON: ${JSON.stringify(staged.manifest)}`,
|
|
332
333
|
...(options.guidance ? [`Human recovery guidance: ${options.guidance}`] : []),
|
|
333
|
-
|
|
334
|
+
handoffAcceptanceInstruction(),
|
|
334
335
|
].join("\n\n");
|
|
335
336
|
let response = await sendAcceptance(acceptancePrompt);
|
|
336
|
-
let
|
|
337
|
+
let acceptanceText = response.rawResponse ?? response.text;
|
|
338
|
+
let validation = validateHandoffAcceptance(staged, successor, acceptanceText);
|
|
337
339
|
if (validation && (validation.code === "missing-acknowledgement" || validation.code === "invalid-continuity-delta")) {
|
|
338
340
|
const repairPrompt = [
|
|
339
341
|
`Your handoff acknowledgement was rejected: ${validation.message}.`,
|
|
340
342
|
"Correction only: do not use tools, repeat completed work, or perform implementation.",
|
|
341
|
-
|
|
343
|
+
handoffAcceptanceInstruction(),
|
|
342
344
|
].join("\n\n");
|
|
343
345
|
response = await sendAcceptance(repairPrompt);
|
|
344
|
-
|
|
346
|
+
acceptanceText = response.rawResponse ?? response.text;
|
|
347
|
+
validation = validateHandoffAcceptance(staged, successor, acceptanceText);
|
|
345
348
|
}
|
|
346
349
|
if (validation)
|
|
347
350
|
throw new HandoffAcceptanceError(validation.code, staged.manifest.runId, staged.manifest.generation, validation.message);
|
|
348
|
-
const parsed = parseContinuityDelta(
|
|
351
|
+
const parsed = parseContinuityDelta(acceptanceText);
|
|
349
352
|
const sessionId = successor.sessionId();
|
|
350
353
|
const observedSuccessorRef = successor.sessionRef?.();
|
|
351
354
|
const successorRef = observedSuccessorRef ? { ...observedSuccessorRef, generation: staged.manifest.generation, validatedAt: new Date().toISOString() } : undefined;
|
|
@@ -513,6 +516,15 @@ function validateHandoffAcceptance(staged, successor, text) {
|
|
|
513
516
|
}
|
|
514
517
|
return undefined;
|
|
515
518
|
}
|
|
519
|
+
/** Keep the small acknowledgement protocol visible above and after a large handoff packet. */
|
|
520
|
+
function handoffAcceptanceInstruction() {
|
|
521
|
+
return [
|
|
522
|
+
"Mandatory response protocol: do not call tools or add a preface, explanation, or Markdown fence.",
|
|
523
|
+
`Your first non-whitespace line must be exactly ${HANDOFF_ACCEPTED}.`,
|
|
524
|
+
"After it, emit exactly one RAFI_CONTINUITY_DELTA record as instructed below.",
|
|
525
|
+
continuityInstruction(),
|
|
526
|
+
].join("\n");
|
|
527
|
+
}
|
|
516
528
|
async function promptHandoffRecovery(error, runtime, allowProviderSwitch) {
|
|
517
529
|
const { select, isCancel } = await import("@clack/prompts");
|
|
518
530
|
return pauseActivityForInput(async () => {
|
|
@@ -64,6 +64,14 @@ export interface DeliveryUnitProgress {
|
|
|
64
64
|
export declare function deliveryProgress(config: DeliveryConfig, states: Map<string, TicketState>): DeliveryUnitProgress[];
|
|
65
65
|
export declare function formatDeliverySummary(config: DeliveryConfig, states: Map<string, TicketState>): string[];
|
|
66
66
|
export declare function selectDeliveryUnitForRun(config: DeliveryConfig, states: Map<string, TicketState>, skipUnitIds?: readonly string[]): DeliveryUnitProgress | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Select the tickets a normal `--steps N` run may admit from delivery units.
|
|
69
|
+
*
|
|
70
|
+
* A delivery unit remains the unit of resume/branch semantics. A multi-unit
|
|
71
|
+
* normal run may contain only per-ticket units: shared and current-branch
|
|
72
|
+
* units have unit-level branch semantics, so they are hard boundaries.
|
|
73
|
+
*/
|
|
74
|
+
export declare function selectDeliveryTicketsForRun(config: DeliveryConfig, states: Map<string, TicketState>, steps: number, skipUnitIds?: readonly string[]): string[];
|
|
67
75
|
export interface StackBatchSelection {
|
|
68
76
|
stacks: DeliveryStack[];
|
|
69
77
|
tickets: string[];
|
package/dist/tickets/delivery.js
CHANGED
|
@@ -283,6 +283,33 @@ export function selectDeliveryUnitForRun(config, states, skipUnitIds = []) {
|
|
|
283
283
|
const progress = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
|
|
284
284
|
return progress.find((item) => item.state === "resume") ?? progress.find((item) => item.state === "ready");
|
|
285
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Select the tickets a normal `--steps N` run may admit from delivery units.
|
|
288
|
+
*
|
|
289
|
+
* A delivery unit remains the unit of resume/branch semantics. A multi-unit
|
|
290
|
+
* normal run may contain only per-ticket units: shared and current-branch
|
|
291
|
+
* units have unit-level branch semantics, so they are hard boundaries.
|
|
292
|
+
*/
|
|
293
|
+
export function selectDeliveryTicketsForRun(config, states, steps, skipUnitIds = []) {
|
|
294
|
+
const skipped = new Set(skipUnitIds);
|
|
295
|
+
const eligible = deliveryProgress(config, states).filter((item) => !skipped.has(item.unit.id));
|
|
296
|
+
const ordered = [...eligible.filter((item) => item.state === "resume"), ...eligible.filter((item) => item.state === "ready")];
|
|
297
|
+
const selected = [];
|
|
298
|
+
for (const item of ordered) {
|
|
299
|
+
if (selected.length >= steps)
|
|
300
|
+
break;
|
|
301
|
+
// Do not add a unit with different branch semantics to an existing batch.
|
|
302
|
+
// If it is the first eligible unit, it is still valid to select work from
|
|
303
|
+
// that one unit; the caller preserves its unit semantics.
|
|
304
|
+
if (selected.length > 0 && item.unit.branch_mode !== "per-ticket")
|
|
305
|
+
break;
|
|
306
|
+
const available = item.remaining.slice(0, steps - selected.length);
|
|
307
|
+
selected.push(...available);
|
|
308
|
+
if (item.unit.branch_mode !== "per-ticket")
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
return selected;
|
|
312
|
+
}
|
|
286
313
|
/** Complete preflight simulation for exact `--stacks N` selection. */
|
|
287
314
|
export function selectStacksForRun(config, ticketDefs, states, count) {
|
|
288
315
|
if (!Number.isInteger(count) || count < 1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.25",
|
|
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",
|
|
@@ -308,7 +308,7 @@
|
|
|
308
308
|
"parse5": "7.2.1",
|
|
309
309
|
"pdfjs-dist": "4.10.38",
|
|
310
310
|
"rafi-spec": "0.8.15",
|
|
311
|
-
"special-agents": "0.8.
|
|
311
|
+
"special-agents": "0.8.15",
|
|
312
312
|
"yaml": "^2.5.1"
|
|
313
313
|
},
|
|
314
314
|
"devDependencies": {
|