@kal-elsam/kairo-runtime 0.16.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +475 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +683 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1263 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1063 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +444 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. 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
+ }