ai-foreman 1.7.24 → 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.
@@ -2,7 +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 { ProviderSessionRefV1 } from "rafi-spec";
5
+ import type { BuildRunRecordV2, ProviderSessionRefV1 } from "rafi-spec";
6
6
  /** Work responses confirm an assignment; they never select the next ticket. */
7
7
  export declare function validateBuildAssignment(ticketId: string, status: StepStatus, responseText?: string): string | undefined;
8
8
  export interface BuildAssignmentCapture {
@@ -20,6 +20,8 @@ type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDi
20
20
  };
21
21
  export declare class BuildAssignmentRejected extends Error {
22
22
  }
23
+ /** A frozen exact-session Builder decision may defer unrelated pending QA. */
24
+ export declare function isInterruptedBuilderRecovery(run: BuildRunRecordV2 | undefined): boolean;
23
25
  /**
24
26
  * Retire only the host-side records for a Builder turn that was interrupted
25
27
  * before Rafi received a durable response. The next turn is sent to the
@@ -32,6 +34,9 @@ export declare function prepareExactSessionBuilderContinuation(projectDir: strin
32
34
  dispatchIds: string[];
33
35
  assignmentIds: string[];
34
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;
35
40
  export interface BuilderGuidanceFollowup {
36
41
  reviewAttemptId: string;
37
42
  requirementsDigest: string;
@@ -26,6 +26,18 @@ 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
+ }
29
41
  /**
30
42
  * Retire only the host-side records for a Builder turn that was interrupted
31
43
  * before Rafi received a durable response. The next turn is sent to the
@@ -78,12 +90,62 @@ export function prepareExactSessionBuilderContinuation(projectDir, runId, sessio
78
90
  db.close();
79
91
  }
80
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
+ }
81
141
  function sameSession(value, session) {
82
142
  if (!value || typeof value !== "object")
83
143
  return false;
84
144
  const candidate = value;
85
145
  return candidate.provider === session.provider
86
146
  && candidate.sessionId === session.sessionId
147
+ && candidate.generation === session.generation
148
+ && session.role === "builder" && session.stream === "builder"
87
149
  && candidate.role === "builder"
88
150
  && candidate.stream === "builder"
89
151
  && candidate.cwd === session.cwd
@@ -93,9 +155,23 @@ export function assertBuildAssignmentReconciled(projectDir, runId, worktree = pr
93
155
  const reader = new WorkflowReader(projectDir);
94
156
  try {
95
157
  const runs = [...new Set([runId, ...reader.buildRuns(true).map(run => run.runId)])];
158
+ const retiredRuns = new Set();
96
159
  for (const operation of runs.flatMap(id => reader.operations(id, true))) {
97
160
  if (operation.kind !== "build-assignment")
98
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
+ }
99
175
  const result = operation.result;
100
176
  if (result?.reconciliationId)
101
177
  continue;
@@ -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(): Command;
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";
@@ -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)
@@ -1985,9 +1989,9 @@ export function buildStartCommand() {
1985
1989
  for (const node of plan.nodes)
1986
1990
  admitApprovedTicket(cwd, masterRun.runId, node.ticket.id, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved branch plan`);
1987
1991
  masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities });
1988
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
1992
+ const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
1989
1993
  let qaProtocolResumeTicket;
1990
- if (recoveryRecord && !qaResumedRecovery) {
1994
+ if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
1991
1995
  const protocolDb = new WorkflowDb(cwd);
1992
1996
  try {
1993
1997
  const pendingHeads = protocolDb.pendingQaTicketHeads(masterRun.runId);
@@ -2300,10 +2304,10 @@ export function buildStartCommand() {
2300
2304
  buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
2301
2305
  activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
2302
2306
  activeContinuityRunId = buildRun.runId;
2303
- const qaResumedRecovery = recoveryRecord ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2307
+ const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, buildRun.runId, requestedTicketIds[0]) : undefined;
2304
2308
  let qaFinalizationTicket;
2305
2309
  let qaProtocolResumeTicket;
2306
- if (recoveryRecord && !qaResumedRecovery) {
2310
+ if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
2307
2311
  const finalizationDb = new WorkflowDb(cwd);
2308
2312
  try {
2309
2313
  const pendingHeads = finalizationDb.pendingQaTicketHeads(buildRun.runId);
@@ -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 {};
@@ -1843,7 +1843,7 @@ export function buildTicketsCommand(options = {}) {
1843
1843
  });
1844
1844
  tickets
1845
1845
  .command("reset [ticketId]")
1846
- .description("Reset one ticket or an explicit ticket scope to pristine active state while retaining history.")
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(" ! Matching build/recovery metadata is not deleted. Use `rafi build:start-over` for a coherent whole-run restart.");
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.24",
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.14",
311
+ "special-agents": "0.8.15",
312
312
  "yaml": "^2.5.1"
313
313
  },
314
314
  "devDependencies": {