ai-foreman 1.7.24 → 1.7.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/adapters/claude.d.ts +15 -0
- package/dist/adapters/claude.js +149 -28
- package/dist/adapters/codex.d.ts +9 -0
- package/dist/adapters/codex.js +77 -8
- package/dist/adapters/fastMode.d.ts +4 -0
- package/dist/adapters/fastMode.js +9 -0
- package/dist/adapters/types.d.ts +2 -0
- package/dist/agentRun.js +1 -0
- package/dist/branch/runner.js +46 -44
- package/dist/buildAssignment.d.ts +21 -1
- package/dist/buildAssignment.js +215 -2
- package/dist/buildRuns.d.ts +6 -0
- package/dist/buildRuns.js +73 -7
- package/dist/cli/start.d.ts +8 -1
- package/dist/cli/start.js +132 -38
- package/dist/cli/tickets.d.ts +1 -0
- package/dist/cli/tickets.js +20 -2
- package/dist/continuity.js +11 -10
- package/dist/foreman.d.ts +8 -0
- package/dist/foreman.js +69 -10
- package/dist/handoffProtocol.d.ts +4 -0
- package/dist/handoffProtocol.js +19 -0
- package/dist/handoffs.d.ts +1 -1
- package/dist/handoffs.js +23 -4
- package/dist/permissions/policy.js +18 -11
- package/dist/permissions/shellForms.d.ts +3 -0
- package/dist/permissions/shellForms.js +203 -0
- package/dist/qaBuilderActivation.d.ts +19 -0
- package/dist/qaBuilderActivation.js +112 -0
- package/dist/qaFailureDelivery.js +1 -1
- package/dist/qaRecovery.js +2 -1
- package/dist/qaReview.d.ts +4 -0
- package/dist/qaReview.js +37 -7
- package/dist/sessionLifecycle.js +1 -1
- package/dist/supervisedStart.js +23 -3
- package/dist/supervisor.js +33 -0
- package/dist/tickets/groupReset.js +100 -96
- package/dist/tickets/reset.js +80 -69
- package/dist/workflowDb.d.ts +16 -1
- package/dist/workflowDb.js +276 -8
- package/package.json +2 -2
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep, dirname, basename } from "node:path";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { canonicalSessionPath } from "../sessionIdentity.js";
|
|
4
|
+
/** Intentionally bounded shell grammar. No expansion, background jobs,
|
|
5
|
+
* conditionals, substitutions, or filesystem redirects are normalized. */
|
|
6
|
+
function segments(command) {
|
|
7
|
+
const result = [];
|
|
8
|
+
let words = [], word = "", started = false;
|
|
9
|
+
let quote;
|
|
10
|
+
let followsPipe = false;
|
|
11
|
+
let hadBackslash = false;
|
|
12
|
+
const flush = () => { if (started)
|
|
13
|
+
words.push(word); word = ""; started = false; };
|
|
14
|
+
const finish = (pipe) => {
|
|
15
|
+
flush();
|
|
16
|
+
if (!words.length)
|
|
17
|
+
throw new Error("empty shell command segment");
|
|
18
|
+
result.push({ words, followsPipe, endsPipe: pipe, hadBackslash });
|
|
19
|
+
words = [];
|
|
20
|
+
followsPipe = pipe;
|
|
21
|
+
hadBackslash = false;
|
|
22
|
+
};
|
|
23
|
+
for (let i = 0; i < command.length; i++) {
|
|
24
|
+
const char = command[i];
|
|
25
|
+
if (char === "\\")
|
|
26
|
+
hadBackslash = true;
|
|
27
|
+
if (char === "\n" || char === "\r")
|
|
28
|
+
throw new Error("multiline shell syntax needs approval");
|
|
29
|
+
if (char === "\\" && quote !== "'") {
|
|
30
|
+
const next = command[++i];
|
|
31
|
+
if (!next || next === "\n" || next === "\r")
|
|
32
|
+
throw new Error("unsupported shell escape");
|
|
33
|
+
word += next;
|
|
34
|
+
started = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (quote) {
|
|
38
|
+
if (char === quote)
|
|
39
|
+
quote = undefined;
|
|
40
|
+
else {
|
|
41
|
+
if (quote === '"' && (char === "$" || char === "`"))
|
|
42
|
+
throw new Error("shell expansion needs approval");
|
|
43
|
+
word += char;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (char === "'" || char === '"') {
|
|
48
|
+
quote = char;
|
|
49
|
+
started = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (/\s/.test(char)) {
|
|
53
|
+
flush();
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!started && command.slice(i, i + 4) === "2>&1" && (i + 4 === command.length || /\s|[&|;]/.test(command[i + 4]))) {
|
|
57
|
+
i += 3;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (command.slice(i, i + 2) === "&&") {
|
|
61
|
+
finish(false);
|
|
62
|
+
i++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (char === "|" && command[i + 1] === "|")
|
|
66
|
+
throw new Error("shell conditionals need approval");
|
|
67
|
+
if (char === "|") {
|
|
68
|
+
finish(true);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/[&;<>$`(){}~#]/.test(char))
|
|
72
|
+
throw new Error("unsupported shell operator or expansion needs approval");
|
|
73
|
+
word += char;
|
|
74
|
+
started = true;
|
|
75
|
+
}
|
|
76
|
+
if (quote)
|
|
77
|
+
throw new Error("unterminated shell quote");
|
|
78
|
+
finish(false);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
export function canonicalToolPath(path) {
|
|
82
|
+
let ancestor = resolve(path);
|
|
83
|
+
const missing = [];
|
|
84
|
+
while (!existsSync(ancestor) && dirname(ancestor) !== ancestor) {
|
|
85
|
+
missing.unshift(basename(ancestor));
|
|
86
|
+
ancestor = dirname(ancestor);
|
|
87
|
+
}
|
|
88
|
+
return resolve(canonicalSessionPath(ancestor), ...missing);
|
|
89
|
+
}
|
|
90
|
+
export function isWithinProject(root, path) {
|
|
91
|
+
const rel = relative(canonicalToolPath(root), canonicalToolPath(path));
|
|
92
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
93
|
+
}
|
|
94
|
+
const inside = isWithinProject;
|
|
95
|
+
function validateRestrictedNodeTests(words, root, cwd) {
|
|
96
|
+
if (words[1] !== "--test")
|
|
97
|
+
return "QA permits Node's test runner, not arbitrary Node execution";
|
|
98
|
+
let files = 0, literalFiles = false;
|
|
99
|
+
for (const arg of words.slice(2)) {
|
|
100
|
+
if (!literalFiles && arg === "--") {
|
|
101
|
+
literalFiles = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!literalFiles && arg.startsWith("-")) {
|
|
105
|
+
if (/^--test-name-pattern=.+$/.test(arg) || /^--test-reporter=(?:tap|spec|dot)$/.test(arg))
|
|
106
|
+
continue;
|
|
107
|
+
if (/^--test-concurrency=\d{1,2}$/.test(arg) && Number(arg.split("=")[1]) >= 1 && Number(arg.split("=")[1]) <= 32)
|
|
108
|
+
continue;
|
|
109
|
+
return "unapproved Node test option; use local test files without eval, preload, loader or output controls";
|
|
110
|
+
}
|
|
111
|
+
if (!/\.test\.(?:mjs|cjs|js|mts|cts|ts)$/.test(arg))
|
|
112
|
+
return "use explicit worktree-local .test files";
|
|
113
|
+
const path = resolve(cwd, arg), directory = dirname(path), name = basename(path);
|
|
114
|
+
if (!inside(root, directory) || /[?\[\]{}*]/.test(dirname(arg)))
|
|
115
|
+
return "Node test path escapes the worktree or uses an unsupported directory glob";
|
|
116
|
+
if (name.includes("*")) {
|
|
117
|
+
if (/[?\[\]{}]/.test(name))
|
|
118
|
+
return "unsupported Node test glob";
|
|
119
|
+
const pattern = new RegExp(`^${name.split("*").map(part => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
|
|
120
|
+
try {
|
|
121
|
+
if (existsSync(directory) && readdirSync(directory).some(entry => pattern.test(entry) && !inside(root, resolve(directory, entry))))
|
|
122
|
+
return "Node test glob resolves outside the worktree";
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "Node test directory inventory is unavailable; inspect the local path before retrying";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (!inside(root, path))
|
|
129
|
+
return "Node test path resolves outside the worktree";
|
|
130
|
+
files++;
|
|
131
|
+
}
|
|
132
|
+
return files ? undefined : "use explicit worktree-local .test files";
|
|
133
|
+
}
|
|
134
|
+
export function classifySupportedShellForm(command, root, initialCwd, allowBash) {
|
|
135
|
+
if (!inside(root, initialCwd))
|
|
136
|
+
return "tool cwd is outside the worktree";
|
|
137
|
+
let cwd = initialCwd;
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = segments(command);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return error.message;
|
|
144
|
+
}
|
|
145
|
+
for (const segment of parsed) {
|
|
146
|
+
const words = [...segment.words];
|
|
147
|
+
if (words[0] === "cd") {
|
|
148
|
+
if (words.length !== 2 || segment.followsPipe || segment.endsPipe)
|
|
149
|
+
return "only a literal in-worktree cd followed by && is supported";
|
|
150
|
+
const target = resolve(cwd, words[1]);
|
|
151
|
+
if (!inside(root, target))
|
|
152
|
+
return "directory change escapes the worktree";
|
|
153
|
+
cwd = target;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
while (/^[A-Za-z_]\w*=/.test(words[0] ?? "")) {
|
|
157
|
+
const assignment = words.shift();
|
|
158
|
+
const split = assignment.indexOf("=");
|
|
159
|
+
const key = assignment.slice(0, split), value = assignment.slice(split + 1);
|
|
160
|
+
const simple = /^(?:CI|NO_COLOR|FORCE_COLOR|PYTHONUNBUFFERED|PYTHONDONTWRITEBYTECODE|PYTEST_DISABLE_PLUGIN_AUTOLOAD)$/;
|
|
161
|
+
if (simple.test(key) && /^(?:0|1|true|false)$/.test(value))
|
|
162
|
+
continue;
|
|
163
|
+
if ((key === "PYTHONPATH" || key === "UV_PROJECT_ENVIRONMENT") && value && value.split(process.platform === "win32" ? ";" : ":").every(path => path && inside(root, resolve(cwd, path))))
|
|
164
|
+
continue;
|
|
165
|
+
return `environment assignment ${key} needs approval; use a literal project-local test environment`;
|
|
166
|
+
}
|
|
167
|
+
if (!words.length)
|
|
168
|
+
return "unsupported shell executable";
|
|
169
|
+
// Offline/no-sync uv is only an environment runner for an already allowed
|
|
170
|
+
// command. Ordinary uv acquisition still requires its configured policy.
|
|
171
|
+
if (words.slice(0, 4).join(" ") === "uv run --offline --no-sync")
|
|
172
|
+
words.splice(0, 4);
|
|
173
|
+
const executable = words[0] ?? "";
|
|
174
|
+
if (/(?:^|[/\\])(?:python3?|pytest)$/.test(executable) && executable.includes("/")) {
|
|
175
|
+
const path = resolve(cwd, executable);
|
|
176
|
+
// POSIX virtualenvs normally symlink their Python binary to the native
|
|
177
|
+
// interpreter. Keep the containing environment inside the worktree and
|
|
178
|
+
// allow only that interpreter alias, never a directory escape or a
|
|
179
|
+
// Python-named symlink to an unrelated executable.
|
|
180
|
+
const nativePythonAlias = /^python(?:3)?$/.test(basename(executable))
|
|
181
|
+
&& /^python(?:\d+(?:\.\d+)*)?$/i.test(basename(canonicalSessionPath(path)))
|
|
182
|
+
&& inside(root, dirname(path));
|
|
183
|
+
if (inside(root, path) || nativePythonAlias)
|
|
184
|
+
words[0] = executable.split(/[/\\]/).at(-1);
|
|
185
|
+
}
|
|
186
|
+
if (!words.length || /\s/.test(words[0]))
|
|
187
|
+
return "unsupported shell executable";
|
|
188
|
+
if (words[0] === "node" && allowBash.includes("node --test") && !allowBash.includes("node ") && !allowBash.includes("node")) {
|
|
189
|
+
const invalid = validateRestrictedNodeTests(words, root, cwd);
|
|
190
|
+
if (process.platform === "win32" && segment.hadBackslash)
|
|
191
|
+
return "restricted Node tests require forward-slash paths on Windows";
|
|
192
|
+
if (invalid)
|
|
193
|
+
return invalid;
|
|
194
|
+
}
|
|
195
|
+
const allowed = allowBash.some(prefix => {
|
|
196
|
+
const expected = prefix.trim().split(/\s+/);
|
|
197
|
+
return expected.every((word, index) => words[index] === word) && (words.length > expected.length || !prefix.endsWith(" "));
|
|
198
|
+
});
|
|
199
|
+
if (!allowed)
|
|
200
|
+
return `command is not allow-listed: ${words.join(" ")}; use a configured project test command`;
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { BuildRunRecordV2, ResolvedAgentSettings } from "rafi-spec";
|
|
2
|
+
import type { BuilderAdapter } from "./adapters/types.js";
|
|
3
|
+
import { type HandoffRecoveryOptions, type StagedHandoff } from "./handoffs.js";
|
|
4
|
+
export interface QaRecoveryBuilderActivation {
|
|
5
|
+
projectDir: string;
|
|
6
|
+
runId: string;
|
|
7
|
+
ticketId: string;
|
|
8
|
+
worktree: string;
|
|
9
|
+
adapter: BuilderAdapter;
|
|
10
|
+
settings: ResolvedAgentSettings;
|
|
11
|
+
expectedCapabilities?: BuildRunRecordV2["builderCapabilities"];
|
|
12
|
+
frozenAction: string;
|
|
13
|
+
createSuccessor: (handoff: StagedHandoff, requestedRuntime?: "claude" | "codex") => Promise<BuilderAdapter>;
|
|
14
|
+
wrap: (adapter: BuilderAdapter) => BuilderAdapter | Promise<BuilderAdapter>;
|
|
15
|
+
recovery?: HandoffRecoveryOptions;
|
|
16
|
+
}
|
|
17
|
+
/** QA-only startup must not replace Builder ownership. Activate a Builder only
|
|
18
|
+
* at an authorized work boundary, through the ordinary validated handoff. */
|
|
19
|
+
export declare function activateQaRecoveryBuilder(input: QaRecoveryBuilderActivation): Promise<BuilderAdapter>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { assertBuildAssignmentReconciled, captureBuildSource } from "./buildAssignment.js";
|
|
4
|
+
import { HandoffService, HandoffRecoveryPausedError } from "./handoffs.js";
|
|
5
|
+
import { qaDigest } from "./qaProtocolV2.js";
|
|
6
|
+
import { providerSessionKey } from "./sessionIdentity.js";
|
|
7
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
8
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
9
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
10
|
+
/** QA-only startup must not replace Builder ownership. Activate a Builder only
|
|
11
|
+
* at an authorized work boundary, through the ordinary validated handoff. */
|
|
12
|
+
export async function activateQaRecoveryBuilder(input) {
|
|
13
|
+
const inspect = () => {
|
|
14
|
+
const db = new WorkflowDb(input.projectDir);
|
|
15
|
+
try {
|
|
16
|
+
const lease = db.currentLease();
|
|
17
|
+
if (lease?.runId !== input.runId || lease.pid !== process.pid)
|
|
18
|
+
throw new Error("Builder activation requires the current build execution lease");
|
|
19
|
+
const run = db.getRun(input.runId)?.state;
|
|
20
|
+
if (!run?.tickets.includes(input.ticketId) || run.progress.completedTickets.includes(input.ticketId))
|
|
21
|
+
throw new Error("QA recovery cannot activate Builder work outside unfinished approved scope");
|
|
22
|
+
const admission = db.assertAdmittedWork(input.runId, input.ticketId);
|
|
23
|
+
const definition = loadTickets(resolveTicketPaths(loadTicketsConfig(input.projectDir), input.projectDir).tickets).find(ticket => ticket.id === input.ticketId);
|
|
24
|
+
if (!definition || qaDigest("admitted-requirements", definition) !== admission.requirementsDigest)
|
|
25
|
+
throw new Error("Builder recovery requirements changed; renew scope approval before implementation");
|
|
26
|
+
if (!run.builder || qaDigest("builder-settings", run.builder.settings) !== qaDigest("builder-settings", input.settings))
|
|
27
|
+
throw new Error("QA recovery cannot replace the saved Builder settings");
|
|
28
|
+
if (input.expectedCapabilities && qaDigest("builder-capabilities", run.builderCapabilities) !== qaDigest("builder-capabilities", input.expectedCapabilities))
|
|
29
|
+
throw new Error("Builder capability approval changed before recovery activation");
|
|
30
|
+
if (db.unresolvedRoleDispatches(input.runId, "builder").length)
|
|
31
|
+
throw new Error("Reconcile unknown Builder dispatch before activating another session");
|
|
32
|
+
assertBuildAssignmentReconciled(input.projectDir, input.runId, input.worktree);
|
|
33
|
+
const owner = db.roleMutationLease(input.runId, "builder");
|
|
34
|
+
const ref = owner?.sessionRef;
|
|
35
|
+
if (!owner || !Number.isSafeInteger(owner.generation) || owner.generation < 0 || !ref || ref.version !== 1 || ref.role !== "builder" || ref.stream !== "builder"
|
|
36
|
+
|| !Number.isSafeInteger(ref.generation) || ref.generation < 0 || ref.sessionId !== owner.providerSessionId
|
|
37
|
+
|| ref.source !== "observed" || !ref.validatedAt || Number.isNaN(Date.parse(ref.validatedAt)) || providerSessionKey(ref) !== owner.sessionKey
|
|
38
|
+
|| realpathSync(ref.cwd) !== realpathSync(input.worktree) || realpathSync(ref.configRoot) !== realpathSync(input.projectDir))
|
|
39
|
+
throw new Error("QA recovery has no validated Builder owner for this worktree");
|
|
40
|
+
const branchSession = db.branchResumeSessions(false).find(session => session.ticket === input.ticketId && realpathSync(session.worktreePath) === realpathSync(input.worktree));
|
|
41
|
+
const branch = branchSession?.branch ?? (realpathSync(run.repository.worktree) === realpathSync(input.worktree) ? run.repository.branch : undefined);
|
|
42
|
+
if (!branch || execFileSync("git", ["branch", "--show-current"], { cwd: input.worktree, encoding: "utf8" }).trim() !== branch)
|
|
43
|
+
throw new Error("Return to the approved Builder worktree branch before recovery activation");
|
|
44
|
+
const head = db.continuityHead(input.runId, "builder");
|
|
45
|
+
if (!head || head.state !== "current" || !db.latestContinuityCheckpoint(input.runId, "builder"))
|
|
46
|
+
throw new Error("Builder continuity needs reconstruction before QA can request implementation");
|
|
47
|
+
return { owner, ref, head, source: captureBuildSource(input.worktree), requirementsDigest: admission.requirementsDigest,
|
|
48
|
+
capabilitiesDigest: qaDigest("builder-capabilities", run.builderCapabilities), compactionCount: db.successfulCompactionCount(input.runId, "builder", ref) };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const before = inspect();
|
|
55
|
+
const currentRef = input.adapter.sessionRef?.();
|
|
56
|
+
if (currentRef && providerSessionKey(currentRef) === before.owner.sessionKey && currentRef.generation === before.ref.generation
|
|
57
|
+
&& currentRef.role === "builder" && currentRef.stream === "builder" && realpathSync(currentRef.configRoot) === realpathSync(input.projectDir)
|
|
58
|
+
&& currentRef.source === "observed" && currentRef.validatedAt)
|
|
59
|
+
return input.adapter;
|
|
60
|
+
const assertPreparedState = (current) => {
|
|
61
|
+
if (current.owner.generation !== before.owner.generation || current.owner.sessionKey !== before.owner.sessionKey
|
|
62
|
+
|| current.head.digest !== before.head.digest || current.source.digest !== before.source.digest || current.capabilitiesDigest !== before.capabilitiesDigest)
|
|
63
|
+
throw new Error("Builder recovery state changed while preparing its handoff; inspect and retry");
|
|
64
|
+
};
|
|
65
|
+
const transfer = await new HandoffService(input.projectDir).transfer({
|
|
66
|
+
runId: input.runId, role: "builder", reason: "Authorized Builder work after QA-only recovery requires a validated Builder owner",
|
|
67
|
+
predecessorSessionId: before.ref.sessionId, predecessorSessionRef: before.ref,
|
|
68
|
+
compactionCount: before.compactionCount, compactMaximum: input.settings.compact_maximum ?? 10,
|
|
69
|
+
roleState: { ticketId: input.ticketId, requirementsDigest: before.requirementsDigest, sourceDigest: before.source.digest,
|
|
70
|
+
capabilitiesDigest: before.capabilitiesDigest, recovery: "qa-builder-activation", providerReplayAuthorized: false },
|
|
71
|
+
resources: [{ label: "frozen-action", content: input.frozenAction, authoritative: true, purpose: "Approved QA remediation or guidance action; no replay of historical work" }],
|
|
72
|
+
}, async (handoff, runtime) => {
|
|
73
|
+
assertPreparedState(inspect());
|
|
74
|
+
const successor = await input.createSuccessor(handoff, runtime);
|
|
75
|
+
try {
|
|
76
|
+
assertPreparedState(inspect());
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await successor.close().catch(() => { });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return successor;
|
|
83
|
+
}, input.recovery);
|
|
84
|
+
let owned;
|
|
85
|
+
try {
|
|
86
|
+
owned = await input.wrap(transfer.successor);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
let drainError;
|
|
90
|
+
try {
|
|
91
|
+
await transfer.successor.close();
|
|
92
|
+
}
|
|
93
|
+
catch (failure) {
|
|
94
|
+
drainError = failure;
|
|
95
|
+
}
|
|
96
|
+
throw new HandoffRecoveryPausedError(input.runId, transfer.manifest.generation, `Builder handoff was accepted but could not install its execution wrapper: ${String(error)}.${drainError ? ` The accepted session also needs cleanup: ${String(drainError)}.` : " The accepted session was closed without implementation."} The recorded accepted owner is preserved; inspect this build with rafi resume before continuing.`);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
await input.adapter.close();
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
let drainError;
|
|
103
|
+
try {
|
|
104
|
+
await owned.close();
|
|
105
|
+
}
|
|
106
|
+
catch (failure) {
|
|
107
|
+
drainError = failure;
|
|
108
|
+
}
|
|
109
|
+
throw new HandoffRecoveryPausedError(input.runId, transfer.manifest.generation, `Builder handoff was accepted, but the unused startup session could not close: ${String(error)}.${drainError ? ` The accepted session also needs cleanup: ${String(drainError)}.` : " The accepted session was closed without implementation."} Ownership remains with the recorded accepted session; reconcile cleanup with rafi resume before continuing.`);
|
|
110
|
+
}
|
|
111
|
+
return owned;
|
|
112
|
+
}
|
|
@@ -372,7 +372,7 @@ export class QaFailureDeliveryService {
|
|
|
372
372
|
Object.assign(record, { providerInstructionDigest: db.putEvidence("handoff", Buffer.from(turn.providerInstruction)), providerInstructionBytes: Buffer.byteLength(turn.providerInstruction), providerInstructionAvailability: "captured" });
|
|
373
373
|
}
|
|
374
374
|
record.eventEvidenceDigest = db.putEvidence("qa", Buffer.from(canonicalEvidence(events)));
|
|
375
|
-
record.toolCount = events.filter(e => e.kind === "tool" || e.kind === "provider-item" && !["agentMessage", "reasoning", "userMessage", "contextCompaction"].includes(e.itemType)).length;
|
|
375
|
+
record.toolCount = events.filter(e => e.kind === "tool" || e.kind === "provider-item" && !["text", "agentMessage", "reasoning", "userMessage", "contextCompaction"].includes(e.itemType)).length;
|
|
376
376
|
record.terminalCount = events.filter(e => e.kind === "turn-complete").length;
|
|
377
377
|
db.recordQaDeliveryTurn(record);
|
|
378
378
|
this.faults.afterResponseStored?.();
|
package/dist/qaRecovery.js
CHANGED
|
@@ -426,9 +426,10 @@ export function materializeQaRecoveryContext(packet, qaWorktree) {
|
|
|
426
426
|
} };
|
|
427
427
|
}
|
|
428
428
|
export function renderQaRecoveryAcknowledgementInstruction(packet, relativePath) {
|
|
429
|
-
const required = packet.manifest.resources.filter((resource) => resource.requiredForRecovery).map((resource) => `- path=${resource.path} purpose=${JSON.stringify(resource.purpose)} media_type=${resource.mediaType} bytes=${resource.bytes} digest=${resource.digest}`).join("\n");
|
|
429
|
+
const required = packet.manifest.resources.filter((resource) => resource.requiredForRecovery).map((resource) => `- read_path=${join(relativePath, resource.path)} packet_path=${resource.path} purpose=${JSON.stringify(resource.purpose)} media_type=${resource.mediaType} bytes=${resource.bytes} digest=${resource.digest}`).join("\n");
|
|
430
430
|
return [
|
|
431
431
|
"Read and verify every required recovery resource in this exact sealed packet revision before replying.",
|
|
432
|
+
"Use each exact read_path below within the current QA working directory. The normal read-only QA permissions apply; preserve the sealed packet and source.",
|
|
432
433
|
`Recovery context directory: ${relativePath}`,
|
|
433
434
|
`Packet ID: ${packet.manifest.packetId}`,
|
|
434
435
|
`Packet revision: ${packet.manifest.revision}`,
|
package/dist/qaReview.d.ts
CHANGED
|
@@ -208,6 +208,10 @@ export declare function runIsolatedQa(opts: IsolatedQaOptions): Promise<Isolated
|
|
|
208
208
|
export declare function buildQaReviewHandoff(ticket: TicketDef, builderSummary: string, diffDigest: string, validationChecklist: string[], changeSummary?: unknown, history?: QaReportHistoryEntry[]): string;
|
|
209
209
|
export declare function compactWithRetry(adapter: BuilderAdapter): Promise<CompactResult>;
|
|
210
210
|
export declare const QA_REPORT_RECOVERY_MENU: readonly ["Try a complete fresh QA review.", "Manually fix saved JSON for human inspection.", "Give QA specific instructions for a fresh review.", "Pause."];
|
|
211
|
+
/** Shared invariant for host staging and the actual QA boundary receipt. */
|
|
212
|
+
export declare function assertQaRecoveryHandoffInventory(resources: ReadonlyArray<{
|
|
213
|
+
label: string;
|
|
214
|
+
}>): void;
|
|
211
215
|
export declare function beginQaFinalization(projectDir: string, sourceWorktree: string, runId: string, ticketId: string, certificateId: string, expectedSourceStateDigest: string, consumer: string, allowedProjectionPaths?: string[]): Promise<void>;
|
|
212
216
|
export declare function verifyPendingQaFinalizationSource(projectDir: string, sourceWorktree: string, runId: string, ticketId: string, allowProjection?: boolean, projectionOperationId?: string): Promise<void>;
|
|
213
217
|
export declare function completeQaFinalization(projectDir: string, runId: string, ticketId: string): void;
|
package/dist/qaReview.js
CHANGED
|
@@ -200,8 +200,10 @@ async function runIsolatedQaOwned(opts) {
|
|
|
200
200
|
if (protocolHead.state === "source-frozen" || protocolHead.state === "review-ready") {
|
|
201
201
|
protocolHead = scopeDb.transitionQa(opts.recovery.runId, opts.ticket.id, protocolHead.revision, { type: "operator-menu" });
|
|
202
202
|
}
|
|
203
|
-
if (opts.resumedRecovery && protocolHead.state === "recheck-required"
|
|
203
|
+
if (opts.resumedRecovery && protocolHead.state === "recheck-required") {
|
|
204
204
|
opts.resumedRecovery = updateQaRecoveryPosition(opts.resumedRecovery, "qa-full-review", opts.resumedRecovery.manifest.correctionTurns, "qa-full-review");
|
|
205
|
+
if (scopeDb.hasConfirmedQaRemediationForRecheck(opts.recovery.runId, opts.ticket.id))
|
|
206
|
+
opts.resumedRecovery = undefined;
|
|
205
207
|
}
|
|
206
208
|
durableReviews = scopeDb.qaReviewAttempts(opts.recovery.runId, opts.ticket.id);
|
|
207
209
|
for (const interrupted of durableReviews.filter((item) => item.status === "started"))
|
|
@@ -324,6 +326,8 @@ async function runIsolatedQaOwned(opts) {
|
|
|
324
326
|
opts.qaRemediationAuthorization = undefined;
|
|
325
327
|
if (!fix.ok)
|
|
326
328
|
return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA remediation failed during restart recovery", fix.outcome === "needs-input" ? "needs-human" : "blocked");
|
|
329
|
+
if (!consumeRecoveryAfterOwnedFix(opts))
|
|
330
|
+
return pauseQaProtocol(opts, "builder-remediation-unconfirmed", "Builder remediation needs durable reconciliation before a fresh review", "needs-human");
|
|
327
331
|
const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? "Builder reported remediation complete");
|
|
328
332
|
opts.state.builderResponseHistory.push({ ticketId: opts.ticket.id, cycle: durableReport.reviewNumber, kind: "remediation", response: fix.response, summary: fixSummary });
|
|
329
333
|
opts.builderSummary = fix.response;
|
|
@@ -387,6 +391,8 @@ async function runIsolatedQaOwned(opts) {
|
|
|
387
391
|
opts.qaRemediationAuthorization = undefined;
|
|
388
392
|
if (!fix.ok)
|
|
389
393
|
return pauseQaProtocol(opts, "builder-remediation-failed", fix.detail ?? "Builder QA fix failed", fix.outcome === "needs-input" ? "needs-human" : "blocked");
|
|
394
|
+
if (!consumeRecoveryAfterOwnedFix(opts))
|
|
395
|
+
return pauseQaProtocol(opts, "builder-remediation-unconfirmed", "Builder remediation needs durable reconciliation before a fresh review", "needs-human");
|
|
390
396
|
const fixSummary = boundedBuilderSummary(fix.summary ?? fix.response ?? fix.detail ?? "Builder reported remediation complete");
|
|
391
397
|
opts.state.builderResponseHistory.push({
|
|
392
398
|
ticketId: opts.ticket.id,
|
|
@@ -1367,6 +1373,22 @@ function markQaRecoveryResolved(opts) {
|
|
|
1367
1373
|
db.close();
|
|
1368
1374
|
}
|
|
1369
1375
|
}
|
|
1376
|
+
function consumeRecoveryAfterOwnedFix(opts) {
|
|
1377
|
+
if (!opts.resumedRecovery)
|
|
1378
|
+
return true;
|
|
1379
|
+
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
1380
|
+
try {
|
|
1381
|
+
if (!db.hasConfirmedQaRemediationForRecheck(opts.recovery.runId, opts.ticket.id))
|
|
1382
|
+
return false;
|
|
1383
|
+
// The validated report led to confirmed source-changing implementation.
|
|
1384
|
+
// Its recovery packet remains durable history, not the next review basis.
|
|
1385
|
+
opts.resumedRecovery = undefined;
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
finally {
|
|
1389
|
+
db.close();
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1370
1392
|
function markQaRecoveryPendingBuilder(opts) {
|
|
1371
1393
|
markQaRecoveryAction(opts, "builder-remediation-pending");
|
|
1372
1394
|
}
|
|
@@ -1542,10 +1564,7 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
|
|
|
1542
1564
|
|| resource.authoritative !== true || typeof resource.requiredForRecovery !== "boolean")) {
|
|
1543
1565
|
throw new Error("QA recovery acceptance receipt contains invalid resource metadata");
|
|
1544
1566
|
}
|
|
1545
|
-
|
|
1546
|
-
if (!receipt.resources.some((resource) => resource.label === required))
|
|
1547
|
-
throw new Error(`QA recovery acceptance receipt is missing ${required}`);
|
|
1548
|
-
}
|
|
1567
|
+
assertQaRecoveryHandoffInventory(receipt.resources);
|
|
1549
1568
|
if (receipt.inventoryDigest !== qaDigest("handoff-inventory", receipt.resources))
|
|
1550
1569
|
throw new Error("QA recovery acceptance receipt inventory digest mismatch");
|
|
1551
1570
|
const receiptDb = new WorkflowDb(packet.projectDir);
|
|
@@ -1566,6 +1585,13 @@ function validateBoundaryReceipt(receipt, packet, successorHandle) {
|
|
|
1566
1585
|
}
|
|
1567
1586
|
}
|
|
1568
1587
|
}
|
|
1588
|
+
/** Shared invariant for host staging and the actual QA boundary receipt. */
|
|
1589
|
+
export function assertQaRecoveryHandoffInventory(resources) {
|
|
1590
|
+
for (const required of ["continuity-checkpoint", "authoritative-run-state", "frozen-qa-action"]) {
|
|
1591
|
+
if (!resources.some(resource => resource.label === required))
|
|
1592
|
+
throw new Error(`QA recovery acceptance receipt is missing ${required}`);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1569
1595
|
function effectiveTurnText(turn) { return turn.cleanedResponse ?? turn.text; }
|
|
1570
1596
|
const qaTurnBindings = new WeakMap();
|
|
1571
1597
|
function beginV2Review(opts, source, reviewedSnapshotPath, instruction, handle, attempt) {
|
|
@@ -1769,8 +1795,12 @@ async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
|
|
|
1769
1795
|
}
|
|
1770
1796
|
let result;
|
|
1771
1797
|
const providerStartedAt = Date.now();
|
|
1798
|
+
// Reading the sealed QA packet is ordinary confined read-only QA work.
|
|
1799
|
+
// Only formatting repair and ownership acceptance are response-only; the
|
|
1800
|
+
// first resource-inspection turn cannot truthfully read with tools disabled.
|
|
1801
|
+
const responseRepair = slot?.includes("correction") || (slot?.includes("acknowledgement") && slot.includes("repair"));
|
|
1772
1802
|
try {
|
|
1773
|
-
result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } :
|
|
1803
|
+
result = await adapter.sendTurn(instruction, slot === "session-initialization" ? { purpose: "initialization", responseOnly: true } : responseRepair ? { purpose: "response-repair", responseOnly: true } : undefined);
|
|
1774
1804
|
}
|
|
1775
1805
|
catch (error) {
|
|
1776
1806
|
const failedDb = new WorkflowDb(binding.projectDir);
|
|
@@ -1827,7 +1857,7 @@ async function sendDurableQaTurnOwned(adapter, instruction, events, slot) {
|
|
|
1827
1857
|
throw new Error(detail);
|
|
1828
1858
|
}
|
|
1829
1859
|
const completedAt = new Date().toISOString();
|
|
1830
|
-
const terminalEventObserved = Boolean(result.turnId
|
|
1860
|
+
const terminalEventObserved = Boolean(result.turnId && result.failure?.dispatchState !== "unknown"
|
|
1831
1861
|
&& events.some((event) => event.kind === "turn-complete" && (event.turnId ?? event.result.turnId) === result.turnId));
|
|
1832
1862
|
const receiptDb = new WorkflowDb(binding.projectDir);
|
|
1833
1863
|
try {
|
package/dist/sessionLifecycle.js
CHANGED
|
@@ -768,7 +768,7 @@ export class ThresholdCompactionController {
|
|
|
768
768
|
handoffGeneration() {
|
|
769
769
|
const db = new WorkflowDb(this.options.projectDir);
|
|
770
770
|
try {
|
|
771
|
-
return db.
|
|
771
|
+
return db.roleMutationLease(this.options.runId, this.options.role)?.generation ?? 0;
|
|
772
772
|
}
|
|
773
773
|
finally {
|
|
774
774
|
db.close();
|
package/dist/supervisedStart.js
CHANGED
|
@@ -3,8 +3,10 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
4
|
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { hostname } from "node:os";
|
|
6
7
|
import { DurableSupervisor } from "./supervisor.js";
|
|
7
8
|
import { WorkflowDb } from "./workflowDb.js";
|
|
9
|
+
import { processStartIdentity } from "./processIdentity.js";
|
|
8
10
|
import { cancelOwnedRuntimeProbes } from "./runtimeReadiness.js";
|
|
9
11
|
/** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
|
|
10
12
|
export function supervisedRunId() {
|
|
@@ -174,6 +176,12 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
174
176
|
return;
|
|
175
177
|
try {
|
|
176
178
|
db.acknowledgeBuildLaunchChild(authority, launch.token, child.pid);
|
|
179
|
+
// Record the incarnation before acknowledging the blocked launch gate.
|
|
180
|
+
// Any persistence failure follows the existing owned-child cleanup.
|
|
181
|
+
db.transitionSupervisor(runId, generation, {
|
|
182
|
+
checkpoint: db.getRun(runId)?.checkpoint ?? "preparing", event: "worker_started",
|
|
183
|
+
payload: { workerPid: child.pid, processStart: processStartIdentity(child.pid), host: hostname(), generation },
|
|
184
|
+
});
|
|
177
185
|
child.send("rafi-launch-ack", () => { });
|
|
178
186
|
clearTimeout(startup);
|
|
179
187
|
}
|
|
@@ -228,12 +236,18 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
228
236
|
const result = new Promise(resolve => {
|
|
229
237
|
child.once("error", error => {
|
|
230
238
|
clearInterval(watchdog);
|
|
239
|
+
exitCode = 1;
|
|
231
240
|
if (child.pid) {
|
|
232
241
|
stop();
|
|
233
242
|
return;
|
|
234
243
|
} // exit handler owns cleanup and settlement
|
|
235
|
-
|
|
236
|
-
|
|
244
|
+
try {
|
|
245
|
+
db.failBuildLaunch(authority, launch.token);
|
|
246
|
+
resolve({ kind: "failed", detail: error.message });
|
|
247
|
+
}
|
|
248
|
+
catch (failure) {
|
|
249
|
+
resolve({ kind: "failed", detail: `${error.message}; launch reconciliation failed: ${String(failure)}` });
|
|
250
|
+
}
|
|
237
251
|
});
|
|
238
252
|
child.once("exit", async (code, signal) => {
|
|
239
253
|
clearInterval(watchdog);
|
|
@@ -309,7 +323,13 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
309
323
|
process.once("SIGINT", stopInt);
|
|
310
324
|
process.once("SIGTERM", stopTerm);
|
|
311
325
|
try {
|
|
312
|
-
await supervisor.run();
|
|
326
|
+
const outcome = await supervisor.run();
|
|
327
|
+
if (outcome.kind === "failed") {
|
|
328
|
+
exitCode ||= 1;
|
|
329
|
+
console.error(`rafi: ${outcome.detail}`);
|
|
330
|
+
}
|
|
331
|
+
else if (outcome.kind === "waiting_for_human")
|
|
332
|
+
exitCode ||= 2;
|
|
313
333
|
}
|
|
314
334
|
finally {
|
|
315
335
|
process.off("SIGINT", stopInt);
|
package/dist/supervisor.js
CHANGED
|
@@ -80,6 +80,8 @@ export class DurableSupervisor {
|
|
|
80
80
|
if (pending.some(decision => !this.options.policy.continueIndependentTickets || !decision.interruptionId.startsWith("ticket:"))) {
|
|
81
81
|
state = { ...state, status: "waiting_for_human", workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
82
82
|
saveState();
|
|
83
|
+
if (db.getRun(this.options.runId)?.status === "running")
|
|
84
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "waiting-for-human", event: "worker_waiting_for_human" });
|
|
83
85
|
return { kind: "waiting_for_human" };
|
|
84
86
|
}
|
|
85
87
|
const checkpoint = this.options.checkpoint();
|
|
@@ -94,6 +96,19 @@ export class DurableSupervisor {
|
|
|
94
96
|
this.active = undefined;
|
|
95
97
|
state = { ...state, workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
96
98
|
if (outcome.kind !== "crashed") {
|
|
99
|
+
if (outcome.kind === "failed" && db.getRun(this.options.runId)?.status === "running") {
|
|
100
|
+
const eligibility = db.preparationEligibility(this.options.runId);
|
|
101
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, {
|
|
102
|
+
status: eligibility.eligible ? "failed" : "paused",
|
|
103
|
+
checkpoint: eligibility.eligible ? "worker-failed" : "worker-reconciliation-required",
|
|
104
|
+
event: "worker_exit_recorded", payload: { detail: outcome.detail, reasons: eligibility.reasons },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (db.getRun(this.options.runId)?.status === "running")
|
|
108
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, {
|
|
109
|
+
status: "paused", checkpoint: outcome.kind === "waiting_for_human" && db.pendingHumanDecisions(this.options.runId).length ? "waiting-for-human" : "worker-exited-before-checkpoint",
|
|
110
|
+
event: "worker_exit_recorded", payload: { outcome: outcome.kind },
|
|
111
|
+
});
|
|
97
112
|
const status = outcome.kind === "completed" ? "stopped" : outcome.kind === "waiting_for_human" ? "waiting_for_human" : outcome.kind === "failed" ? "failed" : "stopped";
|
|
98
113
|
state = { ...state, status };
|
|
99
114
|
saveState();
|
|
@@ -111,6 +126,10 @@ export class DurableSupervisor {
|
|
|
111
126
|
if (state.checkpointRestarts >= checkpointLimit || state.runRestarts >= runLimit) {
|
|
112
127
|
state = { ...state, status: "failed" };
|
|
113
128
|
saveState();
|
|
129
|
+
if (db.getRun(this.options.runId)?.status === "running")
|
|
130
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, {
|
|
131
|
+
status: "failed", checkpoint: "worker-restart-budget-exhausted", event: "worker_restart_budget_exhausted", payload: { checkpoint, detail: outcome.detail },
|
|
132
|
+
});
|
|
114
133
|
return { kind: "failed", detail: `worker restart budget exhausted at ${checkpoint}: ${outcome.detail}` };
|
|
115
134
|
}
|
|
116
135
|
state = { ...state, checkpointRestarts: state.checkpointRestarts + 1, runRestarts: state.runRestarts + 1 };
|
|
@@ -118,9 +137,23 @@ export class DurableSupervisor {
|
|
|
118
137
|
await this.sleep(Math.min(10_000, 250 * (2 ** Math.min(5, state.checkpointRestarts - 1))));
|
|
119
138
|
}
|
|
120
139
|
state = { ...state, status: "stopped", stopRequestedAt: this.now().toISOString(), workerPid: undefined };
|
|
140
|
+
if (db.getRun(this.options.runId)?.status === "running")
|
|
141
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: "paused", checkpoint: "worker-stopped", event: "supervisor_stop_recorded" });
|
|
121
142
|
saveState();
|
|
122
143
|
return { kind: "stopped" };
|
|
123
144
|
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
state = { ...state, status: "failed", workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
147
|
+
saveState();
|
|
148
|
+
if (db.getRun(this.options.runId)?.status === "running") {
|
|
149
|
+
const eligibility = db.preparationEligibility(this.options.runId);
|
|
150
|
+
db.transitionSupervisor(this.options.runId, state.workerGeneration, { status: eligibility.eligible ? "failed" : "paused",
|
|
151
|
+
checkpoint: eligibility.eligible ? "worker-launch-failed" : "worker-reconciliation-required", event: "worker_launch_failed",
|
|
152
|
+
payload: { detail: String(error), reasons: eligibility.reasons },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
124
157
|
finally {
|
|
125
158
|
clearInterval(heartbeat);
|
|
126
159
|
try {
|