@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,924 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resolveHead, resolveWorkingTreeFingerprint, verifyPlanForExecution } from "../architect/architect-store.js";
|
|
7
|
+
import { worktreePaths } from "../paths.js";
|
|
8
|
+
import {
|
|
9
|
+
createWorktreeId, EXECUTION_WORKTREE_SCHEMA, WORKTREE_STATES, isTerminalWorktreeState
|
|
10
|
+
} from "./execution-worktree-types.js";
|
|
11
|
+
import {
|
|
12
|
+
appendCheckpoint, createWorktreeRecord, listWorktreeRecords, readWorktreeState, writeWorktreeState
|
|
13
|
+
} from "./execution-worktree-store.js";
|
|
14
|
+
import { readRunState } from "./run-store.js";
|
|
15
|
+
import { RUN_STATES, isTerminalRunState } from "./run-types.js";
|
|
16
|
+
import { resolveReviewSnapshot } from "./review/review-git.js";
|
|
17
|
+
|
|
18
|
+
/** Only these three roles are real execution-worktree roles — see beginRoleRun's own doc for why this stays a closed list, not an open string. */
|
|
19
|
+
const EXECUTION_WORKTREE_ROLES = new Set(["Builder", "Debugger", "Tester"]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The real project's own working tree must be clean before Kairo ever
|
|
23
|
+
* creates an execution worktree from it — the same `.ai/tasks/**` exclusion
|
|
24
|
+
* resolveWorkingTreeFingerprint already uses (Kairo's own task artifacts
|
|
25
|
+
* are never treated as "dirty"), but here checked directly rather than via
|
|
26
|
+
* a fingerprint comparison, since what matters is "is there anything real
|
|
27
|
+
* to lose track of", not a specific hash value.
|
|
28
|
+
*/
|
|
29
|
+
function assertWorkingTreeClean(projectRoot, { exec }) {
|
|
30
|
+
const diff = exec("git", ["diff", "--binary", "HEAD", "--", ".", ":(exclude).ai/tasks/**"], {
|
|
31
|
+
cwd: projectRoot, encoding: null, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024
|
|
32
|
+
});
|
|
33
|
+
const untracked = exec("git", [
|
|
34
|
+
"ls-files", "--others", "--exclude-standard", "-z", "--", ".", ":(exclude).ai/tasks/**"
|
|
35
|
+
], { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
36
|
+
if (diff.length > 0 || String(untracked).trim().length > 0) {
|
|
37
|
+
throw new Error("Working tree is not clean — commit or stash real changes before creating an execution worktree.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort, idempotent undo for any partially-created execution
|
|
43
|
+
* worktree — safe to call even when nothing real was created yet (a
|
|
44
|
+
* `git worktree remove` on an unregistered path just fails quietly, same
|
|
45
|
+
* for an `rm -rf` on a directory that was never made). Never masks the
|
|
46
|
+
* real error that triggered it; the caller always rethrows the original.
|
|
47
|
+
*/
|
|
48
|
+
async function rollbackWorktree({ projectRoot, treePath, worktreeDir, exec }) {
|
|
49
|
+
try {
|
|
50
|
+
exec("git", ["worktree", "remove", "--force", treePath], { cwd: projectRoot, stdio: "ignore" });
|
|
51
|
+
} catch { /* never registered, or already gone — fine */ }
|
|
52
|
+
try {
|
|
53
|
+
exec("git", ["worktree", "prune"], { cwd: projectRoot, stdio: "ignore" });
|
|
54
|
+
} catch { /* best-effort */ }
|
|
55
|
+
await rm(worktreeDir, { recursive: true, force: true }).catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The execution worktree itself (not the real project) must have zero
|
|
60
|
+
* uncommitted changes — tracked or untracked — before a role starts and
|
|
61
|
+
* again right before a commit is made. This is what makes the "before"
|
|
62
|
+
* checkpoint and the staged-diff validation trustworthy preconditions
|
|
63
|
+
* instead of just an audit trail: a role can never inherit stray state
|
|
64
|
+
* left over from a previous role or from outside interference, and
|
|
65
|
+
* completeRoleRun can trust that whatever it stages is exactly and only
|
|
66
|
+
* what this role's run produced.
|
|
67
|
+
*/
|
|
68
|
+
function assertExecutionWorktreeClean(treePath, { exec }) {
|
|
69
|
+
const diff = exec("git", ["diff", "--binary", "HEAD"], {
|
|
70
|
+
cwd: treePath, encoding: null, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024
|
|
71
|
+
});
|
|
72
|
+
const untracked = exec("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
73
|
+
cwd: treePath, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]
|
|
74
|
+
});
|
|
75
|
+
if (diff.length > 0 || String(untracked).trim().length > 0) {
|
|
76
|
+
throw new Error(`Execution worktree at "${treePath}" is not clean.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Unstages everything without touching the working tree — used to back out a rejected staging attempt. */
|
|
81
|
+
function unstageAll(treePath, exec) {
|
|
82
|
+
try {
|
|
83
|
+
exec("git", ["reset"], { cwd: treePath, stdio: "ignore" });
|
|
84
|
+
} catch { /* best-effort — the completion is already being rejected */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creates one real, isolated execution worktree for an approved
|
|
89
|
+
* architecture plan — the real boundary every future role's run will be
|
|
90
|
+
* launched inside, never the project's own directory. Verifies the plan
|
|
91
|
+
* via the real verifyPlanForExecution (APPROVED state, real artifact
|
|
92
|
+
* digests — catches a plan.md/task.md tampered with after approval, which
|
|
93
|
+
* a bare state+HEAD check would silently miss — and baseSha staleness),
|
|
94
|
+
* the exact same gate executePlan itself goes through, never a
|
|
95
|
+
* hand-rolled duplicate of it. checkWorkingTree:false here on purpose:
|
|
96
|
+
* that flag checks the project's fingerprint is byte-identical to
|
|
97
|
+
* whatever it was AT approval time, which is a different, weaker
|
|
98
|
+
* question than "is it clean right now" — assertWorkingTreeClean below
|
|
99
|
+
* is the real precondition a worktree creation needs. Any failure after
|
|
100
|
+
* the state record is written rolls back completely — see
|
|
101
|
+
* rollbackWorktree's own doc; a real orphaned worktree is never left
|
|
102
|
+
* behind.
|
|
103
|
+
* @param {object} args
|
|
104
|
+
* @param {string} args.projectRoot - already-resolved real project root
|
|
105
|
+
* @param {string} args.taskId
|
|
106
|
+
* @param {string} args.homeDir
|
|
107
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
108
|
+
*/
|
|
109
|
+
export async function createExecutionWorktree({ projectRoot, taskId, homeDir, exec = execFileSync }) {
|
|
110
|
+
const record = await verifyPlanForExecution(projectRoot, taskId, { exec, checkWorkingTree: false });
|
|
111
|
+
// verifyPlanForExecution already proved the project's real current HEAD
|
|
112
|
+
// equals this — reusing it instead of resolving HEAD again avoids a
|
|
113
|
+
// redundant git call for a value already established.
|
|
114
|
+
const currentHead = record.status.baseHead;
|
|
115
|
+
assertWorkingTreeClean(projectRoot, { exec });
|
|
116
|
+
|
|
117
|
+
const worktreeId = createWorktreeId();
|
|
118
|
+
const paths = worktreePaths(homeDir, worktreeId);
|
|
119
|
+
const now = new Date().toISOString();
|
|
120
|
+
const originalWorkingTreeFingerprint = resolveWorkingTreeFingerprint(projectRoot, { exec });
|
|
121
|
+
|
|
122
|
+
const metadata = {
|
|
123
|
+
schema: EXECUTION_WORKTREE_SCHEMA,
|
|
124
|
+
worktreeId,
|
|
125
|
+
projectRoot,
|
|
126
|
+
taskId,
|
|
127
|
+
treePath: paths.treePath,
|
|
128
|
+
baseSha: currentHead,
|
|
129
|
+
originalWorkingTreeFingerprint,
|
|
130
|
+
status: WORKTREE_STATES.PENDING,
|
|
131
|
+
activeRole: null,
|
|
132
|
+
activeRunId: null,
|
|
133
|
+
// The last HEAD Kairo itself legitimately produced or verified —
|
|
134
|
+
// never null, initialized to baseSha since that's the one HEAD Kairo
|
|
135
|
+
// has actually verified so far (via verifyPlanForExecution above).
|
|
136
|
+
// Every later state-changing boundary (beginRoleRun, completeRoleRun,
|
|
137
|
+
// markReadyForReview) checks the worktree's real current HEAD against
|
|
138
|
+
// this exact value before trusting anything about it — a commit
|
|
139
|
+
// landing at ANY point outside a legitimate completeRoleRun, PENDING
|
|
140
|
+
// included, is a rogue commit, not a clean worktree.
|
|
141
|
+
controlledHeadSha: currentHead,
|
|
142
|
+
readyHeadSha: null,
|
|
143
|
+
createdAt: now,
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
error: null
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await createWorktreeRecord(homeDir, metadata);
|
|
150
|
+
exec("git", ["worktree", "add", "--detach", paths.treePath, currentHead], {
|
|
151
|
+
cwd: projectRoot, stdio: ["ignore", "ignore", "pipe"]
|
|
152
|
+
});
|
|
153
|
+
const worktreeHead = resolveHead(paths.treePath, { exec });
|
|
154
|
+
if (worktreeHead !== currentHead) {
|
|
155
|
+
throw new Error(`Execution worktree HEAD "${worktreeHead}" does not match the expected base "${currentHead}".`);
|
|
156
|
+
}
|
|
157
|
+
return metadata;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
await rollbackWorktree({ projectRoot, treePath: paths.treePath, worktreeDir: paths.worktreeDir, exec });
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function requireWorktree(homeDir, worktreeId) {
|
|
165
|
+
const worktree = await readWorktreeState(homeDir, worktreeId);
|
|
166
|
+
if (!worktree) throw new Error(`Execution worktree "${worktreeId}" not found.`);
|
|
167
|
+
return worktree;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Best-effort transition to INTERRUPTED with a real, honest reason — used
|
|
172
|
+
* by completeRoleRun's own failure branches (a run that didn't succeed, an
|
|
173
|
+
* unsafe/private/oversized real diff) and reused directly by
|
|
174
|
+
* execution-worktree-orchestrator.js for its own failure branch (a role's
|
|
175
|
+
* real run couldn't even be started — no run ever existed for
|
|
176
|
+
* completeRoleRun's own checks to find). Never throws itself; the caller
|
|
177
|
+
* still throws its own real error right after calling this, so a
|
|
178
|
+
* persistence failure here never masks the original real reason.
|
|
179
|
+
*/
|
|
180
|
+
export async function markInterrupted(homeDir, worktree, reason) {
|
|
181
|
+
const next = {
|
|
182
|
+
...worktree, status: WORKTREE_STATES.INTERRUPTED, activeRole: null, activeRunId: null,
|
|
183
|
+
updatedAt: new Date().toISOString(), error: reason
|
|
184
|
+
};
|
|
185
|
+
await writeWorktreeState(homeDir, next).catch(() => {});
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The real, systemic guard every state-changing boundary below applies:
|
|
191
|
+
* a clean worktree only proves everything is committed, never that Kairo
|
|
192
|
+
* authorized those commits. PENDING is a real trust boundary exactly like
|
|
193
|
+
* ACTIVE is — a rogue commit made while nothing is "running" would
|
|
194
|
+
* otherwise get silently laundered into legitimacy the moment the next
|
|
195
|
+
* role begins, or the moment the worktree is marked ready for review.
|
|
196
|
+
*/
|
|
197
|
+
function assertHeadIsControlled(worktree, currentHead, action) {
|
|
198
|
+
if (currentHead !== worktree.controlledHeadSha) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Execution worktree HEAD is "${currentHead}", but Kairo's last controlled HEAD is `
|
|
201
|
+
+ `"${worktree.controlledHeadSha}" — a commit landed outside Kairo's control. Refusing to ${action}.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Starts one real role's run inside an already-created execution worktree
|
|
208
|
+
* — the real boundary a role's own agent run is launched inside (the
|
|
209
|
+
* caller still owns actually starting the real run itself, e.g. via
|
|
210
|
+
* run-manager.js's startRun, pointed at this worktree's treePath as cwd;
|
|
211
|
+
* this function only owns the worktree's own bookkeeping around it).
|
|
212
|
+
* Only Builder/Debugger/Tester are real execution-worktree roles —
|
|
213
|
+
* Explorer/Architect/Reviewer stay non-operational per the plan's own
|
|
214
|
+
* explicitly-deferred scope, and this is a closed list rather than any
|
|
215
|
+
* string precisely so a typo or a future role added elsewhere can never
|
|
216
|
+
* silently start "running" inside a worktree without this file's own
|
|
217
|
+
* explicit sign-off. Requires the worktree to be PENDING (a fresh
|
|
218
|
+
* creation, or the state completeRoleRun leaves it in after a prior
|
|
219
|
+
* role) — never lets two roles run concurrently in the same worktree.
|
|
220
|
+
* Also requires the worktree itself to be completely clean right now
|
|
221
|
+
* (assertExecutionWorktreeClean) AND its real current HEAD to still equal
|
|
222
|
+
* controlledHeadSha (assertHeadIsControlled) — a worktree can sit in
|
|
223
|
+
* PENDING indefinitely between roles, and nothing stops a rogue commit
|
|
224
|
+
* from landing there; a clean tree alone would let it through silently.
|
|
225
|
+
* Records a real "before" checkpoint (the worktree's own real HEAD +
|
|
226
|
+
* working-tree fingerprint right now) before flipping PENDING -> ACTIVE.
|
|
227
|
+
* @param {object} args
|
|
228
|
+
* @param {string} args.worktreeId
|
|
229
|
+
* @param {string} args.role
|
|
230
|
+
* @param {string} args.runId - the real run-manager.js runId this role's
|
|
231
|
+
* agent process is (or will be) running under — completeRoleRun later
|
|
232
|
+
* requires this exact same id back.
|
|
233
|
+
* @param {string} args.homeDir
|
|
234
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
235
|
+
*/
|
|
236
|
+
export async function beginRoleRun({ worktreeId, role, runId, homeDir, exec = execFileSync }) {
|
|
237
|
+
if (!EXECUTION_WORKTREE_ROLES.has(role)) {
|
|
238
|
+
throw new Error(`"${role}" is not a real execution-worktree role — only Builder, Debugger, and Tester may run inside one.`);
|
|
239
|
+
}
|
|
240
|
+
const worktree = await requireWorktree(homeDir, worktreeId);
|
|
241
|
+
if (worktree.status !== WORKTREE_STATES.PENDING) {
|
|
242
|
+
throw new Error(`Execution worktree "${worktreeId}" is ${worktree.status}; expected PENDING to begin a new role run.`);
|
|
243
|
+
}
|
|
244
|
+
assertExecutionWorktreeClean(worktree.treePath, { exec });
|
|
245
|
+
|
|
246
|
+
const now = new Date().toISOString();
|
|
247
|
+
const headSha = resolveHead(worktree.treePath, { exec });
|
|
248
|
+
assertHeadIsControlled(worktree, headSha, "begin a new role run");
|
|
249
|
+
const fingerprint = resolveWorkingTreeFingerprint(worktree.treePath, { exec });
|
|
250
|
+
await appendCheckpoint(homeDir, worktreeId, {
|
|
251
|
+
worktreeId, role, runId, phase: "before", headSha, fingerprint, timestamp: now
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const next = {
|
|
255
|
+
...worktree, status: WORKTREE_STATES.ACTIVE, activeRole: role, activeRunId: runId, updatedAt: now
|
|
256
|
+
};
|
|
257
|
+
await writeWorktreeState(homeDir, next);
|
|
258
|
+
return next;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Completes one real role's run — the real boundary that decides
|
|
263
|
+
* whether anything the role's agent touched ever becomes a real commit.
|
|
264
|
+
* Requires the exact same runId beginRoleRun recorded (a stale or wrong
|
|
265
|
+
* runId is always rejected, never silently accepted) and the real run
|
|
266
|
+
* (read fresh from run-store.js, never trusted from the caller) to have
|
|
267
|
+
* actually reached a terminal state:
|
|
268
|
+
* - Not yet terminal (still PENDING/STARTING/RUNNING): rejected outright,
|
|
269
|
+
* no state change at all — this is a caller usage error (completing too
|
|
270
|
+
* early), not a real worktree failure.
|
|
271
|
+
* The real run also has to prove it actually ran inside this exact
|
|
272
|
+
* worktree (`runState.cwd === worktree.treePath`) — a COMPLETED state
|
|
273
|
+
* alone is never enough, since nothing stops a run claiming completion
|
|
274
|
+
* from a wholly different directory.
|
|
275
|
+
* - Terminal but not COMPLETED (FAILED/CANCELLED/INTERRUPTED), or
|
|
276
|
+
* COMPLETED from the wrong cwd: the role's real attempt failed — no
|
|
277
|
+
* commit is ever created for it, and the worktree moves straight to
|
|
278
|
+
* INTERRUPTED.
|
|
279
|
+
* - COMPLETED, from the right cwd: the real success path, guarded end to
|
|
280
|
+
* end so what's validated is provably what's committed:
|
|
281
|
+
* 1. The worktree's real current HEAD must still equal
|
|
282
|
+
* `controlledHeadSha` (the same value beginRoleRun itself verified)
|
|
283
|
+
* — if it moved at all, the agent ran `git commit` itself instead
|
|
284
|
+
* of only editing files, and that's rejected outright (INTERRUPTED,
|
|
285
|
+
* no further commit).
|
|
286
|
+
* 2. The real, current uncommitted working-tree diff is validated via
|
|
287
|
+
* resolveReviewSnapshot (real path safety, real size/line/file
|
|
288
|
+
* limits, real symlink/binary/non-regular handling). ANY excluded
|
|
289
|
+
* entry at all — not just private paths — fails the completion
|
|
290
|
+
* outright, since there is no consent/cockpit surface yet at this
|
|
291
|
+
* increment and a validated file riding alongside an excluded one
|
|
292
|
+
* must never let the excluded one through.
|
|
293
|
+
* 3. When there are real changes, Kairo stages exactly the validated
|
|
294
|
+
* paths (never `git add -A`), then re-validates the real STAGED
|
|
295
|
+
* content (not the pre-staging view) — the staged path set must
|
|
296
|
+
* match what was validated, staged modes must be regular files
|
|
297
|
+
* only (no submodule gitlinks), and zero unstaged/untracked
|
|
298
|
+
* changes may remain. HEAD is re-checked immediately before the
|
|
299
|
+
* commit itself as a final race guard, and the worktree is
|
|
300
|
+
* re-verified clean immediately after committing.
|
|
301
|
+
* 4. When there are no real changes at all, no commit is fabricated;
|
|
302
|
+
* the real "after" checkpoint records the exact same real HEAD as
|
|
303
|
+
* "before".
|
|
304
|
+
* Either way, the worktree returns to PENDING afterward, ready for the
|
|
305
|
+
* next role or for markReadyForReview. The agent never runs
|
|
306
|
+
* `git commit`/`git add` itself at any point — only ever edits files.
|
|
307
|
+
* @param {object} args
|
|
308
|
+
* @param {string} args.worktreeId
|
|
309
|
+
* @param {string} args.role
|
|
310
|
+
* @param {string} args.runId
|
|
311
|
+
* @param {string} args.homeDir
|
|
312
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
313
|
+
* @param {(command: string, args: string[], options: object) => Promise<{stdout: string}>} [args.execFileImpl] - see resolveReviewSnapshot's own doc
|
|
314
|
+
* @param {(homeDir: string, runId: string) => Promise<object|null>} [args.readRun]
|
|
315
|
+
*/
|
|
316
|
+
export async function completeRoleRun({
|
|
317
|
+
worktreeId, role, runId, homeDir, exec = execFileSync, execFileImpl, readRun = readRunState
|
|
318
|
+
}) {
|
|
319
|
+
const worktree = await requireWorktree(homeDir, worktreeId);
|
|
320
|
+
if (worktree.status !== WORKTREE_STATES.ACTIVE) {
|
|
321
|
+
throw new Error(`Execution worktree "${worktreeId}" is ${worktree.status}; expected ACTIVE to complete a role run.`);
|
|
322
|
+
}
|
|
323
|
+
if (worktree.activeRole !== role || worktree.activeRunId !== runId) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Role run mismatch for execution worktree "${worktreeId}": active is `
|
|
326
|
+
+ `"${worktree.activeRole}"/"${worktree.activeRunId}", not "${role}"/"${runId}".`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const runState = await readRun(homeDir, runId);
|
|
331
|
+
if (!runState || !isTerminalRunState(runState.state)) {
|
|
332
|
+
throw new Error(`Cannot complete role run: run "${runId}" has not finished yet (state: ${runState?.state ?? "unknown"}).`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A COMPLETED state alone proves nothing about *where* the run actually
|
|
336
|
+
// executed — a run claiming completion from a different cwd never really
|
|
337
|
+
// touched this worktree, so it can never be trusted to close it out.
|
|
338
|
+
if (runState.cwd !== worktree.treePath) {
|
|
339
|
+
await markInterrupted(
|
|
340
|
+
homeDir, worktree,
|
|
341
|
+
`Role "${role}" run "${runId}" ran in "${runState.cwd ?? "unknown"}", not the execution worktree "${worktree.treePath}".`
|
|
342
|
+
);
|
|
343
|
+
throw new Error(
|
|
344
|
+
`Cannot complete role run: run "${runId}" executed outside its execution worktree — `
|
|
345
|
+
+ `execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (runState.state !== RUN_STATES.COMPLETED) {
|
|
350
|
+
await markInterrupted(homeDir, worktree, `Role "${role}" run "${runId}" ended in state ${runState.state}.`);
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Role "${role}" run "${runId}" did not complete successfully (state: ${runState.state}) — `
|
|
353
|
+
+ `execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The "before" HEAD is a precondition, not just an audit fact: if it
|
|
358
|
+
// moved at all, the agent (or anything else) ran `git commit` itself
|
|
359
|
+
// instead of only editing files, and Kairo can no longer be sure what
|
|
360
|
+
// it's about to stage is exactly and only this role's own work.
|
|
361
|
+
const headBeforeStaging = resolveHead(worktree.treePath, { exec });
|
|
362
|
+
if (headBeforeStaging !== worktree.controlledHeadSha) {
|
|
363
|
+
await markInterrupted(
|
|
364
|
+
homeDir, worktree,
|
|
365
|
+
`Execution worktree HEAD moved from "${worktree.controlledHeadSha}" to "${headBeforeStaging}" `
|
|
366
|
+
+ `outside Kairo's control during role "${role}" — the agent committed directly.`
|
|
367
|
+
);
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Cannot complete role run: HEAD changed unexpectedly during role "${role}" — the agent committed `
|
|
370
|
+
+ `directly instead of only editing files. Execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let workingSnapshot;
|
|
375
|
+
try {
|
|
376
|
+
workingSnapshot = await resolveReviewSnapshot({ cwd: worktree.treePath, execFileImpl });
|
|
377
|
+
} catch (error) {
|
|
378
|
+
await markInterrupted(homeDir, worktree, error.message ?? String(error));
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ANY excluded entry — private, binary, symlink, non-regular — blocks
|
|
383
|
+
// the whole completion. There is no consent/cockpit surface yet at this
|
|
384
|
+
// increment to ask a human about any of them, and silently committing
|
|
385
|
+
// only the admitted subset is exactly the bypass this guards against:
|
|
386
|
+
// an unrelated excluded file must never ride along just because some
|
|
387
|
+
// other, validated file was also touched.
|
|
388
|
+
if (workingSnapshot.excluded.length > 0) {
|
|
389
|
+
const reason = `Role "${role}" touched path(s) Kairo refuses to commit: `
|
|
390
|
+
+ workingSnapshot.excluded.map((e) => `${e.path} (${e.reason})`).join(", ") + ".";
|
|
391
|
+
await markInterrupted(homeDir, worktree, reason);
|
|
392
|
+
throw new Error(`Cannot complete role run: ${reason} Execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (workingSnapshot.files.length === 0) {
|
|
396
|
+
const now = new Date().toISOString();
|
|
397
|
+
const fingerprint = resolveWorkingTreeFingerprint(worktree.treePath, { exec });
|
|
398
|
+
await appendCheckpoint(homeDir, worktreeId, {
|
|
399
|
+
worktreeId, role, runId, phase: "after", headSha: headBeforeStaging, fingerprint, timestamp: now
|
|
400
|
+
});
|
|
401
|
+
const next = {
|
|
402
|
+
...worktree, status: WORKTREE_STATES.PENDING, activeRole: null, activeRunId: null,
|
|
403
|
+
controlledHeadSha: headBeforeStaging, updatedAt: now
|
|
404
|
+
};
|
|
405
|
+
await writeWorktreeState(homeDir, next);
|
|
406
|
+
return next;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Stage exactly the validated paths — never `git add -A`, which would
|
|
410
|
+
// sweep in anything else sitting in the worktree regardless of what was
|
|
411
|
+
// actually validated above.
|
|
412
|
+
const validatedPaths = workingSnapshot.files.map((f) => f.path);
|
|
413
|
+
exec("git", ["add", "--", ...validatedPaths], { cwd: worktree.treePath, stdio: "ignore" });
|
|
414
|
+
|
|
415
|
+
// What's validated must match what's actually staged and about to be
|
|
416
|
+
// committed — re-run the same review snapshot logic against the real
|
|
417
|
+
// staged content, not the pre-staging working-tree view of it.
|
|
418
|
+
let stagedSnapshot;
|
|
419
|
+
try {
|
|
420
|
+
stagedSnapshot = await resolveReviewSnapshot({ cwd: worktree.treePath, staged: true, execFileImpl });
|
|
421
|
+
} catch (error) {
|
|
422
|
+
unstageAll(worktree.treePath, exec);
|
|
423
|
+
await markInterrupted(homeDir, worktree, error.message ?? String(error));
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (stagedSnapshot.excluded.length > 0) {
|
|
428
|
+
unstageAll(worktree.treePath, exec);
|
|
429
|
+
const reason = `Staged content includes excluded path(s): `
|
|
430
|
+
+ stagedSnapshot.excluded.map((e) => `${e.path} (${e.reason})`).join(", ") + ".";
|
|
431
|
+
await markInterrupted(homeDir, worktree, reason);
|
|
432
|
+
throw new Error(`Cannot complete role run: ${reason} Execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const stagedPaths = new Set(stagedSnapshot.files.map((f) => f.path));
|
|
436
|
+
const workingPaths = new Set(workingSnapshot.files.map((f) => f.path));
|
|
437
|
+
const pathSetsMatch = stagedPaths.size === workingPaths.size && [...stagedPaths].every((p) => workingPaths.has(p));
|
|
438
|
+
if (!pathSetsMatch) {
|
|
439
|
+
unstageAll(worktree.treePath, exec);
|
|
440
|
+
const reason = "Staged path set does not match the validated working-tree snapshot.";
|
|
441
|
+
await markInterrupted(homeDir, worktree, reason);
|
|
442
|
+
throw new Error(`Cannot complete role run: ${reason} Execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Symlinks/binaries are already excluded above; this additionally
|
|
446
|
+
// catches submodule gitlinks (mode 160000) and any other non-regular
|
|
447
|
+
// mode git itself is willing to stage.
|
|
448
|
+
const badMode = stagedSnapshot.files.find((f) => f.mode != null && f.mode !== "100644" && f.mode !== "100755");
|
|
449
|
+
if (badMode) {
|
|
450
|
+
unstageAll(worktree.treePath, exec);
|
|
451
|
+
const reason = `Staged path "${badMode.path}" has a non-regular file mode (${badMode.mode}).`;
|
|
452
|
+
await markInterrupted(homeDir, worktree, reason);
|
|
453
|
+
throw new Error(`Cannot complete role run: ${reason} Execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const unstagedDiff = exec("git", ["diff", "--name-only"], {
|
|
457
|
+
cwd: worktree.treePath, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]
|
|
458
|
+
});
|
|
459
|
+
const untrackedFiles = exec("git", ["ls-files", "--others", "--exclude-standard"], {
|
|
460
|
+
cwd: worktree.treePath, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]
|
|
461
|
+
});
|
|
462
|
+
if (String(unstagedDiff).trim().length > 0 || String(untrackedFiles).trim().length > 0) {
|
|
463
|
+
unstageAll(worktree.treePath, exec);
|
|
464
|
+
const reason = "Unstaged or untracked changes remain after staging the validated diff.";
|
|
465
|
+
await markInterrupted(homeDir, worktree, reason);
|
|
466
|
+
throw new Error(`Cannot complete role run: ${reason} Execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Final race guard: re-check HEAD immediately before the real commit,
|
|
470
|
+
// not just once at the top of this function.
|
|
471
|
+
const headBeforeCommit = resolveHead(worktree.treePath, { exec });
|
|
472
|
+
if (headBeforeCommit !== headBeforeStaging) {
|
|
473
|
+
unstageAll(worktree.treePath, exec);
|
|
474
|
+
const reason = `Execution worktree HEAD moved from "${headBeforeStaging}" to "${headBeforeCommit}" while staging.`;
|
|
475
|
+
await markInterrupted(homeDir, worktree, reason);
|
|
476
|
+
throw new Error(`Cannot complete role run: ${reason} Execution worktree "${worktreeId}" moved to INTERRUPTED, no commit created.`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
exec("git", ["commit", "-qm", `checkpoint(${role}): run ${runId}`], { cwd: worktree.treePath, stdio: "ignore" });
|
|
480
|
+
|
|
481
|
+
const now = new Date().toISOString();
|
|
482
|
+
const headSha = resolveHead(worktree.treePath, { exec });
|
|
483
|
+
const fingerprint = resolveWorkingTreeFingerprint(worktree.treePath, { exec });
|
|
484
|
+
|
|
485
|
+
// Cleanliness after the commit is itself verified, not assumed — an
|
|
486
|
+
// execution worktree only ever hands back to PENDING in a state the
|
|
487
|
+
// next role (or markReadyForReview) can trust as a real precondition.
|
|
488
|
+
// The commit already happened at this point, so a failure here goes to
|
|
489
|
+
// INTERRUPTED rather than unstaging anything.
|
|
490
|
+
try {
|
|
491
|
+
assertExecutionWorktreeClean(worktree.treePath, { exec });
|
|
492
|
+
} catch (error) {
|
|
493
|
+
await markInterrupted(homeDir, worktree, `Execution worktree left dirty after committing role "${role}": ${error.message}`);
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
await appendCheckpoint(homeDir, worktreeId, {
|
|
498
|
+
worktreeId, role, runId, phase: "after", headSha, fingerprint, timestamp: now
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const next = {
|
|
502
|
+
...worktree, status: WORKTREE_STATES.PENDING, activeRole: null, activeRunId: null,
|
|
503
|
+
controlledHeadSha: headSha, updatedAt: now
|
|
504
|
+
};
|
|
505
|
+
await writeWorktreeState(homeDir, next);
|
|
506
|
+
return next;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Explicitly marks an execution worktree ready for a real preview/merge
|
|
511
|
+
* (increment 3) — only ever from PENDING (no role run active right now);
|
|
512
|
+
* never automatic after a single role completes, since a real multi-role
|
|
513
|
+
* chain (Builder -> Debugger -> Tester) may still have more roles left to
|
|
514
|
+
* run. That automatic chaining itself is still out of scope here — this
|
|
515
|
+
* is only the explicit, single-worktree "I'm done, this is ready" signal.
|
|
516
|
+
* PENDING alone only proves no role is running right now — it says
|
|
517
|
+
* nothing about whether the worktree is still clean, since nothing stops
|
|
518
|
+
* a stray edit (or leftover excluded file) from landing after the last
|
|
519
|
+
* role completed. READY_FOR_REVIEW is a claim that everything reviewable
|
|
520
|
+
* is already contained in real commits, so that claim is verified here
|
|
521
|
+
* too (assertExecutionWorktreeClean), not just assumed from the status
|
|
522
|
+
* name. But a clean tree alone only proves everything is committed, never
|
|
523
|
+
* that Kairo authorized those commits — a rogue commit made while the
|
|
524
|
+
* worktree sat idle in PENDING (nothing "running" to catch it) would
|
|
525
|
+
* otherwise be laundered into legitimacy right here, so the real current
|
|
526
|
+
* HEAD must also still equal controlledHeadSha (assertHeadIsControlled).
|
|
527
|
+
* Either rejection is a caller/environment usage error, not a worktree
|
|
528
|
+
* failure — zero state change, stays PENDING rather than moving to
|
|
529
|
+
* INTERRUPTED.
|
|
530
|
+
* @param {object} args
|
|
531
|
+
* @param {string} args.worktreeId
|
|
532
|
+
* @param {string} args.homeDir
|
|
533
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
534
|
+
*/
|
|
535
|
+
export async function markReadyForReview({ worktreeId, homeDir, exec = execFileSync }) {
|
|
536
|
+
const worktree = await requireWorktree(homeDir, worktreeId);
|
|
537
|
+
if (worktree.status !== WORKTREE_STATES.PENDING) {
|
|
538
|
+
throw new Error(`Execution worktree "${worktreeId}" is ${worktree.status}; expected PENDING (no active role run) to mark ready for review.`);
|
|
539
|
+
}
|
|
540
|
+
assertExecutionWorktreeClean(worktree.treePath, { exec });
|
|
541
|
+
// The real HEAD is frozen here, not just cleanliness — previewWorktreeMerge
|
|
542
|
+
// later requires the worktree's current HEAD to still equal this exact
|
|
543
|
+
// value, so any commit landing after this point (however that happened)
|
|
544
|
+
// invalidates the preview instead of silently riding along.
|
|
545
|
+
const readyHeadSha = resolveHead(worktree.treePath, { exec });
|
|
546
|
+
assertHeadIsControlled(worktree, readyHeadSha, "mark ready for review");
|
|
547
|
+
const next = {
|
|
548
|
+
...worktree, status: WORKTREE_STATES.READY_FOR_REVIEW, readyHeadSha,
|
|
549
|
+
controlledHeadSha: readyHeadSha, updatedAt: new Date().toISOString()
|
|
550
|
+
};
|
|
551
|
+
await writeWorktreeState(homeDir, next);
|
|
552
|
+
return next;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Computes one real, deterministic merge preview for an execution worktree
|
|
557
|
+
* already in READY_FOR_REVIEW — shared by previewWorktreeMerge (the public
|
|
558
|
+
* read-only preview) and applyWorktreeMerge (which recomputes this exact
|
|
559
|
+
* same preview fresh, right before applying, and refuses to trust a stale
|
|
560
|
+
* one). Never mutates anything and never reserves any resource.
|
|
561
|
+
*
|
|
562
|
+
* - The worktree's own current HEAD must still equal readyHeadSha —
|
|
563
|
+
* anything else means a commit landed after markReadyForReview (however
|
|
564
|
+
* that happened), and the caller must mark ready again before previewing.
|
|
565
|
+
* - finalHeadSha (the worktree's current HEAD) must actually descend from
|
|
566
|
+
* baseSha (`git merge-base --is-ancestor`) — refuses to preview history
|
|
567
|
+
* that was rewritten or diverged out from under the worktree.
|
|
568
|
+
* - The accumulated diff baseSha..finalHeadSha is validated exactly like a
|
|
569
|
+
* review snapshot (resolveReviewSnapshot — real path safety, real
|
|
570
|
+
* accumulated size/line/file limits across every role's commits
|
|
571
|
+
* combined, real symlink/binary/non-regular handling). ANY excluded
|
|
572
|
+
* entry blocks the preview outright, same as completeRoleRun's own rule.
|
|
573
|
+
* - The fingerprint binds baseSha, finalHeadSha, and a real digest of the
|
|
574
|
+
* exact binary diff bytes between them — applyWorktreeMerge's
|
|
575
|
+
* confirmationTarget must match all three exactly, not just the SHAs.
|
|
576
|
+
* @param {object} worktree
|
|
577
|
+
* @param {object} deps
|
|
578
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} deps.exec
|
|
579
|
+
* @param {(command: string, args: string[], options: object) => Promise<{stdout: string}>} [deps.execFileImpl]
|
|
580
|
+
*/
|
|
581
|
+
async function computeMergePreview(worktree, { exec, execFileImpl }) {
|
|
582
|
+
assertExecutionWorktreeClean(worktree.treePath, { exec });
|
|
583
|
+
|
|
584
|
+
const currentHead = resolveHead(worktree.treePath, { exec });
|
|
585
|
+
if (currentHead !== worktree.readyHeadSha) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`Execution worktree HEAD moved from "${worktree.readyHeadSha}" to "${currentHead}" after it was marked `
|
|
588
|
+
+ "ready for review — a fresh markReadyForReview is required before previewing again."
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const baseSha = worktree.baseSha;
|
|
593
|
+
const finalHeadSha = currentHead;
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
exec("git", ["merge-base", "--is-ancestor", baseSha, finalHeadSha], { cwd: worktree.treePath, stdio: "ignore" });
|
|
597
|
+
} catch {
|
|
598
|
+
throw new Error(`Execution worktree HEAD "${finalHeadSha}" does not descend from its own baseSha "${baseSha}" — refusing to preview.`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const snapshot = await resolveReviewSnapshot({ cwd: worktree.treePath, base: baseSha, execFileImpl });
|
|
602
|
+
if (snapshot.excluded.length > 0) {
|
|
603
|
+
const reason = `Execution worktree's accumulated diff touches path(s) Kairo refuses to merge: `
|
|
604
|
+
+ snapshot.excluded.map((e) => `${e.path} (${e.reason})`).join(", ") + ".";
|
|
605
|
+
throw new Error(reason);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const rawDiff = exec("git", ["diff", "--binary", `${baseSha}..${finalHeadSha}`], {
|
|
609
|
+
cwd: worktree.treePath, encoding: null, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024
|
|
610
|
+
});
|
|
611
|
+
const diffDigest = createHash("sha256").update(rawDiff).digest("hex");
|
|
612
|
+
const fingerprint = createHash("sha256").update(JSON.stringify({ baseSha, finalHeadSha, diffDigest })).digest("hex");
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
baseSha,
|
|
616
|
+
finalHeadSha,
|
|
617
|
+
fingerprint,
|
|
618
|
+
diffText: rawDiff.toString("utf8"),
|
|
619
|
+
stats: {
|
|
620
|
+
fileCount: snapshot.totals.fileCount,
|
|
621
|
+
changedLines: snapshot.totals.changedLines,
|
|
622
|
+
diffBytes: snapshot.totals.diffBytes
|
|
623
|
+
},
|
|
624
|
+
noChanges: baseSha === finalHeadSha
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Read-only preview of what applyWorktreeMerge would apply — real diff,
|
|
630
|
+
* real stats, real fingerprint, computed fresh every call. Never changes
|
|
631
|
+
* the worktree's state and never reserves anything; see computeMergePreview
|
|
632
|
+
* for the real validation this performs.
|
|
633
|
+
* @param {object} args
|
|
634
|
+
* @param {string} args.worktreeId
|
|
635
|
+
* @param {string} args.homeDir
|
|
636
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
637
|
+
* @param {(command: string, args: string[], options: object) => Promise<{stdout: string}>} [args.execFileImpl]
|
|
638
|
+
*/
|
|
639
|
+
export async function previewWorktreeMerge({ worktreeId, homeDir, exec = execFileSync, execFileImpl }) {
|
|
640
|
+
const worktree = await requireWorktree(homeDir, worktreeId);
|
|
641
|
+
if (worktree.status !== WORKTREE_STATES.READY_FOR_REVIEW) {
|
|
642
|
+
throw new Error(`Execution worktree "${worktreeId}" is ${worktree.status}; expected READY_FOR_REVIEW to preview a merge.`);
|
|
643
|
+
}
|
|
644
|
+
return computeMergePreview(worktree, { exec, execFileImpl });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Real, exclusive, file-based lock per worktree — the git mutation
|
|
649
|
+
* applyWorktreeMerge performs against the real project needs actual
|
|
650
|
+
* exclusion, not just serialized state writes. writeWorktreeState's own
|
|
651
|
+
* per-worktreeId in-memory queue only serializes the write itself; it does
|
|
652
|
+
* nothing to stop two concurrent applyWorktreeMerge calls from both
|
|
653
|
+
* reading READY_FOR_REVIEW and both passing every check before either one
|
|
654
|
+
* writes APPLYING. An exclusive `wx` file create is atomic at the
|
|
655
|
+
* filesystem level and closes that whole window, not just the write.
|
|
656
|
+
*/
|
|
657
|
+
function acquireApplyLock(worktreeDir) {
|
|
658
|
+
const lockPath = join(worktreeDir, "apply.lock");
|
|
659
|
+
let fd;
|
|
660
|
+
try {
|
|
661
|
+
fd = openSync(lockPath, "wx");
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (error?.code === "EEXIST") {
|
|
664
|
+
throw new Error("Another apply is already in progress for this execution worktree.");
|
|
665
|
+
}
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
closeSync(fd);
|
|
669
|
+
return lockPath;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function releaseApplyLock(lockPath) {
|
|
673
|
+
try {
|
|
674
|
+
unlinkSync(lockPath);
|
|
675
|
+
} catch { /* best-effort — recovery of a stale lock is increment 4's own scope */ }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Applies one real, previously previewed merge — the only place a
|
|
680
|
+
* confirmed, reviewed commit chain ever reaches the real project, and
|
|
681
|
+
* only ever via `git merge --ff-only`: never cherry-pick, never a partial
|
|
682
|
+
* patch, never automatic conflict resolution.
|
|
683
|
+
*
|
|
684
|
+
* Every check runs BEFORE anything is mutated, and every one of them
|
|
685
|
+
* rejects with zero state change (the worktree stays READY_FOR_REVIEW):
|
|
686
|
+
* a stale or hand-built confirmationTarget that doesn't match a freshly
|
|
687
|
+
* recomputed preview, a worktree whose HEAD moved since the preview, or
|
|
688
|
+
* a real project that no longer has the exact HEAD, working-tree
|
|
689
|
+
* fingerprint, and cleanliness it had when this worktree was created.
|
|
690
|
+
* Drift is never resolved automatically — the caller must get a fresh
|
|
691
|
+
* preview and confirm again.
|
|
692
|
+
*
|
|
693
|
+
* Only once every one of those has passed does this persist APPLYING and
|
|
694
|
+
* actually run `git merge --ff-only` against the real project. Any
|
|
695
|
+
* failure from this point on — the merge itself failing (e.g. the real
|
|
696
|
+
* project stopped being fast-forwardable in the tiny window since the
|
|
697
|
+
* last check), or the post-merge verification (real HEAD must equal
|
|
698
|
+
* finalHeadSha, real project tree must be clean) — moves the worktree to
|
|
699
|
+
* INTERRUPTED. `git merge --ff-only` itself guarantees no partial merge
|
|
700
|
+
* on failure; this never attempts to rewrite history to recover.
|
|
701
|
+
*
|
|
702
|
+
* A no-op preview (baseSha === finalHeadSha, nothing was ever committed
|
|
703
|
+
* across any role) skips the real git mutation entirely and goes
|
|
704
|
+
* straight to APPLIED — there is nothing to merge.
|
|
705
|
+
* @param {object} args
|
|
706
|
+
* @param {string} args.worktreeId
|
|
707
|
+
* @param {{baseSha: string, finalHeadSha: string, fingerprint: string}} args.confirmationTarget - a preview's own exact output, never hand-built by a caller
|
|
708
|
+
* @param {string} args.homeDir
|
|
709
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
710
|
+
* @param {(command: string, args: string[], options: object) => Promise<{stdout: string}>} [args.execFileImpl]
|
|
711
|
+
*/
|
|
712
|
+
export async function applyWorktreeMerge({ worktreeId, confirmationTarget, homeDir, exec = execFileSync, execFileImpl }) {
|
|
713
|
+
const worktree = await requireWorktree(homeDir, worktreeId);
|
|
714
|
+
if (worktree.status !== WORKTREE_STATES.READY_FOR_REVIEW) {
|
|
715
|
+
throw new Error(`Execution worktree "${worktreeId}" is ${worktree.status}; expected READY_FOR_REVIEW to apply a merge.`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const { worktreeDir } = worktreePaths(homeDir, worktreeId);
|
|
719
|
+
const lockPath = acquireApplyLock(worktreeDir);
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
// Re-read fresh now that the lock is actually held — another apply
|
|
723
|
+
// may have already moved this worktree while this call was blocked
|
|
724
|
+
// acquiring the lock (or, absent a real lock, would have raced here).
|
|
725
|
+
const fresh = await requireWorktree(homeDir, worktreeId);
|
|
726
|
+
if (fresh.status !== WORKTREE_STATES.READY_FOR_REVIEW) {
|
|
727
|
+
throw new Error(`Execution worktree "${worktreeId}" is ${fresh.status}; expected READY_FOR_REVIEW to apply a merge.`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const preview = await computeMergePreview(fresh, { exec, execFileImpl });
|
|
731
|
+
const matches = confirmationTarget
|
|
732
|
+
&& confirmationTarget.baseSha === preview.baseSha
|
|
733
|
+
&& confirmationTarget.finalHeadSha === preview.finalHeadSha
|
|
734
|
+
&& confirmationTarget.fingerprint === preview.fingerprint;
|
|
735
|
+
if (!matches) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
"Cannot apply: confirmationTarget does not match a fresh preview of this execution worktree — "
|
|
738
|
+
+ "request a new preview and confirm again."
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const projectHead = resolveHead(fresh.projectRoot, { exec });
|
|
743
|
+
if (projectHead !== fresh.baseSha) {
|
|
744
|
+
throw new Error(
|
|
745
|
+
`Cannot apply: the real project's HEAD is "${projectHead}", not the execution worktree's own baseSha `
|
|
746
|
+
+ `"${fresh.baseSha}" — the project moved since this worktree was created.`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const projectFingerprint = resolveWorkingTreeFingerprint(fresh.projectRoot, { exec });
|
|
750
|
+
if (projectFingerprint !== fresh.originalWorkingTreeFingerprint) {
|
|
751
|
+
throw new Error("Cannot apply: the real project's working tree no longer matches its original fingerprint.");
|
|
752
|
+
}
|
|
753
|
+
assertWorkingTreeClean(fresh.projectRoot, { exec });
|
|
754
|
+
|
|
755
|
+
if (preview.noChanges) {
|
|
756
|
+
const next = { ...fresh, status: WORKTREE_STATES.APPLIED, updatedAt: new Date().toISOString() };
|
|
757
|
+
await writeWorktreeState(homeDir, next);
|
|
758
|
+
return next;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const applying = { ...fresh, status: WORKTREE_STATES.APPLYING, updatedAt: new Date().toISOString() };
|
|
762
|
+
await writeWorktreeState(homeDir, applying);
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
exec("git", ["merge", "--ff-only", preview.finalHeadSha], {
|
|
766
|
+
cwd: fresh.projectRoot, stdio: ["ignore", "ignore", "pipe"]
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const mergedHead = resolveHead(fresh.projectRoot, { exec });
|
|
770
|
+
if (mergedHead !== preview.finalHeadSha) {
|
|
771
|
+
throw new Error(`Real project HEAD is "${mergedHead}" after the merge, expected "${preview.finalHeadSha}".`);
|
|
772
|
+
}
|
|
773
|
+
assertWorkingTreeClean(fresh.projectRoot, { exec });
|
|
774
|
+
|
|
775
|
+
const applied = { ...applying, status: WORKTREE_STATES.APPLIED, updatedAt: new Date().toISOString() };
|
|
776
|
+
await writeWorktreeState(homeDir, applied);
|
|
777
|
+
return applied;
|
|
778
|
+
} catch (error) {
|
|
779
|
+
await markInterrupted(homeDir, applying, `Merge into the real project failed or left it in an unexpected state: ${error.message}`);
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
} finally {
|
|
783
|
+
releaseApplyLock(lockPath);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Explicit human/orchestrator cancellation — valid from any state where
|
|
789
|
+
* nothing real is being mutated right now (PENDING, ACTIVE,
|
|
790
|
+
* READY_FOR_REVIEW), never from APPLYING: a real git mutation against the
|
|
791
|
+
* real project may be in flight there, and cancelling mid-mutation is
|
|
792
|
+
* undefined, not "safe to abandon". Never commits anything on the way
|
|
793
|
+
* out — any real, uncommitted work sitting in the worktree is simply left
|
|
794
|
+
* behind for discardWorktree to eventually remove. If a role was ACTIVE,
|
|
795
|
+
* this only closes the worktree's own bookkeeping; stopping the real
|
|
796
|
+
* underlying agent run (if it's still alive) is run-manager.js's own
|
|
797
|
+
* responsibility, never this file's.
|
|
798
|
+
* @param {object} args
|
|
799
|
+
* @param {string} args.worktreeId
|
|
800
|
+
* @param {string} args.homeDir
|
|
801
|
+
* @param {string} [args.reason]
|
|
802
|
+
*/
|
|
803
|
+
export async function cancelWorktree({ worktreeId, homeDir, reason = "Cancelled." }) {
|
|
804
|
+
const worktree = await requireWorktree(homeDir, worktreeId);
|
|
805
|
+
if (worktree.status === WORKTREE_STATES.APPLYING) {
|
|
806
|
+
throw new Error(`Execution worktree "${worktreeId}" is APPLYING; cancelling mid-merge is not supported.`);
|
|
807
|
+
}
|
|
808
|
+
if (isTerminalWorktreeState(worktree.status)) {
|
|
809
|
+
throw new Error(`Execution worktree "${worktreeId}" is already ${worktree.status}; nothing to cancel.`);
|
|
810
|
+
}
|
|
811
|
+
const next = {
|
|
812
|
+
...worktree, status: WORKTREE_STATES.DISCARDED, activeRole: null, activeRunId: null,
|
|
813
|
+
updatedAt: new Date().toISOString(), error: reason
|
|
814
|
+
};
|
|
815
|
+
await writeWorktreeState(homeDir, next);
|
|
816
|
+
return next;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Reconciles every real, currently-active execution worktree against the
|
|
821
|
+
* real world — meant to run once when Kairo itself starts, mirroring
|
|
822
|
+
* run-store.js's own reconcileActiveRuns for exactly the same reason: a
|
|
823
|
+
* previous process may have died mid-operation, and nothing here ever
|
|
824
|
+
* guesses what should have happened.
|
|
825
|
+
*
|
|
826
|
+
* - ACTIVE, whose real run is no longer alive (isTerminalRunState says
|
|
827
|
+
* so, or the run doesn't exist at all): the role's own attempt is
|
|
828
|
+
* abandoned — INTERRUPTED, no commit is ever fabricated on its behalf.
|
|
829
|
+
* - APPLYING, the one case where a real git mutation may have been
|
|
830
|
+
* mid-flight when Kairo died:
|
|
831
|
+
* - the real project's HEAD already equals this worktree's own
|
|
832
|
+
* controlledHeadSha (the commit that was being merged) AND the real
|
|
833
|
+
* project tree is clean: the merge had actually already succeeded
|
|
834
|
+
* before the crash — recovered as APPLIED, the real outcome is never
|
|
835
|
+
* lost just because Kairo wasn't there to see it finish.
|
|
836
|
+
* - the real project's HEAD is still exactly baseSha AND clean: the
|
|
837
|
+
* merge never touched anything — back to READY_FOR_REVIEW; a fresh
|
|
838
|
+
* preview is required before retrying, never a resumed one.
|
|
839
|
+
* - anything else (an unexpected HEAD, or a dirty tree): ambiguous —
|
|
840
|
+
* INTERRUPTED, never guessed at or auto-repaired.
|
|
841
|
+
* - PENDING / READY_FOR_REVIEW are left untouched here: nothing
|
|
842
|
+
* supervises them while idle, and the real drift checks already built
|
|
843
|
+
* into beginRoleRun / markReadyForReview / previewWorktreeMerge /
|
|
844
|
+
* applyWorktreeMerge catch anything wrong with them the moment they're
|
|
845
|
+
* used again.
|
|
846
|
+
* @param {object} args
|
|
847
|
+
* @param {string} args.homeDir
|
|
848
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
849
|
+
* @param {(homeDir: string, runId: string) => Promise<object|null>} [args.readRun]
|
|
850
|
+
*/
|
|
851
|
+
export async function reconcileWorktrees({ homeDir, exec = execFileSync, readRun = readRunState }) {
|
|
852
|
+
const records = await listWorktreeRecords(homeDir);
|
|
853
|
+
const reconciled = [];
|
|
854
|
+
|
|
855
|
+
for (const worktree of records) {
|
|
856
|
+
if (worktree.status === WORKTREE_STATES.ACTIVE) {
|
|
857
|
+
const runState = worktree.activeRunId ? await readRun(homeDir, worktree.activeRunId) : null;
|
|
858
|
+
if (!runState || isTerminalRunState(runState.state)) {
|
|
859
|
+
const next = await markInterrupted(
|
|
860
|
+
homeDir, worktree,
|
|
861
|
+
`Execution worktree recovered on restart: role "${worktree.activeRole}" run `
|
|
862
|
+
+ `"${worktree.activeRunId}" is no longer active.`
|
|
863
|
+
);
|
|
864
|
+
reconciled.push(next);
|
|
865
|
+
}
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (worktree.status === WORKTREE_STATES.APPLYING) {
|
|
870
|
+
let projectHead = null;
|
|
871
|
+
let projectClean = false;
|
|
872
|
+
try {
|
|
873
|
+
projectHead = resolveHead(worktree.projectRoot, { exec });
|
|
874
|
+
assertWorkingTreeClean(worktree.projectRoot, { exec });
|
|
875
|
+
projectClean = true;
|
|
876
|
+
} catch { /* an unreadable or dirty real project falls through to the ambiguous, INTERRUPTED branch below */ }
|
|
877
|
+
|
|
878
|
+
let next;
|
|
879
|
+
if (projectClean && projectHead === worktree.controlledHeadSha) {
|
|
880
|
+
next = { ...worktree, status: WORKTREE_STATES.APPLIED, updatedAt: new Date().toISOString() };
|
|
881
|
+
await writeWorktreeState(homeDir, next);
|
|
882
|
+
} else if (projectClean && projectHead === worktree.baseSha) {
|
|
883
|
+
next = { ...worktree, status: WORKTREE_STATES.READY_FOR_REVIEW, updatedAt: new Date().toISOString() };
|
|
884
|
+
await writeWorktreeState(homeDir, next);
|
|
885
|
+
} else {
|
|
886
|
+
next = await markInterrupted(
|
|
887
|
+
homeDir, worktree,
|
|
888
|
+
"Execution worktree recovered on restart: the real project was left in an ambiguous state "
|
|
889
|
+
+ "mid-merge — neither the original baseSha nor the expected merged HEAD, or a dirty tree."
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
reconciled.push(next);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return reconciled;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Removes the real, checked-out git worktree and its own local
|
|
901
|
+
* ~/.harness/worktrees/<id>/ directory entirely — only ever from a
|
|
902
|
+
* terminal state (APPLIED, DISCARDED, INTERRUPTED). A non-terminal
|
|
903
|
+
* worktree still represents real, potentially unreviewed work; abandoning
|
|
904
|
+
* it is cancelWorktree's own job first — this function only ever cleans
|
|
905
|
+
* up what's already been decided. Reuses rollbackWorktree, the same
|
|
906
|
+
* best-effort, idempotent real cleanup createExecutionWorktree's own
|
|
907
|
+
* failure path already relies on.
|
|
908
|
+
* @param {object} args
|
|
909
|
+
* @param {string} args.worktreeId
|
|
910
|
+
* @param {string} args.homeDir
|
|
911
|
+
* @param {(command: string, args: string[], options: object) => Buffer|string} [args.exec]
|
|
912
|
+
*/
|
|
913
|
+
export async function discardWorktree({ worktreeId, homeDir, exec = execFileSync }) {
|
|
914
|
+
const worktree = await requireWorktree(homeDir, worktreeId);
|
|
915
|
+
if (!isTerminalWorktreeState(worktree.status)) {
|
|
916
|
+
throw new Error(
|
|
917
|
+
`Execution worktree "${worktreeId}" is ${worktree.status}; only a terminal worktree `
|
|
918
|
+
+ "(applied, discarded, interrupted) can be cleaned up — cancel it first."
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
const { worktreeDir } = worktreePaths(homeDir, worktreeId);
|
|
922
|
+
await rollbackWorktree({ projectRoot: worktree.projectRoot, treePath: worktree.treePath, worktreeDir, exec });
|
|
923
|
+
return worktree;
|
|
924
|
+
}
|