@kal-elsam/kairo-runtime 0.15.0 → 0.17.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/CHANGELOG.md +76 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +106 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +12 -1
- package/src/global/cockpit/app.js +475 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +683 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1263 -0
- package/src/global/control-plane/attention.js +141 -0
- package/src/global/control-plane/build-report.js +146 -0
- package/src/global/control-plane/cli.js +36 -0
- package/src/global/control-plane/constants.js +38 -0
- package/src/global/control-plane/gentle-adapters.js +183 -0
- package/src/global/control-plane/provider.js +69 -0
- package/src/global/control-plane/review-status.js +115 -0
- package/src/global/control-plane/sdd-status.js +49 -0
- package/src/global/control-plane/team.js +63 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1063 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +444 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/gentle-probe.js +30 -2
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/index.js +2 -1
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- package/src/global/runtime/usage-types.js +62 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createRunId } from "./run-types.js";
|
|
2
|
+
import { startRun as startRunDefault } from "./run-manager.js";
|
|
3
|
+
import {
|
|
4
|
+
beginRoleRun as beginRoleRunDefault,
|
|
5
|
+
completeRoleRun as completeRoleRunDefault,
|
|
6
|
+
markInterrupted,
|
|
7
|
+
markReadyForReview as markReadyForReviewDefault
|
|
8
|
+
} from "./execution-worktree-manager.js";
|
|
9
|
+
import { readWorktreeState } from "./execution-worktree-store.js";
|
|
10
|
+
import { WORKTREE_STATES } from "./execution-worktree-types.js";
|
|
11
|
+
import { readTaskRecord } from "../architect/architect-store.js";
|
|
12
|
+
import { readSkillCatalog } from "../intelligence/skill-catalog.js";
|
|
13
|
+
import { matchSkills } from "../intelligence/execution-router.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strict order — never parallel, never reordered. Debugger's real work
|
|
17
|
+
* only makes sense against Builder's own real commit, and Tester's only
|
|
18
|
+
* against both.
|
|
19
|
+
*/
|
|
20
|
+
const ROLE_CHAIN = Object.freeze(["Builder", "Debugger", "Tester"]);
|
|
21
|
+
|
|
22
|
+
const ROLE_INSTRUCTIONS = Object.freeze({
|
|
23
|
+
Builder:
|
|
24
|
+
"You are acting as the Builder role in this execution worktree. Implement the following "
|
|
25
|
+
+ "approved plan directly in the current working tree. Only edit real files — Kairo itself "
|
|
26
|
+
+ "owns every real commit; never run `git add` or `git commit` yourself.",
|
|
27
|
+
Debugger:
|
|
28
|
+
"You are acting as the Debugger role in this execution worktree. The Builder role's real "
|
|
29
|
+
+ "implementation of the following approved plan is already committed in the current working "
|
|
30
|
+
+ "tree. Review it for real bugs, edge cases, or deviations from the plan, and fix what you "
|
|
31
|
+
+ "find. Only edit real files — Kairo itself owns every real commit; never run `git add` or "
|
|
32
|
+
+ "`git commit` yourself.",
|
|
33
|
+
Tester:
|
|
34
|
+
"You are acting as the Tester role in this execution worktree. The Builder and Debugger "
|
|
35
|
+
+ "roles' real work on the following approved plan is already committed in the current "
|
|
36
|
+
+ "working tree. Write and run real tests that validate this implementation against the plan. "
|
|
37
|
+
+ "Only edit real files — Kairo itself owns every real commit; never run `git add` or `git "
|
|
38
|
+
+ "commit` yourself."
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Appends the real, project-local skills (docs/skills, .claude/skills, etc
|
|
43
|
+
* — see skill-catalog.js) whose own real description actually overlaps
|
|
44
|
+
* this plan's text — the exact same word-overlap matcher execution-
|
|
45
|
+
* router.js already uses for ASK-mode routing, never a new heuristic.
|
|
46
|
+
* Only the real name + real file path are ever included; the skill's own
|
|
47
|
+
* content is never duplicated into the prompt — the role's real agent run
|
|
48
|
+
* has real filesystem access inside this same worktree and can read the
|
|
49
|
+
* real SKILL.md itself if it decides the match is relevant. An empty
|
|
50
|
+
* catalog or zero matches changes nothing: the plan text is untouched.
|
|
51
|
+
*/
|
|
52
|
+
function buildRoleTask(role, planMarkdown, skills = []) {
|
|
53
|
+
const matches = matchSkills(planMarkdown, skills);
|
|
54
|
+
const skillsByName = new Map(skills.map((skill) => [skill.name, skill]));
|
|
55
|
+
const skillLines = matches
|
|
56
|
+
.map((match) => skillsByName.get(match.name))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((skill) => `- ${skill.name} (${skill.path}): ${skill.description}`);
|
|
59
|
+
|
|
60
|
+
const skillsSection = skillLines.length > 0
|
|
61
|
+
? `\n\n## Relevant project skills\nThese real project skills may apply — read the file at its own path if useful, never assume its content:\n${skillLines.join("\n")}`
|
|
62
|
+
: "";
|
|
63
|
+
|
|
64
|
+
return `${ROLE_INSTRUCTIONS[role]}\n\n${planMarkdown}${skillsSection}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Automatically chains Builder -> Debugger -> Tester inside one already-
|
|
69
|
+
* created execution worktree — the real point of everything increments
|
|
70
|
+
* 1-4 built: isolation (worktree), a trustworthy per-role transaction
|
|
71
|
+
* (beginRoleRun/completeRoleRun), and a governed budget
|
|
72
|
+
* (assertProviderNotExhausted, already wired into startRun itself) all
|
|
73
|
+
* had to exist BEFORE this was safe to automate.
|
|
74
|
+
*
|
|
75
|
+
* Only ever drives a freshly-created worktree (real status PENDING, no
|
|
76
|
+
* role has run yet) — this is a strict, from-scratch chain, not a
|
|
77
|
+
* resumable one; a worktree that already progressed manually should keep
|
|
78
|
+
* being driven manually via beginRoleRun/completeRoleRun/
|
|
79
|
+
* markReadyForReview directly.
|
|
80
|
+
*
|
|
81
|
+
* Per role, in strict order: beginRoleRun, a real startRun pointed at the
|
|
82
|
+
* worktree's own treePath (the same governed budget gate from
|
|
83
|
+
* usage-manager.js already runs inside startRun itself — no separate
|
|
84
|
+
* check needed here), then completeRoleRun once the real run reaches a
|
|
85
|
+
* terminal state. The task text sent to each role's real agent run wraps
|
|
86
|
+
* the same real, approved plan.md with a role-specific framing — the
|
|
87
|
+
* plan itself is never split or rewritten, only presented differently.
|
|
88
|
+
*
|
|
89
|
+
* Any real failure — the run itself failing, completeRoleRun rejecting a
|
|
90
|
+
* bad diff, or the run never even starting (a budget/preflight/adapter
|
|
91
|
+
* failure before any real process existed) — stops the chain immediately
|
|
92
|
+
* at that role. A never-started run has no run for completeRoleRun's own
|
|
93
|
+
* checks to find, so this function marks the worktree INTERRUPTED itself
|
|
94
|
+
* in that one case; every other real failure is already handled by
|
|
95
|
+
* completeRoleRun's own existing logic. The next role is never attempted
|
|
96
|
+
* once one fails.
|
|
97
|
+
*
|
|
98
|
+
* Only once all three roles complete does this call markReadyForReview
|
|
99
|
+
* automatically — the one, explicit boundary automation stops at. Preview
|
|
100
|
+
* and apply remain entirely manual, exactly as designed in increment 3:
|
|
101
|
+
* a reviewed, confirmed `git merge --ff-only` is never automatic.
|
|
102
|
+
* @param {object} args
|
|
103
|
+
* @param {string} args.worktreeId
|
|
104
|
+
* @param {string} args.homeDir
|
|
105
|
+
* @param {string} args.agentId - the default execution-adapters/index.js provider for every role
|
|
106
|
+
* @param {Partial<Record<"Builder"|"Debugger"|"Tester", string>>} [args.roleAgents] - per-role provider override
|
|
107
|
+
* @param {string|null} [args.model]
|
|
108
|
+
* @param {object|null} [args.profile] - the resolveProfile() shape ({profile, sources}), passed straight through to startRun
|
|
109
|
+
* @param {string} [args.cliVersion]
|
|
110
|
+
* @param {string[]} [args.permissions]
|
|
111
|
+
* @param {boolean} [args.captureTranscript]
|
|
112
|
+
* @param {Function} [args.spawnImpl] - forwarded to every real startRun call, see run-manager.js's own
|
|
113
|
+
* @param {Function} [args.resolveAdapterImpl] - forwarded to every real startRun call, see run-manager.js's own
|
|
114
|
+
*/
|
|
115
|
+
export async function runOrchestratedChain({
|
|
116
|
+
worktreeId,
|
|
117
|
+
homeDir,
|
|
118
|
+
agentId,
|
|
119
|
+
roleAgents = {},
|
|
120
|
+
model = null,
|
|
121
|
+
profile = null,
|
|
122
|
+
cliVersion,
|
|
123
|
+
permissions = [],
|
|
124
|
+
captureTranscript = false,
|
|
125
|
+
spawnImpl,
|
|
126
|
+
resolveAdapterImpl,
|
|
127
|
+
startRunImpl = startRunDefault,
|
|
128
|
+
beginRoleRunImpl = beginRoleRunDefault,
|
|
129
|
+
completeRoleRunImpl = completeRoleRunDefault,
|
|
130
|
+
markReadyForReviewImpl = markReadyForReviewDefault
|
|
131
|
+
}) {
|
|
132
|
+
const initial = await readWorktreeState(homeDir, worktreeId);
|
|
133
|
+
if (!initial) throw new Error(`Execution worktree "${worktreeId}" not found.`);
|
|
134
|
+
if (initial.status !== WORKTREE_STATES.PENDING) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Execution worktree "${worktreeId}" is ${initial.status}; runOrchestratedChain only drives a `
|
|
137
|
+
+ "freshly-created worktree from PENDING — a worktree already in progress must be driven manually."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const record = await readTaskRecord(initial.projectRoot, initial.taskId);
|
|
142
|
+
if (!record?.planMarkdown) {
|
|
143
|
+
throw new Error(`Execution worktree "${worktreeId}" has no real approved plan text to orchestrate roles from.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Read once, from the worktree's own real checkout — exactly what each
|
|
147
|
+
// role's real agent run will itself see, not the main project's
|
|
148
|
+
// possibly-different working tree.
|
|
149
|
+
const skills = await readSkillCatalog(initial.treePath);
|
|
150
|
+
|
|
151
|
+
const completedRoles = [];
|
|
152
|
+
|
|
153
|
+
for (const role of ROLE_CHAIN) {
|
|
154
|
+
const runId = createRunId();
|
|
155
|
+
await beginRoleRunImpl({ worktreeId, role, runId, homeDir });
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const { completion } = await startRunImpl({
|
|
159
|
+
homeDir,
|
|
160
|
+
runId,
|
|
161
|
+
agentId: roleAgents[role] ?? agentId,
|
|
162
|
+
task: buildRoleTask(role, record.planMarkdown, skills),
|
|
163
|
+
cwd: initial.treePath,
|
|
164
|
+
model,
|
|
165
|
+
permissions,
|
|
166
|
+
captureTranscript,
|
|
167
|
+
cliVersion,
|
|
168
|
+
profile,
|
|
169
|
+
wait: true,
|
|
170
|
+
...(spawnImpl ? { spawnImpl } : {}),
|
|
171
|
+
...(resolveAdapterImpl ? { resolveAdapterImpl } : {})
|
|
172
|
+
});
|
|
173
|
+
await completion;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const worktreeNow = (await readWorktreeState(homeDir, worktreeId)) ?? initial;
|
|
176
|
+
await markInterrupted(
|
|
177
|
+
homeDir, worktreeNow,
|
|
178
|
+
`Role "${role}" run "${runId}" could not be started: ${error.message ?? String(error)}`
|
|
179
|
+
);
|
|
180
|
+
throw new Error(`Orchestrated chain stopped at role "${role}": ${error.message ?? String(error)}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await completeRoleRunImpl({ worktreeId, role, runId, homeDir });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new Error(`Orchestrated chain stopped at role "${role}": ${error.message ?? String(error)}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
completedRoles.push(role);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ready = await markReadyForReviewImpl({ worktreeId, homeDir });
|
|
193
|
+
return { worktree: ready, completedRoles };
|
|
194
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { harnessHomePaths, worktreePaths } from "../paths.js";
|
|
4
|
+
import { writeAtomicJson } from "./write-atomic-json.js";
|
|
5
|
+
|
|
6
|
+
const writeLocks = new Map();
|
|
7
|
+
|
|
8
|
+
export function getWorktreesDir(homeDir) {
|
|
9
|
+
return harnessHomePaths(homeDir).worktreesDir;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Creates the initial, real PENDING record — exclusive so two concurrent creations can never collide on the same worktreeId. */
|
|
13
|
+
export async function createWorktreeRecord(homeDir, metadata) {
|
|
14
|
+
const { worktreeDir, statePath } = worktreePaths(homeDir, metadata.worktreeId);
|
|
15
|
+
await mkdir(worktreeDir, { recursive: true });
|
|
16
|
+
await writeAtomicJson(statePath, metadata, { createExclusive: true });
|
|
17
|
+
return metadata;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readWorktreeState(homeDir, worktreeId) {
|
|
21
|
+
const { statePath } = worktreePaths(homeDir, worktreeId);
|
|
22
|
+
if (!existsSync(statePath)) return null;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(await readFile(statePath, "utf8"));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(`Invalid execution worktree state at ${statePath}: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read-modify-write is serialized per worktreeId — same real race protection run-store.js's writeRunState already relies on. */
|
|
32
|
+
export async function writeWorktreeState(homeDir, metadata) {
|
|
33
|
+
const key = metadata.worktreeId;
|
|
34
|
+
const previous = writeLocks.get(key) ?? Promise.resolve();
|
|
35
|
+
const next = previous.then(async () => {
|
|
36
|
+
const { worktreeDir, statePath } = worktreePaths(homeDir, metadata.worktreeId);
|
|
37
|
+
await mkdir(worktreeDir, { recursive: true });
|
|
38
|
+
await writeAtomicJson(statePath, metadata);
|
|
39
|
+
return metadata;
|
|
40
|
+
});
|
|
41
|
+
writeLocks.set(key, next.catch(() => {}));
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Append-only, one real checkpoint per line — mirrors run-store.js's own appendRunEvent for events.jsonl. */
|
|
46
|
+
export async function appendCheckpoint(homeDir, worktreeId, checkpoint) {
|
|
47
|
+
const { worktreeDir, checkpointsPath } = worktreePaths(homeDir, worktreeId);
|
|
48
|
+
await mkdir(worktreeDir, { recursive: true });
|
|
49
|
+
await appendFile(checkpointsPath, `${JSON.stringify(checkpoint)}\n`, "utf8");
|
|
50
|
+
return checkpoint;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readCheckpoints(homeDir, worktreeId) {
|
|
54
|
+
const { checkpointsPath } = worktreePaths(homeDir, worktreeId);
|
|
55
|
+
if (!existsSync(checkpointsPath)) return [];
|
|
56
|
+
|
|
57
|
+
const content = await readFile(checkpointsPath, "utf8");
|
|
58
|
+
return content
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter((line) => line.trim().length > 0)
|
|
61
|
+
.map((line, index) => {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(line);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { parseError: true, line: index + 1, message: error.message };
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function listWorktreeRecords(homeDir) {
|
|
71
|
+
const worktreesDir = getWorktreesDir(homeDir);
|
|
72
|
+
if (!existsSync(worktreesDir)) return [];
|
|
73
|
+
|
|
74
|
+
const entries = await readdir(worktreesDir, { withFileTypes: true });
|
|
75
|
+
const worktrees = [];
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (!entry.isDirectory()) continue;
|
|
78
|
+
const state = await readWorktreeState(homeDir, entry.name);
|
|
79
|
+
if (state) worktrees.push(state);
|
|
80
|
+
}
|
|
81
|
+
worktrees.sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)));
|
|
82
|
+
return worktrees;
|
|
83
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const EXECUTION_WORKTREE_SCHEMA = "kairo.execution-worktree/v1";
|
|
2
|
+
|
|
3
|
+
// pending: registered, git worktree add not yet confirmed.
|
|
4
|
+
// active: at least one role has a real run in progress inside the worktree.
|
|
5
|
+
// ready_for_review: all roles finished; a real preview/merge is possible.
|
|
6
|
+
// applying: a confirmed merge is in flight against the real project.
|
|
7
|
+
// applied / discarded: terminal, successful/abandoned outcomes.
|
|
8
|
+
// interrupted: Kairo restarted mid-operation — see recovery, never guessed.
|
|
9
|
+
export const WORKTREE_STATES = Object.freeze({
|
|
10
|
+
PENDING: "pending",
|
|
11
|
+
ACTIVE: "active",
|
|
12
|
+
READY_FOR_REVIEW: "ready_for_review",
|
|
13
|
+
APPLYING: "applying",
|
|
14
|
+
APPLIED: "applied",
|
|
15
|
+
DISCARDED: "discarded",
|
|
16
|
+
INTERRUPTED: "interrupted"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const ACTIVE_WORKTREE_STATES = new Set([
|
|
20
|
+
WORKTREE_STATES.PENDING,
|
|
21
|
+
WORKTREE_STATES.ACTIVE,
|
|
22
|
+
WORKTREE_STATES.READY_FOR_REVIEW,
|
|
23
|
+
WORKTREE_STATES.APPLYING
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export function isActiveWorktreeState(state) {
|
|
27
|
+
return ACTIVE_WORKTREE_STATES.has(state);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const TERMINAL_WORKTREE_STATES = new Set([
|
|
31
|
+
WORKTREE_STATES.APPLIED,
|
|
32
|
+
WORKTREE_STATES.DISCARDED,
|
|
33
|
+
WORKTREE_STATES.INTERRUPTED
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export function isTerminalWorktreeState(state) {
|
|
37
|
+
return TERMINAL_WORKTREE_STATES.has(state);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Same shape as run-types.js's own createRunId — an opaque, sortable-enough, collision-resistant local id, never a real git ref name. */
|
|
41
|
+
export function createWorktreeId() {
|
|
42
|
+
const timestamp = Date.now().toString(36);
|
|
43
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
44
|
+
return `wt_${timestamp}_${random}`;
|
|
45
|
+
}
|
|
@@ -131,6 +131,44 @@ export function applyEventToMetadata(metadata, event) {
|
|
|
131
131
|
return next;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Best-effort real human-readable text out of a real "run.transcript"
|
|
136
|
+
* event's own `data` — the shape varies by adapter (Codex/Claude each pass
|
|
137
|
+
* their own CLI's raw JSON event through mostly as-is, see each adapter's
|
|
138
|
+
* own parseEventLine), so this tries the common real shapes in order
|
|
139
|
+
* (flat string, `text`, `content` as a string or an Anthropic-style
|
|
140
|
+
* content-block array, a nested `message`, `result`) and only ever falls
|
|
141
|
+
* back to a raw JSON dump when none of those match — it never silently
|
|
142
|
+
* drops a real event's content just because its exact shape wasn't
|
|
143
|
+
* anticipated.
|
|
144
|
+
* @param {unknown} data
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function formatTranscriptEventText(data) {
|
|
148
|
+
if (data == null) return "";
|
|
149
|
+
if (typeof data === "string") return data;
|
|
150
|
+
if (typeof data !== "object") return String(data);
|
|
151
|
+
if (typeof data.text === "string") return data.text;
|
|
152
|
+
if (typeof data.content === "string") return data.content;
|
|
153
|
+
if (Array.isArray(data.content)) {
|
|
154
|
+
const text = data.content
|
|
155
|
+
.filter((block) => block && typeof block === "object" && typeof block.text === "string")
|
|
156
|
+
.map((block) => block.text)
|
|
157
|
+
.join("");
|
|
158
|
+
if (text) return text;
|
|
159
|
+
}
|
|
160
|
+
if (data.message && typeof data.message === "object") {
|
|
161
|
+
const nested = formatTranscriptEventText(data.message);
|
|
162
|
+
if (nested) return nested;
|
|
163
|
+
}
|
|
164
|
+
if (typeof data.result === "string") return data.result;
|
|
165
|
+
try {
|
|
166
|
+
return JSON.stringify(data);
|
|
167
|
+
} catch {
|
|
168
|
+
return String(data);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
134
172
|
export function transitionRunState(metadata, nextState, { exitCode = null, error = null } = {}) {
|
|
135
173
|
const now = new Date().toISOString();
|
|
136
174
|
return {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
writeRunState
|
|
12
12
|
} from "./run-store.js";
|
|
13
13
|
import { resolveExecutionAdapter } from "./execution-adapters/index.js";
|
|
14
|
+
import { assertProviderNotExhausted } from "./usage-manager.js";
|
|
14
15
|
import { resolveProfileAgents } from "../profile.js";
|
|
15
16
|
import { resolveRuntimeOptions } from "./run-profile.js";
|
|
16
17
|
import { authorizeRunPermissions } from "./run-permissions.js";
|
|
@@ -100,10 +101,12 @@ async function prepareRun({
|
|
|
100
101
|
captureTranscript = false,
|
|
101
102
|
cliVersion,
|
|
102
103
|
profile = null,
|
|
103
|
-
strategy = "direct"
|
|
104
|
+
strategy = "direct",
|
|
105
|
+
runId: requestedRunId = null,
|
|
106
|
+
resolveAdapterImpl = resolveExecutionAdapter
|
|
104
107
|
}) {
|
|
105
108
|
const normalizedStrategy = assertOrchestratedAgent(agentId, strategy);
|
|
106
|
-
const adapter =
|
|
109
|
+
const adapter = resolveAdapterImpl(agentId);
|
|
107
110
|
const availability = adapter.availability({ cwd });
|
|
108
111
|
|
|
109
112
|
if (!availability.available) {
|
|
@@ -117,6 +120,14 @@ async function prepareRun({
|
|
|
117
120
|
);
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
await adapter.preflight({ cwd });
|
|
124
|
+
// The one real, enforced budget gate this increment adds — refuses only
|
|
125
|
+
// a provider whose real, cumulative consumption already reached its own
|
|
126
|
+
// configured budget (usage-manager.js). No configured
|
|
127
|
+
// profile.providerTokenBudgets.<agentId> means no gate: this never
|
|
128
|
+
// blocks a provider nobody set a budget for.
|
|
129
|
+
await assertProviderNotExhausted({ homeDir, provider: agentId, profile: profile?.profile ?? null });
|
|
130
|
+
|
|
120
131
|
const authorized = authorizeRunPermissions({
|
|
121
132
|
permissions,
|
|
122
133
|
agentId,
|
|
@@ -129,7 +140,7 @@ async function prepareRun({
|
|
|
129
140
|
await assertManagedMinionExtension(homeDir);
|
|
130
141
|
}
|
|
131
142
|
|
|
132
|
-
const runId = createRunId();
|
|
143
|
+
const runId = requestedRunId ?? createRunId();
|
|
133
144
|
const lineage = createRootRunLineage(runId);
|
|
134
145
|
const metadata = createRunMetadata({
|
|
135
146
|
runId,
|
|
@@ -197,7 +208,9 @@ export async function startRun({
|
|
|
197
208
|
timeoutMs = null,
|
|
198
209
|
wait = true,
|
|
199
210
|
spawnImpl = spawn,
|
|
200
|
-
forkDetachedSupervisorImpl = forkDetachedSupervisor
|
|
211
|
+
forkDetachedSupervisorImpl = forkDetachedSupervisor,
|
|
212
|
+
runId: requestedRunId = null,
|
|
213
|
+
resolveAdapterImpl = resolveExecutionAdapter
|
|
201
214
|
}) {
|
|
202
215
|
const { runId, metadata } = await prepareRun({
|
|
203
216
|
homeDir,
|
|
@@ -212,7 +225,9 @@ export async function startRun({
|
|
|
212
225
|
captureTranscript,
|
|
213
226
|
cliVersion,
|
|
214
227
|
profile,
|
|
215
|
-
strategy: normalizeRunStrategy(strategy)
|
|
228
|
+
strategy: normalizeRunStrategy(strategy),
|
|
229
|
+
runId: requestedRunId,
|
|
230
|
+
resolveAdapterImpl
|
|
216
231
|
});
|
|
217
232
|
|
|
218
233
|
if (!wait) {
|
|
@@ -274,7 +289,8 @@ export async function startRun({
|
|
|
274
289
|
timeoutMs,
|
|
275
290
|
spawnImpl,
|
|
276
291
|
cancelledRuns,
|
|
277
|
-
activeProcesses
|
|
292
|
+
activeProcesses,
|
|
293
|
+
resolveAdapterImpl
|
|
278
294
|
});
|
|
279
295
|
|
|
280
296
|
return {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
writeRunState
|
|
15
15
|
} from "./run-store.js";
|
|
16
16
|
import { resolveExecutionAdapter } from "./execution-adapters/index.js";
|
|
17
|
+
import { recordProviderUsage } from "./usage-manager.js";
|
|
17
18
|
import { consumeRunHandoff } from "./run-handoff.js";
|
|
18
19
|
import { isRunCancelRequested } from "./run-cancel-signal.js";
|
|
19
20
|
import { readSupervisorLock, touchSupervisorLock, writeSupervisorLock } from "./run-supervisor-lock.js";
|
|
@@ -72,10 +73,11 @@ export async function supervisePreparedRun({
|
|
|
72
73
|
timeoutMs = null,
|
|
73
74
|
spawnImpl = spawn,
|
|
74
75
|
cancelledRuns = null,
|
|
75
|
-
activeProcesses = null
|
|
76
|
+
activeProcesses = null,
|
|
77
|
+
resolveAdapterImpl = resolveExecutionAdapter
|
|
76
78
|
}) {
|
|
77
79
|
const handoff = await consumeRunHandoff(homeDir, runId);
|
|
78
|
-
const adapter =
|
|
80
|
+
const adapter = resolveAdapterImpl(handoff.agentId);
|
|
79
81
|
let metadata = await readRunState(homeDir, runId);
|
|
80
82
|
|
|
81
83
|
if (!metadata) {
|
|
@@ -85,16 +87,6 @@ export async function supervisePreparedRun({
|
|
|
85
87
|
const captureTranscript = handoff.captureTranscript === true;
|
|
86
88
|
const strategy = normalizeRunStrategy(handoff.strategy ?? metadata.strategy ?? "direct");
|
|
87
89
|
const extensionPath = resolveOrchestratedExtensionPath(homeDir, strategy);
|
|
88
|
-
const launch = adapter.buildLaunch({
|
|
89
|
-
task: handoff.task,
|
|
90
|
-
cwd: handoff.cwd,
|
|
91
|
-
model: handoff.model,
|
|
92
|
-
permissions: handoff.permissions ?? [],
|
|
93
|
-
profile: handoff.profile ?? null,
|
|
94
|
-
strategy,
|
|
95
|
-
extensionPath
|
|
96
|
-
});
|
|
97
|
-
|
|
98
90
|
metadata = {
|
|
99
91
|
...metadata,
|
|
100
92
|
state: RUN_STATES.RUNNING,
|
|
@@ -109,6 +101,26 @@ export async function supervisePreparedRun({
|
|
|
109
101
|
lastHeartbeat: new Date().toISOString()
|
|
110
102
|
});
|
|
111
103
|
|
|
104
|
+
try {
|
|
105
|
+
await adapter.preflight({ cwd: handoff.cwd });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
metadata = transitionRunState(metadata, RUN_STATES.FAILED, { error: error.message });
|
|
108
|
+
await writeRunState(homeDir, metadata);
|
|
109
|
+
await appendRunEvent(homeDir, createRunEvent({
|
|
110
|
+
runId, type: "run.failed", data: { error: error.message }
|
|
111
|
+
}), { captureTranscript: shouldPersistTranscript(captureTranscript) });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
const launch = adapter.buildLaunch({
|
|
115
|
+
task: handoff.task,
|
|
116
|
+
cwd: handoff.cwd,
|
|
117
|
+
model: handoff.model,
|
|
118
|
+
permissions: handoff.permissions ?? [],
|
|
119
|
+
profile: handoff.profile ?? null,
|
|
120
|
+
strategy,
|
|
121
|
+
extensionPath
|
|
122
|
+
});
|
|
123
|
+
|
|
112
124
|
const child = spawnImpl(launch.command, launch.args, {
|
|
113
125
|
cwd: launch.cwd,
|
|
114
126
|
env: buildOrchestratedRuntimeEnv({
|
|
@@ -169,6 +181,13 @@ export async function supervisePreparedRun({
|
|
|
169
181
|
captureTranscript: true
|
|
170
182
|
}),
|
|
171
183
|
runId,
|
|
184
|
+
// createRunEvent defaults source to "kairo" — the real event this
|
|
185
|
+
// is mirrored from already carries the real adapter id (see
|
|
186
|
+
// normalizeAdapterEvent's own `source: adapterId`), so this must
|
|
187
|
+
// be explicitly propagated, or every provider's transcript would
|
|
188
|
+
// collapse to a generic "kairo" label with no way to tell whether
|
|
189
|
+
// a given line came from Codex, Claude, or another real provider.
|
|
190
|
+
source: event.source ?? adapter.id,
|
|
172
191
|
timestamp: event.timestamp
|
|
173
192
|
});
|
|
174
193
|
}
|
|
@@ -301,6 +320,16 @@ export async function supervisePreparedRun({
|
|
|
301
320
|
type: failed ? "run.failed" : "run.completed",
|
|
302
321
|
data: { exitCode }
|
|
303
322
|
}), { captureTranscript: shouldPersistTranscript(captureTranscript) });
|
|
323
|
+
// Recorded exactly once, here, at the real terminal state — never
|
|
324
|
+
// on intermediate streamed usage events, which would double-count
|
|
325
|
+
// as they arrive. A run whose adapter never reported real usage
|
|
326
|
+
// (tokenUsage.total stays null) records nothing.
|
|
327
|
+
await recordProviderUsage(homeDir, adapter.id, {
|
|
328
|
+
input: metadata.tokenUsage?.input,
|
|
329
|
+
output: metadata.tokenUsage?.output,
|
|
330
|
+
total: metadata.tokenUsage?.total,
|
|
331
|
+
cost: metadata.cost
|
|
332
|
+
});
|
|
304
333
|
if (!failed && strategy === RUN_STRATEGIES.ORCHESTRATED) {
|
|
305
334
|
await finalizeOrchState(runId, { homeDir, recovered: false });
|
|
306
335
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { classifyUsageTier, createProviderUsageRecord, isProviderExhausted } from "./usage-types.js";
|
|
2
|
+
import { readProviderUsage, writeProviderUsage } from "./usage-store.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Records one real run's own usage onto a provider's real, cumulative
|
|
6
|
+
* total — called exactly once per real run, at the moment it reaches a
|
|
7
|
+
* terminal state (run-supervisor.js), never on intermediate streamed
|
|
8
|
+
* usage events (which would double-count as they arrive). A run with no
|
|
9
|
+
* real usage data at all (tokens null — the adapter never reported any,
|
|
10
|
+
* or the process never really started) records nothing: there is no
|
|
11
|
+
* real consumption to attribute, and bumping runCount for it would be
|
|
12
|
+
* noise, not evidence.
|
|
13
|
+
* @param {string} homeDir
|
|
14
|
+
* @param {string} provider - a real execution-adapters/index.js id
|
|
15
|
+
* @param {{input?: number|null, output?: number|null, total?: number|null, cost?: number|null}} usage
|
|
16
|
+
*/
|
|
17
|
+
export async function recordProviderUsage(homeDir, provider, usage = {}) {
|
|
18
|
+
const total = Number.isFinite(usage.total) ? usage.total : null;
|
|
19
|
+
if (total == null) return null;
|
|
20
|
+
|
|
21
|
+
const current = (await readProviderUsage(homeDir, provider)) ?? createProviderUsageRecord(provider);
|
|
22
|
+
const input = Number.isFinite(usage.input) ? usage.input : 0;
|
|
23
|
+
const output = Number.isFinite(usage.output) ? usage.output : 0;
|
|
24
|
+
const cost = Number.isFinite(usage.cost) ? usage.cost : 0;
|
|
25
|
+
|
|
26
|
+
const next = {
|
|
27
|
+
...current,
|
|
28
|
+
totalInputTokens: current.totalInputTokens + input,
|
|
29
|
+
totalOutputTokens: current.totalOutputTokens + output,
|
|
30
|
+
totalTokens: current.totalTokens + total,
|
|
31
|
+
totalCost: current.totalCost + cost,
|
|
32
|
+
runCount: current.runCount + 1,
|
|
33
|
+
updatedAt: new Date().toISOString()
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
await writeProviderUsage(homeDir, provider, next);
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveProviderBudget(profile, provider) {
|
|
41
|
+
const perProvider = profile?.providerTokenBudgets;
|
|
42
|
+
if (perProvider && typeof perProvider === "object" && Number.isFinite(perProvider[provider])) {
|
|
43
|
+
return perProvider[provider];
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The real, current classification for one provider — real cumulative
|
|
50
|
+
* consumption (readProviderUsage) against the real configured budget for
|
|
51
|
+
* that provider (profile.providerTokenBudgets), never a single run's own
|
|
52
|
+
* usage. No configured budget for this provider means no real consumption
|
|
53
|
+
* record is even required to answer HEALTHY.
|
|
54
|
+
* @param {object} args
|
|
55
|
+
* @param {string} args.homeDir
|
|
56
|
+
* @param {string} args.provider
|
|
57
|
+
* @param {object} [args.profile] - a resolved profile object (profile.js's own `profile` field)
|
|
58
|
+
*/
|
|
59
|
+
export async function getProviderUsageState({ homeDir, provider, profile = null }) {
|
|
60
|
+
const budgetTokens = resolveProviderBudget(profile, provider);
|
|
61
|
+
const record = (await readProviderUsage(homeDir, provider)) ?? createProviderUsageRecord(provider);
|
|
62
|
+
const tier = classifyUsageTier(record.totalTokens, budgetTokens);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
provider,
|
|
66
|
+
budgetTokens,
|
|
67
|
+
consumedTokens: record.totalTokens,
|
|
68
|
+
consumedCost: record.totalCost,
|
|
69
|
+
runCount: record.runCount,
|
|
70
|
+
tier
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The real, single enforcement point this increment adds — called from
|
|
76
|
+
* run-manager.js's prepareRun, right after adapter.preflight and before a
|
|
77
|
+
* new run is ever created. Refuses only a provider whose real cumulative
|
|
78
|
+
* consumption has reached its own configured budget; every other tier
|
|
79
|
+
* (HEALTHY through CRITICAL) is informational only in this increment —
|
|
80
|
+
* there is no automatic model-chaining yet for a softer tier to throttle.
|
|
81
|
+
* @param {object} args
|
|
82
|
+
* @param {string} args.homeDir
|
|
83
|
+
* @param {string} args.provider
|
|
84
|
+
* @param {object} [args.profile]
|
|
85
|
+
*/
|
|
86
|
+
export async function assertProviderNotExhausted({ homeDir, provider, profile = null }) {
|
|
87
|
+
const state = await getProviderUsageState({ homeDir, provider, profile });
|
|
88
|
+
if (isProviderExhausted(state.tier)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Provider "${provider}" has exhausted its configured token budget `
|
|
91
|
+
+ `(${state.consumedTokens}/${state.budgetTokens} tokens). Raise providerTokenBudgets.${provider} `
|
|
92
|
+
+ "in the profile, or wait for the budget to be reset, before starting a new run."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return state;
|
|
96
|
+
}
|