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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
const RULES = ["qa.nonconvergence", "runtime.transient", "plan.material_change"];
|
|
6
|
+
const PROFILE_LIMITS = {
|
|
7
|
+
supervised: {
|
|
8
|
+
limits: { builderQaFixesPerTicket: 2, transientPreDispatchRetries: 0, protocolCorrections: 1, reconciledOperationRetries: 0, workerRestartsPerCheckpoint: 0, workerRestartsPerRun: 0 },
|
|
9
|
+
providerFallback: "never", planChanges: "review-all",
|
|
10
|
+
},
|
|
11
|
+
balanced: {
|
|
12
|
+
limits: { builderQaFixesPerTicket: 3, transientPreDispatchRetries: 3, protocolCorrections: 2, reconciledOperationRetries: 2, workerRestartsPerCheckpoint: 3, workerRestartsPerRun: 10 },
|
|
13
|
+
providerFallback: "safe-boundaries", planChanges: "review-material",
|
|
14
|
+
},
|
|
15
|
+
unattended: {
|
|
16
|
+
limits: { builderQaFixesPerTicket: 5, transientPreDispatchRetries: 5, protocolCorrections: 3, reconciledOperationRetries: 4, workerRestartsPerCheckpoint: 5, workerRestartsPerRun: 10 },
|
|
17
|
+
providerFallback: "safe-boundaries", planChanges: "auto-bounded",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export const DEFAULT_AUTONOMY_CONFIG = {
|
|
21
|
+
profile: "balanced",
|
|
22
|
+
continue_independent_tickets: true,
|
|
23
|
+
rules: {
|
|
24
|
+
"qa.nonconvergence": { action: "retry_builder", max_attempts: 3 },
|
|
25
|
+
"runtime.transient": { action: "retry", max_attempts: 3 },
|
|
26
|
+
"plan.material_change": { action: "human_required" },
|
|
27
|
+
},
|
|
28
|
+
supervisor: { enabled: true, max_worker_restarts_per_checkpoint: 3, max_worker_restarts_per_run: 10 },
|
|
29
|
+
};
|
|
30
|
+
export function loadProjectAutonomyConfig(projectDir) {
|
|
31
|
+
const path = join(projectDir, "rafi-config.yaml");
|
|
32
|
+
if (!existsSync(path))
|
|
33
|
+
return undefined;
|
|
34
|
+
const raw = parseYaml(readFileSync(path, "utf8"));
|
|
35
|
+
return raw?.autonomy === undefined ? undefined : validateAutonomyConfig(raw.autonomy);
|
|
36
|
+
}
|
|
37
|
+
/** QA precedence: CLI -> frozen run -> canonical project config -> legacy foreman.yaml. */
|
|
38
|
+
export function resolveQaEnablement(input) {
|
|
39
|
+
if (input.cli !== undefined)
|
|
40
|
+
return { enabled: input.cli, source: "cli" };
|
|
41
|
+
if (input.frozen !== undefined)
|
|
42
|
+
return { enabled: input.frozen, source: "frozen" };
|
|
43
|
+
const path = join(input.projectDir, "rafi-config.yaml");
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
const raw = parseYaml(readFileSync(path, "utf8"));
|
|
46
|
+
if (raw?.autonomy !== undefined)
|
|
47
|
+
return { enabled: raw.harness?.qa !== false, source: "project" };
|
|
48
|
+
}
|
|
49
|
+
if (input.legacyForeman !== undefined)
|
|
50
|
+
return { enabled: input.legacyForeman, source: "legacy-foreman", deprecationWarning: "foreman.yaml qa.enabled is deprecated; set harness.qa and autonomy in rafi-config.yaml" };
|
|
51
|
+
return { enabled: true, source: "default" };
|
|
52
|
+
}
|
|
53
|
+
export function validateAutonomyConfig(value) {
|
|
54
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
55
|
+
throw new Error("autonomy must be an object");
|
|
56
|
+
const input = value;
|
|
57
|
+
rejectUnknown(input, ["profile", "continue_independent_tickets", "rules", "supervisor"], "autonomy");
|
|
58
|
+
if (!["supervised", "balanced", "unattended"].includes(input.profile))
|
|
59
|
+
throw new Error("autonomy.profile must be supervised, balanced, or unattended");
|
|
60
|
+
if (typeof input.continue_independent_tickets !== "boolean")
|
|
61
|
+
throw new Error("autonomy.continue_independent_tickets must be a boolean");
|
|
62
|
+
if (!input.supervisor || typeof input.supervisor !== "object" || Array.isArray(input.supervisor))
|
|
63
|
+
throw new Error("autonomy.supervisor must be an object");
|
|
64
|
+
const supervisor = input.supervisor;
|
|
65
|
+
rejectUnknown(supervisor, ["enabled", "max_worker_restarts_per_checkpoint", "max_worker_restarts_per_run"], "autonomy.supervisor");
|
|
66
|
+
if (typeof supervisor.enabled !== "boolean")
|
|
67
|
+
throw new Error("autonomy.supervisor.enabled must be a boolean");
|
|
68
|
+
positiveInteger(supervisor.max_worker_restarts_per_checkpoint, "autonomy.supervisor.max_worker_restarts_per_checkpoint", true);
|
|
69
|
+
positiveInteger(supervisor.max_worker_restarts_per_run, "autonomy.supervisor.max_worker_restarts_per_run", true);
|
|
70
|
+
const overrides = {};
|
|
71
|
+
if (input.rules !== undefined) {
|
|
72
|
+
if (!input.rules || typeof input.rules !== "object" || Array.isArray(input.rules))
|
|
73
|
+
throw new Error("autonomy.rules must be an object");
|
|
74
|
+
const rawRules = input.rules;
|
|
75
|
+
rejectUnknown(rawRules, [...RULES], "autonomy.rules");
|
|
76
|
+
for (const [id, raw] of Object.entries(rawRules)) {
|
|
77
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
78
|
+
throw new Error(`autonomy.rules.${id} must be an object`);
|
|
79
|
+
const rule = raw;
|
|
80
|
+
rejectUnknown(rule, ["action", "max_attempts"], `autonomy.rules.${id}`);
|
|
81
|
+
const allowed = id === "qa.nonconvergence" ? ["retry_builder", "human_required"] : id === "runtime.transient" ? ["retry", "human_required"] : ["human_required"];
|
|
82
|
+
if (!allowed.includes(String(rule.action)))
|
|
83
|
+
throw new Error(`autonomy.rules.${id}.action is unsafe or unsupported`);
|
|
84
|
+
if (rule.max_attempts !== undefined)
|
|
85
|
+
positiveInteger(rule.max_attempts, `autonomy.rules.${id}.max_attempts`, true);
|
|
86
|
+
overrides[id] = { action: rule.action, ...(rule.max_attempts === undefined ? {} : { max_attempts: Number(rule.max_attempts) }) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
profile: input.profile,
|
|
91
|
+
continue_independent_tickets: input.continue_independent_tickets,
|
|
92
|
+
rules: overrides,
|
|
93
|
+
supervisor: { enabled: supervisor.enabled, max_worker_restarts_per_checkpoint: Number(supervisor.max_worker_restarts_per_checkpoint), max_worker_restarts_per_run: Number(supervisor.max_worker_restarts_per_run) },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function resolveAutonomyPolicy(config, profileOverride, now = new Date()) {
|
|
97
|
+
const profile = profileOverride ?? config?.profile ?? "balanced";
|
|
98
|
+
const defaults = PROFILE_LIMITS[profile];
|
|
99
|
+
const supervisor = config?.supervisor ?? DEFAULT_AUTONOMY_CONFIG.supervisor;
|
|
100
|
+
const defaultRules = {
|
|
101
|
+
"qa.nonconvergence": { action: profile === "supervised" ? "human_required" : "retry_builder", max_attempts: defaults.limits.builderQaFixesPerTicket },
|
|
102
|
+
"runtime.transient": { action: defaults.limits.transientPreDispatchRetries ? "retry" : "human_required", max_attempts: defaults.limits.transientPreDispatchRetries },
|
|
103
|
+
"plan.material_change": { action: profile === "unattended" ? "retry" : "human_required", max_attempts: profile === "unattended" ? 1 : 0 },
|
|
104
|
+
};
|
|
105
|
+
const policyWithoutDigest = {
|
|
106
|
+
version: 1, profile,
|
|
107
|
+
continueIndependentTickets: config?.continue_independent_tickets ?? profile !== "supervised",
|
|
108
|
+
limits: {
|
|
109
|
+
...defaults.limits,
|
|
110
|
+
workerRestartsPerCheckpoint: supervisor.max_worker_restarts_per_checkpoint,
|
|
111
|
+
workerRestartsPerRun: supervisor.max_worker_restarts_per_run,
|
|
112
|
+
},
|
|
113
|
+
providerFallback: defaults.providerFallback,
|
|
114
|
+
planChanges: defaults.planChanges,
|
|
115
|
+
rules: Object.fromEntries(RULES.map((id) => [id, { ...defaultRules[id], ...(config?.rules?.[id] ?? {}) }])),
|
|
116
|
+
supervisorEnabled: supervisor.enabled,
|
|
117
|
+
resolvedAt: now.toISOString(),
|
|
118
|
+
};
|
|
119
|
+
return { ...policyWithoutDigest, digest: digestPolicy(policyWithoutDigest) };
|
|
120
|
+
}
|
|
121
|
+
export function mapWorkflowIssueDomain(code) {
|
|
122
|
+
if (code.startsWith("qa_"))
|
|
123
|
+
return "qa";
|
|
124
|
+
if (code.startsWith("role_") || code === "unstructured_agent_question")
|
|
125
|
+
return "agent-protocol";
|
|
126
|
+
if (code.includes("runtime") || code === "session_unavailable")
|
|
127
|
+
return "runtime";
|
|
128
|
+
if (code.startsWith("session_") || code === "recovery_failure")
|
|
129
|
+
return "continuity";
|
|
130
|
+
if (code.startsWith("ticket_") || code === "tracker_update_failure")
|
|
131
|
+
return "tracker/dependency";
|
|
132
|
+
if (code.startsWith("plan_") || code === "slice_mapping_failure" || code === "retirement_authorization_failure")
|
|
133
|
+
return "gate/plan";
|
|
134
|
+
if (code.startsWith("delivery_") || code.startsWith("remote_action_"))
|
|
135
|
+
return "delivery";
|
|
136
|
+
if (code === "builder_tool_policy_failure")
|
|
137
|
+
return "permissions";
|
|
138
|
+
if (code === "terminal_incompatibility" || code === "depth_exceeded")
|
|
139
|
+
return "process";
|
|
140
|
+
return "unknown";
|
|
141
|
+
}
|
|
142
|
+
const HARD_BOUNDARY_CODES = new Set([
|
|
143
|
+
"qa_waiver", "builder_tool_policy_failure", "remote_action_denied", "remote_action_uncertain",
|
|
144
|
+
"ticket_corruption", "ticket_validation_failure", "plan_pair_mismatch", "slice_mapping_failure",
|
|
145
|
+
"retirement_authorization_failure", "workspace_integrity_drift", "semantic_scope_ambiguity",
|
|
146
|
+
"invalid_dependency_semantics", "unknown",
|
|
147
|
+
]);
|
|
148
|
+
export function evaluateRecovery(policy, interruption, attemptsUsed) {
|
|
149
|
+
if (interruption.domain === "unknown" || HARD_BOUNDARY_CODES.has(interruption.code))
|
|
150
|
+
return disposition("human_required", "pause", "hard safety boundary requires human review", 0, attemptsUsed);
|
|
151
|
+
if (interruption.code === "user_cancelled")
|
|
152
|
+
return disposition("terminal", "stop", "the user cancelled the run", 0, attemptsUsed);
|
|
153
|
+
if (interruption.dispatchState === "unknown")
|
|
154
|
+
return disposition("human_required", "pause", "dispatch could not be reconciled; replay may duplicate a mutation", 0, attemptsUsed);
|
|
155
|
+
if (interruption.domain === "qa" && interruption.code === "qa_nonconvergence") {
|
|
156
|
+
const rule = policy.rules["qa.nonconvergence"];
|
|
157
|
+
const max = rule.max_attempts ?? policy.limits.builderQaFixesPerTicket;
|
|
158
|
+
if (rule.action === "retry_builder" && attemptsUsed < max)
|
|
159
|
+
return { ...disposition("configured_decision", "retry_builder", "frozen qa.nonconvergence rule permits another Builder fix", max, attemptsUsed), rule: "qa.nonconvergence" };
|
|
160
|
+
return { ...disposition("human_required", "pause", "Builder QA fix budget exhausted", max, attemptsUsed), rule: "qa.nonconvergence" };
|
|
161
|
+
}
|
|
162
|
+
if (interruption.domain === "runtime" && ["network", "timeout", "rate-limit", "readiness", "builder_runtime_failure", "qa_runtime_failure"].includes(interruption.code)) {
|
|
163
|
+
const rule = policy.rules["runtime.transient"];
|
|
164
|
+
const max = rule.max_attempts ?? policy.limits.transientPreDispatchRetries;
|
|
165
|
+
if (rule.action === "retry" && attemptsUsed < max && (interruption.dispatchState === "not_dispatched" || interruption.dispatchState === "idempotent"))
|
|
166
|
+
return { ...disposition("auto_retry", "retry", "transient operation is safe to replay", max, attemptsUsed), rule: "runtime.transient" };
|
|
167
|
+
return { ...disposition("human_required", "pause", "transient retry is unsafe or exhausted", max, attemptsUsed), rule: "runtime.transient" };
|
|
168
|
+
}
|
|
169
|
+
if (interruption.domain === "agent-protocol" || interruption.domain === "continuity") {
|
|
170
|
+
const max = policy.limits.protocolCorrections;
|
|
171
|
+
return attemptsUsed < max ? disposition("auto_retry", "correct_protocol", "bounded protocol/continuity correction", max, attemptsUsed) : disposition("human_required", "pause", "protocol correction budget exhausted", max, attemptsUsed);
|
|
172
|
+
}
|
|
173
|
+
if (interruption.operation && interruption.dispatchState === "dispatched") {
|
|
174
|
+
const max = policy.limits.reconciledOperationRetries;
|
|
175
|
+
return attemptsUsed < max ? disposition("reconcile_then_retry", "reconcile", "external mutation must be reconciled before replay", max, attemptsUsed) : disposition("human_required", "pause", "reconciled operation retry budget exhausted", max, attemptsUsed);
|
|
176
|
+
}
|
|
177
|
+
return disposition("human_required", "pause", "no safe automatic disposition is registered", 0, attemptsUsed);
|
|
178
|
+
}
|
|
179
|
+
export function qaDecisionChoices() {
|
|
180
|
+
return [
|
|
181
|
+
{ id: "retry_builder_once", label: "Retry Builder once" },
|
|
182
|
+
{ id: "planner_remediation", label: "Ask Planner for read-only remediation" },
|
|
183
|
+
{ id: "waive_qa", label: "Waive QA", requiresConfirmation: true },
|
|
184
|
+
{ id: "pause", label: "Pause" },
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
export class RecoveryDispatcher {
|
|
188
|
+
db;
|
|
189
|
+
policy;
|
|
190
|
+
constructor(db, policy) {
|
|
191
|
+
this.db = db;
|
|
192
|
+
this.policy = policy;
|
|
193
|
+
}
|
|
194
|
+
async dispatch(input) {
|
|
195
|
+
const interruption = this.db.recordInterruption(input.interruption);
|
|
196
|
+
let effective = interruption;
|
|
197
|
+
if (effective.dispatchState === "unknown" && input.reconcile) {
|
|
198
|
+
const result = await input.reconcile();
|
|
199
|
+
effective = { ...effective, dispatchState: result === "absent" ? "not_dispatched" : result === "confirmed" ? "dispatched" : "unknown" };
|
|
200
|
+
}
|
|
201
|
+
const operationKey = effective.operation?.idempotencyKey ?? `${effective.domain}:${effective.code}`;
|
|
202
|
+
const attempts = this.db.recoveryAttemptCount(effective.runId, effective.ticket, effective.phase, effective.cause, operationKey);
|
|
203
|
+
const disposition = evaluateRecovery(this.policy, effective, attempts);
|
|
204
|
+
if (disposition.kind === "human_required") {
|
|
205
|
+
const decision = this.db.ensureHumanDecision({
|
|
206
|
+
decisionKey: `${effective.id}:${operationKey}`, runId: effective.runId, interruptionId: effective.id,
|
|
207
|
+
prompt: effective.domain === "qa" ? "QA recovery needs a decision" : disposition.reason,
|
|
208
|
+
choices: effective.domain === "qa" ? qaDecisionChoices() : [{ id: "retry_once", label: "Retry once" }, { id: "pause", label: "Pause" }], evidence: effective.evidence,
|
|
209
|
+
});
|
|
210
|
+
return { disposition, decision };
|
|
211
|
+
}
|
|
212
|
+
if (disposition.kind === "terminal")
|
|
213
|
+
return { disposition };
|
|
214
|
+
const runId = effective.runId;
|
|
215
|
+
const receipt = this.db.recordRecoveryAttempt({
|
|
216
|
+
attemptId: randomUUID(), runId, ticket: effective.ticket, phase: effective.phase, cause: effective.cause,
|
|
217
|
+
operationKey, attempt: attempts + 1, disposition: disposition.kind, action: disposition.action,
|
|
218
|
+
outcome: "intended", intendedAt: new Date().toISOString(),
|
|
219
|
+
});
|
|
220
|
+
this.db.updateRecoveryAttempt(receipt.attemptId, "started");
|
|
221
|
+
try {
|
|
222
|
+
const result = await input.execute(disposition);
|
|
223
|
+
this.db.updateRecoveryAttempt(receipt.attemptId, result.ok ? "succeeded" : "failed", result.detail);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
this.db.updateRecoveryAttempt(receipt.attemptId, "failed", error instanceof Error ? error.message : String(error));
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
return { disposition, attemptId: receipt.attemptId };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function disposition(kind, action, reason, maxAttempts, attemptsUsed) {
|
|
233
|
+
return { kind, action, reason, maxAttempts, attemptsUsed };
|
|
234
|
+
}
|
|
235
|
+
function positiveInteger(value, path, allowZero = false) {
|
|
236
|
+
if (!Number.isInteger(value) || Number(value) < (allowZero ? 0 : 1))
|
|
237
|
+
throw new Error(`${path} must be ${allowZero ? "a non-negative" : "a positive"} integer`);
|
|
238
|
+
}
|
|
239
|
+
function rejectUnknown(value, allowed, path) {
|
|
240
|
+
for (const key of Object.keys(value))
|
|
241
|
+
if (!allowed.includes(key))
|
|
242
|
+
throw new Error(`${path}: unknown setting ${key}`);
|
|
243
|
+
}
|
|
244
|
+
function stable(value) {
|
|
245
|
+
if (Array.isArray(value))
|
|
246
|
+
return `[${value.map(stable).join(",")}]`;
|
|
247
|
+
if (value && typeof value === "object")
|
|
248
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`).join(",")}}`;
|
|
249
|
+
return JSON.stringify(value ?? null);
|
|
250
|
+
}
|
|
251
|
+
function digestPolicy(value) { return createHash("sha256").update(stable(value)).digest("hex"); }
|
package/dist/roles.js
CHANGED
|
@@ -18,6 +18,7 @@ export const AGENT_ROLE_REGISTRY = [
|
|
|
18
18
|
{ id: "qa", label: "QA (separate review session)", consumers: ["run-wide independent review"], configurable: true, manifest: "qa", commands: ["rafi start", "rafi build:resume"] },
|
|
19
19
|
{ id: "ticket-maker", label: "Ticket maker", consumers: ["ticket population"], configurable: true, manifest: "ticket-maker", commands: ["rafi tickets populate"] },
|
|
20
20
|
{ id: "uninstaller", label: "Uninstaller", consumers: ["non-empty uninstall instructions only"], configurable: true, manifest: "uninstaller", commands: ["rafi uninstall"] },
|
|
21
|
+
{ id: "manager", label: "Manager (read-only diagnostics)", consumers: ["build diagnostics"], configurable: true, manifest: "manager", commands: ["rafi manager", "ai-foreman manager"] },
|
|
21
22
|
];
|
|
22
23
|
function libraryGetter(role) {
|
|
23
24
|
const a = getAgent(role);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ResolvedAutonomyPolicy, SupervisorState } from "rafi-spec";
|
|
2
|
+
export type WorkerOutcome = {
|
|
3
|
+
kind: "completed";
|
|
4
|
+
} | {
|
|
5
|
+
kind: "waiting_for_human";
|
|
6
|
+
} | {
|
|
7
|
+
kind: "stopped";
|
|
8
|
+
} | {
|
|
9
|
+
kind: "failed";
|
|
10
|
+
detail: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "crashed";
|
|
13
|
+
detail: string;
|
|
14
|
+
};
|
|
15
|
+
export interface SupervisorWorkerHandle {
|
|
16
|
+
pid?: number;
|
|
17
|
+
result: Promise<WorkerOutcome>;
|
|
18
|
+
stop: () => Promise<void> | void;
|
|
19
|
+
}
|
|
20
|
+
export interface DurableSupervisorOptions {
|
|
21
|
+
projectDir: string;
|
|
22
|
+
runId: string;
|
|
23
|
+
policy: ResolvedAutonomyPolicy;
|
|
24
|
+
spawnWorker: (generation: number) => Promise<SupervisorWorkerHandle> | SupervisorWorkerHandle;
|
|
25
|
+
checkpoint: () => string;
|
|
26
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
27
|
+
now?: () => Date;
|
|
28
|
+
}
|
|
29
|
+
/** Durable worker restart loop. The supervisor never owns the project's mutation lease. */
|
|
30
|
+
export declare class DurableSupervisor {
|
|
31
|
+
private readonly options;
|
|
32
|
+
private stopRequested;
|
|
33
|
+
private active?;
|
|
34
|
+
private readonly sleep;
|
|
35
|
+
private readonly now;
|
|
36
|
+
constructor(options: DurableSupervisorOptions);
|
|
37
|
+
requestStop(): void;
|
|
38
|
+
run(): Promise<WorkerOutcome>;
|
|
39
|
+
}
|
|
40
|
+
export declare function initialSupervisorState(): SupervisorState;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
2
|
+
/** Durable worker restart loop. The supervisor never owns the project's mutation lease. */
|
|
3
|
+
export class DurableSupervisor {
|
|
4
|
+
options;
|
|
5
|
+
stopRequested = false;
|
|
6
|
+
active;
|
|
7
|
+
sleep;
|
|
8
|
+
now;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
12
|
+
this.now = options.now ?? (() => new Date());
|
|
13
|
+
}
|
|
14
|
+
requestStop() {
|
|
15
|
+
this.stopRequested = true;
|
|
16
|
+
void this.active?.stop();
|
|
17
|
+
}
|
|
18
|
+
async run() {
|
|
19
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
20
|
+
let state = db.supervisorState(this.options.runId) ?? initialSupervisorState();
|
|
21
|
+
if (!this.options.policy.supervisorEnabled) {
|
|
22
|
+
state = { ...state, status: "disabled" };
|
|
23
|
+
db.putSupervisorState(this.options.runId, state);
|
|
24
|
+
db.close();
|
|
25
|
+
return { kind: "stopped" };
|
|
26
|
+
}
|
|
27
|
+
if (state.status === "running" && state.pid && state.pid !== process.pid && processLooksLive(state.pid, state.heartbeatAt)) {
|
|
28
|
+
db.close();
|
|
29
|
+
throw new Error(`supervisor already active for run ${this.options.runId} (pid ${state.pid})`);
|
|
30
|
+
}
|
|
31
|
+
state = { ...state, status: "running", pid: process.pid, generation: state.generation + 1, heartbeatAt: this.now().toISOString() };
|
|
32
|
+
db.putSupervisorState(this.options.runId, state);
|
|
33
|
+
let lastCheckpoint = this.options.checkpoint();
|
|
34
|
+
const heartbeat = setInterval(() => {
|
|
35
|
+
state = { ...state, heartbeatAt: this.now().toISOString() };
|
|
36
|
+
try {
|
|
37
|
+
db.putSupervisorState(this.options.runId, state);
|
|
38
|
+
}
|
|
39
|
+
catch { /* next durable boundary reports failure */ }
|
|
40
|
+
}, 10_000);
|
|
41
|
+
heartbeat.unref();
|
|
42
|
+
try {
|
|
43
|
+
while (!this.stopRequested) {
|
|
44
|
+
const pending = db.pendingHumanDecisions(this.options.runId);
|
|
45
|
+
if (pending.length) {
|
|
46
|
+
state = { ...state, status: "waiting_for_human", workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
47
|
+
db.putSupervisorState(this.options.runId, state);
|
|
48
|
+
return { kind: "waiting_for_human" };
|
|
49
|
+
}
|
|
50
|
+
const checkpoint = this.options.checkpoint();
|
|
51
|
+
if (checkpoint !== lastCheckpoint) {
|
|
52
|
+
lastCheckpoint = checkpoint;
|
|
53
|
+
state = { ...state, checkpointRestarts: 0 };
|
|
54
|
+
}
|
|
55
|
+
state = { ...state, status: "running", workerGeneration: state.workerGeneration + 1, heartbeatAt: this.now().toISOString() };
|
|
56
|
+
this.active = await this.options.spawnWorker(state.workerGeneration);
|
|
57
|
+
state = { ...state, workerPid: this.active.pid };
|
|
58
|
+
db.putSupervisorState(this.options.runId, state);
|
|
59
|
+
const outcome = await this.active.result;
|
|
60
|
+
this.active = undefined;
|
|
61
|
+
state = { ...state, workerPid: undefined, heartbeatAt: this.now().toISOString() };
|
|
62
|
+
if (outcome.kind !== "crashed") {
|
|
63
|
+
const status = outcome.kind === "completed" ? "stopped" : outcome.kind === "waiting_for_human" ? "waiting_for_human" : outcome.kind === "failed" ? "failed" : "stopped";
|
|
64
|
+
state = { ...state, status };
|
|
65
|
+
db.putSupervisorState(this.options.runId, state);
|
|
66
|
+
return outcome;
|
|
67
|
+
}
|
|
68
|
+
const checkpointLimit = this.options.policy.limits.workerRestartsPerCheckpoint;
|
|
69
|
+
const runLimit = this.options.policy.limits.workerRestartsPerRun;
|
|
70
|
+
if (state.checkpointRestarts >= checkpointLimit || state.runRestarts >= runLimit) {
|
|
71
|
+
state = { ...state, status: "failed" };
|
|
72
|
+
db.putSupervisorState(this.options.runId, state);
|
|
73
|
+
return { kind: "failed", detail: `worker restart budget exhausted at ${checkpoint}: ${outcome.detail}` };
|
|
74
|
+
}
|
|
75
|
+
state = { ...state, checkpointRestarts: state.checkpointRestarts + 1, runRestarts: state.runRestarts + 1 };
|
|
76
|
+
db.putSupervisorState(this.options.runId, state);
|
|
77
|
+
await this.sleep(Math.min(10_000, 250 * (2 ** Math.min(5, state.checkpointRestarts - 1))));
|
|
78
|
+
}
|
|
79
|
+
state = { ...state, status: "stopped", stopRequestedAt: this.now().toISOString(), workerPid: undefined };
|
|
80
|
+
db.putSupervisorState(this.options.runId, state);
|
|
81
|
+
return { kind: "stopped" };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
clearInterval(heartbeat);
|
|
85
|
+
db.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function initialSupervisorState() {
|
|
90
|
+
return { status: "starting", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 };
|
|
91
|
+
}
|
|
92
|
+
function processLooksLive(pid, heartbeatAt) {
|
|
93
|
+
if (!heartbeatAt || Date.now() - new Date(heartbeatAt).getTime() > 45_000)
|
|
94
|
+
return false;
|
|
95
|
+
try {
|
|
96
|
+
process.kill(pid, 0);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TicketEvent, TicketState } from "./tickets/stateDb.js";
|
|
2
|
+
/** Read-only ticket history access. Unlike StateDb, construction never creates or migrates a database. */
|
|
3
|
+
export declare class TicketHistoryReader {
|
|
4
|
+
readonly path?: string;
|
|
5
|
+
private readonly db?;
|
|
6
|
+
constructor(projectDir: string);
|
|
7
|
+
available(): boolean;
|
|
8
|
+
close(): void;
|
|
9
|
+
states(ticketIds: readonly string[]): TicketState[];
|
|
10
|
+
events(ticketIds: readonly string[], limit?: number): TicketEvent[];
|
|
11
|
+
operationReceipts(runIds: readonly string[], limit?: number): Array<{
|
|
12
|
+
operationId: string;
|
|
13
|
+
operationType: string;
|
|
14
|
+
ticketId?: string;
|
|
15
|
+
runId: string;
|
|
16
|
+
completedAt: string;
|
|
17
|
+
payload: unknown;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
5
|
+
/** Read-only ticket history access. Unlike StateDb, construction never creates or migrates a database. */
|
|
6
|
+
export class TicketHistoryReader {
|
|
7
|
+
path;
|
|
8
|
+
db;
|
|
9
|
+
constructor(projectDir) {
|
|
10
|
+
const root = resolve(projectDir);
|
|
11
|
+
if (!isTicketsInitialized(root))
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
const path = resolveTicketPaths(loadTicketsConfig(root), root).stateDb;
|
|
15
|
+
this.path = path;
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return;
|
|
18
|
+
this.db = new Database(path, { readonly: true, fileMustExist: true });
|
|
19
|
+
this.db.pragma("query_only = ON");
|
|
20
|
+
}
|
|
21
|
+
catch { /* unavailable or legacy ticket setup */ }
|
|
22
|
+
}
|
|
23
|
+
available() { return Boolean(this.db); }
|
|
24
|
+
close() { this.db?.close(); }
|
|
25
|
+
states(ticketIds) {
|
|
26
|
+
if (!this.db || !ticketIds.length)
|
|
27
|
+
return [];
|
|
28
|
+
try {
|
|
29
|
+
const ids = [...new Set(ticketIds)].slice(0, 500);
|
|
30
|
+
return this.db.prepare(`SELECT * FROM ticket_state WHERE ticket_id IN (${ids.map(() => "?").join(",")}) ORDER BY ticket_id`).all(...ids);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
events(ticketIds, limit = 500) {
|
|
37
|
+
if (!this.db || !ticketIds.length)
|
|
38
|
+
return [];
|
|
39
|
+
try {
|
|
40
|
+
const ids = [...new Set(ticketIds)].slice(0, 500);
|
|
41
|
+
return this.db.prepare(`SELECT * FROM ticket_events WHERE ticket_id IN (${ids.map(() => "?").join(",")}) ORDER BY timestamp DESC,id DESC LIMIT ?`).all(...ids, Math.max(1, Math.min(2_000, limit)));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
operationReceipts(runIds, limit = 500) {
|
|
48
|
+
if (!this.db || !runIds.length)
|
|
49
|
+
return [];
|
|
50
|
+
try {
|
|
51
|
+
const ids = [...new Set(runIds)].slice(0, 100);
|
|
52
|
+
return this.db.prepare(`SELECT * FROM operation_receipts WHERE run_id IN (${ids.map(() => "?").join(",")}) ORDER BY completed_at DESC,operation_id LIMIT ?`).all(...ids, Math.max(1, Math.min(2_000, limit))).map(row => ({ operationId: String(row.operation_id), operationType: String(row.operation_type), ...(row.ticket_id ? { ticketId: String(row.ticket_id) } : {}), runId: String(row.run_id), completedAt: String(row.completed_at), payload: JSON.parse(String(row.payload_json)) }));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -13,5 +13,13 @@ export interface TicketDetails {
|
|
|
13
13
|
events: TicketEvent[];
|
|
14
14
|
delivery: TicketDeliveryDetails;
|
|
15
15
|
}
|
|
16
|
+
export interface AllTicketDetails {
|
|
17
|
+
version: 1;
|
|
18
|
+
generated_at: string;
|
|
19
|
+
ticket_count: number;
|
|
20
|
+
tickets: TicketDetails[];
|
|
21
|
+
}
|
|
16
22
|
export declare function getTicketDetails(projectDir: string, ticketId: string, now?: Date): TicketDetails;
|
|
23
|
+
export declare function getAllTicketDetails(projectDir: string, now?: Date): AllTicketDetails;
|
|
17
24
|
export declare function formatTicketDetails(details: TicketDetails): string[];
|
|
25
|
+
export declare function formatAllTicketDetails(details: AllTicketDetails): string[];
|
package/dist/tickets/details.js
CHANGED
|
@@ -18,22 +18,60 @@ export function getTicketDetails(projectDir, ticketId, now = new Date()) {
|
|
|
18
18
|
const states = db.getAllStates();
|
|
19
19
|
const state = states.get(ticketId) ?? pristineTicketState(ticketId, now.toISOString());
|
|
20
20
|
const delivery = loadDeliveryConfig(projectDir);
|
|
21
|
-
|
|
22
|
-
const unitIds = new Set(units.map((unit) => unit.id));
|
|
23
|
-
const stacks = delivery?.stacks?.filter((stack) => stack.units.some((unit) => unitIds.has(unit))) ?? [];
|
|
24
|
-
return {
|
|
25
|
-
definition,
|
|
26
|
-
state,
|
|
27
|
-
effective_blockers: resolveBlockers(definition, states),
|
|
28
|
-
validation_history: db.getValidationSnapshots(ticketId),
|
|
29
|
-
events: db.getTicketEvents(ticketId),
|
|
30
|
-
delivery: { units, stacks },
|
|
31
|
-
};
|
|
21
|
+
return assembleTicketDetails(definition, state, states, db.getValidationSnapshots(ticketId), db.getTicketEvents(ticketId), delivery);
|
|
32
22
|
}
|
|
33
23
|
finally {
|
|
34
24
|
db.close();
|
|
35
25
|
}
|
|
36
26
|
}
|
|
27
|
+
export function getAllTicketDetails(projectDir, now = new Date()) {
|
|
28
|
+
const config = loadTicketsConfig(projectDir);
|
|
29
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
30
|
+
if (!existsSync(paths.tickets))
|
|
31
|
+
throw new Error("ticket tracker is not initialized; run `rafi tickets init`");
|
|
32
|
+
const definitions = loadTickets(paths.tickets)
|
|
33
|
+
.sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
|
|
34
|
+
const generatedAt = now.toISOString();
|
|
35
|
+
const db = new StateDb(paths.stateDb);
|
|
36
|
+
try {
|
|
37
|
+
const states = db.getAllStates();
|
|
38
|
+
const validations = groupByTicketId(db.getValidationSnapshots(), (snapshot) => snapshot.scope);
|
|
39
|
+
const events = groupByTicketId(db.getAllTicketEvents(), (event) => event.ticket_id);
|
|
40
|
+
const delivery = loadDeliveryConfig(projectDir);
|
|
41
|
+
const tickets = definitions.map((definition) => assembleTicketDetails(definition, states.get(definition.id) ?? pristineTicketState(definition.id, generatedAt), states, validations.get(definition.id) ?? [], events.get(definition.id) ?? [], delivery));
|
|
42
|
+
return { version: 1, generated_at: generatedAt, ticket_count: tickets.length, tickets };
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function assembleTicketDetails(definition, state, states, validationHistory, events, delivery) {
|
|
49
|
+
const units = delivery?.units.filter((unit) => unit.tickets.includes(definition.id)) ?? [];
|
|
50
|
+
const unitIds = new Set(units.map((unit) => unit.id));
|
|
51
|
+
const stacks = delivery?.stacks?.filter((stack) => stack.units.some((unit) => unitIds.has(unit))) ?? [];
|
|
52
|
+
return {
|
|
53
|
+
definition,
|
|
54
|
+
state,
|
|
55
|
+
effective_blockers: resolveBlockers(definition, states),
|
|
56
|
+
validation_history: validationHistory,
|
|
57
|
+
events,
|
|
58
|
+
delivery: { units, stacks },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function groupByTicketId(records, ticketId) {
|
|
62
|
+
const grouped = new Map();
|
|
63
|
+
for (const record of records) {
|
|
64
|
+
const id = ticketId(record);
|
|
65
|
+
if (id === null)
|
|
66
|
+
continue;
|
|
67
|
+
const group = grouped.get(id);
|
|
68
|
+
if (group)
|
|
69
|
+
group.push(record);
|
|
70
|
+
else
|
|
71
|
+
grouped.set(id, [record]);
|
|
72
|
+
}
|
|
73
|
+
return grouped;
|
|
74
|
+
}
|
|
37
75
|
export function formatTicketDetails(details) {
|
|
38
76
|
const lines = [`${details.definition.id}: ${details.definition.title}`, "", "Canonical ticket"];
|
|
39
77
|
const preferred = [
|
|
@@ -62,6 +100,16 @@ export function formatTicketDetails(details) {
|
|
|
62
100
|
lines.push(...formatRecords(details.events));
|
|
63
101
|
return lines;
|
|
64
102
|
}
|
|
103
|
+
export function formatAllTicketDetails(details) {
|
|
104
|
+
const lines = [`All tickets (${details.ticket_count})`, `Snapshot: ${details.generated_at}`];
|
|
105
|
+
details.tickets.forEach((ticket, index) => {
|
|
106
|
+
lines.push("");
|
|
107
|
+
if (index > 0)
|
|
108
|
+
lines.push("--------------------------------------------------------------------------------", "");
|
|
109
|
+
lines.push(...formatTicketDetails(ticket));
|
|
110
|
+
});
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
65
113
|
function formatField(label, value) {
|
|
66
114
|
if (Array.isArray(value)) {
|
|
67
115
|
if (value.length === 0)
|