ai-foreman 1.7.6 → 1.7.8
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 +26 -1
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +31 -6
- package/dist/adapters/claude.d.ts +61 -6
- package/dist/adapters/claude.js +544 -36
- package/dist/adapters/codex.d.ts +28 -1
- package/dist/adapters/codex.js +398 -27
- package/dist/adapters/recovering.d.ts +13 -2
- package/dist/adapters/recovering.js +32 -4
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +117 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +38 -0
- package/dist/branch/currentGuard.js +71 -0
- package/dist/branch/planner.d.ts +1 -1
- package/dist/branch/planner.js +2 -7
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +13 -4
- package/dist/buildRuns.js +85 -19
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +1079 -43
- package/dist/cli/tickets.js +199 -3
- package/dist/continuity.d.ts +92 -0
- package/dist/continuity.js +415 -0
- package/dist/foreman.d.ts +5 -1
- package/dist/foreman.js +41 -6
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +546 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +6 -6
- package/dist/qaReview.js +24 -14
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +198 -5
- package/dist/sessionLifecycle.js +1373 -20
- package/dist/statusReporter.d.ts +84 -8
- package/dist/statusReporter.js +270 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +1 -0
- package/dist/tickets/reset.js +1 -0
- package/dist/tickets/setupConfig.d.ts +1 -0
- package/dist/tickets/setupConfig.js +10 -2
- package/dist/tickets/stateDb.d.ts +34 -0
- package/dist/tickets/stateDb.js +207 -0
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +145 -3
- package/dist/workflowDb.js +571 -10
- package/package.json +32 -4
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { BuilderEventQueue } from "./activity.js";
|
|
3
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
4
|
+
export const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
|
|
5
|
+
export class ContinuityValidationError extends Error {
|
|
6
|
+
problems;
|
|
7
|
+
constructor(problems) {
|
|
8
|
+
super(`invalid continuity delta: ${problems.join("; ")}`);
|
|
9
|
+
this.problems = problems;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class ContinuityRecoveryRequiredError extends Error {
|
|
13
|
+
runId;
|
|
14
|
+
role;
|
|
15
|
+
constructor(runId, role, message) {
|
|
16
|
+
super(`${message}; run rafi build:resume --run ${runId} --guided-recovery in a TTY`);
|
|
17
|
+
this.runId = runId;
|
|
18
|
+
this.role = role;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Exact-session loss is a host recovery boundary, never a continuity-repair
|
|
23
|
+
* prompt. `guidedRecovery` is true when the provider may already have received
|
|
24
|
+
* the frozen instruction, so a fresh successor must reconcile receipts first.
|
|
25
|
+
*/
|
|
26
|
+
export class SessionUnavailableContinuityError extends Error {
|
|
27
|
+
runId;
|
|
28
|
+
role;
|
|
29
|
+
failure;
|
|
30
|
+
guidedRecovery;
|
|
31
|
+
constructor(runId, role, failure) {
|
|
32
|
+
const guidedRecovery = failure.dispatchState === "unknown";
|
|
33
|
+
super(guidedRecovery
|
|
34
|
+
? `the exact ${role} session became unavailable after dispatch may have begun; run rafi build:resume --run ${runId} --guided-recovery in a TTY`
|
|
35
|
+
: `the exact ${role} session is unavailable before dispatch; inspect run ${runId} and choose fresh-with-handoff recovery`);
|
|
36
|
+
this.runId = runId;
|
|
37
|
+
this.role = role;
|
|
38
|
+
this.failure = failure;
|
|
39
|
+
this.name = "SessionUnavailableContinuityError";
|
|
40
|
+
this.guidedRecovery = guidedRecovery;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function validateContinuityDelta(value) {
|
|
44
|
+
const problems = [];
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
46
|
+
throw new ContinuityValidationError(["delta must be an object"]);
|
|
47
|
+
const input = value;
|
|
48
|
+
if (input.version !== 1)
|
|
49
|
+
problems.push("version must be 1");
|
|
50
|
+
const output = {
|
|
51
|
+
version: 1,
|
|
52
|
+
decisions: strings(input.decisions, "decisions", problems),
|
|
53
|
+
constraints: strings(input.constraints, "constraints", problems),
|
|
54
|
+
discoveries: strings(input.discoveries, "discoveries", problems),
|
|
55
|
+
completedActions: strings(input.completedActions, "completedActions", problems),
|
|
56
|
+
evidence: strings(input.evidence, "evidence", problems),
|
|
57
|
+
failures: strings(input.failures, "failures", problems),
|
|
58
|
+
blockers: strings(input.blockers, "blockers", problems),
|
|
59
|
+
openWork: strings(input.openWork, "openWork", problems),
|
|
60
|
+
nextAction: typeof input.nextAction === "string" ? clean(input.nextAction, 4_000) : "",
|
|
61
|
+
};
|
|
62
|
+
if (!output.nextAction)
|
|
63
|
+
problems.push("nextAction must be a non-empty string");
|
|
64
|
+
if (JSON.stringify(output).length > 64_000)
|
|
65
|
+
problems.push("delta exceeds 64 KiB");
|
|
66
|
+
if (problems.length)
|
|
67
|
+
throw new ContinuityValidationError(problems);
|
|
68
|
+
return output;
|
|
69
|
+
}
|
|
70
|
+
export function parseContinuityDelta(text) {
|
|
71
|
+
const lines = text.split(/\r?\n/);
|
|
72
|
+
const markerIndexes = lines.flatMap((line, index) => line.trimStart().startsWith(CONTINUITY_MARKER) ? [index] : []);
|
|
73
|
+
const cleanText = lines.filter((_line, index) => !markerIndexes.includes(index)).join("\n").trimEnd();
|
|
74
|
+
if (markerIndexes.length !== 1)
|
|
75
|
+
return { cleanText, error: new ContinuityValidationError([markerIndexes.length ? "multiple continuity markers" : "missing continuity marker"]) };
|
|
76
|
+
const raw = lines[markerIndexes[0]].trimStart().slice(CONTINUITY_MARKER.length).trim();
|
|
77
|
+
try {
|
|
78
|
+
return { delta: validateContinuityDelta(JSON.parse(raw)), cleanText };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return { cleanText, error: error instanceof ContinuityValidationError ? error : new ContinuityValidationError([`marker is not valid JSON: ${String(error)}`]) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function mergeContinuityDeltas(deltas) {
|
|
85
|
+
const result = baselineContinuityDelta();
|
|
86
|
+
for (const delta of deltas) {
|
|
87
|
+
for (const key of ["decisions", "constraints", "discoveries", "completedActions", "evidence", "failures", "blockers", "openWork"]) {
|
|
88
|
+
result[key] = unique([...result[key], ...delta[key]]).slice(-500);
|
|
89
|
+
}
|
|
90
|
+
if (delta.nextAction)
|
|
91
|
+
result.nextAction = delta.nextAction;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
export function baselineContinuityDelta(nextAction = "Await the first role action.") {
|
|
96
|
+
return { version: 1, decisions: [], constraints: [], discoveries: [], completedActions: [], evidence: [], failures: [], blockers: [], openWork: [], nextAction };
|
|
97
|
+
}
|
|
98
|
+
export function continuityInstruction() {
|
|
99
|
+
return [
|
|
100
|
+
"Before your final STEP_STATUS line (or as the final line when STEP_STATUS is not requested), emit exactly one single-line continuity record:",
|
|
101
|
+
`${CONTINUITY_MARKER} {"version":1,"decisions":[],"constraints":[],"discoveries":[],"completedActions":[],"evidence":[],"failures":[],"blockers":[],"openWork":[],"nextAction":"specific next action"}`,
|
|
102
|
+
"Keep it concise and cumulative for facts learned this turn. Do not include credentials, hidden reasoning, or raw transcripts.",
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
/** Enforces a durable role checkpoint after every completed provider turn. */
|
|
106
|
+
export class ContinuityAdapter {
|
|
107
|
+
options;
|
|
108
|
+
adapter;
|
|
109
|
+
queue = new BuilderEventQueue();
|
|
110
|
+
sourcePump;
|
|
111
|
+
recoveryLeasePending;
|
|
112
|
+
turnDispatcher;
|
|
113
|
+
constructor(options) {
|
|
114
|
+
this.options = options;
|
|
115
|
+
this.adapter = options.adapter;
|
|
116
|
+
this.recoveryLeasePending = Boolean(options.replaceRecoveryLeaseAfterCheckpoint);
|
|
117
|
+
const db = new WorkflowDb(options.projectDir);
|
|
118
|
+
try {
|
|
119
|
+
db.ensureRun(options.runId);
|
|
120
|
+
if (!db.continuityHead(options.runId, options.role)) {
|
|
121
|
+
db.appendContinuityEvent({ runId: options.runId, role: "host", kind: "role_baseline", payload: { role: options.role, provider: options.settings.make, model: options.settings.model }, authoritativeStateRevision: this.revision() });
|
|
122
|
+
db.publishContinuityCheckpoint({ runId: options.runId, role: options.role, delta: baselineContinuityDelta(), authoritativeStateRevision: this.revision() });
|
|
123
|
+
}
|
|
124
|
+
const initialSession = options.adapter.sessionRef?.() ?? options.adapter.sessionId();
|
|
125
|
+
if (initialSession)
|
|
126
|
+
db.claimInitialRoleLease(options.runId, options.role, initialSession);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
db.close();
|
|
130
|
+
}
|
|
131
|
+
this.pumpEvents();
|
|
132
|
+
}
|
|
133
|
+
get agent() { return this.adapter.agent; }
|
|
134
|
+
async sendTurn(instruction) {
|
|
135
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
136
|
+
try {
|
|
137
|
+
db.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
db.close();
|
|
141
|
+
}
|
|
142
|
+
const original = await this.providerTurn(`${instruction}\n\n${continuityInstruction()}`);
|
|
143
|
+
const activeSession = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
|
|
144
|
+
if (activeSession) {
|
|
145
|
+
const leaseDb = new WorkflowDb(this.options.projectDir);
|
|
146
|
+
try {
|
|
147
|
+
leaseDb.claimInitialRoleLease(this.options.runId, this.options.role, activeSession);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
leaseDb.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (original.failure?.category === "session-unavailable") {
|
|
154
|
+
this.recordSessionUnavailable(original.failure, instruction);
|
|
155
|
+
throw new SessionUnavailableContinuityError(this.options.runId, this.options.role, original.failure);
|
|
156
|
+
}
|
|
157
|
+
if (original.interrupted?.reason === "compaction-boundary") {
|
|
158
|
+
// The coordinator owns the validated handoff and replay of this frozen
|
|
159
|
+
// action. A protocol-repair turn here would run in the interrupted
|
|
160
|
+
// predecessor (or race successor adoption) without a completed delta.
|
|
161
|
+
const interruptedDb = new WorkflowDb(this.options.projectDir);
|
|
162
|
+
try {
|
|
163
|
+
interruptedDb.appendContinuityEvent({
|
|
164
|
+
runId: this.options.runId,
|
|
165
|
+
role: "host",
|
|
166
|
+
kind: "turn_interrupted_at_compaction_boundary",
|
|
167
|
+
payload: {
|
|
168
|
+
role: this.options.role,
|
|
169
|
+
providerEventId: original.interrupted.providerEventId,
|
|
170
|
+
instructionDigest: sha(instruction),
|
|
171
|
+
},
|
|
172
|
+
authoritativeStateRevision: this.revision(),
|
|
173
|
+
sessionRef: this.adapter.sessionRef?.(),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
interruptedDb.close();
|
|
178
|
+
}
|
|
179
|
+
return original;
|
|
180
|
+
}
|
|
181
|
+
const parsed = parseContinuityDelta(original.text);
|
|
182
|
+
if (parsed.delta) {
|
|
183
|
+
this.publish(parsed.delta, "turn_completed", original);
|
|
184
|
+
this.moveRecoveryLeaseAfterCheckpoint();
|
|
185
|
+
const successor = await this.options.handleHandoffRequest?.(original.text, this.adapter, instruction);
|
|
186
|
+
if (successor && successor !== this.adapter) {
|
|
187
|
+
await this.adoptValidatedSuccessor(successor);
|
|
188
|
+
return this.sendTurn([
|
|
189
|
+
"Continue the frozen action after the accepted Rafi handoff. Do not repeat completed side effects.",
|
|
190
|
+
instruction,
|
|
191
|
+
].join("\n\n"));
|
|
192
|
+
}
|
|
193
|
+
return { ...original, text: parsed.cleanText };
|
|
194
|
+
}
|
|
195
|
+
const repair = await this.providerTurn([
|
|
196
|
+
"Continuity protocol repair only. Do not run tools, repeat work, or change files.",
|
|
197
|
+
`Your prior turn's continuity record was invalid: ${parsed.error?.problems.join("; ")}.`,
|
|
198
|
+
continuityInstruction(),
|
|
199
|
+
"Return only the continuity record.",
|
|
200
|
+
].join("\n"));
|
|
201
|
+
if (repair.interrupted?.reason === "compaction-boundary") {
|
|
202
|
+
// Preserve the predecessor and let the outer managed dispatch perform
|
|
203
|
+
// the validated handoff/replay. Treating this as another invalid delta
|
|
204
|
+
// would race continuity recovery against the coordinator and could lose
|
|
205
|
+
// the original frozen action.
|
|
206
|
+
const interruptedDb = new WorkflowDb(this.options.projectDir);
|
|
207
|
+
try {
|
|
208
|
+
interruptedDb.appendContinuityEvent({
|
|
209
|
+
runId: this.options.runId,
|
|
210
|
+
role: "host",
|
|
211
|
+
kind: "continuity_repair_interrupted_at_compaction_boundary",
|
|
212
|
+
payload: {
|
|
213
|
+
role: this.options.role,
|
|
214
|
+
providerEventId: repair.interrupted.providerEventId,
|
|
215
|
+
instructionDigest: sha(instruction),
|
|
216
|
+
},
|
|
217
|
+
authoritativeStateRevision: this.revision(),
|
|
218
|
+
sessionRef: this.adapter.sessionRef?.(),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
interruptedDb.close();
|
|
223
|
+
}
|
|
224
|
+
return repair;
|
|
225
|
+
}
|
|
226
|
+
const repaired = parseContinuityDelta(repair.text);
|
|
227
|
+
if (repaired.delta) {
|
|
228
|
+
this.publish(repaired.delta, "turn_completed_after_repair", original);
|
|
229
|
+
this.moveRecoveryLeaseAfterCheckpoint();
|
|
230
|
+
return { ...original, text: parsed.cleanText };
|
|
231
|
+
}
|
|
232
|
+
const invalidDb = new WorkflowDb(this.options.projectDir);
|
|
233
|
+
try {
|
|
234
|
+
invalidDb.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "continuity_invalid_twice", payload: { original: parsed.error?.problems, repair: repaired.error?.problems }, authoritativeStateRevision: this.revision() });
|
|
235
|
+
invalidDb.setContinuityHeadState(this.options.runId, this.options.role, "invalid");
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
invalidDb.close();
|
|
239
|
+
}
|
|
240
|
+
const handoff = this.reconstructionHandoff();
|
|
241
|
+
if (this.options.recoverWithHandoff) {
|
|
242
|
+
let successor;
|
|
243
|
+
try {
|
|
244
|
+
successor = await this.options.recoverWithHandoff({
|
|
245
|
+
reason: "continuity delta and same-session repair were both invalid",
|
|
246
|
+
reconstruction: handoff,
|
|
247
|
+
predecessor: this.adapter,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, `fresh validated successor also failed after the same-session repair: ${error instanceof Error ? error.message : String(error)}`);
|
|
252
|
+
}
|
|
253
|
+
if (!successor.sessionId() || successor.sessionId() === this.adapter.sessionId()) {
|
|
254
|
+
await successor.close().catch(() => { });
|
|
255
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "validated recovery callback did not return a genuinely fresh successor");
|
|
256
|
+
}
|
|
257
|
+
await this.adoptValidatedSuccessor(successor);
|
|
258
|
+
return { ...original, text: parsed.cleanText };
|
|
259
|
+
}
|
|
260
|
+
// A raw create-and-send fallback cannot install native context control,
|
|
261
|
+
// account compactions, or validate the scoped successor atomically. Refuse
|
|
262
|
+
// it instead of allowing continuity recovery to escape the managed gate.
|
|
263
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no lifecycle-managed recoverWithHandoff callback is configured");
|
|
264
|
+
}
|
|
265
|
+
sessionId() { return this.adapter.sessionId(); }
|
|
266
|
+
sessionRef() { return this.adapter.sessionRef?.(); }
|
|
267
|
+
adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
|
|
268
|
+
validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
|
|
269
|
+
compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
|
|
270
|
+
prepareContextManagement(policy) { if (!this.adapter.prepareContextManagement)
|
|
271
|
+
return Promise.reject(new Error("provider adapter does not expose native context management")); return this.adapter.prepareContextManagement(policy); }
|
|
272
|
+
updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
|
|
273
|
+
return Promise.reject(new Error("provider adapter does not expose native context reconfiguration")); return this.adapter.updateContextManagement(policy); }
|
|
274
|
+
interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "provider adapter does not expose compaction-boundary interruption", providerEventId }); }
|
|
275
|
+
installManagedTurnDispatcher(dispatcher) {
|
|
276
|
+
this.turnDispatcher = dispatcher;
|
|
277
|
+
this.adapter.installManagedTurnDispatcher?.(dispatcher);
|
|
278
|
+
}
|
|
279
|
+
contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
|
|
280
|
+
sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
|
|
281
|
+
switchSettings(settings) { return this.adapter.switchSettings ? this.adapter.switchSettings(settings) : Promise.resolve({ ok: false, error: "provider adapter does not support settings changes" }); }
|
|
282
|
+
events() { return this.queue; }
|
|
283
|
+
async close() { await this.adapter.close(); await this.sourcePump?.catch(() => { }); this.queue.close(); }
|
|
284
|
+
/** Keep the observable wrapper and event stream stable while moving to an accepted successor. */
|
|
285
|
+
async adoptValidatedSuccessor(successor) {
|
|
286
|
+
if (successor === this.adapter)
|
|
287
|
+
return;
|
|
288
|
+
const prior = this.adapter;
|
|
289
|
+
this.adapter = successor;
|
|
290
|
+
if (this.turnDispatcher)
|
|
291
|
+
successor.installManagedTurnDispatcher?.(this.turnDispatcher);
|
|
292
|
+
this.pumpEvents();
|
|
293
|
+
await prior.close().catch(() => { });
|
|
294
|
+
}
|
|
295
|
+
providerTurn(text) {
|
|
296
|
+
const invoke = () => this.adapter.sendTurn(text);
|
|
297
|
+
return this.turnDispatcher ? this.turnDispatcher(text, invoke) : invoke();
|
|
298
|
+
}
|
|
299
|
+
publish(delta, kind, result) {
|
|
300
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
301
|
+
try {
|
|
302
|
+
const sessionRef = this.adapter.sessionRef?.();
|
|
303
|
+
db.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind, payload: { isError: result.isError, numTurns: result.numTurns, sessionId: this.adapter.sessionId(), sessionRef, delta }, authoritativeStateRevision: this.revision(), sessionRef });
|
|
304
|
+
return db.publishContinuityCheckpoint({ runId: this.options.runId, role: this.options.role, delta, authoritativeStateRevision: this.revision(), sessionRef });
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
db.close();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
moveRecoveryLeaseAfterCheckpoint() {
|
|
311
|
+
if (!this.recoveryLeasePending)
|
|
312
|
+
return;
|
|
313
|
+
const session = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
|
|
314
|
+
if (!session)
|
|
315
|
+
return;
|
|
316
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
317
|
+
try {
|
|
318
|
+
db.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, session, "explicit fresh-recovery-only continuation");
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
db.close();
|
|
322
|
+
}
|
|
323
|
+
this.recoveryLeasePending = false;
|
|
324
|
+
}
|
|
325
|
+
recordSessionUnavailable(failure, instruction) {
|
|
326
|
+
const sessionRef = this.adapter.sessionRef?.();
|
|
327
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
328
|
+
try {
|
|
329
|
+
db.appendContinuityEvent({
|
|
330
|
+
runId: this.options.runId,
|
|
331
|
+
role: "host",
|
|
332
|
+
kind: "session_unavailable",
|
|
333
|
+
payload: {
|
|
334
|
+
role: this.options.role,
|
|
335
|
+
phase: failure.phase,
|
|
336
|
+
dispatchState: failure.dispatchState ?? "unknown",
|
|
337
|
+
reason: failure.availability?.reason,
|
|
338
|
+
detail: failure.diagnostics,
|
|
339
|
+
instructionDigest: sha(instruction),
|
|
340
|
+
},
|
|
341
|
+
authoritativeStateRevision: this.revision(),
|
|
342
|
+
sessionRef,
|
|
343
|
+
});
|
|
344
|
+
if (failure.dispatchState === "unknown") {
|
|
345
|
+
db.setContinuityHeadState(this.options.runId, this.options.role, "degraded");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
db.close();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
reconstructionHandoff() {
|
|
353
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
354
|
+
try {
|
|
355
|
+
const checkpoints = db.continuityCheckpoints(this.options.runId, this.options.role);
|
|
356
|
+
const events = db.continuityEvents(this.options.runId, checkpoints.at(-1)?.sequence ?? 0);
|
|
357
|
+
return [
|
|
358
|
+
`Validated cumulative recovery for run ${this.options.runId}, role ${this.options.role}.`,
|
|
359
|
+
JSON.stringify(mergeContinuityDeltas(checkpoints.map((checkpoint) => checkpoint.delta))),
|
|
360
|
+
`Later host facts: ${JSON.stringify(events.map((event) => ({ kind: event.kind, payload: event.payload, digest: event.digest })))}`,
|
|
361
|
+
"The prior in-flight operation is unknown unless a host fact proves completion. Reconcile receipts before retrying side effects.",
|
|
362
|
+
].join("\n\n");
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
db.close();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
revision() { return this.options.authoritativeStateRevision?.() ?? this.options.settings.settings_revision; }
|
|
369
|
+
pumpEvents() {
|
|
370
|
+
const source = this.adapter;
|
|
371
|
+
this.sourcePump = (async () => {
|
|
372
|
+
try {
|
|
373
|
+
for await (const event of source.events()) {
|
|
374
|
+
this.queue.push(event);
|
|
375
|
+
if (event.kind === "tool" || event.kind === "session-transition" || event.kind === "context-usage" || event.kind === "context-compaction") {
|
|
376
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
377
|
+
try {
|
|
378
|
+
const payload = event.kind === "tool"
|
|
379
|
+
? { name: event.name, inputDigest: sha(JSON.stringify(event.input ?? null)) }
|
|
380
|
+
: event;
|
|
381
|
+
db.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: `provider_${event.kind.replace(/-/g, "_")}`, payload, authoritativeStateRevision: this.revision() });
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
db.close();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
this.queue.push({ kind: "error", message: error instanceof Error ? error.message : String(error) });
|
|
391
|
+
}
|
|
392
|
+
})();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function strings(value, field, problems) {
|
|
396
|
+
if (!Array.isArray(value)) {
|
|
397
|
+
problems.push(`${field} must be an array`);
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
if (value.length > 500)
|
|
401
|
+
problems.push(`${field} exceeds 500 entries`);
|
|
402
|
+
const result = [];
|
|
403
|
+
for (const entry of value.slice(0, 500)) {
|
|
404
|
+
if (typeof entry !== "string" || !entry.trim())
|
|
405
|
+
problems.push(`${field} entries must be non-empty strings`);
|
|
406
|
+
else
|
|
407
|
+
result.push(clean(entry, 4_000));
|
|
408
|
+
}
|
|
409
|
+
return unique(result);
|
|
410
|
+
}
|
|
411
|
+
function clean(value, maximum) {
|
|
412
|
+
return value.replace(/\b(sk-[A-Za-z0-9_-]{12,}|(?:api[_-]?key|token|password)\s*[:=]\s*\S+)/gi, "[REDACTED]").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").trim().slice(0, maximum);
|
|
413
|
+
}
|
|
414
|
+
function unique(values) { return [...new Set(values)]; }
|
|
415
|
+
function sha(value) { return createHash("sha256").update(value).digest("hex"); }
|
package/dist/foreman.d.ts
CHANGED
|
@@ -62,10 +62,13 @@ export declare class Foreman {
|
|
|
62
62
|
private readonly builderFactory?;
|
|
63
63
|
private readonly builderSessionStrategy;
|
|
64
64
|
private readonly qaNonconvergence?;
|
|
65
|
+
private readonly beforeBuilderTurn?;
|
|
66
|
+
private readonly builderSessionBoundary?;
|
|
67
|
+
private readonly qaSessionBoundary?;
|
|
65
68
|
private readonly ticketsEnabled;
|
|
66
69
|
private readonly qaStream;
|
|
67
70
|
private builderWorkSessions;
|
|
68
|
-
constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined);
|
|
71
|
+
constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined);
|
|
69
72
|
/**
|
|
70
73
|
* Send one instruction and resolve any needs_input exchanges before returning.
|
|
71
74
|
* The returned result and status always reflect the final (non-needs_input) turn.
|
|
@@ -94,6 +97,7 @@ export declare class Foreman {
|
|
|
94
97
|
summary?: string;
|
|
95
98
|
}>;
|
|
96
99
|
qaSessionId(): string | undefined;
|
|
100
|
+
qaSessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
|
|
97
101
|
builderSessionId(): string | undefined;
|
|
98
102
|
builderAdapter(): BuilderAdapter;
|
|
99
103
|
close(): Promise<void>;
|
package/dist/foreman.js
CHANGED
|
@@ -9,6 +9,7 @@ import { runIsolatedQa } from "./qaReview.js";
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
11
11
|
import { join, relative } from "node:path";
|
|
12
|
+
import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
|
|
12
13
|
/** Phrases that suggest the builder ended its turn by asking the human. */
|
|
13
14
|
const QUESTION_HINTS = [
|
|
14
15
|
"should i",
|
|
@@ -225,10 +226,13 @@ export class Foreman {
|
|
|
225
226
|
builderFactory;
|
|
226
227
|
builderSessionStrategy;
|
|
227
228
|
qaNonconvergence;
|
|
229
|
+
beforeBuilderTurn;
|
|
230
|
+
builderSessionBoundary;
|
|
231
|
+
qaSessionBoundary;
|
|
228
232
|
ticketsEnabled;
|
|
229
233
|
qaStream = { reviews: 0, modificationViolations: 0 };
|
|
230
234
|
builderWorkSessions = 0;
|
|
231
|
-
constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence) {
|
|
235
|
+
constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary) {
|
|
232
236
|
this.builder = builder;
|
|
233
237
|
this.log = log;
|
|
234
238
|
this.notificationsEnabled = notificationsEnabled;
|
|
@@ -241,6 +245,9 @@ export class Foreman {
|
|
|
241
245
|
this.builderFactory = builderFactory;
|
|
242
246
|
this.builderSessionStrategy = builderSessionStrategy;
|
|
243
247
|
this.qaNonconvergence = qaNonconvergence;
|
|
248
|
+
this.beforeBuilderTurn = beforeBuilderTurn;
|
|
249
|
+
this.builderSessionBoundary = builderSessionBoundary;
|
|
250
|
+
this.qaSessionBoundary = qaSessionBoundary;
|
|
244
251
|
this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
|
|
245
252
|
}
|
|
246
253
|
/**
|
|
@@ -250,9 +257,14 @@ export class Foreman {
|
|
|
250
257
|
async doTurn(instruction) {
|
|
251
258
|
return this.doTurnWith(this.builder, instruction);
|
|
252
259
|
}
|
|
253
|
-
async prepareBuilderBoundary() {
|
|
260
|
+
async prepareBuilderBoundary(frozenAction) {
|
|
254
261
|
if (!this.builderFactory || !this.projectDir)
|
|
255
262
|
return;
|
|
263
|
+
if (this.builderSessionBoundary) {
|
|
264
|
+
this.builder = await this.builderSessionBoundary(this.builder, frozenAction, this.builderSessionStrategy);
|
|
265
|
+
this.log.write("branch-session", { role: "builder", transition: this.builderSessionStrategy === "fresh" ? "validated-handoff" : "accounted-compaction", workSession: this.builderWorkSessions + 1, sessionId: this.builder.sessionId() });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
256
268
|
if (this.builderSessionStrategy === "fresh") {
|
|
257
269
|
await this.builder.close();
|
|
258
270
|
this.builder = await this.builderFactory(this.projectDir);
|
|
@@ -263,7 +275,17 @@ export class Foreman {
|
|
|
263
275
|
if (priorSession && this.builder.compact) {
|
|
264
276
|
let error = "";
|
|
265
277
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
266
|
-
|
|
278
|
+
let result;
|
|
279
|
+
try {
|
|
280
|
+
result = await this.builder.compact();
|
|
281
|
+
}
|
|
282
|
+
catch (cause) {
|
|
283
|
+
if (cause instanceof SessionUnavailableError)
|
|
284
|
+
throw cause;
|
|
285
|
+
result = { ok: false, error: cause instanceof Error ? cause.message : String(cause) };
|
|
286
|
+
}
|
|
287
|
+
if (result.failure?.category === "session-unavailable")
|
|
288
|
+
throw sessionUnavailableErrorFromFailure(result.failure);
|
|
267
289
|
if (result.ok) {
|
|
268
290
|
this.log.write("branch-session", { role: "builder", transition: attempt === 1 ? "compacted" : "compaction-retry-succeeded", workSession: this.builderWorkSessions + 1, sessionId: priorSession });
|
|
269
291
|
return;
|
|
@@ -282,8 +304,14 @@ export class Foreman {
|
|
|
282
304
|
this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
|
|
283
305
|
}
|
|
284
306
|
async doTurnWith(adapter, instruction) {
|
|
307
|
+
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
308
|
+
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
309
|
+
adapter = this.builder;
|
|
310
|
+
}
|
|
285
311
|
let result = await adapter.sendTurn(instruction);
|
|
286
312
|
let status = parseStepStatus(result.text);
|
|
313
|
+
if (result.failure?.category === "session-unavailable")
|
|
314
|
+
return { result, status };
|
|
287
315
|
if (status.kind === "unknown") {
|
|
288
316
|
if (!status.error && looksLikeQuestion(result.text)) {
|
|
289
317
|
status = { kind: "needs_input", question: lastLine(result.text), choices: ["Continue", "Cancel"] };
|
|
@@ -326,6 +354,8 @@ export class Foreman {
|
|
|
326
354
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
327
355
|
async runPreflight(n, ticketsContent, preferredTicketId) {
|
|
328
356
|
const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId);
|
|
357
|
+
if (this.beforeBuilderTurn)
|
|
358
|
+
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
329
359
|
const result = await this.builder.sendTurn(instruction);
|
|
330
360
|
this.log.write("preflight", {
|
|
331
361
|
ticketsProvided: ticketsContent !== undefined,
|
|
@@ -338,6 +368,8 @@ export class Foreman {
|
|
|
338
368
|
}
|
|
339
369
|
/** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
|
|
340
370
|
async sendPreflightFeedback(feedback) {
|
|
371
|
+
if (this.beforeBuilderTurn)
|
|
372
|
+
this.builder = await this.beforeBuilderTurn(this.builder, feedback);
|
|
341
373
|
const result = await this.builder.sendTurn(feedback);
|
|
342
374
|
this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
|
|
343
375
|
if (result.isError)
|
|
@@ -361,11 +393,13 @@ export class Foreman {
|
|
|
361
393
|
qaStrategy: this.qaSessionStrategy,
|
|
362
394
|
state: this.qaStream,
|
|
363
395
|
createQa: this.qaFactory,
|
|
396
|
+
sessionBoundary: this.qaSessionBoundary,
|
|
364
397
|
maxCycles: this.qaMaxCycles,
|
|
365
398
|
fix: async (issues) => {
|
|
366
|
-
|
|
399
|
+
const instruction = buildQaFixInstruction(issues);
|
|
400
|
+
await this.prepareBuilderBoundary(instruction);
|
|
367
401
|
this.builderWorkSessions += 1;
|
|
368
|
-
const fix = await this.doTurn(
|
|
402
|
+
const fix = await this.doTurn(instruction);
|
|
369
403
|
this.log.write("qa-fix", { stepIndex, statusKind: fix.status.kind, costUsd: fix.result.costUsd, isError: fix.result.isError });
|
|
370
404
|
return fix.result.isError || fix.status.kind !== "done"
|
|
371
405
|
? { ok: false, detail: fix.status.reason ?? fix.status.error ?? fix.result.text.slice(0, 200) }
|
|
@@ -442,6 +476,7 @@ export class Foreman {
|
|
|
442
476
|
return this.runQa(stepIndex);
|
|
443
477
|
}
|
|
444
478
|
qaSessionId() { return this.qaStream.sessionId ?? this.qaReviewer?.sessionId(); }
|
|
479
|
+
qaSessionRef() { return this.qaStream.sessionRef ?? this.qaReviewer?.sessionRef?.(); }
|
|
445
480
|
builderSessionId() { return this.builder.sessionId(); }
|
|
446
481
|
builderAdapter() { return this.builder; }
|
|
447
482
|
async close() { await this.builder.close(); }
|
|
@@ -505,7 +540,7 @@ export class Foreman {
|
|
|
505
540
|
? buildPrimer(n, trackerPath, this.ticketsEnabled, preferredTicketId)
|
|
506
541
|
: buildNextStepInstruction(i, n);
|
|
507
542
|
if (i > 1)
|
|
508
|
-
await this.prepareBuilderBoundary();
|
|
543
|
+
await this.prepareBuilderBoundary(instruction);
|
|
509
544
|
this.builderWorkSessions += 1;
|
|
510
545
|
const { result, status } = await this.doTurn(instruction);
|
|
511
546
|
this.log.write("step", {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ContinuityDelta, HandoffLineage, HandoffManifestV1, ProviderSessionRefV1, SessionUsageSample } from "rafi-spec";
|
|
2
|
+
import type { BuilderAdapter } from "./adapters/types.js";
|
|
3
|
+
export declare const HANDOFF_CACHE_RETENTION_DAYS = 30;
|
|
4
|
+
export declare const HANDOFF_ACCEPTED = "HANDOFF_ACCEPTED";
|
|
5
|
+
export declare const HANDOFF_REQUEST_START = "RAFI_HANDOFF_REQUEST_START";
|
|
6
|
+
export declare const HANDOFF_REQUEST_END = "RAFI_HANDOFF_REQUEST_END";
|
|
7
|
+
export interface BuilderHandoffRequest {
|
|
8
|
+
version: 1;
|
|
9
|
+
reason: string;
|
|
10
|
+
delta: ContinuityDelta;
|
|
11
|
+
roleState: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseBuilderHandoffRequest(text: string): BuilderHandoffRequest | undefined;
|
|
14
|
+
export interface CreateHandoffInput {
|
|
15
|
+
runId: string;
|
|
16
|
+
role: "builder" | "qa";
|
|
17
|
+
reason: string;
|
|
18
|
+
predecessorSessionId?: string;
|
|
19
|
+
predecessorSessionRef?: ProviderSessionRefV1;
|
|
20
|
+
roleState?: Record<string, unknown>;
|
|
21
|
+
sessionUsage?: SessionUsageSample;
|
|
22
|
+
compactionCount: number;
|
|
23
|
+
compactMaximum: number;
|
|
24
|
+
resources?: Array<{
|
|
25
|
+
label: string;
|
|
26
|
+
content: string | Buffer;
|
|
27
|
+
authoritative: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
requestedByBuilder?: boolean;
|
|
30
|
+
/** Internal recovery path: use the last valid checkpoint while its head is marked degraded/invalid. */
|
|
31
|
+
allowNonCurrentContinuity?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface StagedHandoff {
|
|
34
|
+
manifest: HandoffManifestV1;
|
|
35
|
+
markdown: string;
|
|
36
|
+
lineage: HandoffLineage;
|
|
37
|
+
cacheDirectory: string;
|
|
38
|
+
}
|
|
39
|
+
export interface HandoffTransferResult extends StagedHandoff {
|
|
40
|
+
successor: BuilderAdapter;
|
|
41
|
+
successorSessionId: string;
|
|
42
|
+
acceptanceCheckpointDigest: string;
|
|
43
|
+
}
|
|
44
|
+
export declare class HandoffLoopError extends Error {
|
|
45
|
+
readonly runId: string;
|
|
46
|
+
constructor(runId: string);
|
|
47
|
+
}
|
|
48
|
+
/** Durable, validated ownership transfer; cache copies are never authoritative. */
|
|
49
|
+
export declare class HandoffService {
|
|
50
|
+
readonly projectDir: string;
|
|
51
|
+
readonly cacheRoot: string;
|
|
52
|
+
constructor(projectDir: string);
|
|
53
|
+
stage(input: CreateHandoffInput, now?: Date): StagedHandoff;
|
|
54
|
+
transfer(input: CreateHandoffInput, createSuccessor: (handoff: StagedHandoff) => Promise<BuilderAdapter>): Promise<HandoffTransferResult>;
|
|
55
|
+
acceptStaged(staged: StagedHandoff, successor: BuilderAdapter): Promise<HandoffTransferResult>;
|
|
56
|
+
inspect(runId: string, generation?: number): {
|
|
57
|
+
manifest: HandoffManifestV1;
|
|
58
|
+
markdown: string;
|
|
59
|
+
lineage: HandoffLineage;
|
|
60
|
+
};
|
|
61
|
+
loadStaged(runId: string, generation: number): StagedHandoff;
|
|
62
|
+
pruneCache(runId: string, keepLatest?: number): string[];
|
|
63
|
+
pruneExpiredCache(now?: Date, retentionDays?: number): string[];
|
|
64
|
+
private materialize;
|
|
65
|
+
private safeRunCache;
|
|
66
|
+
private consecutiveBuilderRequests;
|
|
67
|
+
}
|
|
68
|
+
export declare function renderHandoffMarkdown(manifest: HandoffManifestV1): string;
|
|
69
|
+
export declare function writeHandoffInspection(path: string, content: string): void;
|