ennodia 0.4.1 → 0.5.1
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/CONTRIBUTING.md +6 -0
- package/README.md +31 -3
- package/bin/ennodia +14 -3
- package/docs/getting-started.md +15 -3
- package/docs/guides/understand-results.md +45 -0
- package/docs/install.md +9 -3
- package/docs/questions.md +1 -1
- package/docs/reference/mcp-tools.md +94 -3
- package/docs/reference/supported-harnesses.md +86 -6
- package/docs/releases/0.5.0.md +43 -0
- package/docs/roadmap.md +5 -1
- package/package.json +6 -2
- package/skills/compositional-audit/SKILL.md +9 -0
- package/skills/pragmatic/SKILL.md +11 -0
- package/src/advisor.ts +3 -1
- package/src/budget.ts +5 -4
- package/src/compare.ts +83 -8
- package/src/core.ts +162 -23
- package/src/harnesses.ts +27 -2
- package/src/history.ts +2 -0
- package/src/index.ts +3 -0
- package/src/installation.ts +143 -0
- package/src/plan-advice.ts +20 -9
- package/src/planner.ts +4 -1
- package/src/readiness.ts +302 -0
- package/src/runs.ts +40 -1
- package/src/server.ts +106 -7
- package/src/setup-app.ts +338 -0
- package/src/tasks.ts +15 -0
- package/src/version.ts +1 -1
package/src/plan-advice.ts
CHANGED
|
@@ -156,11 +156,7 @@ export class PlanAdviceManager {
|
|
|
156
156
|
this.maxAdvice = Math.max(1, options.maxAdvice ?? DEFAULT_MAX_ADVICE);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
if (this.shuttingDown) {
|
|
161
|
-
throw new Error("PlanAdviceManager is shutting down.");
|
|
162
|
-
}
|
|
163
|
-
|
|
159
|
+
estimateBudget(input: PlanAdviceManagerStartInput): BudgetCheck {
|
|
164
160
|
const advisorPrompt = buildPlanAdvisorPrompt(input.prompt, input.inventory);
|
|
165
161
|
const advisorBudget = checkBudgetLimits(
|
|
166
162
|
estimateTaskBatchBudget({
|
|
@@ -173,6 +169,15 @@ export class PlanAdviceManager {
|
|
|
173
169
|
input.budget,
|
|
174
170
|
);
|
|
175
171
|
assertBudgetWithinLimits(advisorBudget);
|
|
172
|
+
return advisorBudget;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
start(input: PlanAdviceManagerStartInput): PlanAdviceView {
|
|
176
|
+
if (this.shuttingDown) {
|
|
177
|
+
throw new Error("PlanAdviceManager is shutting down.");
|
|
178
|
+
}
|
|
179
|
+
const advisorPrompt = buildPlanAdvisorPrompt(input.prompt, input.inventory);
|
|
180
|
+
const advisorBudget = this.estimateBudget(input);
|
|
176
181
|
|
|
177
182
|
// The Advisor receives the closed inventory snapshot, not the target
|
|
178
183
|
// workspace. A fresh empty cwd also avoids accidentally inheriting the
|
|
@@ -286,7 +291,7 @@ export class PlanAdviceManager {
|
|
|
286
291
|
};
|
|
287
292
|
}
|
|
288
293
|
|
|
289
|
-
|
|
294
|
+
validateExecution(
|
|
290
295
|
id: string,
|
|
291
296
|
expectedPlanDigest: string,
|
|
292
297
|
currentInventory: AdvisorInventorySnapshot,
|
|
@@ -324,9 +329,15 @@ export class PlanAdviceManager {
|
|
|
324
329
|
throw new Error("Advised plan changed after approval; no tasks were started.");
|
|
325
330
|
}
|
|
326
331
|
|
|
327
|
-
// Keep this final state change after every caller-supplied validation. The
|
|
328
|
-
// callback is synchronous, so no second authorization can interleave.
|
|
329
332
|
validate?.(structuredClone(compiled.plan));
|
|
333
|
+
return structuredClone(compiled.plan);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
authorizeExecution(id: string, expectedPlanDigest: string, currentInventory: AdvisorInventorySnapshot, validate?: (plan: AdvisorExecutionPlan) => void): AdvisorExecutionPlan {
|
|
337
|
+
const plan = this.validateExecution(id, expectedPlanDigest, currentInventory, validate);
|
|
338
|
+
const record = this.require(id);
|
|
339
|
+
// Validation and consumption remain synchronous and single-use. Async
|
|
340
|
+
// readiness checks happen before this and cannot consume a blocked plan.
|
|
330
341
|
record.status = "consumed";
|
|
331
342
|
record.consumedAtMs = Date.now();
|
|
332
343
|
record.endedAtMs = record.consumedAtMs;
|
|
@@ -335,7 +346,7 @@ export class PlanAdviceManager {
|
|
|
335
346
|
message: "Validated plan authorized for one execution.",
|
|
336
347
|
});
|
|
337
348
|
|
|
338
|
-
return
|
|
349
|
+
return plan;
|
|
339
350
|
}
|
|
340
351
|
|
|
341
352
|
async shutdown(options: { deadlineMs?: number } = {}): Promise<void> {
|
package/src/planner.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { HarnessDiscovery } from "./harnesses";
|
|
2
2
|
import { hasMultimodalInput, MULTIMODAL_INPUT_GUIDANCE } from "./harnesses";
|
|
3
|
+
import { harnessAttention, readinessBlocked, type HarnessAttention } from "./readiness";
|
|
3
4
|
import {
|
|
4
5
|
CATEGORY_HARNESS_PRIORITIES,
|
|
5
6
|
type RouteCategory,
|
|
6
7
|
} from "./priority";
|
|
7
8
|
|
|
8
9
|
export type RoutePlan = {
|
|
10
|
+
actionRequired?: HarnessAttention[];
|
|
9
11
|
category: RouteCategory;
|
|
10
12
|
reasons: string[];
|
|
11
13
|
candidates: string[];
|
|
@@ -25,7 +27,7 @@ export function planRoute(
|
|
|
25
27
|
options: PlanRouteOptions = {},
|
|
26
28
|
): RoutePlan {
|
|
27
29
|
const lower = prompt.toLowerCase();
|
|
28
|
-
const runnable = harnesses.filter((harness) => harness.runnable);
|
|
30
|
+
const runnable = harnesses.filter((harness) => harness.runnable && !readinessBlocked(harness.readiness));
|
|
29
31
|
const runnableIds = new Set(runnable.map((harness) => harness.id));
|
|
30
32
|
const reasons: string[] = [];
|
|
31
33
|
const audioInspectionSignals = /\bnative audio\b|\baudio listening\b|\blistening comparison\b|\blisten to\b[^.!?\n]{0,80}\b(audio|recordings?|files?|samples?)\b|\b(compare|assess|judge)\b[^.!?\n]{0,60}\b(audio|recordings?|voice|sound)\b/.test(lower);
|
|
@@ -92,6 +94,7 @@ export function planRoute(
|
|
|
92
94
|
const compareSuggested = parallelSuggested;
|
|
93
95
|
|
|
94
96
|
return {
|
|
97
|
+
...(harnessAttention(harnesses.filter(h => h.runnable)).length ? { actionRequired: harnessAttention(harnesses.filter(h => h.runnable)) } : {}),
|
|
95
98
|
category,
|
|
96
99
|
reasons,
|
|
97
100
|
candidates,
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { stripVTControlCharacters } from "node:util";
|
|
6
|
+
import type { CommandSpec, HarnessAdapter, HarnessDiscovery, HarnessRunInput } from "./harnesses";
|
|
7
|
+
import { signalOwnedProcess } from "./process";
|
|
8
|
+
|
|
9
|
+
export type ReadinessState = "unverified" | "ready" | "login-required" | "credentials-expired" | "setup-required" | "model-unavailable" | "quota-exhausted" | "unreachable" | "updating";
|
|
10
|
+
export type HarnessReadiness = {
|
|
11
|
+
state: ReadinessState;
|
|
12
|
+
checkedAt: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
message: string;
|
|
15
|
+
recovery?: { command: string[]; instruction: string; recheck: { tool: "ennodia_check_harnesses"; harnessIds: string[]; model?: string; verify: boolean } };
|
|
16
|
+
upgrade?: { status: "succeeded" | "failed" | "deferred" | "unsupported"; checkedAt: string; command?: string[]; message: string };
|
|
17
|
+
};
|
|
18
|
+
export type HarnessAttention = { harnessId: string; harnessName: string; readiness: HarnessReadiness };
|
|
19
|
+
export type HarnessCheckInput = { harnessIds?: string[]; model?: string; cwd?: string; refresh?: boolean; verify?: boolean; upgrade?: boolean };
|
|
20
|
+
export type PreflightReport = { checks: HarnessAttention[]; modelProbes: number; estimatedInputTokens: number; usage: "unknown" };
|
|
21
|
+
export type CommandResult = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean };
|
|
22
|
+
export type MaintenanceCommandRunner = (spec: CommandSpec, timeoutMs: number) => Promise<CommandResult>;
|
|
23
|
+
export type HarnessReadinessOptions = {
|
|
24
|
+
runCommand?: MaintenanceCommandRunner;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
stateDir?: string;
|
|
27
|
+
autoUpgrade?: boolean;
|
|
28
|
+
isBusy?: () => boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const BLOCKED = new Set<ReadinessState>(["login-required", "credentials-expired", "setup-required", "model-unavailable", "quota-exhausted", "unreachable", "updating"]);
|
|
32
|
+
export function readinessBlocked(readiness?: HarnessReadiness): boolean { return Boolean(readiness && BLOCKED.has(readiness.state)); }
|
|
33
|
+
export function harnessAttention(harnesses: HarnessDiscovery[]): HarnessAttention[] {
|
|
34
|
+
return harnesses.filter(h => readinessBlocked(h.readiness)).map(h => ({ harnessId: h.id, harnessName: h.name, readiness: h.readiness! }));
|
|
35
|
+
}
|
|
36
|
+
export class HarnessReadinessError extends Error {
|
|
37
|
+
constructor(readonly actionRequired: HarnessAttention[]) {
|
|
38
|
+
super(`Review not started. ${actionRequired.map(a => `${a.harnessName}: ${a.readiness.message}`).join(" ")}`);
|
|
39
|
+
this.name = "HarnessReadinessError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Recognize native failure envelopes, never quoted prose inside an answer. */
|
|
44
|
+
export function nativeReadinessFailure(stdout: string, stderr: string, allowPlainStdout = false): ReadinessState | undefined {
|
|
45
|
+
const stderrLines = stripVTControlCharacters(stderr).split(/\r?\n/);
|
|
46
|
+
const lines = [...stderrLines, ...stripVTControlCharacters(stdout).split(/\r?\n/)];
|
|
47
|
+
for (const [index, line] of lines.entries()) {
|
|
48
|
+
let message = line.trim();
|
|
49
|
+
try {
|
|
50
|
+
const event = JSON.parse(line);
|
|
51
|
+
if (event.type === "error") message = typeof event.message === "string" ? event.message : event.error?.message ?? "";
|
|
52
|
+
else if (event.type === "turn.failed") message = event.error?.message ?? "";
|
|
53
|
+
else continue;
|
|
54
|
+
} catch {
|
|
55
|
+
// Only status/nonce checks can distinguish plain stdout from worker prose.
|
|
56
|
+
if (index >= stderrLines.length && !allowPlainStdout) continue;
|
|
57
|
+
}
|
|
58
|
+
message = stripVTControlCharacters(message).trim();
|
|
59
|
+
if (/^(?:Error:\s*)?(?:This |The |Your )?API key (?:has expired|is expired)\b/i.test(message)) return "credentials-expired";
|
|
60
|
+
if (/^(?:(?:API )?Error:\s*)?(?:Failed to authenticate\b|OAuth session expired\b|Authentication failed\b|Not logged in\b|Unauthorized\b|401\b|Re-authentication required\b|No access token found\b|Token refresh failed\b|Failed to refresh (?:OAuth|token)\b|Your authentication token .*expired\b)/i.test(message)) return "login-required";
|
|
61
|
+
if (/^(?:Error:\s*)?(?:You've hit your usage limit\b|quota (?:exceeded|exhausted)\b|insufficient (?:credits|balance)\b|rate limit exceeded\b|Add credits to continue\b)/i.test(message)) return "quota-exhausted";
|
|
62
|
+
if (/^(?:Error:\s*)?(?:model .* (?:not found|not available|not supported)|unknown model\b|ModelNotFoundError\b)/i.test(message)) return "model-unavailable";
|
|
63
|
+
if (/^(?:Error:\s*)?(?:No (?:API key|provider|credentials)\b|Missing .*API.?KEY\b|unrecognized arguments\b|unknown (?:option|command)\b)/i.test(message)) return "setup-required";
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recipe(id: string): { auth?: string[]; login: string[]; update?: string[] } {
|
|
69
|
+
switch (id) {
|
|
70
|
+
case "codex": return { auth: ["login", "status"], login: ["login"] };
|
|
71
|
+
case "claude-code": return { auth: ["auth", "status", "--json"], login: ["auth", "login"], update: ["update"] };
|
|
72
|
+
case "opencode": return { auth: ["auth", "list"], login: ["auth", "login"], update: ["upgrade"] };
|
|
73
|
+
case "kilo": return { auth: ["auth", "list"], login: ["auth", "login"], update: ["upgrade"] };
|
|
74
|
+
case "hermes-agent": return { auth: ["auth", "list"], login: ["model"], update: ["update", "--yes"] };
|
|
75
|
+
case "kiro": return { auth: ["whoami", "--format", "json"], login: ["login"], update: ["update", "--non-interactive"] };
|
|
76
|
+
case "antigravity": return { login: [], update: ["update"] };
|
|
77
|
+
default: return { login: [] };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Preserve the installed distribution and channel. Never force an upgrade. */
|
|
82
|
+
export function harnessUpgradeCommand(h: HarnessDiscovery): string[] | undefined {
|
|
83
|
+
if (!h.commandPath) return undefined;
|
|
84
|
+
let actual: string;
|
|
85
|
+
try { actual = realpathSync(h.commandPath); } catch { return undefined; }
|
|
86
|
+
const brew = Bun.which("brew");
|
|
87
|
+
const cask = /\/Caskroom\/([^/]+)\//.exec(actual)?.[1];
|
|
88
|
+
const formula = /\/Cellar\/([^/]+)\//.exec(actual)?.[1];
|
|
89
|
+
const distribution = cask ?? formula;
|
|
90
|
+
const distributions: Record<string, string[]> = { codex: ["codex"], "claude-code": ["claude-code", "claude-code@latest"], opencode: ["opencode"], kilo: ["kilo"], kiro: ["kiro-cli"], "hermes-agent": ["hermes", "hermes-agent"], antigravity: ["antigravity", "antigravity-cli"], cline: ["cline"] };
|
|
91
|
+
if (distribution) return brew && distributions[h.id]?.includes(distribution) ? [brew, "upgrade", ...(cask ? ["--cask", "--greedy", "--no-quit"] : []), distribution] : undefined;
|
|
92
|
+
if (h.id === "codex") {
|
|
93
|
+
const npm = Bun.which("npm");
|
|
94
|
+
return npm && /\/node_modules\/@openai\/codex\//.test(actual) ? [npm, "install", "-g", "@openai/codex@latest"] : undefined;
|
|
95
|
+
}
|
|
96
|
+
const args = recipe(h.id).update;
|
|
97
|
+
return args ? [h.commandPath, ...args] : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const messages: Record<ReadinessState, string> = {
|
|
101
|
+
unverified: "Model access is unverified; a small request is required before review.",
|
|
102
|
+
ready: "The selected route returned a verified response.",
|
|
103
|
+
"login-required": "Sign-in is required before review.",
|
|
104
|
+
"credentials-expired": "The configured provider API key has expired. Replace the key in the native CLI/provider settings; signing in again will not renew it.",
|
|
105
|
+
"setup-required": "Install or repair the native CLI/provider configuration before review.",
|
|
106
|
+
"model-unavailable": "The selected model is unavailable on this route.",
|
|
107
|
+
"quota-exhausted": "Provider quota or credits are exhausted; sign-in will not resolve it.",
|
|
108
|
+
unreachable: "The readiness check did not complete; inspect connectivity or native setup before retrying.",
|
|
109
|
+
updating: "An upgrade is in progress. Recheck after it completes.",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Core owns readiness, failure memory and maintenance; native CLIs own credentials. */
|
|
113
|
+
export class HarnessReadinessManager {
|
|
114
|
+
private readonly cache = new Map<string, { at: number; value: HarnessReadiness }>();
|
|
115
|
+
private readonly flights = new Map<string, Promise<HarnessReadiness>>();
|
|
116
|
+
private readonly updates = new Map<string, NonNullable<HarnessReadiness["upgrade"]>>();
|
|
117
|
+
private readonly busyHarnesses = new Set<string>();
|
|
118
|
+
private readonly upgradeFlights = new Map<string, Promise<void>>();
|
|
119
|
+
private readonly abort = new AbortController();
|
|
120
|
+
private closing = false;
|
|
121
|
+
private readonly runner: MaintenanceCommandRunner;
|
|
122
|
+
private readonly now: () => number;
|
|
123
|
+
private readonly stateDir: string;
|
|
124
|
+
readonly autoUpgrade: boolean;
|
|
125
|
+
get stateDirectory(): string { return this.stateDir; }
|
|
126
|
+
constructor(private readonly options: HarnessReadinessOptions = {}) {
|
|
127
|
+
this.runner = options.runCommand ?? ((spec, timeout) => runMaintenanceCommand(spec, timeout, this.abort.signal));
|
|
128
|
+
this.now = options.now ?? Date.now;
|
|
129
|
+
this.stateDir = options.stateDir ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "ennodia", "harnesses");
|
|
130
|
+
this.autoUpgrade = options.autoUpgrade ?? process.env.ENNODIA_AUTO_UPDATE !== "false";
|
|
131
|
+
}
|
|
132
|
+
private key(h: HarnessDiscovery, model?: string, cwd?: string): string {
|
|
133
|
+
// Environment values may be secret: hash them, never expose or persist them.
|
|
134
|
+
const environment = createHash("sha256").update(JSON.stringify(process.env)).digest("hex");
|
|
135
|
+
return JSON.stringify([h.id, h.commandPath, h.version, model, resolve(cwd ?? process.cwd()), environment]);
|
|
136
|
+
}
|
|
137
|
+
private value(h: HarnessDiscovery, state: ReadinessState, model?: string): HarnessReadiness {
|
|
138
|
+
const r = recipe(h.id);
|
|
139
|
+
const result: HarnessReadiness = { state, checkedAt: new Date(this.now()).toISOString(), model, message: messages[state], upgrade: this.updates.get(h.id) };
|
|
140
|
+
if (state !== "ready") result.recovery = {
|
|
141
|
+
command: h.commandPath ? [h.commandPath, ...(state === "login-required" || state === "credentials-expired" || state === "unverified" ? r.login : ["--help"])] : [],
|
|
142
|
+
instruction: !h.commandPath ? "Install the supported native CLI, then recheck."
|
|
143
|
+
: state === "unverified" ? "Run the targeted recheck to verify model access. Sign in only when a login blocker is reported."
|
|
144
|
+
: state === "login-required" ? "Run the native command in an interactive terminal using the intended account/provider, then recheck."
|
|
145
|
+
: state === "credentials-expired" ? "Replace the expired API key through the native credential flow for the intended provider, then recheck the same route. Signing in again will not renew the key."
|
|
146
|
+
: state === "quota-exhausted" ? "Restore credits or quota through the intended provider, then recheck the same route. Do not repeat sign-in or switch to a different model silently."
|
|
147
|
+
: "Inspect the reported blocker through the native CLI; then recheck the same route. Do not change account, provider or model silently.",
|
|
148
|
+
recheck: { tool: "ennodia_check_harnesses", harnessIds: [h.id], ...(model ? { model } : {}), verify: true },
|
|
149
|
+
};
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
describe(h: HarnessDiscovery, state: ReadinessState, model?: string): HarnessReadiness { return this.value(h, state, model); }
|
|
153
|
+
invalidate(id: string): void {
|
|
154
|
+
for (const [key] of this.cache) if (JSON.parse(key)[0] === id) this.cache.delete(key);
|
|
155
|
+
}
|
|
156
|
+
private installationState(h: HarnessDiscovery): { stamp: string; lock: string } {
|
|
157
|
+
const identity = createHash("sha256").update(h.commandPath ?? h.id).digest("hex").slice(0, 20);
|
|
158
|
+
return { stamp: join(this.stateDir, `${identity}.json`), lock: join(this.stateDir, `${identity}.lock`) };
|
|
159
|
+
}
|
|
160
|
+
private clearDeadLease(lock: string): void {
|
|
161
|
+
if (!existsSync(lock)) return;
|
|
162
|
+
let owner: number;
|
|
163
|
+
try { owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")).pid; }
|
|
164
|
+
catch {
|
|
165
|
+
// A process can die between mkdir and recording its ownership.
|
|
166
|
+
try { if (this.now() - lstatSync(lock).mtimeMs > 120_000) rmSync(lock, { recursive: true, force: true }); } catch { /* raced with release */ }
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!Number.isInteger(owner) || owner < 1) return;
|
|
170
|
+
try { process.kill(owner, 0); }
|
|
171
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(lock, { recursive: true, force: true }); }
|
|
172
|
+
}
|
|
173
|
+
async check(h: HarnessDiscovery, adapter?: HarnessAdapter, input: HarnessCheckInput = {}): Promise<HarnessReadiness> {
|
|
174
|
+
if (this.closing) throw new Error("Harness maintenance is shutting down.");
|
|
175
|
+
const { lock } = this.installationState(h);
|
|
176
|
+
this.clearDeadLease(lock);
|
|
177
|
+
if (this.busyHarnesses.has(h.id) || existsSync(lock)) return this.value(h, "updating", input.model);
|
|
178
|
+
const key = this.key(h, input.model, input.cwd);
|
|
179
|
+
const existing = this.cache.get(key);
|
|
180
|
+
if (!input.refresh && existing && (!input.verify || existing.value.state !== "unverified") && this.now() - existing.at < (existing.value.state === "ready" ? 300_000 : 30_000)) return existing.value;
|
|
181
|
+
const flightKey = `${key}:${Boolean(input.verify)}`;
|
|
182
|
+
const flight = this.flights.get(flightKey);
|
|
183
|
+
if (flight) return flight;
|
|
184
|
+
const pending = this.performCheck(h, adapter, input).catch(() => this.value(h, "unreachable", input.model)).then(value => {
|
|
185
|
+
this.cache.set(key, { at: this.now(), value });
|
|
186
|
+
return value;
|
|
187
|
+
}).finally(() => this.flights.delete(flightKey));
|
|
188
|
+
this.flights.set(flightKey, pending);
|
|
189
|
+
return pending;
|
|
190
|
+
}
|
|
191
|
+
private async performCheck(h: HarnessDiscovery, adapter: HarnessAdapter | undefined, input: HarnessCheckInput): Promise<HarnessReadiness> {
|
|
192
|
+
if (!h.runnable || !h.commandPath || !adapter?.buildCommand) return this.value(h, "setup-required", input.model);
|
|
193
|
+
const { lock } = this.installationState(h);
|
|
194
|
+
this.clearDeadLease(lock);
|
|
195
|
+
if (this.busyHarnesses.has(h.id) || existsSync(lock)) return this.value(h, "updating", input.model);
|
|
196
|
+
const auth = recipe(h.id).auth;
|
|
197
|
+
if (auth) {
|
|
198
|
+
const result = await this.runner({ command: h.commandPath, args: auth }, 5_000);
|
|
199
|
+
const state = nativeReadinessFailure(result.stdout, result.stderr, true);
|
|
200
|
+
if (state) return this.value(h, state, input.model);
|
|
201
|
+
if (h.id === "claude-code") {
|
|
202
|
+
try { if (JSON.parse(result.stdout).loggedIn === false) return this.value(h, "login-required", input.model); } catch { /* verification below */ }
|
|
203
|
+
}
|
|
204
|
+
if (result.timedOut) return this.value(h, "unreachable", input.model);
|
|
205
|
+
if (result.exitCode !== 0) return this.value(h, ["codex", "claude-code", "kiro"].includes(h.id) ? "login-required" : "setup-required", input.model);
|
|
206
|
+
}
|
|
207
|
+
if (!input.verify) return this.value(h, "unverified", input.model);
|
|
208
|
+
const dir = mkdtempSync(join(tmpdir(), "ennodia-readiness-"));
|
|
209
|
+
const nonce = `ENNODIA_READY_${randomUUID().replaceAll("-", "")}`;
|
|
210
|
+
try {
|
|
211
|
+
const spec = adapter.buildCommand(h.commandPath, { prompt: `Reply with exactly ${nonce}. Do not use tools, read files, or perform any other work.`, cwd: dir, model: input.model, timeoutMs: 20_000, finalMessagePath: join(dir, "answer.txt"), nativeSandbox: adapter.supportsNativeSandbox ? "read-only" : undefined });
|
|
212
|
+
const result = await this.runner(spec, 25_000);
|
|
213
|
+
const failure = nativeReadinessFailure(result.stdout, result.stderr, true);
|
|
214
|
+
if (failure) return this.value(h, failure, input.model);
|
|
215
|
+
if (result.timedOut || result.exitCode !== 0 || adapter.failureReason?.(result.stdout, result.stderr)) return this.value(h, "unreachable", input.model);
|
|
216
|
+
let answer = adapter.readinessAnswer ? adapter.readinessAnswer(result.stdout) ?? "" : result.stdout;
|
|
217
|
+
try { answer = readFileSync(join(dir, "answer.txt"), "utf8"); } catch { /* native text output */ }
|
|
218
|
+
// An echoed prompt is not success. Require a standalone returned nonce.
|
|
219
|
+
if (!stripVTControlCharacters(answer).split(/\r?\n/).some(line => line.trim() === nonce)) return this.value(h, "setup-required", input.model);
|
|
220
|
+
return this.value(h, "ready", input.model);
|
|
221
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
222
|
+
}
|
|
223
|
+
observe(h: HarnessDiscovery, input: Pick<HarnessRunInput, "model" | "cwd">, result: { status: string; stdout: string; stderr: string; timedOut: boolean; hasOutput?: boolean }): void {
|
|
224
|
+
if (this.closing) return;
|
|
225
|
+
if (result.status === "cancelled" || result.timedOut) return;
|
|
226
|
+
const failure = result.status === "failed" ? nativeReadinessFailure(result.stdout, result.stderr) : undefined;
|
|
227
|
+
if (failure) this.invalidate(h.id);
|
|
228
|
+
if (failure || (result.status === "succeeded" && result.hasOutput)) {
|
|
229
|
+
this.cache.set(this.key(h, input.model, input.cwd), { at: this.now(), value: this.value(h, failure ?? "ready", input.model) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Upgrade at most daily across server restarts; use an exclusive per-install lease. */
|
|
233
|
+
async upgrade(h: HarnessDiscovery, force = false): Promise<void> {
|
|
234
|
+
if (this.closing) throw new Error("Harness maintenance is shutting down.");
|
|
235
|
+
const existing = this.upgradeFlights.get(h.id);
|
|
236
|
+
if (existing) return existing;
|
|
237
|
+
const pending = this.performUpgrade(h, force).finally(() => this.upgradeFlights.delete(h.id));
|
|
238
|
+
this.upgradeFlights.set(h.id, pending);
|
|
239
|
+
return pending;
|
|
240
|
+
}
|
|
241
|
+
private async performUpgrade(h: HarnessDiscovery, force: boolean): Promise<void> {
|
|
242
|
+
const command = harnessUpgradeCommand(h);
|
|
243
|
+
const checkedAt = new Date(this.now()).toISOString();
|
|
244
|
+
const save = (status: NonNullable<HarnessReadiness["upgrade"]>["status"], message: string) => {
|
|
245
|
+
this.updates.set(h.id, { status, checkedAt, command, message }); this.invalidate(h.id);
|
|
246
|
+
};
|
|
247
|
+
if (!command) { save("unsupported", "No supported updater was found for this installation. Use its original installer."); return; }
|
|
248
|
+
if (this.options.isBusy?.() || this.flights.size > 0) { save("deferred", "Upgrade deferred while Ennodia work is active."); return; }
|
|
249
|
+
const { stamp, lock } = this.installationState(h);
|
|
250
|
+
try {
|
|
251
|
+
mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
|
|
252
|
+
if (!force) {
|
|
253
|
+
try {
|
|
254
|
+
const previous = JSON.parse(readFileSync(stamp, "utf8"));
|
|
255
|
+
if (this.now() - previous.at < 86_400_000) {
|
|
256
|
+
if (previous.result) this.updates.set(h.id, previous.result);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
} catch { /* first check */ }
|
|
260
|
+
}
|
|
261
|
+
this.clearDeadLease(lock);
|
|
262
|
+
try { mkdirSync(lock); writeFileSync(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }), { mode: 0o600 }); } catch {
|
|
263
|
+
save("deferred", "Another maintenance process holds the installation lock. Recheck later."); return;
|
|
264
|
+
}
|
|
265
|
+
} catch { save("failed", "Maintenance state could not be written; no upgrade was attempted."); return; }
|
|
266
|
+
this.busyHarnesses.add(h.id);
|
|
267
|
+
try {
|
|
268
|
+
writeFileSync(stamp, JSON.stringify({ at: this.now() }), { mode: 0o600 });
|
|
269
|
+
const result = await this.runner({ command: command[0]!, args: command.slice(1) }, 120_000);
|
|
270
|
+
save(result.exitCode === 0 && !result.timedOut ? "succeeded" : "failed", result.exitCode === 0 && !result.timedOut ? "Native updater completed; version and readiness must be rechecked." : "Native updater did not complete successfully. Run the displayed update command interactively; no force flags were used.");
|
|
271
|
+
writeFileSync(stamp, JSON.stringify({ at: this.now(), result: this.updates.get(h.id) }), { mode: 0o600 });
|
|
272
|
+
} catch { save("failed", "Native updater failed to start. Run the displayed command interactively."); }
|
|
273
|
+
finally { this.busyHarnesses.delete(h.id); rmSync(lock, { recursive: true, force: true }); }
|
|
274
|
+
}
|
|
275
|
+
async shutdown(): Promise<void> {
|
|
276
|
+
this.closing = true;
|
|
277
|
+
this.abort.abort();
|
|
278
|
+
await Promise.allSettled([...this.flights.values(), ...this.upgradeFlights.values()]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function runMaintenanceCommand(spec: CommandSpec, timeoutMs: number, signal?: AbortSignal): Promise<CommandResult> {
|
|
283
|
+
if (signal?.aborted) throw new Error("Harness maintenance was cancelled.");
|
|
284
|
+
const child = Bun.spawn([spec.command, ...spec.args], { cwd: spec.cwd, env: { ...process.env, ...spec.env }, stdin: spec.stdin ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe", detached: process.platform !== "win32" });
|
|
285
|
+
if (spec.stdin && child.stdin) { child.stdin.write(spec.stdin); child.stdin.end(); }
|
|
286
|
+
let timedOut = false;
|
|
287
|
+
const readers = [child.stdout.getReader(), child.stderr.getReader()];
|
|
288
|
+
const chunks = ["", ""];
|
|
289
|
+
const kill = () => {
|
|
290
|
+
signalOwnedProcess(child, "SIGKILL", process.platform !== "win32");
|
|
291
|
+
for (const reader of readers) void reader.cancel().catch(() => {});
|
|
292
|
+
};
|
|
293
|
+
const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
|
|
294
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
295
|
+
try {
|
|
296
|
+
await Promise.all(readers.map(async (reader, index) => {
|
|
297
|
+
const decoder = new TextDecoder();
|
|
298
|
+
try { for (;;) { const { done, value } = await reader.read(); if (done) break; chunks[index] = (chunks[index] + decoder.decode(value, { stream: true })).slice(-32_000); } } finally { reader.releaseLock(); }
|
|
299
|
+
}));
|
|
300
|
+
return { exitCode: await child.exited, stdout: chunks[0]!, stderr: chunks[1]!, timedOut };
|
|
301
|
+
} finally { clearTimeout(timer); signal?.removeEventListener("abort", kill); signalOwnedProcess(child, "SIGKILL", process.platform !== "win32"); }
|
|
302
|
+
}
|
package/src/runs.ts
CHANGED
|
@@ -6,8 +6,9 @@ import {
|
|
|
6
6
|
type BudgetCheck,
|
|
7
7
|
type BudgetLimits,
|
|
8
8
|
} from "./budget";
|
|
9
|
-
import { MAX_PROMPT_CANDIDATE_CHARS, type CompareManager, type CompareView } from "./compare";
|
|
9
|
+
import { MAX_PROMPT_CANDIDATE_CHARS, type CompareManager, type CompareView, type KeepInMindNote } from "./compare";
|
|
10
10
|
import { diagnoseTasks, type TaskDiagnosis } from "./diagnosis";
|
|
11
|
+
import { HarnessReadinessError, harnessAttention, nativeReadinessFailure, type HarnessAttention, type PreflightReport, type HarnessReadiness, type ReadinessState } from "./readiness";
|
|
11
12
|
import type {
|
|
12
13
|
DiscoverHarnessesOptions,
|
|
13
14
|
HarnessAdapter,
|
|
@@ -98,6 +99,10 @@ export type RunStartInput = {
|
|
|
98
99
|
};
|
|
99
100
|
|
|
100
101
|
export type RunView = {
|
|
102
|
+
installation?: InstallationReport;
|
|
103
|
+
actionRequired?: HarnessAttention[];
|
|
104
|
+
preflight?: PreflightReport;
|
|
105
|
+
coverage?: { requested: number; succeeded: number; failed: number; skipped: number };
|
|
101
106
|
id: string;
|
|
102
107
|
status: RunStatus;
|
|
103
108
|
mode: RunMode;
|
|
@@ -126,6 +131,8 @@ export type RunView = {
|
|
|
126
131
|
etaConfidence: "timeout-budget" | "unknown" | "complete";
|
|
127
132
|
finalAnswer?: string;
|
|
128
133
|
finalAnswerChars: number;
|
|
134
|
+
/** Recipient context from Compare, kept separate from answer artifacts. */
|
|
135
|
+
keepInMind?: KeepInMindNote[];
|
|
129
136
|
error?: string;
|
|
130
137
|
diagnosis?: TaskDiagnosis;
|
|
131
138
|
failedTaskDiagnosis?: TaskDiagnosis;
|
|
@@ -158,11 +165,14 @@ export type PlanRoute = (
|
|
|
158
165
|
) => RoutePlan;
|
|
159
166
|
|
|
160
167
|
export type RunManagerDependencies = {
|
|
168
|
+
getInstallation?: () => InstallationReport;
|
|
161
169
|
taskManager: TaskManager;
|
|
162
170
|
compareManager: CompareManager;
|
|
163
171
|
discoverHarnesses: DiscoverHarnesses;
|
|
164
172
|
findHarnessAdapter: FindHarnessAdapter;
|
|
165
173
|
planRoute: PlanRoute;
|
|
174
|
+
prepareHarnesses?: (harnesses: HarnessDiscovery[], ids: string[], input: RunStartInput) => Promise<PreflightReport>;
|
|
175
|
+
describeReadiness?: (harness: HarnessDiscovery, state: ReadinessState, model?: string) => HarnessReadiness;
|
|
166
176
|
};
|
|
167
177
|
|
|
168
178
|
export type RunManagerShutdownOptions = {
|
|
@@ -175,6 +185,8 @@ export type RunManagerOptions = {
|
|
|
175
185
|
};
|
|
176
186
|
|
|
177
187
|
type InternalRun = {
|
|
188
|
+
actionRequired?: HarnessAttention[];
|
|
189
|
+
preflight?: PreflightReport;
|
|
178
190
|
id: string;
|
|
179
191
|
status: RunStatus;
|
|
180
192
|
mode: RunMode;
|
|
@@ -191,6 +203,7 @@ type InternalRun = {
|
|
|
191
203
|
taskIds: string[];
|
|
192
204
|
compareId?: string;
|
|
193
205
|
finalAnswer?: string;
|
|
206
|
+
keepInMind?: KeepInMindNote[];
|
|
194
207
|
error?: string;
|
|
195
208
|
diagnosis?: TaskDiagnosis;
|
|
196
209
|
failedTaskDiagnosis?: TaskDiagnosis;
|
|
@@ -258,6 +271,8 @@ export class RunManager {
|
|
|
258
271
|
const selectedHarnessIds = selectHarnessIds(input.harnessId, mode, plan);
|
|
259
272
|
|
|
260
273
|
if (selectedHarnessIds.length === 0) {
|
|
274
|
+
const blocked = harnessAttention(harnesses);
|
|
275
|
+
if (blocked.length) throw new HarnessReadinessError(blocked);
|
|
261
276
|
throw new Error("No runnable harnesses were found.");
|
|
262
277
|
}
|
|
263
278
|
|
|
@@ -290,6 +305,9 @@ export class RunManager {
|
|
|
290
305
|
throw new Error(`Budget limit exceeded: ${budget.issues.join(" ")}`);
|
|
291
306
|
}
|
|
292
307
|
|
|
308
|
+
const preflight = await this.dependencies.prepareHarnesses?.(harnesses, selectedHarnessIds, {
|
|
309
|
+
...input, budget: input.budget?.maxEstimatedInputTokens !== undefined ? { ...input.budget, maxEstimatedInputTokens: input.budget.maxEstimatedInputTokens - budget.estimate.estimatedTotalInputTokens } : input.budget,
|
|
310
|
+
});
|
|
293
311
|
const now = Date.now();
|
|
294
312
|
const run: InternalRun = {
|
|
295
313
|
id: randomUUID(),
|
|
@@ -309,6 +327,8 @@ export class RunManager {
|
|
|
309
327
|
createdAtMs: now,
|
|
310
328
|
updatedAtMs: now,
|
|
311
329
|
events: [],
|
|
330
|
+
preflight,
|
|
331
|
+
actionRequired: input.harnessId ? undefined : plan.actionRequired,
|
|
312
332
|
appliedSkills: input.skills?.map(toAppliedSkillInfo),
|
|
313
333
|
budget,
|
|
314
334
|
};
|
|
@@ -460,6 +480,11 @@ export class RunManager {
|
|
|
460
480
|
const task = await this.dependencies.taskManager.waitForTerminal(taskId);
|
|
461
481
|
if (task && run.status !== "cancelled") {
|
|
462
482
|
this.pushEvent(run, eventFromTask(task));
|
|
483
|
+
const state = task.status === "failed" ? nativeReadinessFailure(task.stdout, task.stderr) : undefined;
|
|
484
|
+
if (state) {
|
|
485
|
+
const discovery = (await this.dependencies.discoverHarnesses()).find(h => h.id === task.harnessId);
|
|
486
|
+
if (discovery?.readiness) run.actionRequired = [...(run.actionRequired ?? []).filter(a => a.harnessId !== task.harnessId), { harnessId: task.harnessId, harnessName: task.harnessName, readiness: this.dependencies.describeReadiness?.(discovery, state, task.model) ?? { ...discovery.readiness, state, model: task.model } }];
|
|
487
|
+
}
|
|
463
488
|
}
|
|
464
489
|
return task;
|
|
465
490
|
});
|
|
@@ -568,6 +593,9 @@ export class RunManager {
|
|
|
568
593
|
? `${advice}\n\nChosen answer from ${chosen.label ?? chosen.id}, unchanged:\n\n${chosen.content}`
|
|
569
594
|
: advice;
|
|
570
595
|
if (result.status === "succeeded" && finalAnswer) {
|
|
596
|
+
if (result.advisor?.keepInMind?.length) {
|
|
597
|
+
run.keepInMind = result.advisor.keepInMind;
|
|
598
|
+
}
|
|
571
599
|
this.pushEvent(run, {
|
|
572
600
|
type: "compare-succeeded",
|
|
573
601
|
compareId: compare.id,
|
|
@@ -667,9 +695,19 @@ export class RunManager {
|
|
|
667
695
|
etaConfidence: eta.etaConfidence,
|
|
668
696
|
finalAnswer: finalAnswer ? truncate(finalAnswer, maxAnswerChars) : undefined,
|
|
669
697
|
finalAnswerChars: finalAnswer?.length ?? 0,
|
|
698
|
+
keepInMind: run.keepInMind,
|
|
670
699
|
error: run.error,
|
|
671
700
|
diagnosis: run.diagnosis,
|
|
672
701
|
failedTaskDiagnosis: run.failedTaskDiagnosis,
|
|
702
|
+
actionRequired: run.actionRequired,
|
|
703
|
+
installation: this.dependencies.getInstallation?.(),
|
|
704
|
+
preflight: run.preflight,
|
|
705
|
+
coverage: {
|
|
706
|
+
requested: run.selectedHarnessIds.length + (run.harnessOverridden ? 0 : run.plan.actionRequired?.length ?? 0),
|
|
707
|
+
succeeded: run.taskIds.filter(id => this.dependencies.taskManager.get(id, { includeOutput: false, includeEvents: false })?.status === "succeeded").length,
|
|
708
|
+
failed: run.taskIds.filter(id => ["failed", "cancelled"].includes(this.dependencies.taskManager.get(id, { includeOutput: false, includeEvents: false })?.status ?? "")).length,
|
|
709
|
+
skipped: run.harnessOverridden ? 0 : run.plan.actionRequired?.length ?? 0,
|
|
710
|
+
},
|
|
673
711
|
eventCount: run.events.length,
|
|
674
712
|
events: options.includeEvents === false ? [] : tailItems(run.events, maxEvents),
|
|
675
713
|
appliedSkills: run.appliedSkills,
|
|
@@ -1094,3 +1132,4 @@ async function settleWithDeadline(
|
|
|
1094
1132
|
});
|
|
1095
1133
|
});
|
|
1096
1134
|
}
|
|
1135
|
+
import type { InstallationReport } from "./installation";
|