@kal-elsam/kairo-runtime 0.7.0 → 0.8.0
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 +12 -0
- package/global-template/components/catalog.json +4 -1
- package/global-template/components/orchestrator/extensions/pi/kairo-minion.js +604 -0
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/src/cli.js +10 -1
- package/src/global/adapters/pi.js +1 -1
- package/src/global/runtime/execution-adapters/pi.js +12 -2
- package/src/global/runtime/orchestration/index.js +23 -0
- package/src/global/runtime/orchestration/orch-receipts.js +234 -0
- package/src/global/runtime/orchestration/orch-types.js +173 -0
- package/src/global/runtime/orchestration/orch-validate.js +63 -0
- package/src/global/runtime/run-cli.js +1 -0
- package/src/global/runtime/run-manager.js +59 -4
- package/src/global/runtime/run-strategy.js +71 -0
- package/src/global/runtime/run-supervisor.js +22 -2
- package/src/global/runtime/run-types.js +5 -1
|
@@ -7,7 +7,7 @@ export const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
|
|
7
7
|
|
|
8
8
|
const CUSTOM_DIR_MESSAGE =
|
|
9
9
|
`Pi config writes are unsupported when ${PI_CODING_AGENT_DIR_ENV} points away from `
|
|
10
|
-
+ `~/${PI_DEFAULT_ROOT_DIR} (out of scope for Kairo
|
|
10
|
+
+ `~/${PI_DEFAULT_ROOT_DIR} (out of scope for Kairo managed config). `
|
|
11
11
|
+ `Unset ${PI_CODING_AGENT_DIR_ENV} to manage the default directory; runtime launches remain available.`;
|
|
12
12
|
|
|
13
13
|
/** True when PI_CODING_AGENT_DIR is set to a non-default absolute/relative path. */
|
|
@@ -30,12 +30,15 @@ export function buildPiPermissionsArgs(permissions = []) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
throw new Error(
|
|
33
|
-
`Pi permissions only support "read-only"
|
|
33
|
+
`Pi permissions only support "read-only" (got: ${permissions.join(", ")}). `
|
|
34
34
|
+ "Other aliases are rejected and are never translated to --approve."
|
|
35
35
|
);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function buildPiLaunch({
|
|
38
|
+
export function buildPiLaunch({
|
|
39
|
+
task, cwd, model, permissions = [], env = process.env,
|
|
40
|
+
strategy = "direct", extensionPath = null
|
|
41
|
+
} = {}) {
|
|
39
42
|
const args = [
|
|
40
43
|
"--mode",
|
|
41
44
|
"json",
|
|
@@ -47,6 +50,13 @@ export function buildPiLaunch({ task, cwd, model, permissions = [], env = proces
|
|
|
47
50
|
args.push("--model", model);
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
if (strategy === "orchestrated") {
|
|
54
|
+
if (typeof extensionPath !== "string" || !extensionPath) {
|
|
55
|
+
throw new Error("Orchestrated Pi requires a managed extension path.");
|
|
56
|
+
}
|
|
57
|
+
args.push("--no-extensions", "--extension", extensionPath);
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
args.push(task);
|
|
51
61
|
|
|
52
62
|
return {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { harnessHomePaths } from "../../paths.js";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
RUN_STRATEGIES, DAG_NODE_STATES, DAG_TERMINAL_STATES, ORCH_LIMITS, ORCH_ERROR_CODES,
|
|
6
|
+
OrchContractError, createTaskId, isTerminalDagState, normalizeRunStrategy,
|
|
7
|
+
createOrchLineage, createBudgetUsage, createDagNode, createMinionBrief,
|
|
8
|
+
createMinionResult, digestAllowlisted
|
|
9
|
+
} from "./orch-types.js";
|
|
10
|
+
export { assertOrchReceiptSecretFree, FORBIDDEN_KEYS, walkForbiddenKeys } from "./orch-validate.js";
|
|
11
|
+
export {
|
|
12
|
+
orchPaths, buildOrchReceipt, saveOrchReceipt, loadOrchReceipt,
|
|
13
|
+
createOrchState, saveOrchState, loadOrchState, terminalizeOrchNodes,
|
|
14
|
+
updateOrchState, applyMinionDagUpdate, finalizeOrchState, reconcileOrchState
|
|
15
|
+
} from "./orch-receipts.js";
|
|
16
|
+
|
|
17
|
+
export const KAIRO_MINION_RELATIVE_ASSET =
|
|
18
|
+
"components/orchestrator/extensions/pi/kairo-minion.js";
|
|
19
|
+
|
|
20
|
+
/** Materialized extension path under ~/.harness (never Pi global auto-discover). */
|
|
21
|
+
export function resolveKairoMinionExtensionPath(homeDir) {
|
|
22
|
+
return join(harnessHomePaths(homeDir).root, KAIRO_MINION_RELATIVE_ASSET);
|
|
23
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runPaths } from "../../paths.js";
|
|
5
|
+
import { writeAtomicJson } from "../write-atomic-json.js";
|
|
6
|
+
import {
|
|
7
|
+
DAG_NODE_STATES, ORCH_ERROR_CODES, OrchContractError, RUN_STRATEGIES,
|
|
8
|
+
createBudgetUsage, createDagNode, createMinionResult, createOrchLineage,
|
|
9
|
+
digestAllowlisted, isTerminalDagState, normalizeRunStrategy
|
|
10
|
+
} from "./orch-types.js";
|
|
11
|
+
import { assertOrchReceiptSecretFree, walkForbiddenKeys } from "./orch-validate.js";
|
|
12
|
+
|
|
13
|
+
export function orchPaths(homeDir, rootRunId) {
|
|
14
|
+
if (typeof rootRunId !== "string" || !rootRunId) {
|
|
15
|
+
throw new OrchContractError("rootRunId is required for orchestration paths.", {
|
|
16
|
+
code: ORCH_ERROR_CODES.INVALID_LINEAGE
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const { runDir } = runPaths(homeDir, rootRunId);
|
|
20
|
+
const orchDir = join(runDir, "orchestration");
|
|
21
|
+
return {
|
|
22
|
+
runDir, orchDir,
|
|
23
|
+
receiptPath: join(orchDir, "receipt.json"),
|
|
24
|
+
statePath: join(orchDir, "state.json")
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeNodes(nodes = []) {
|
|
29
|
+
return nodes.map((node) => {
|
|
30
|
+
const { objective, ...rest } = node;
|
|
31
|
+
return createDagNode({
|
|
32
|
+
...rest,
|
|
33
|
+
budget: createBudgetUsage(rest.budget ?? {}),
|
|
34
|
+
objectiveDigest: rest.objectiveDigest
|
|
35
|
+
?? (objective ? digestAllowlisted({ objective }) : null)
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildOrchReceipt({
|
|
41
|
+
rootRunId, strategy = RUN_STRATEGIES.ORCHESTRATED, lineage = null,
|
|
42
|
+
nodes = [], results = [], cliVersion = null, createdAt = null, recovered = false
|
|
43
|
+
} = {}) {
|
|
44
|
+
const normalizedStrategy = normalizeRunStrategy(strategy);
|
|
45
|
+
const normalizedLineage = createOrchLineage(lineage ?? { rootRunId, parentRunId: null, depth: 0 });
|
|
46
|
+
if (normalizedLineage.rootRunId !== rootRunId) {
|
|
47
|
+
throw new OrchContractError("lineage.rootRunId must match receipt rootRunId.", {
|
|
48
|
+
code: ORCH_ERROR_CODES.INVALID_LINEAGE
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return assertOrchReceiptSecretFree({
|
|
52
|
+
version: 1, strategy: normalizedStrategy, rootRunId, lineage: normalizedLineage,
|
|
53
|
+
nodes: normalizeNodes(nodes),
|
|
54
|
+
results: results.map((entry) => createMinionResult(entry)),
|
|
55
|
+
createdAt: createdAt ?? new Date().toISOString(),
|
|
56
|
+
cliVersion,
|
|
57
|
+
recovered: Boolean(recovered)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function saveOrchReceipt(receipt, { homeDir } = {}) {
|
|
62
|
+
const sanitized = assertOrchReceiptSecretFree(receipt);
|
|
63
|
+
const { orchDir, receiptPath } = orchPaths(homeDir, sanitized.rootRunId);
|
|
64
|
+
await mkdir(orchDir, { recursive: true });
|
|
65
|
+
try {
|
|
66
|
+
await writeAtomicJson(receiptPath, sanitized, { createExclusive: true });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code === "EEXIST") {
|
|
69
|
+
throw new OrchContractError(`Orchestration receipt already exists: ${sanitized.rootRunId}`, {
|
|
70
|
+
code: ORCH_ERROR_CODES.RECEIPT_EXISTS,
|
|
71
|
+
details: { rootRunId: sanitized.rootRunId, path: receiptPath }
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return { path: receiptPath, receipt: sanitized };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loadOrchReceipt(rootRunId, { homeDir } = {}) {
|
|
80
|
+
const { receiptPath } = orchPaths(homeDir, rootRunId);
|
|
81
|
+
if (!existsSync(receiptPath)) {
|
|
82
|
+
throw new OrchContractError(`Orchestration receipt not found: ${rootRunId}`, {
|
|
83
|
+
code: ORCH_ERROR_CODES.INVALID_LINEAGE, details: { rootRunId }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return assertOrchReceiptSecretFree(JSON.parse(await readFile(receiptPath, "utf8")));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createOrchState({
|
|
90
|
+
rootRunId, strategy = RUN_STRATEGIES.ORCHESTRATED, lineage = null,
|
|
91
|
+
nodes = [], results = [], cliVersion = null, updatedAt = null
|
|
92
|
+
} = {}) {
|
|
93
|
+
const normalizedStrategy = normalizeRunStrategy(strategy);
|
|
94
|
+
if (normalizedStrategy !== RUN_STRATEGIES.ORCHESTRATED) {
|
|
95
|
+
throw new OrchContractError("Orchestration state requires strategy orchestrated.", {
|
|
96
|
+
code: ORCH_ERROR_CODES.INVALID_STRATEGY
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const normalizedLineage = createOrchLineage(lineage ?? { rootRunId, parentRunId: null, depth: 0 });
|
|
100
|
+
if (normalizedLineage.rootRunId !== rootRunId) {
|
|
101
|
+
throw new OrchContractError("lineage.rootRunId must match state rootRunId.", {
|
|
102
|
+
code: ORCH_ERROR_CODES.INVALID_LINEAGE
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const state = {
|
|
106
|
+
version: 1, strategy: normalizedStrategy, rootRunId, lineage: normalizedLineage,
|
|
107
|
+
nodes: normalizeNodes(nodes),
|
|
108
|
+
results: (results ?? []).map((entry) => createMinionResult(entry)),
|
|
109
|
+
cliVersion: cliVersion ?? null,
|
|
110
|
+
updatedAt: updatedAt ?? new Date().toISOString()
|
|
111
|
+
};
|
|
112
|
+
walkForbiddenKeys(state);
|
|
113
|
+
return state;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function saveOrchState(state, { homeDir } = {}) {
|
|
117
|
+
const sanitized = createOrchState(state);
|
|
118
|
+
const { orchDir, statePath } = orchPaths(homeDir, sanitized.rootRunId);
|
|
119
|
+
await mkdir(orchDir, { recursive: true });
|
|
120
|
+
await writeAtomicJson(statePath, sanitized);
|
|
121
|
+
return { path: statePath, state: sanitized };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function loadOrchState(rootRunId, { homeDir } = {}) {
|
|
125
|
+
const { statePath } = orchPaths(homeDir, rootRunId);
|
|
126
|
+
if (!existsSync(statePath)) {
|
|
127
|
+
throw new OrchContractError(`Orchestration state not found: ${rootRunId}`, {
|
|
128
|
+
code: ORCH_ERROR_CODES.INVALID_NODE, details: { rootRunId }
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return createOrchState(JSON.parse(await readFile(statePath, "utf8")));
|
|
133
|
+
} catch {
|
|
134
|
+
throw new OrchContractError(`Corrupt orchestration state: ${rootRunId}`, {
|
|
135
|
+
code: ORCH_ERROR_CODES.INVALID_NODE, details: { rootRunId }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function terminalizeOrchNodes(nodes, { recovered = false } = {}) {
|
|
141
|
+
return (nodes ?? []).map((node) => {
|
|
142
|
+
if (isTerminalDagState(node.state)) return createDagNode(node);
|
|
143
|
+
return createDagNode({
|
|
144
|
+
...node,
|
|
145
|
+
state: recovered ? DAG_NODE_STATES.CANCELLED : DAG_NODE_STATES.COMPLETED,
|
|
146
|
+
error: recovered ? (node.error ?? { code: "interrupted" }) : node.error
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const orchWriteLocks = new Map();
|
|
152
|
+
|
|
153
|
+
function withOrchWriteLock(rootRunId, work) {
|
|
154
|
+
const previous = orchWriteLocks.get(rootRunId) ?? Promise.resolve();
|
|
155
|
+
const next = previous.then(work);
|
|
156
|
+
orchWriteLocks.set(rootRunId, next.catch(() => {}));
|
|
157
|
+
return next;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Serialized load → mutate → save for concurrent minion DAG updates. */
|
|
161
|
+
export async function updateOrchState(rootRunId, mutator, { homeDir } = {}) {
|
|
162
|
+
return withOrchWriteLock(rootRunId, async () => {
|
|
163
|
+
const current = await loadOrchState(rootRunId, { homeDir });
|
|
164
|
+
return saveOrchState(await mutator(current), { homeDir });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Upsert one depth-1 node by taskId; append/replace MinionResult on completed. */
|
|
169
|
+
export async function applyMinionDagUpdate(rootRunId, {
|
|
170
|
+
homeDir, taskId, parentTaskId, attempt = 0, state,
|
|
171
|
+
objectiveDigest = null, result = null, error = null
|
|
172
|
+
} = {}) {
|
|
173
|
+
return updateOrchState(rootRunId, (current) => {
|
|
174
|
+
const rootTaskId = current.lineage?.taskId;
|
|
175
|
+
if (!rootTaskId || taskId === rootTaskId || parentTaskId !== rootTaskId) {
|
|
176
|
+
throw new OrchContractError("Minion taskId/parentTaskId must honor supervisor rootTaskId.", {
|
|
177
|
+
code: ORCH_ERROR_CODES.INVALID_NODE, details: { taskId, parentTaskId, rootTaskId }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const node = createDagNode({
|
|
181
|
+
taskId, parentTaskId, depth: 1, state, attempt,
|
|
182
|
+
objectiveDigest: objectiveDigest ?? null, error: error ?? null
|
|
183
|
+
});
|
|
184
|
+
const nodes = [...current.nodes];
|
|
185
|
+
const idx = nodes.findIndex((entry) => entry.taskId === taskId);
|
|
186
|
+
if (idx >= 0) {
|
|
187
|
+
nodes[idx] = createDagNode({
|
|
188
|
+
...nodes[idx], ...node,
|
|
189
|
+
objectiveDigest: node.objectiveDigest ?? nodes[idx].objectiveDigest
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
nodes.push(node);
|
|
193
|
+
}
|
|
194
|
+
let results = current.results;
|
|
195
|
+
if (state === DAG_NODE_STATES.COMPLETED && result) {
|
|
196
|
+
const sealed = createMinionResult(result);
|
|
197
|
+
results = [...results.filter((entry) => entry.taskId !== taskId), sealed];
|
|
198
|
+
}
|
|
199
|
+
return { ...current, nodes, results, updatedAt: new Date().toISOString() };
|
|
200
|
+
}, { homeDir });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function finalizeOrchState(rootRunId, { homeDir, recovered = false } = {}) {
|
|
204
|
+
return withOrchWriteLock(rootRunId, async () => {
|
|
205
|
+
const { receiptPath } = orchPaths(homeDir, rootRunId);
|
|
206
|
+
if (existsSync(receiptPath)) {
|
|
207
|
+
return { path: receiptPath, receipt: await loadOrchReceipt(rootRunId, { homeDir }), idempotent: true };
|
|
208
|
+
}
|
|
209
|
+
const state = await loadOrchState(rootRunId, { homeDir });
|
|
210
|
+
const nodes = terminalizeOrchNodes(state.nodes, { recovered });
|
|
211
|
+
await saveOrchState({ ...state, nodes, updatedAt: new Date().toISOString() }, { homeDir });
|
|
212
|
+
try {
|
|
213
|
+
const saved = await saveOrchReceipt(buildOrchReceipt({
|
|
214
|
+
rootRunId: state.rootRunId, strategy: state.strategy, lineage: state.lineage,
|
|
215
|
+
nodes, results: state.results, cliVersion: state.cliVersion, recovered
|
|
216
|
+
}), { homeDir });
|
|
217
|
+
return { ...saved, idempotent: false };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error?.code === ORCH_ERROR_CODES.RECEIPT_EXISTS) {
|
|
220
|
+
return { path: receiptPath, receipt: await loadOrchReceipt(rootRunId, { homeDir }), idempotent: true };
|
|
221
|
+
}
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function reconcileOrchState(rootRunId, { homeDir } = {}) {
|
|
228
|
+
const { statePath, receiptPath } = orchPaths(homeDir, rootRunId);
|
|
229
|
+
if (existsSync(receiptPath)) {
|
|
230
|
+
return { rootRunId, path: receiptPath, receipt: await loadOrchReceipt(rootRunId, { homeDir }), idempotent: true };
|
|
231
|
+
}
|
|
232
|
+
if (!existsSync(statePath)) return null;
|
|
233
|
+
return finalizeOrchState(rootRunId, { homeDir, recovered: true });
|
|
234
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
export const RUN_STRATEGIES = Object.freeze({ DIRECT: "direct", ORCHESTRATED: "orchestrated" });
|
|
3
|
+
export const DAG_NODE_STATES = Object.freeze({
|
|
4
|
+
PENDING: "pending", READY: "ready", RUNNING: "running", COMPACTING: "compacting",
|
|
5
|
+
COMPLETED: "completed", FAILED: "failed", CANCELLED: "cancelled", BLOCKED: "blocked"
|
|
6
|
+
});
|
|
7
|
+
export const DAG_TERMINAL_STATES = new Set([
|
|
8
|
+
DAG_NODE_STATES.COMPLETED, DAG_NODE_STATES.FAILED, DAG_NODE_STATES.CANCELLED
|
|
9
|
+
]);
|
|
10
|
+
export const ORCH_LIMITS = Object.freeze({
|
|
11
|
+
MAX_DEPTH: 1, DEFAULT_CONCURRENCY: 2, MAX_ATTEMPTS: 2, COMPACT_RATIO: 0.7, STOP_RATIO: 0.9
|
|
12
|
+
});
|
|
13
|
+
export const ORCH_ERROR_CODES = Object.freeze({
|
|
14
|
+
INVALID_STRATEGY: "invalid_strategy", INVALID_DEPTH: "invalid_depth",
|
|
15
|
+
INVALID_LINEAGE: "invalid_lineage", INVALID_HANDOFF: "invalid_handoff",
|
|
16
|
+
INVALID_NODE: "invalid_node", LIMIT_EXCEEDED: "limit_exceeded",
|
|
17
|
+
FORBIDDEN_FIELD: "forbidden_field", RECEIPT_EXISTS: "receipt_exists"
|
|
18
|
+
});
|
|
19
|
+
export class OrchContractError extends Error {
|
|
20
|
+
constructor(message, { code, details = null } = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "OrchContractError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.details = details;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function createTaskId() {
|
|
28
|
+
return `task_${randomBytes(8).toString("hex")}`;
|
|
29
|
+
}
|
|
30
|
+
export function isTerminalDagState(state) {
|
|
31
|
+
return DAG_TERMINAL_STATES.has(state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertDepth(depth) {
|
|
35
|
+
const d = Number(depth);
|
|
36
|
+
if (!Number.isInteger(d) || d < 0) {
|
|
37
|
+
throw new OrchContractError(`Invalid depth "${depth}".`, {
|
|
38
|
+
code: ORCH_ERROR_CODES.INVALID_DEPTH, details: { depth }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (d > ORCH_LIMITS.MAX_DEPTH) {
|
|
42
|
+
throw new OrchContractError(`Orchestration depth ${d} exceeds max ${ORCH_LIMITS.MAX_DEPTH}.`, {
|
|
43
|
+
code: ORCH_ERROR_CODES.INVALID_DEPTH, details: { depth: d }
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return d;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertParentForDepth(depth, parentId, { rootMsg, minionMsg, code }) {
|
|
50
|
+
if (depth === 0 && parentId != null) {
|
|
51
|
+
throw new OrchContractError(rootMsg, { code });
|
|
52
|
+
}
|
|
53
|
+
if (depth > 0 && !parentId) {
|
|
54
|
+
throw new OrchContractError(minionMsg, { code });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeRunStrategy(value = RUN_STRATEGIES.DIRECT) {
|
|
59
|
+
const strategy = String(value ?? RUN_STRATEGIES.DIRECT).trim().toLowerCase();
|
|
60
|
+
if (!Object.values(RUN_STRATEGIES).includes(strategy)) {
|
|
61
|
+
throw new OrchContractError(`Invalid run strategy "${value}". Use direct or orchestrated.`, {
|
|
62
|
+
code: ORCH_ERROR_CODES.INVALID_STRATEGY, details: { value }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return strategy;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Lineage: max depth 1 (root=0, minion=1). */
|
|
69
|
+
export function createOrchLineage({ rootRunId, parentRunId = null, taskId = null, depth = 0 } = {}) {
|
|
70
|
+
if (typeof rootRunId !== "string" || !rootRunId) {
|
|
71
|
+
throw new OrchContractError("rootRunId is required.", { code: ORCH_ERROR_CODES.INVALID_LINEAGE });
|
|
72
|
+
}
|
|
73
|
+
const d = assertDepth(depth);
|
|
74
|
+
assertParentForDepth(d, parentRunId, {
|
|
75
|
+
rootMsg: "Root nodes must not set parentRunId.",
|
|
76
|
+
minionMsg: "Minion nodes require parentRunId.",
|
|
77
|
+
code: ORCH_ERROR_CODES.INVALID_LINEAGE
|
|
78
|
+
});
|
|
79
|
+
return { rootRunId, parentRunId: parentRunId ?? null, taskId: taskId ?? createTaskId(), depth: d };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createBudgetUsage({
|
|
83
|
+
contextTokens = 0, contextLimit = 0,
|
|
84
|
+
compactRatio = ORCH_LIMITS.COMPACT_RATIO, stopRatio = ORCH_LIMITS.STOP_RATIO
|
|
85
|
+
} = {}) {
|
|
86
|
+
const tokens = Math.max(0, Number(contextTokens) || 0);
|
|
87
|
+
const limit = Math.max(0, Number(contextLimit) || 0);
|
|
88
|
+
const ratio = limit > 0 ? tokens / limit : 0;
|
|
89
|
+
return {
|
|
90
|
+
contextTokens: tokens, contextLimit: limit, ratio,
|
|
91
|
+
shouldCompact: limit > 0 && ratio >= compactRatio && ratio < stopRatio,
|
|
92
|
+
shouldStop: limit > 0 && ratio >= stopRatio
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createDagNode({
|
|
97
|
+
taskId = null, runId = null, parentTaskId = null, depth = 0,
|
|
98
|
+
state = DAG_NODE_STATES.PENDING, dependsOn = [], attempt = 0,
|
|
99
|
+
objectiveDigest = null, budget = null, resultDigest = null, error = null
|
|
100
|
+
} = {}) {
|
|
101
|
+
if (!Object.values(DAG_NODE_STATES).includes(state)) {
|
|
102
|
+
throw new OrchContractError(`Invalid DAG node state "${state}".`, {
|
|
103
|
+
code: ORCH_ERROR_CODES.INVALID_NODE, details: { state }
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const d = assertDepth(depth);
|
|
107
|
+
assertParentForDepth(d, parentTaskId, {
|
|
108
|
+
rootMsg: "Root nodes must not set parentTaskId.",
|
|
109
|
+
minionMsg: "Minion nodes require parentTaskId.",
|
|
110
|
+
code: ORCH_ERROR_CODES.INVALID_NODE
|
|
111
|
+
});
|
|
112
|
+
const attempts = Number(attempt) || 0;
|
|
113
|
+
if (attempts < 0 || attempts > ORCH_LIMITS.MAX_ATTEMPTS) {
|
|
114
|
+
throw new OrchContractError(`Attempt ${attempts} outside 0..${ORCH_LIMITS.MAX_ATTEMPTS}.`, {
|
|
115
|
+
code: ORCH_ERROR_CODES.LIMIT_EXCEEDED, details: { attempt: attempts }
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const deps = [...new Set((dependsOn ?? []).map((id) => {
|
|
119
|
+
if (typeof id !== "string" || !id) {
|
|
120
|
+
throw new OrchContractError("Dependency taskId is required.", { code: ORCH_ERROR_CODES.INVALID_NODE });
|
|
121
|
+
}
|
|
122
|
+
return id;
|
|
123
|
+
}))];
|
|
124
|
+
return {
|
|
125
|
+
taskId: taskId ?? createTaskId(), runId: runId ?? null, parentTaskId: parentTaskId ?? null,
|
|
126
|
+
depth: d, state, dependsOn: deps, attempt: attempts,
|
|
127
|
+
objectiveDigest, budget: budget ?? null, resultDigest, error: error ?? null
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createMinionBrief({
|
|
132
|
+
objective, constraints = [], admittedPaths = [], exitCriteria = [],
|
|
133
|
+
parentTaskId = null, taskId = null
|
|
134
|
+
} = {}) {
|
|
135
|
+
if (typeof objective !== "string" || !objective.trim()) {
|
|
136
|
+
throw new OrchContractError("Minion brief requires a non-empty objective.", {
|
|
137
|
+
code: ORCH_ERROR_CODES.INVALID_HANDOFF
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
taskId: taskId ?? createTaskId(), parentTaskId: parentTaskId ?? null,
|
|
142
|
+
objective: objective.trim(), constraints: (constraints ?? []).map(String),
|
|
143
|
+
admittedPaths: (admittedPaths ?? []).map(String), exitCriteria: (exitCriteria ?? []).map(String)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createMinionResult({
|
|
148
|
+
taskId = null, summary, decisions = [], files = [], risks = [],
|
|
149
|
+
evidence = [], usage = null, compact = false
|
|
150
|
+
} = {}) {
|
|
151
|
+
if (typeof taskId !== "string" || !taskId) {
|
|
152
|
+
throw new OrchContractError("Minion result requires taskId.", { code: ORCH_ERROR_CODES.INVALID_HANDOFF });
|
|
153
|
+
}
|
|
154
|
+
if (typeof summary !== "string" || !summary.trim()) {
|
|
155
|
+
throw new OrchContractError("Minion result requires a non-empty summary.", {
|
|
156
|
+
code: ORCH_ERROR_CODES.INVALID_HANDOFF
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
taskId, summary: summary.trim(),
|
|
161
|
+
decisions: (decisions ?? []).map(String), files: (files ?? []).map(String),
|
|
162
|
+
risks: (risks ?? []).map(String), evidence: (evidence ?? []).map(String),
|
|
163
|
+
usage: {
|
|
164
|
+
inputTokens: usage?.inputTokens ?? null, outputTokens: usage?.outputTokens ?? null,
|
|
165
|
+
totalTokens: usage?.totalTokens ?? null, cost: usage?.cost ?? null
|
|
166
|
+
},
|
|
167
|
+
compact: Boolean(compact)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function digestAllowlisted(value) {
|
|
172
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
|
|
173
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ORCH_ERROR_CODES, OrchContractError, createBudgetUsage, createDagNode,
|
|
3
|
+
createMinionResult, createOrchLineage, normalizeRunStrategy
|
|
4
|
+
} from "./orch-types.js";
|
|
5
|
+
export const FORBIDDEN_KEYS = new Set([
|
|
6
|
+
"prompt", "diff", "transcript", "raw", "rawOutput", "stdout", "stderr", "output",
|
|
7
|
+
"message", "messages", "content", "secret", "secrets", "token", "apiKey",
|
|
8
|
+
"conversation", "history", "toolArgs", "arguments", "objective"
|
|
9
|
+
]);
|
|
10
|
+
export function walkForbiddenKeys(value, path = "") {
|
|
11
|
+
if (!value || typeof value !== "object") return;
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
value.forEach((item, i) => walkForbiddenKeys(item, `${path}[${i}]`));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
for (const [key, child] of Object.entries(value)) {
|
|
17
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
18
|
+
throw new OrchContractError(`Forbidden field "${path}${key}" in orchestration receipt.`, {
|
|
19
|
+
code: ORCH_ERROR_CODES.FORBIDDEN_FIELD, details: { key, path }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
walkForbiddenKeys(child, `${path}${key}.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function assertOrchReceiptSecretFree(receipt) {
|
|
26
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
|
|
27
|
+
throw new OrchContractError("Invalid orchestration receipt: expected object.", {
|
|
28
|
+
code: ORCH_ERROR_CODES.FORBIDDEN_FIELD
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
walkForbiddenKeys(receipt);
|
|
32
|
+
if (receipt.version !== 1) {
|
|
33
|
+
throw new OrchContractError("Orchestration receipt version must be 1.", {
|
|
34
|
+
code: ORCH_ERROR_CODES.FORBIDDEN_FIELD
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
normalizeRunStrategy(receipt.strategy);
|
|
38
|
+
if (typeof receipt.rootRunId !== "string" || !receipt.rootRunId) {
|
|
39
|
+
throw new OrchContractError("rootRunId is required on receipt.", {
|
|
40
|
+
code: ORCH_ERROR_CODES.INVALID_LINEAGE
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
createOrchLineage(receipt.lineage);
|
|
44
|
+
if (receipt.lineage.rootRunId !== receipt.rootRunId) {
|
|
45
|
+
throw new OrchContractError("lineage.rootRunId must match receipt.rootRunId.", {
|
|
46
|
+
code: ORCH_ERROR_CODES.INVALID_LINEAGE
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(receipt.nodes) || !Array.isArray(receipt.results)) {
|
|
50
|
+
throw new OrchContractError("Receipt requires nodes[] and results[].", {
|
|
51
|
+
code: ORCH_ERROR_CODES.INVALID_NODE
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const node of receipt.nodes) {
|
|
55
|
+
createDagNode(node);
|
|
56
|
+
if (node.budget) createBudgetUsage(node.budget);
|
|
57
|
+
}
|
|
58
|
+
for (const result of receipt.results) createMinionResult(result);
|
|
59
|
+
if (typeof receipt.createdAt !== "string" || !receipt.createdAt) {
|
|
60
|
+
throw new OrchContractError("createdAt is required.", { code: ORCH_ERROR_CODES.FORBIDDEN_FIELD });
|
|
61
|
+
}
|
|
62
|
+
return receipt;
|
|
63
|
+
}
|
|
@@ -45,6 +45,7 @@ export async function runGlobalRun(options, packageManifest, { startRunImpl = st
|
|
|
45
45
|
captureTranscript,
|
|
46
46
|
cliVersion: packageManifest.version,
|
|
47
47
|
profile: profileResolved,
|
|
48
|
+
strategy: options.strategy ?? "direct",
|
|
48
49
|
follow: options.follow,
|
|
49
50
|
timeoutMs: options.timeoutMs,
|
|
50
51
|
wait: options.wait !== false
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
appendRunEvent,
|
|
6
6
|
appendRunStartedEvent,
|
|
7
7
|
createRunRecord,
|
|
8
|
+
listRunRecords,
|
|
8
9
|
readRunState,
|
|
9
10
|
reconcileActiveRuns,
|
|
10
11
|
writeRunState
|
|
@@ -22,6 +23,20 @@ import {
|
|
|
22
23
|
readSupervisorLockForRun,
|
|
23
24
|
supervisePreparedRun
|
|
24
25
|
} from "./run-supervisor.js";
|
|
26
|
+
import {
|
|
27
|
+
assertManagedMinionExtension,
|
|
28
|
+
assertOrchestratedAgent,
|
|
29
|
+
createRootRunLineage,
|
|
30
|
+
normalizeRunStrategy,
|
|
31
|
+
RUN_STRATEGIES
|
|
32
|
+
} from "./run-strategy.js";
|
|
33
|
+
import {
|
|
34
|
+
DAG_NODE_STATES,
|
|
35
|
+
createDagNode,
|
|
36
|
+
createOrchState,
|
|
37
|
+
reconcileOrchState,
|
|
38
|
+
saveOrchState
|
|
39
|
+
} from "./orchestration/index.js";
|
|
25
40
|
|
|
26
41
|
const activeProcesses = new Map();
|
|
27
42
|
const cancelledRuns = new Set();
|
|
@@ -55,6 +70,19 @@ export async function recoverRuns(homeDir) {
|
|
|
55
70
|
exceptRunIds: listActiveRunIds(),
|
|
56
71
|
isRunAliveImpl: isRunSupervisedAlive
|
|
57
72
|
});
|
|
73
|
+
for (const run of await listRunRecords(homeDir)) {
|
|
74
|
+
if (normalizeRunStrategy(run.strategy ?? RUN_STRATEGIES.DIRECT) !== RUN_STRATEGIES.ORCHESTRATED) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (await isRunSupervisedAlive(homeDir, run)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await reconcileOrchState(run.runId, { homeDir });
|
|
82
|
+
} catch {
|
|
83
|
+
// Fail closed per root: never invent receipt evidence from corrupt state.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
58
86
|
return interrupted;
|
|
59
87
|
}
|
|
60
88
|
|
|
@@ -67,8 +95,10 @@ async function prepareRun({
|
|
|
67
95
|
permissions = [],
|
|
68
96
|
captureTranscript = false,
|
|
69
97
|
cliVersion,
|
|
70
|
-
profile = null
|
|
98
|
+
profile = null,
|
|
99
|
+
strategy = "direct"
|
|
71
100
|
}) {
|
|
101
|
+
const normalizedStrategy = assertOrchestratedAgent(agentId, strategy);
|
|
72
102
|
const adapter = resolveExecutionAdapter(agentId);
|
|
73
103
|
const availability = adapter.availability({ cwd });
|
|
74
104
|
|
|
@@ -83,7 +113,12 @@ async function prepareRun({
|
|
|
83
113
|
);
|
|
84
114
|
}
|
|
85
115
|
|
|
116
|
+
if (normalizedStrategy === RUN_STRATEGIES.ORCHESTRATED) {
|
|
117
|
+
await assertManagedMinionExtension(homeDir);
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
const runId = createRunId();
|
|
121
|
+
const lineage = createRootRunLineage(runId);
|
|
87
122
|
const metadata = createRunMetadata({
|
|
88
123
|
runId,
|
|
89
124
|
agentId,
|
|
@@ -94,7 +129,9 @@ async function prepareRun({
|
|
|
94
129
|
permissions,
|
|
95
130
|
captureTranscript,
|
|
96
131
|
cliVersion,
|
|
97
|
-
profileSources: profile?.sources ?? null
|
|
132
|
+
profileSources: profile?.sources ?? null,
|
|
133
|
+
strategy: normalizedStrategy,
|
|
134
|
+
lineage
|
|
98
135
|
});
|
|
99
136
|
|
|
100
137
|
await createRunRecord(homeDir, metadata);
|
|
@@ -107,9 +144,25 @@ async function prepareRun({
|
|
|
107
144
|
permissions,
|
|
108
145
|
captureTranscript,
|
|
109
146
|
cliVersion,
|
|
110
|
-
profile: profile?.profile ?? null
|
|
147
|
+
profile: profile?.profile ?? null,
|
|
148
|
+
strategy: normalizedStrategy
|
|
111
149
|
});
|
|
112
150
|
|
|
151
|
+
if (normalizedStrategy === RUN_STRATEGIES.ORCHESTRATED) {
|
|
152
|
+
await saveOrchState(createOrchState({
|
|
153
|
+
rootRunId: runId,
|
|
154
|
+
strategy: normalizedStrategy,
|
|
155
|
+
lineage,
|
|
156
|
+
nodes: [createDagNode({
|
|
157
|
+
taskId: lineage.taskId,
|
|
158
|
+
runId,
|
|
159
|
+
depth: 0,
|
|
160
|
+
state: DAG_NODE_STATES.RUNNING
|
|
161
|
+
})],
|
|
162
|
+
cliVersion
|
|
163
|
+
}), { homeDir });
|
|
164
|
+
}
|
|
165
|
+
|
|
113
166
|
return { runId, metadata };
|
|
114
167
|
}
|
|
115
168
|
|
|
@@ -123,6 +176,7 @@ export async function startRun({
|
|
|
123
176
|
captureTranscript = false,
|
|
124
177
|
cliVersion,
|
|
125
178
|
profile = null,
|
|
179
|
+
strategy = "direct",
|
|
126
180
|
follow = false,
|
|
127
181
|
timeoutMs = null,
|
|
128
182
|
wait = true,
|
|
@@ -138,7 +192,8 @@ export async function startRun({
|
|
|
138
192
|
permissions,
|
|
139
193
|
captureTranscript,
|
|
140
194
|
cliVersion,
|
|
141
|
-
profile
|
|
195
|
+
profile,
|
|
196
|
+
strategy: normalizeRunStrategy(strategy)
|
|
142
197
|
});
|
|
143
198
|
|
|
144
199
|
if (!wait) {
|