ai-foreman 1.7.11 → 1.7.13
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 +15 -0
- package/dist/activity.d.ts +5 -1
- package/dist/activity.js +1 -1
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +133 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +121 -11
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +11 -1
- package/dist/adapters/types.d.ts +47 -1
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +8 -2
- package/dist/branch/resume.js +11 -1
- package/dist/branch/runner.d.ts +12 -3
- package/dist/branch/runner.js +131 -67
- package/dist/buildRuns.d.ts +6 -2
- package/dist/buildRuns.js +76 -32
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +84 -1
- package/dist/cli/manager.d.ts +17 -0
- package/dist/cli/manager.js +135 -0
- package/dist/cli/recovery.d.ts +4 -0
- package/dist/cli/recovery.js +80 -0
- package/dist/cli/start.js +446 -142
- package/dist/cli/status.js +13 -2
- package/dist/cli/tickets.js +27 -9
- package/dist/config.d.ts +4 -0
- package/dist/config.js +28 -1
- package/dist/continuity.js +14 -9
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +428 -0
- package/dist/foreman.d.ts +26 -6
- package/dist/foreman.js +147 -104
- package/dist/handoffs.d.ts +35 -3
- package/dist/handoffs.js +155 -17
- package/dist/index.js +6 -0
- package/dist/log.d.ts +3 -3
- package/dist/log.js +4 -1
- package/dist/managerPacket.d.ts +25 -0
- package/dist/managerPacket.js +83 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +4 -1
- package/dist/notify.d.ts +4 -0
- package/dist/notify.js +15 -0
- package/dist/observability.d.ts +248 -0
- package/dist/observability.js +1018 -0
- package/dist/planChanges.d.ts +4 -0
- package/dist/planChanges.js +16 -0
- package/dist/projectDiagnostics.d.ts +27 -0
- package/dist/projectDiagnostics.js +407 -0
- package/dist/qaRecovery.d.ts +140 -0
- package/dist/qaRecovery.js +595 -0
- package/dist/qaReview.d.ts +133 -14
- package/dist/qaReview.js +984 -59
- package/dist/qaSnapshot.d.ts +31 -5
- package/dist/qaSnapshot.js +209 -134
- package/dist/recoveryPolicy.d.ts +37 -0
- package/dist/recoveryPolicy.js +251 -0
- package/dist/roles.js +1 -0
- package/dist/supervisor.d.ts +40 -0
- package/dist/supervisor.js +102 -0
- package/dist/ticketHistoryReader.d.ts +19 -0
- package/dist/ticketHistoryReader.js +58 -0
- package/dist/tickets/details.d.ts +8 -0
- package/dist/tickets/details.js +59 -11
- package/dist/tickets/output.d.ts +5 -0
- package/dist/tickets/output.js +73 -0
- package/dist/tickets/stateDb.d.ts +1 -0
- package/dist/tickets/stateDb.js +5 -0
- package/dist/workflowDb.d.ts +31 -2
- package/dist/workflowDb.js +192 -23
- package/dist/workflowReader.d.ts +42 -0
- package/dist/workflowReader.js +171 -0
- package/foreman.yaml +13 -0
- package/package.json +51 -3
package/dist/buildRuns.js
CHANGED
|
@@ -4,24 +4,31 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import Database from "better-sqlite3";
|
|
7
|
-
import { WorkflowDb } from "./workflowDb.js";
|
|
7
|
+
import { WorkflowDb, heartbeatCurrentWorkflowLease, readCurrentWorkflowLease } from "./workflowDb.js";
|
|
8
|
+
import { WorkflowReader } from "./workflowReader.js";
|
|
8
9
|
import { canonicalSessionPath, captureWorkspaceIdentity, createProviderSessionRef, latestSessionBinding, upsertSessionBinding } from "./sessionIdentity.js";
|
|
9
10
|
import { resolveProviderSessionAvailability } from "./sessionAvailability.js";
|
|
10
11
|
import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } from "./branch/currentGuard.js";
|
|
11
12
|
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
12
13
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
13
|
-
|
|
14
|
+
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
15
|
+
export const BUILD_RUN_VERSION = 3;
|
|
14
16
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
15
17
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
16
18
|
export const BUILD_LEASE_STALE_MS = 45_000;
|
|
17
19
|
export function createBuildRun(input) {
|
|
18
20
|
const now = input.now ?? new Date();
|
|
21
|
+
// Complete the one-time legacy import before the new mutable build snapshot
|
|
22
|
+
// exists, so a brand-new run is never mistaken for legacy input.
|
|
23
|
+
const migration = new WorkflowDb(input.repositoryRoot);
|
|
24
|
+
migration.close();
|
|
19
25
|
const stamp = now.toISOString();
|
|
20
26
|
const worktree = resolve(input.worktree ?? input.repositoryRoot);
|
|
21
27
|
const snapshot = captureGitSnapshot(worktree, input);
|
|
28
|
+
const frozenPolicy = input.frozenPolicy ?? resolveAutonomyPolicy(loadProjectAutonomyConfig(input.repositoryRoot), input.autonomyProfile, now);
|
|
22
29
|
const run = {
|
|
23
|
-
version:
|
|
24
|
-
runId: randomUUID(),
|
|
30
|
+
version: 3,
|
|
31
|
+
runId: input.runId ?? randomUUID(),
|
|
25
32
|
status: "running",
|
|
26
33
|
tickets: [...input.tickets],
|
|
27
34
|
deliveryUnit: input.deliveryUnit,
|
|
@@ -40,6 +47,13 @@ export function createBuildRun(input) {
|
|
|
40
47
|
baselineComplete: Boolean(snapshot.baselineHead && snapshot.startHead && snapshot.branch),
|
|
41
48
|
},
|
|
42
49
|
progress: { completedTickets: [], completedOperations: [], remainingTickets: [...input.tickets], nextAction: input.tickets[0] ? `Start ${input.tickets[0]}` : "Plan next work" },
|
|
50
|
+
frozenPolicy,
|
|
51
|
+
phase: "created",
|
|
52
|
+
qaEnabled: input.qaEnabled ?? Boolean(input.qa),
|
|
53
|
+
recoveryAttempts: [],
|
|
54
|
+
supervisor: { status: frozenPolicy.supervisorEnabled ? "starting" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 },
|
|
55
|
+
pendingDecisions: [],
|
|
56
|
+
deferredTickets: [],
|
|
43
57
|
runDecisions: input.runDecisions,
|
|
44
58
|
receipts: {},
|
|
45
59
|
lease: currentLease(now),
|
|
@@ -95,18 +109,17 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
95
109
|
validateBuildRun(run);
|
|
96
110
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
97
111
|
mkdirSync(directory, { recursive: true });
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
112
|
+
const upgraded = upgradeBuildRun(run, projectDir, now);
|
|
113
|
+
const completedOperations = Object.keys(upgraded.receipts).sort();
|
|
114
|
+
let next = {
|
|
115
|
+
...upgraded,
|
|
101
116
|
progress: { ...run.progress, completedOperations, remainingTickets: remainingTickets(run) },
|
|
102
117
|
updatedAt: now.toISOString(),
|
|
103
118
|
};
|
|
104
|
-
const target = join(directory, `${run.runId}.json`);
|
|
105
|
-
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
106
|
-
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
107
|
-
renameSync(temp, target);
|
|
108
119
|
const workflow = new WorkflowDb(projectDir);
|
|
109
120
|
try {
|
|
121
|
+
const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
|
|
122
|
+
next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
|
|
110
123
|
const existing = workflow.getRun(next.runId);
|
|
111
124
|
if (!existing)
|
|
112
125
|
workflow.createRun({
|
|
@@ -127,6 +140,10 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
|
|
|
127
140
|
finally {
|
|
128
141
|
workflow.close();
|
|
129
142
|
}
|
|
143
|
+
const target = join(directory, `${run.runId}.json`);
|
|
144
|
+
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
145
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
146
|
+
renameSync(temp, target);
|
|
130
147
|
return next;
|
|
131
148
|
}
|
|
132
149
|
export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
|
|
@@ -185,17 +202,11 @@ export function recordBuildReceipt(projectDir, run, operationId, detail) {
|
|
|
185
202
|
return next;
|
|
186
203
|
}
|
|
187
204
|
export function heartbeatBuildRun(projectDir, run, now = new Date()) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
workflow.heartbeatLease(lease, now);
|
|
194
|
-
}
|
|
195
|
-
finally {
|
|
196
|
-
workflow.close();
|
|
197
|
-
}
|
|
198
|
-
return saved;
|
|
205
|
+
const lease = readCurrentWorkflowLease(projectDir);
|
|
206
|
+
if (!lease || lease.runId !== run.runId)
|
|
207
|
+
throw new Error("workflow lease ownership changed");
|
|
208
|
+
const next = heartbeatCurrentWorkflowLease(projectDir, lease, now);
|
|
209
|
+
return { ...run, lease: { hostname: next.host, pid: next.pid, processStart: next.processStart, heartbeatAt: next.heartbeatAt } };
|
|
199
210
|
}
|
|
200
211
|
export function releaseBuildLease(projectDir, run, status = "interrupted") {
|
|
201
212
|
const saved = saveBuildRun(projectDir, { ...run, status, lease: undefined });
|
|
@@ -211,21 +222,41 @@ export function readBuildRuns(projectDir) {
|
|
|
211
222
|
const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
|
|
212
223
|
if (!existsSync(directory))
|
|
213
224
|
return [];
|
|
214
|
-
|
|
225
|
+
const workflow = existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3")) ? new WorkflowDb(projectDir) : undefined;
|
|
226
|
+
const runs = readdirSync(directory).filter((name) => name.endsWith(".json")).flatMap((name) => {
|
|
215
227
|
try {
|
|
216
|
-
const
|
|
217
|
-
validateBuildRun(
|
|
218
|
-
|
|
228
|
+
const projection = JSON.parse(readFileSync(join(directory, name), "utf8"));
|
|
229
|
+
validateBuildRun(projection);
|
|
230
|
+
const authoritative = workflow?.getRun(projection.runId)?.state;
|
|
231
|
+
const run = authoritative?.runId ? authoritative : projection;
|
|
232
|
+
const upgraded = upgradeBuildRun(run, projectDir);
|
|
233
|
+
return [{ ...upgraded, frozenPolicy: workflow?.autonomyPolicy(run.runId) ?? upgraded.frozenPolicy, recoveryAttempts: workflow?.recoveryAttempts(run.runId) ?? upgraded.recoveryAttempts, pendingDecisions: workflow?.pendingHumanDecisions(run.runId) ?? upgraded.pendingDecisions, supervisor: workflow?.supervisorState(run.runId) ?? upgraded.supervisor }];
|
|
219
234
|
}
|
|
220
235
|
catch {
|
|
221
236
|
return [];
|
|
222
237
|
}
|
|
223
238
|
}).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
239
|
+
workflow?.close();
|
|
240
|
+
return runs;
|
|
224
241
|
}
|
|
225
242
|
export function recoverableBuildRuns(projectDir, now = new Date()) {
|
|
243
|
+
const databaseLease = readCurrentWorkflowLease(projectDir);
|
|
226
244
|
return readBuildRuns(projectDir)
|
|
227
245
|
.filter((run) => run.status !== "completed")
|
|
228
|
-
.map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: isLeaseActive(run, now) }));
|
|
246
|
+
.map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: databaseLease?.runId === run.runId ? workflowLeaseLooksLive(databaseLease, now) : isLeaseActive(run, now) }));
|
|
247
|
+
}
|
|
248
|
+
function workflowLeaseLooksLive(lease, now) {
|
|
249
|
+
if (now.getTime() - new Date(lease.heartbeatAt).getTime() > BUILD_LEASE_STALE_MS)
|
|
250
|
+
return false;
|
|
251
|
+
if (lease.host !== hostname())
|
|
252
|
+
return true;
|
|
253
|
+
try {
|
|
254
|
+
process.kill(lease.pid, 0);
|
|
255
|
+
return lease.processStart === processStartIdentity(lease.pid);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
229
260
|
}
|
|
230
261
|
/**
|
|
231
262
|
* Current-branch runs created before ticket checkpointing did not retain their
|
|
@@ -298,7 +329,7 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
|
|
|
298
329
|
let issues = [];
|
|
299
330
|
let workflowState = {};
|
|
300
331
|
if (existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3"))) {
|
|
301
|
-
const workflow = new
|
|
332
|
+
const workflow = new WorkflowReader(projectDir);
|
|
302
333
|
try {
|
|
303
334
|
operationNames = workflow.operations(run.runId).filter((operation) => operation.status === "confirmed").map((operation) => operation.kind);
|
|
304
335
|
issues = workflow.issues(run.runId).map((issue) => issue.detail);
|
|
@@ -425,7 +456,7 @@ function processStartIdentity(pid) {
|
|
|
425
456
|
}
|
|
426
457
|
}
|
|
427
458
|
function validateBuildRun(run) {
|
|
428
|
-
if (![1, 2].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
|
|
459
|
+
if (![1, 2, 3].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
|
|
429
460
|
throw new Error("invalid build run record");
|
|
430
461
|
}
|
|
431
462
|
}
|
|
@@ -497,14 +528,20 @@ function relativeTime(value, now) {
|
|
|
497
528
|
return `${Math.floor(seconds / 3600)}h ago`;
|
|
498
529
|
return `${Math.floor(seconds / 86_400)}d ago`;
|
|
499
530
|
}
|
|
500
|
-
function upgradeBuildRun(run) {
|
|
501
|
-
if (run.version ===
|
|
531
|
+
function upgradeBuildRun(run, projectDir = run.repository.root, now = new Date()) {
|
|
532
|
+
if (run.version === 3)
|
|
502
533
|
return run;
|
|
534
|
+
const policy = resolveAutonomyPolicy(loadProjectAutonomyConfig(projectDir), undefined, new Date(run.createdAt || now));
|
|
535
|
+
if (run.version === 2)
|
|
536
|
+
return {
|
|
537
|
+
...run, version: 3, frozenPolicy: policy, phase: run.checkpoint, qaEnabled: Boolean(run.qa), recoveryAttempts: [],
|
|
538
|
+
supervisor: { status: policy.supervisorEnabled ? "stopped" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 }, pendingDecisions: [], deferredTickets: [], legacy: true,
|
|
539
|
+
};
|
|
503
540
|
const legacy = run;
|
|
504
541
|
const remaining = legacy.status === "completed" ? [] : legacy.tickets.slice(Math.max(0, legacy.currentTicket ? legacy.tickets.indexOf(legacy.currentTicket) : 0));
|
|
505
542
|
return {
|
|
506
543
|
...legacy,
|
|
507
|
-
version:
|
|
544
|
+
version: 3,
|
|
508
545
|
legacy: true,
|
|
509
546
|
repository: {
|
|
510
547
|
...legacy.repository,
|
|
@@ -524,6 +561,13 @@ function upgradeBuildRun(run) {
|
|
|
524
561
|
lastSuccessfulAction: legacy.checkpoint,
|
|
525
562
|
nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
|
|
526
563
|
},
|
|
564
|
+
frozenPolicy: policy,
|
|
565
|
+
phase: legacy.checkpoint,
|
|
566
|
+
qaEnabled: Boolean(legacy.qa),
|
|
567
|
+
recoveryAttempts: [],
|
|
568
|
+
supervisor: { status: policy.supervisorEnabled ? "stopped" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 },
|
|
569
|
+
pendingDecisions: [],
|
|
570
|
+
deferredTickets: [],
|
|
527
571
|
};
|
|
528
572
|
}
|
|
529
573
|
function releaseWorkflowLease(projectDir, runId, now = new Date()) {
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
export declare function buildDoctorCommand(): Command;
|
|
3
|
+
export declare function storageReport(projectDir: string): {
|
|
4
|
+
recoveryBytes: number;
|
|
5
|
+
observabilityBytes: number;
|
|
6
|
+
observabilityWalBytes: number;
|
|
7
|
+
logBytes: number;
|
|
8
|
+
summaryOnly: boolean;
|
|
9
|
+
};
|
package/dist/cli/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { resolve, join } from "node:path";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { isTicketsInitialized } from "../tickets/config.js";
|
|
@@ -8,6 +8,9 @@ import { cmdValidate } from "../tickets/commands.js";
|
|
|
8
8
|
import { checkGitHubReadiness, inspectGitHubRemote } from "../branch/github.js";
|
|
9
9
|
import { requireClaudeSDK } from "../adapters/claude.js";
|
|
10
10
|
import { resolveExecutablePath, sanitizeDiagnostics } from "../runtimeReadiness.js";
|
|
11
|
+
import { ObservabilityReader, ObservabilityStore } from "../observability.js";
|
|
12
|
+
import { WorkflowReader } from "../workflowReader.js";
|
|
13
|
+
import { WorkflowDb } from "../workflowDb.js";
|
|
11
14
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))?.version;
|
|
12
15
|
function commandVersion(command) {
|
|
13
16
|
const result = spawnSync(command, ["--version"], {
|
|
@@ -26,8 +29,16 @@ export function buildDoctorCommand() {
|
|
|
26
29
|
.argument("[project]", "path to the project directory", ".")
|
|
27
30
|
.option("--github", "run GitHub PR readiness checks")
|
|
28
31
|
.option("--live-claude", "run a bounded no-tools Claude adapter request (uses account quota)")
|
|
32
|
+
.option("--storage", "report recovery, observability, and JSONL storage without writing")
|
|
33
|
+
.option("--cleanup-storage", "prune expired observability detail and fully compact storage")
|
|
29
34
|
.action(async (project, opts) => {
|
|
30
35
|
const cwd = resolve(project);
|
|
36
|
+
if (opts.storage || opts.cleanupStorage) {
|
|
37
|
+
if (opts.cleanupStorage)
|
|
38
|
+
cleanupStorage(cwd);
|
|
39
|
+
reportStorage(cwd);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
31
42
|
let errors = 0;
|
|
32
43
|
const report = (ok, label, detail) => {
|
|
33
44
|
console.log(`${ok ? "ok" : "!!"} ${label}${detail ? ` — ${detail}` : ""}`);
|
|
@@ -120,6 +131,78 @@ export function buildDoctorCommand() {
|
|
|
120
131
|
process.exit(errors === 0 ? 0 : 1);
|
|
121
132
|
});
|
|
122
133
|
}
|
|
134
|
+
export function storageReport(projectDir) {
|
|
135
|
+
const root = resolve(projectDir);
|
|
136
|
+
const recovery = join(root, ".rafi", "recovery.sqlite3");
|
|
137
|
+
const reader = new ObservabilityReader(root);
|
|
138
|
+
try {
|
|
139
|
+
const storage = reader.storage();
|
|
140
|
+
return { recoveryBytes: fileBytes(recovery) + fileBytes(`${recovery}-wal`) + fileBytes(`${recovery}-shm`), observabilityBytes: storage.databaseBytes, observabilityWalBytes: storage.walBytes, logBytes: directoryLogBytes(join(root, ".foreman")), summaryOnly: storage.summaryOnly };
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
reader.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function reportStorage(projectDir) {
|
|
147
|
+
const report = storageReport(projectDir);
|
|
148
|
+
console.log(`storage recovery=${report.recoveryBytes} bytes observability=${report.observabilityBytes} bytes wal=${report.observabilityWalBytes} bytes logs=${report.logBytes} bytes summary_only=${report.summaryOnly}`);
|
|
149
|
+
}
|
|
150
|
+
function cleanupStorage(projectDir) {
|
|
151
|
+
const workflow = new WorkflowReader(projectDir);
|
|
152
|
+
try {
|
|
153
|
+
const lease = workflow.currentLease();
|
|
154
|
+
if (lease && Date.now() - new Date(lease.heartbeatAt).getTime() <= 45_000)
|
|
155
|
+
throw new Error(`refusing storage cleanup while live lease ${lease.runId} is active`);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
workflow.close();
|
|
159
|
+
}
|
|
160
|
+
const before = storageReport(projectDir);
|
|
161
|
+
const config = loadConfig(join(projectDir, "foreman.yaml"));
|
|
162
|
+
const store = new ObservabilityStore(projectDir, { config: config.observability });
|
|
163
|
+
let rows = 0;
|
|
164
|
+
let recoveryRows = 0;
|
|
165
|
+
let logs = { compressed: 0, deleted: 0, reclaimedBytes: 0 };
|
|
166
|
+
try {
|
|
167
|
+
rows = store.enforceLimits().prunedRows;
|
|
168
|
+
logs = store.maintainLogs(projectDir);
|
|
169
|
+
store.compact();
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
store.close();
|
|
173
|
+
}
|
|
174
|
+
const recovery = new WorkflowDb(projectDir);
|
|
175
|
+
try {
|
|
176
|
+
recoveryRows = recovery.pruneTerminalTelemetry(config.observability.detail_retention_days);
|
|
177
|
+
recovery.compactStorage();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
recovery.close();
|
|
181
|
+
}
|
|
182
|
+
const after = storageReport(projectDir);
|
|
183
|
+
const reclaimed = before.observabilityBytes + before.observabilityWalBytes + before.logBytes
|
|
184
|
+
- after.observabilityBytes - after.observabilityWalBytes - after.logBytes;
|
|
185
|
+
console.log(`storage cleanup reclaimed=${Math.max(0, reclaimed)} bytes pruned_rows=${rows} recovery_pruned_rows=${recoveryRows} compressed_logs=${logs.compressed} deleted_logs=${logs.deleted}`);
|
|
186
|
+
}
|
|
187
|
+
function fileBytes(path) { try {
|
|
188
|
+
return statSync(path).size;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return 0;
|
|
192
|
+
} }
|
|
193
|
+
function directoryLogBytes(path) {
|
|
194
|
+
if (!existsSync(path))
|
|
195
|
+
return 0;
|
|
196
|
+
let total = 0;
|
|
197
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
198
|
+
const child = join(path, entry.name);
|
|
199
|
+
if (entry.isDirectory())
|
|
200
|
+
total += directoryLogBytes(child);
|
|
201
|
+
else if (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.gz"))
|
|
202
|
+
total += fileBytes(child);
|
|
203
|
+
}
|
|
204
|
+
return total;
|
|
205
|
+
}
|
|
123
206
|
async function liveClaudeSmoke(cwd, executable) {
|
|
124
207
|
const abortController = new AbortController();
|
|
125
208
|
const timer = setTimeout(() => abortController.abort(), 45_000);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import type { EffortLevel } from "../adapters/types.js";
|
|
3
|
+
import type { ExternalDiagnosticMode } from "../diagnostics.js";
|
|
4
|
+
export interface ManagerCommandOptions {
|
|
5
|
+
resolveProject?: (project: string | undefined) => string;
|
|
6
|
+
requireProject?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildManagerCommand(options?: ManagerCommandOptions): Command;
|
|
9
|
+
export declare function runManager(projectDir: string, options: {
|
|
10
|
+
runId?: string;
|
|
11
|
+
ask?: string;
|
|
12
|
+
agent?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
effort?: EffortLevel;
|
|
15
|
+
fast?: boolean;
|
|
16
|
+
external?: ExternalDiagnosticMode;
|
|
17
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { createRoleBuilder, readOnlyPermissionConfig } from "../agentRun.js";
|
|
7
|
+
import { Log } from "../log.js";
|
|
8
|
+
import { buildManagerEvidencePacket, buildManagerProjectPacket } from "../managerPacket.js";
|
|
9
|
+
import { ManagerSessionRecorder } from "../observability.js";
|
|
10
|
+
import { collectManagerProjectDiagnostics, executeManagerEvidenceRequest, MANAGER_LOOKUP_MAX_ROUNDS, parseManagerEvidenceRequest, resolveManagerQuestionRuns } from "../projectDiagnostics.js";
|
|
11
|
+
export function buildManagerCommand(options = {}) {
|
|
12
|
+
return new Command("manager")
|
|
13
|
+
.description("Ask a read-only Manager about all retained builds in a project.")
|
|
14
|
+
.argument(options.requireProject ? "<project>" : "[project]", "project directory")
|
|
15
|
+
.option("--run <run-id>", "set the initial focused build run")
|
|
16
|
+
.option("--ask <question>", "ask one question and exit")
|
|
17
|
+
.option("--agent <runtime>", "claude | codex")
|
|
18
|
+
.option("--model <model>", "provider model ID")
|
|
19
|
+
.option("--effort <level>", "low | medium | high | xhigh")
|
|
20
|
+
.option("--fast", "use the provider fast mode")
|
|
21
|
+
.option("--external <mode>", "auto | on | off", "auto")
|
|
22
|
+
.action(async (project, opts) => {
|
|
23
|
+
const root = options.resolveProject ? options.resolveProject(project) : resolve(project ?? ".");
|
|
24
|
+
await runManager(root, {
|
|
25
|
+
runId: stringOption(opts.run), ask: stringOption(opts.ask), agent: stringOption(opts.agent), model: stringOption(opts.model),
|
|
26
|
+
effort: stringOption(opts.effort), fast: Boolean(opts.fast), external: validateExternal(opts.external),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export async function runManager(projectDir, options) {
|
|
31
|
+
if (!options.ask && (!input.isTTY || !output.isTTY))
|
|
32
|
+
throw new Error("non-TTY Manager use requires --ask <question>");
|
|
33
|
+
const initialCollection = collectManagerProjectDiagnostics(projectDir, { initialFocusRunId: options.runId, question: options.ask, external: "off" });
|
|
34
|
+
const initialReport = initialCollection.report;
|
|
35
|
+
let currentFocusRunId = initialReport.initialFocusRunId;
|
|
36
|
+
let referencedRunIds = [currentFocusRunId];
|
|
37
|
+
output.write(`rafi manager: ${initialReport.totalRunCount} retained build run${initialReport.totalRunCount === 1 ? "" : "s"}\n`);
|
|
38
|
+
output.write(`rafi manager: verified active run ${initialReport.verifiedActiveRunId ?? "none"}\n`);
|
|
39
|
+
output.write(`rafi manager: initial focus ${currentFocusRunId}\n`);
|
|
40
|
+
if (initialReport.staleRecoveryRunIds.length)
|
|
41
|
+
output.write(`rafi manager: stale recovery state ${initialReport.staleRecoveryRunIds.join(", ")}\n`);
|
|
42
|
+
const metadata = new ManagerSessionRecorder(projectDir);
|
|
43
|
+
const managerSessionId = randomUUID();
|
|
44
|
+
const startedAt = new Date().toISOString();
|
|
45
|
+
metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, startedAt, reportDigest: initialReport.digest, scope: "project", latestFocusRunId: currentFocusRunId, projectReportDigest: initialReport.projectDigest });
|
|
46
|
+
let role;
|
|
47
|
+
let packetState;
|
|
48
|
+
let usage;
|
|
49
|
+
let lookupRounds = 0;
|
|
50
|
+
let lookupOperations = 0;
|
|
51
|
+
try {
|
|
52
|
+
const activeRole = role = await createRoleBuilder({ projectDir, role: "manager", agent: options.agent, model: options.model, effort: options.effort, fast: options.fast,
|
|
53
|
+
yes: true, allowSwitch: false, label: "Manager", log: new Log(), permissionConfig: denyManagerTools(), sandboxMode: "read-only", persistSessionBindings: false });
|
|
54
|
+
const ask = async (question) => {
|
|
55
|
+
const refreshed = collectManagerProjectDiagnostics(projectDir, { initialFocusRunId: initialReport.initialFocusRunId, currentFocusRunId, referencedRunIds, question, external: "off" });
|
|
56
|
+
const resolved = resolveManagerQuestionRuns(refreshed.allSummaries, question, currentFocusRunId, referencedRunIds);
|
|
57
|
+
currentFocusRunId = resolved.focusRunId;
|
|
58
|
+
referencedRunIds = resolved.referencedRunIds.length ? resolved.referencedRunIds : referencedRunIds;
|
|
59
|
+
const collection = collectManagerProjectDiagnostics(projectDir, { initialFocusRunId: initialReport.initialFocusRunId, currentFocusRunId, referencedRunIds, question, external: options.external });
|
|
60
|
+
const report = collection.report;
|
|
61
|
+
const packet = buildManagerProjectPacket(report, question, packetState, referencedRunIds);
|
|
62
|
+
packetState = packet.state;
|
|
63
|
+
let result = await activeRole.builder.sendTurn(packet.prompt);
|
|
64
|
+
let questionLookupRounds = 0;
|
|
65
|
+
while (questionLookupRounds < MANAGER_LOOKUP_MAX_ROUNDS) {
|
|
66
|
+
const request = parseManagerEvidenceRequest(result.text);
|
|
67
|
+
if (!request) {
|
|
68
|
+
if (!looksLikeEvidenceRequest(result.text))
|
|
69
|
+
break;
|
|
70
|
+
questionLookupRounds += 1;
|
|
71
|
+
lookupRounds += 1;
|
|
72
|
+
result = await activeRole.builder.sendTurn(buildManagerEvidencePacket({ version: 1, requestId: "invalid-request", results: [{ kind: "list_runs", status: "invalid", limitation: "The evidence envelope was invalid. Use only the documented fixed fields and read-only operations, then answer with any remaining limitation disclosed." }], lookupRound: questionLookupRounds, remainingRounds: MANAGER_LOOKUP_MAX_ROUNDS - questionLookupRounds, digest: report.digest }, question));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
questionLookupRounds += 1;
|
|
76
|
+
lookupRounds += 1;
|
|
77
|
+
lookupOperations += Math.min(6, request.operations.length);
|
|
78
|
+
const response = executeManagerEvidenceRequest(projectDir, request, collection, questionLookupRounds);
|
|
79
|
+
packetState.lastEvidenceScope = request.operations.flatMap(operation => "runIds" in operation ? operation.runIds : []);
|
|
80
|
+
result = await activeRole.builder.sendTurn(buildManagerEvidencePacket(response, question));
|
|
81
|
+
}
|
|
82
|
+
const unfulfilled = parseManagerEvidenceRequest(result.text);
|
|
83
|
+
if (unfulfilled) {
|
|
84
|
+
const limitation = { version: 1, requestId: unfulfilled.requestId, results: unfulfilled.operations.slice(0, 6).map(operation => ({ kind: operation.kind, status: "limited", limitation: "the two-round evidence lookup budget is exhausted; answer with this limitation disclosed" })), lookupRound: MANAGER_LOOKUP_MAX_ROUNDS, remainingRounds: 0, digest: report.digest };
|
|
85
|
+
result = await activeRole.builder.sendTurn(buildManagerEvidencePacket(limitation, question));
|
|
86
|
+
}
|
|
87
|
+
output.write(`${parseManagerEvidenceRequest(result.text) || looksLikeEvidenceRequest(result.text) ? "Manager could not complete an answer within the bounded evidence lookup budget; the available project report remains partial." : result.text.trim()}\n`);
|
|
88
|
+
usage = await activeRole.builder.sessionUsage?.() ?? usage;
|
|
89
|
+
metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, provider: activeRole.builder.agent, startedAt, reportDigest: report.digest,
|
|
90
|
+
inputTokens: usage?.inputTokens, outputTokens: usage?.outputTokens, costUsd: usage?.authoritativeCostUsd, scope: "project", latestFocusRunId: currentFocusRunId,
|
|
91
|
+
projectReportDigest: report.projectDigest, lookupRounds, lookupOperations });
|
|
92
|
+
};
|
|
93
|
+
if (options.ask)
|
|
94
|
+
await ask(options.ask);
|
|
95
|
+
else {
|
|
96
|
+
const readline = createInterface({ input, output });
|
|
97
|
+
let interrupted = false;
|
|
98
|
+
const onInterrupt = () => { interrupted = true; readline.close(); };
|
|
99
|
+
readline.on("SIGINT", onInterrupt);
|
|
100
|
+
try {
|
|
101
|
+
while (true) {
|
|
102
|
+
const question = await readline.question("manager> ");
|
|
103
|
+
if (question.trim() === "/exit")
|
|
104
|
+
break;
|
|
105
|
+
if (question.trim())
|
|
106
|
+
await ask(question.trim());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!interrupted && error.code !== "ABORT_ERR" && error.code !== "ERR_USE_AFTER_CLOSE")
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
readline.off("SIGINT", onInterrupt);
|
|
115
|
+
readline.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, provider: activeRole.builder.agent, startedAt, endedAt: new Date().toISOString(), outcome: "completed", reportDigest: packetState?.digest ?? initialReport.digest, inputTokens: usage?.inputTokens, outputTokens: usage?.outputTokens, costUsd: usage?.authoritativeCostUsd, scope: "project", latestFocusRunId: currentFocusRunId, projectReportDigest: packetState?.projectDigest ?? initialReport.projectDigest, lookupRounds, lookupOperations });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, provider: role?.builder.agent, startedAt, endedAt: new Date().toISOString(), outcome: "error", reportDigest: packetState?.digest ?? initialReport.digest, errorCode: "manager_error", scope: "project", latestFocusRunId: currentFocusRunId, projectReportDigest: packetState?.projectDigest ?? initialReport.projectDigest, lookupRounds, lookupOperations });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await role?.builder.close().catch(() => { });
|
|
126
|
+
metadata.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function denyManagerTools() {
|
|
130
|
+
return { ...readOnlyPermissionConfig(), allowBash: [], allowTools: [], escalateBash: [""], escalateTools: ["Read", "Glob", "Grep", "Edit", "Write", "MultiEdit", "NotebookEdit", "TodoWrite", "WebFetch", "WebSearch", "Bash"] };
|
|
131
|
+
}
|
|
132
|
+
function stringOption(value) { return typeof value === "string" && value.length ? value : undefined; }
|
|
133
|
+
function validateExternal(value) { if (value === "auto" || value === "on" || value === "off")
|
|
134
|
+
return value; throw new Error("--external must be auto, on, or off"); }
|
|
135
|
+
function looksLikeEvidenceRequest(value) { return /^\s*(?:```(?:json)?\s*)?\{[\s\S]*"(?:operations|requestId)"/i.test(value); }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { readBuildRuns } from "../buildRuns.js";
|
|
4
|
+
import { WorkflowDb } from "../workflowDb.js";
|
|
5
|
+
export function buildAttachCommand() {
|
|
6
|
+
return new Command("build:attach")
|
|
7
|
+
.description("Attach to a supervised build and show its durable recovery state.")
|
|
8
|
+
.argument("[project]", "project directory", ".")
|
|
9
|
+
.requiredOption("--run <id>", "build run ID")
|
|
10
|
+
.action((project, opts) => {
|
|
11
|
+
const projectDir = resolve(project);
|
|
12
|
+
const run = readBuildRuns(projectDir).find((item) => item.runId === opts.run);
|
|
13
|
+
if (!run)
|
|
14
|
+
throw new Error(`build run not found: ${opts.run}`);
|
|
15
|
+
const db = new WorkflowDb(projectDir);
|
|
16
|
+
try {
|
|
17
|
+
const supervisor = db.supervisorState(run.runId);
|
|
18
|
+
console.log(`rafi: run ${run.runId}`);
|
|
19
|
+
console.log(`rafi: phase ${"phase" in run && typeof run.phase === "string" ? run.phase : run.checkpoint}; status ${run.status}`);
|
|
20
|
+
console.log(`rafi: supervisor ${supervisor?.status ?? "unavailable"}${supervisor?.pid ? ` pid=${supervisor.pid}` : ""}`);
|
|
21
|
+
const attempts = db.recoveryAttempts(run.runId);
|
|
22
|
+
console.log(`rafi: recovery attempts ${attempts.length}`);
|
|
23
|
+
for (const decision of db.pendingHumanDecisions(run.runId))
|
|
24
|
+
console.log(`rafi: decision ${decision.decisionId}: ${decision.prompt} [${decision.choices.map((choice) => choice.id).join(" | ")}]`);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
db.close();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function buildDecideCommand() {
|
|
32
|
+
return new Command("build:decide")
|
|
33
|
+
.description("Answer a durable supervised-build decision.")
|
|
34
|
+
.argument("[project]", "project directory", ".")
|
|
35
|
+
.requiredOption("--run <id>", "build run ID")
|
|
36
|
+
.requiredOption("--decision <id>", "decision ID")
|
|
37
|
+
.requiredOption("--choice <id>", "stable choice ID")
|
|
38
|
+
.action((project, opts) => {
|
|
39
|
+
const db = new WorkflowDb(resolve(project));
|
|
40
|
+
try {
|
|
41
|
+
const decision = db.answerHumanDecision(opts.run, opts.decision, opts.choice);
|
|
42
|
+
console.log(`rafi: recorded ${decision.decisionId}=${opts.choice}`);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function buildStopCommand() {
|
|
50
|
+
return new Command("build:stop")
|
|
51
|
+
.description("Request graceful termination of a supervised build.")
|
|
52
|
+
.argument("[project]", "project directory", ".")
|
|
53
|
+
.requiredOption("--run <id>", "build run ID")
|
|
54
|
+
.action((project, opts) => {
|
|
55
|
+
const db = new WorkflowDb(resolve(project));
|
|
56
|
+
try {
|
|
57
|
+
const state = db.supervisorState(opts.run);
|
|
58
|
+
if (!state)
|
|
59
|
+
throw new Error(`supervisor state not found for run ${opts.run}`);
|
|
60
|
+
const next = { ...state, status: "stopping", stopRequestedAt: new Date().toISOString() };
|
|
61
|
+
db.putSupervisorState(opts.run, next);
|
|
62
|
+
if (state.workerPid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(state.workerPid, "SIGTERM");
|
|
65
|
+
}
|
|
66
|
+
catch { /* stale worker */ }
|
|
67
|
+
}
|
|
68
|
+
if (state.pid && state.pid !== process.pid) {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(state.pid, "SIGTERM");
|
|
71
|
+
}
|
|
72
|
+
catch { /* stale supervisor */ }
|
|
73
|
+
}
|
|
74
|
+
console.log(`rafi: stop requested for ${opts.run}`);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
db.close();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|