toolroll 0.9.59 → 0.9.61

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 (152) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -6
  3. package/dist/agent-board.d.ts +208 -0
  4. package/dist/agent-board.js +454 -0
  5. package/dist/agent-fence.d.ts +19 -0
  6. package/dist/agent-fence.js +100 -8
  7. package/dist/agent-pulse.d.ts +43 -0
  8. package/dist/agent-pulse.js +214 -0
  9. package/dist/agent-turns.d.ts +52 -0
  10. package/dist/agent-turns.js +137 -0
  11. package/dist/agents-board.d.ts +101 -0
  12. package/dist/agents-board.js +225 -0
  13. package/dist/assignment-adapters.d.ts +2 -1
  14. package/dist/assignment-adapters.js +1 -1
  15. package/dist/assignment-delivery.d.ts +16 -0
  16. package/dist/assignment-delivery.js +54 -28
  17. package/dist/assignment-status.d.ts +14 -5
  18. package/dist/assignment-status.js +70 -39
  19. package/dist/assignment.d.ts +10 -2
  20. package/dist/assignment.js +21 -14
  21. package/dist/bin.js +11 -2
  22. package/dist/brief-fence.d.ts +9 -0
  23. package/dist/brief-fence.js +22 -0
  24. package/dist/browser/workspace.css +2 -2
  25. package/dist/browser/workspace.js +29 -25
  26. package/dist/browser-crew.js +2 -0
  27. package/dist/browser-workspace.d.ts +79 -1
  28. package/dist/browser-workspace.js +1 -0
  29. package/dist/builder.d.ts +8 -0
  30. package/dist/builder.js +178 -29
  31. package/dist/chat-channel.js +10 -1
  32. package/dist/chat-core.d.ts +6 -0
  33. package/dist/chat-core.js +8 -0
  34. package/dist/chat-delivery.js +22 -0
  35. package/dist/chat-messages.js +8 -1
  36. package/dist/chat-quiet.js +1 -0
  37. package/dist/chat-voice.d.ts +2 -0
  38. package/dist/chat-voice.js +2 -0
  39. package/dist/checkout-cleanup.d.ts +3 -0
  40. package/dist/checkout-cleanup.js +11 -2
  41. package/dist/cli-fast.d.ts +8 -0
  42. package/dist/cli-fast.js +18 -0
  43. package/dist/cli-help.d.ts +5 -0
  44. package/dist/cli-help.js +91 -0
  45. package/dist/cli.d.ts +6 -5
  46. package/dist/cli.js +17 -96
  47. package/dist/contracts/console-api.d.ts +3 -1
  48. package/dist/contracts/console-api.js +5 -1
  49. package/dist/contracts/lead-tools.d.ts +16 -2
  50. package/dist/contracts/lead-tools.js +5 -1
  51. package/dist/design/tokens.css +234 -0
  52. package/dist/desktop-update.d.ts +1 -1
  53. package/dist/discord.js +2 -1
  54. package/dist/evidence-pack.js +1 -1
  55. package/dist/evidence.d.ts +28 -0
  56. package/dist/evidence.js +87 -17
  57. package/dist/exec.d.ts +32 -0
  58. package/dist/exec.js +229 -10
  59. package/dist/flow-actions.js +2 -1
  60. package/dist/guides.js +14 -11
  61. package/dist/invoke.js +110 -18
  62. package/dist/landed-cleanup.d.ts +157 -0
  63. package/dist/landed-cleanup.js +707 -0
  64. package/dist/lead-cli.js +22 -19
  65. package/dist/lead-contract.js +1 -1
  66. package/dist/lead-doors.js +49 -2
  67. package/dist/lead-follow.d.ts +2 -0
  68. package/dist/lead-follow.js +138 -88
  69. package/dist/lead-tools.js +48 -8
  70. package/dist/live-input.d.ts +68 -0
  71. package/dist/live-input.js +254 -0
  72. package/dist/live.d.ts +1 -1
  73. package/dist/live.js +4 -2
  74. package/dist/mailbox.js +3 -2
  75. package/dist/maintenance.d.ts +2 -0
  76. package/dist/maintenance.js +5 -2
  77. package/dist/mcp-core.js +1 -1
  78. package/dist/operate-remote-arguments.js +5 -1
  79. package/dist/operate-remote.js +1 -1
  80. package/dist/operate.d.ts +1 -1
  81. package/dist/operate.js +201 -19
  82. package/dist/orphan-sweep.d.ts +2 -0
  83. package/dist/orphan-sweep.js +5 -3
  84. package/dist/project-delete-ui.d.ts +11 -0
  85. package/dist/project-delete-ui.js +29 -2
  86. package/dist/project-tools.d.ts +7 -11
  87. package/dist/project-tools.js +13 -37
  88. package/dist/provider.d.ts +73 -8
  89. package/dist/provider.js +87 -17
  90. package/dist/result-completion.d.ts +7 -0
  91. package/dist/result-completion.js +9 -0
  92. package/dist/runner.d.ts +4 -2
  93. package/dist/runner.js +3 -3
  94. package/dist/serve.js +22 -3
  95. package/dist/server/chrome.d.ts +6 -3
  96. package/dist/server/chrome.js +8 -4
  97. package/dist/server/console.css +9 -64
  98. package/dist/server/live.d.ts +3 -0
  99. package/dist/server/live.js +35 -2
  100. package/dist/server/pages.js +59 -35
  101. package/dist/server/render-chat.js +14 -2
  102. package/dist/server/render-tasks.d.ts +14 -2
  103. package/dist/server/render-tasks.js +38 -20
  104. package/dist/server/route-table.js +4 -1
  105. package/dist/server/settings.js +2 -1
  106. package/dist/server/task-views.js +10 -0
  107. package/dist/server/tasks.js +63 -0
  108. package/dist/shared-deps.d.ts +9 -4
  109. package/dist/shared-deps.js +89 -13
  110. package/dist/slack-socket.d.ts +12 -0
  111. package/dist/slack-socket.js +29 -0
  112. package/dist/slack.d.ts +0 -7
  113. package/dist/slack.js +2 -24
  114. package/dist/staged-runtimes.d.ts +16 -4
  115. package/dist/staged-runtimes.js +160 -38
  116. package/dist/steer-live.d.ts +19 -0
  117. package/dist/steer-live.js +93 -0
  118. package/dist/steer-mailbox.d.ts +39 -0
  119. package/dist/steer-mailbox.js +207 -0
  120. package/dist/steer-status.d.ts +25 -0
  121. package/dist/steer-status.js +44 -0
  122. package/dist/storage-sweep-child.d.ts +1 -0
  123. package/dist/storage-sweep-child.js +76 -0
  124. package/dist/storage-sweep-process.d.ts +41 -0
  125. package/dist/storage-sweep-process.js +187 -0
  126. package/dist/storage-sweep.d.ts +30 -6
  127. package/dist/storage-sweep.js +214 -28
  128. package/dist/storage-ui.d.ts +2 -0
  129. package/dist/storage-ui.js +16 -4
  130. package/dist/storage.d.ts +6 -0
  131. package/dist/storage.js +35 -0
  132. package/dist/store.d.ts +202 -2
  133. package/dist/store.js +440 -13
  134. package/dist/surface.js +5 -3
  135. package/dist/sweep-gate.d.ts +10 -0
  136. package/dist/sweep-gate.js +18 -0
  137. package/dist/sweep-quarantine.d.ts +28 -0
  138. package/dist/sweep-quarantine.js +153 -0
  139. package/dist/task-status.d.ts +2 -0
  140. package/dist/task-status.js +5 -1
  141. package/dist/team-updates.js +3 -3
  142. package/dist/test-temp.d.ts +6 -3
  143. package/dist/test-temp.js +15 -9
  144. package/dist/toolroll-update.js +15 -2
  145. package/dist/work-index.d.ts +1 -0
  146. package/dist/work-index.js +32 -12
  147. package/dist/work-summary.d.ts +2 -2
  148. package/dist/workspace-revision.js +3 -1
  149. package/dist/worktree.d.ts +3 -1
  150. package/dist/worktree.js +93 -10
  151. package/package.json +21 -21
  152. package/scripts/provider-canary.mjs +235 -147
@@ -0,0 +1,707 @@
1
+ /**
2
+ * Landed work (reconcile, and `toolroll storage clean --landed` for what piled up before): a finished result whose
3
+ * work is already in main is marked complete by Toolroll, with a ledger entry saying why, and its clean checkout and
4
+ * local build and plan branches go.
5
+ *
6
+ * Landed means, for the exact current result: its pull request merged for that commit, its commit is reachable from
7
+ * the local main, or every path it changed since it left main has the same committed entry in main (both sides of a
8
+ * rename; a file it deleted is deleted there too). Anything missing or ambiguous, and every git error, is "not
9
+ * proven", and nothing happens. Remotes are never fetched or changed, and tags are never touched.
10
+ *
11
+ * Teardown is crash-safe: the completion and the teardown intent (each checkout and branch, with the commit it must
12
+ * still be at) are written in one transaction before anything is removed. Each removal is checked again right before
13
+ * it happens and recorded right after; a crash in between leaves its item pending, the next reconcile finishes it, and
14
+ * a resource already gone finishes its item.
15
+ *
16
+ * Never removed: a checkout with changes (untracked files count), one in use, or one whose HEAD moved past the landed
17
+ * work; a branch that moved, is checked out anywhere, or that an unfinished task uses.
18
+ */
19
+ import { existsSync, realpathSync } from "node:fs";
20
+ import { basename } from "node:path";
21
+ import { assignmentOf, owedAcceptance } from "./assignment.js";
22
+ import { noteAssignmentStatus } from "./assignment-status.js";
23
+ import { checkoutKeepWhy, taskStatuses, whyWords } from "./checkout-cleanup.js";
24
+ import { run } from "./exec.js";
25
+ import { taskBranches } from "./names.js";
26
+ import { COMPLETION_ACTION, LANDED_ACTOR, familyChangedFiles } from "./result-completion.js";
27
+ import { WORKTREE_TIMEOUT_MS } from "./worktree.js";
28
+ const DAY_MS = 86_400_000;
29
+ /** `storage clean --landed` takes only what has waited at least this long. */
30
+ export const BACKLOG_DAYS = 7;
31
+ /** The local branches the one-time pass looks at: Toolroll's build and plan branches, under either name. */
32
+ export const BACKLOG_PREFIXES = ["toolroll/", "toolroll-plan/", "standing-orders/"];
33
+ /** The ledger action that closes a teardown. */
34
+ export const LANDED_CLEANED_ACTION = "landed work cleaned up";
35
+ /** Reconcile looks at most this many results and finishes at most this many items a pass; the pass by hand has no cap. */
36
+ export const AUTO_LOOKS = 20;
37
+ export const AUTO_ITEMS = 40;
38
+ const MAIN = "refs/heads/main";
39
+ const SHA = /^[0-9a-f]{40}$/;
40
+ export function gitOf(runner = run) {
41
+ return (cwd, args) => runner("git", ["--no-optional-locks", ...args], { cwd, timeoutMs: WORKTREE_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
42
+ }
43
+ const notProven = (why) => ({ landed: false, why });
44
+ /** The commit a ref names, null when it doesn't exist, or "error" when git couldn't say. */
45
+ async function commitOf(git, repo, ref) {
46
+ const found = await git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
47
+ if (found.code === 1 && found.stdout.trim() === "")
48
+ return null;
49
+ const oid = found.stdout.trim();
50
+ return found.code === 0 && SHA.test(oid) ? oid : "error";
51
+ }
52
+ /** Whether `older` is `newer` or one of its ancestors; null when git couldn't say. */
53
+ async function within(git, repo, older, newer) {
54
+ if (older === newer)
55
+ return true;
56
+ const asked = await git(repo, ["merge-base", "--is-ancestor", older, newer]);
57
+ return asked.code === 0 ? true : asked.code === 1 ? false : null;
58
+ }
59
+ /** Every committed entry of a commit's tree, path → "mode oid"; null on a git error. */
60
+ async function treeOf(git, repo, commit) {
61
+ const listed = await git(repo, ["ls-tree", "-r", "-z", "--full-tree", commit]);
62
+ if (listed.code !== 0)
63
+ return null;
64
+ const entries = new Map();
65
+ for (const line of listed.stdout.split("\0")) {
66
+ if (line === "")
67
+ continue;
68
+ const tab = line.indexOf("\t");
69
+ const [mode, , oid] = line.slice(0, tab).split(" ");
70
+ if (tab < 0 || mode === undefined || oid === undefined)
71
+ return null;
72
+ entries.set(line.slice(tab + 1), `${mode} ${oid}`);
73
+ }
74
+ return entries;
75
+ }
76
+ /**
77
+ * Whether this commit's work landed, read from what Toolroll recorded and the local repository only. Fails closed:
78
+ * every doubt is "not proven", with the reason in a few words.
79
+ */
80
+ export async function landedProof(git, repo, head, pullRequest = null) {
81
+ if (head === null || !SHA.test(head))
82
+ return notProven("it has no saved commit");
83
+ if (pullRequest !== null && pullRequest.merged && pullRequest.headSha === head) {
84
+ return { landed: true, how: "merged", reason: pullRequest.number === null ? "its pull request merged" : `pull request #${pullRequest.number} merged` };
85
+ }
86
+ const main = await commitOf(git, repo, MAIN);
87
+ if (main === "error")
88
+ return notProven("git couldn't read main");
89
+ if (main === null)
90
+ return notProven("the project has no local main branch");
91
+ const exists = await git(repo, ["cat-file", "-e", `${head}^{commit}`]);
92
+ if (exists.code !== 0)
93
+ return notProven("its commit isn't in the repository");
94
+ const reachable = await within(git, repo, head, main);
95
+ if (reachable === null)
96
+ return notProven("git couldn't compare it with main");
97
+ if (reachable)
98
+ return { landed: true, how: "in-main", reason: "its commits are in main" };
99
+ const forked = await git(repo, ["merge-base", head, main]);
100
+ const base = forked.stdout.trim();
101
+ if (forked.code !== 0 || !SHA.test(base))
102
+ return notProven(forked.code === 1 ? "it shares no history with main" : "git couldn't compare it with main");
103
+ // Every path it changed since it left main, both sides of a rename (no rename detection: a rename is a delete and an add).
104
+ const changed = await git(repo, ["diff-tree", "-r", "-z", "--name-only", "--no-renames", "--no-commit-id", base, head]);
105
+ if (changed.code !== 0)
106
+ return notProven("git couldn't list its changes");
107
+ const paths = [...new Set(changed.stdout.split("\0").filter(path => path !== ""))];
108
+ if (paths.length === 0)
109
+ return notProven("it changed nothing main lacks");
110
+ const [ours, theirs] = await Promise.all([treeOf(git, repo, head), treeOf(git, repo, main)]);
111
+ if (ours === null || theirs === null)
112
+ return notProven("git couldn't read its files");
113
+ const differs = paths.find(path => ours.get(path) !== theirs.get(path));
114
+ if (differs !== undefined)
115
+ return notProven("its changes aren't all in main");
116
+ return { landed: true, how: "content-in-main", reason: "its changes are already in main" };
117
+ }
118
+ /** Every local branch: name → commit and when it was last committed to (unix seconds). */
119
+ async function localBranches(git, repo) {
120
+ const listed = await git(repo, ["for-each-ref", "--format=%(refname)%00%(objectname)%00%(committerdate:unix)", "refs/heads/"]);
121
+ if (listed.code !== 0)
122
+ return null;
123
+ const out = new Map();
124
+ for (const line of listed.stdout.split("\n")) {
125
+ const [ref, oid, at] = line.split("\0");
126
+ if (ref === undefined || oid === undefined || !ref.startsWith("refs/heads/") || !SHA.test(oid))
127
+ continue;
128
+ out.set(ref.slice("refs/heads/".length), { oid, at: Number(at) || 0 });
129
+ }
130
+ return out;
131
+ }
132
+ /** A path as the file system names it (git reports /private/var for /var on macOS). */
133
+ const real = (path) => { try {
134
+ return realpathSync(path);
135
+ }
136
+ catch {
137
+ return path;
138
+ } };
139
+ /** The branch each working tree of the repository has checked out: branch → path. */
140
+ async function checkedOut(git, repo) {
141
+ const listed = await git(repo, ["worktree", "list", "--porcelain"]);
142
+ if (listed.code !== 0)
143
+ return null;
144
+ const out = new Map();
145
+ let path = "";
146
+ for (const line of listed.stdout.split("\n")) {
147
+ if (line.startsWith("worktree "))
148
+ path = line.slice("worktree ".length);
149
+ else if (line.startsWith("branch refs/heads/"))
150
+ out.set(line.slice("branch refs/heads/".length), path);
151
+ }
152
+ return out;
153
+ }
154
+ /** Only the exact build and plan names owned by this family's versions. A run can record any branch. */
155
+ function familyBranches(versions) {
156
+ const names = new Set();
157
+ for (const version of versions) {
158
+ for (const name of [...taskBranches(version.id), `toolroll-plan/${version.id}`])
159
+ names.add(name);
160
+ }
161
+ return names;
162
+ }
163
+ /** Finished results in a project whose family is settled on them (the current version, done), newest first, that no
164
+ * teardown has taken yet. */
165
+ function settledResults(store, repo) {
166
+ return store.handle.prepare(`SELECT r.external_id AS id, r.id AS ref, res.id AS run, res.head_revision AS head, res.finished_at AS finished_at,
167
+ EXISTS (SELECT 1 FROM action_ledger a WHERE a.run_id = res.id AND a.action = ? AND a.source = 'work') AS completed
168
+ FROM task_ref r JOIN task t ON t.id = r.external_id AND r.backend = 'built-in'
169
+ JOIN run res ON res.id = (SELECT MAX(x.id) FROM run x WHERE x.task_ref = r.id AND x.finished_at IS NOT NULL AND x.role IN ('builder','scout'))
170
+ WHERE r.repo = ? AND t.state = 'done' AND res.role = 'builder'
171
+ AND NOT EXISTS (SELECT 1 FROM task_ref c WHERE c.backend = r.backend AND c.revision_of = r.external_id AND c.repo IS r.repo)
172
+ AND NOT EXISTS (SELECT 1 FROM landed_cleanup lc WHERE lc.run_id = res.id)
173
+ ORDER BY res.id DESC`).all(COMPLETION_ACTION, repo).map(row => ({
174
+ id: String(row["id"]), ref: Number(row["ref"]), run: Number(row["run"]), head: row["head"] === null ? null : String(row["head"]),
175
+ finishedAt: String(row["finished_at"]), completed: Number(row["completed"]) === 1,
176
+ }));
177
+ }
178
+ function pullRequestOf(store, runId) {
179
+ const publication = store.publicationForRun(runId);
180
+ if (publication === null)
181
+ return null;
182
+ const follow = store.handle.prepare("SELECT merged_at FROM pull_request_follow WHERE publication = ?").get(publication.id);
183
+ const merged = publication.state === "opened" && (publication.remoteState === "MERGED" || (follow?.["merged_at"] ?? null) !== null);
184
+ return { merged, headSha: publication.headSha, number: publication.prNumber };
185
+ }
186
+ /** A base commit is not work this run landed, even if it is already in main. */
187
+ function resultProblem(store, runId, head) {
188
+ const run = store.getRun(runId);
189
+ if (run === null || run.role !== "builder" || run.finishedAt === null || head === null || run.headRevision !== head)
190
+ return "its result couldn't be read";
191
+ return run.outcome === "no-change" || run.baseRevision === head ? "its result has no new work" : null;
192
+ }
193
+ /** Use the current manual completion guards. Only a recorded merge of this exact result can settle them. */
194
+ function completionProblem(store, assignment, root) {
195
+ if (assignment.state !== "ready-to-check" && assignment.state !== "complete")
196
+ return "it needs a decision first";
197
+ if (assignment.state === "complete")
198
+ return null;
199
+ const receipt = assignment.receipt;
200
+ const pr = pullRequestOf(store, receipt.runId);
201
+ if (pr?.merged === true && pr.headSha === receipt.head)
202
+ return null;
203
+ const blockers = assignment.readiness?.blockers ?? [];
204
+ if (blockers.length > 0)
205
+ return blockers.map(one => one.message).join(" ");
206
+ if (owedAcceptance(receipt, blockers) !== null)
207
+ return "a person still needs to accept its result";
208
+ return store.protectedResultProblem(receipt.taskId, familyChangedFiles(store, receipt.taskId, receipt.runId, root), null);
209
+ }
210
+ /** Results Reconcile found not landed, by what they were read against: read again only once something changed. */
211
+ const notLanded = new Map();
212
+ /**
213
+ * Results in a project whose work landed, with what their teardown would remove and what stays. Reads only.
214
+ * `limit` caps how many results are read against git (reconcile's bound; results read before and unchanged since
215
+ * don't count); `before` takes only results finished before it (the one-time pass's seven days).
216
+ */
217
+ export async function landedResults(store, git, repo, root, now, options = {}) {
218
+ const found = [], kept = [];
219
+ if (!existsSync(repo))
220
+ return { found, kept };
221
+ const [branches, inUse, main] = await Promise.all([localBranches(git, repo), checkedOut(git, repo), commitOf(git, repo, MAIN)]);
222
+ if (branches === null || inUse === null)
223
+ return { found, kept: [{ repo, what: repo, why: "git couldn't read the project" }] };
224
+ const worktrees = store.listWorktrees().filter(row => row.repo === repo);
225
+ let looked = 0;
226
+ for (const row of settledResults(store, repo)) {
227
+ if (options.before !== undefined && row.finishedAt >= options.before) {
228
+ kept.push({ repo, what: row.id, why: `finished less than ${BACKLOG_DAYS} days ago` });
229
+ continue;
230
+ }
231
+ const problem = resultProblem(store, row.run, row.head);
232
+ if (problem !== null) {
233
+ kept.push({ repo, what: row.id, why: problem });
234
+ continue;
235
+ }
236
+ const family = store.taskFamilyOf(row.id, null, true);
237
+ if (family === null || family.current.id !== row.id)
238
+ continue;
239
+ const names = familyBranches(family.versions);
240
+ const checkouts = worktrees.filter(one => names.has(one.branch));
241
+ const present = [...names].filter(name => branches.has(name));
242
+ // Complete and nothing left to remove: nothing to do.
243
+ if (row.completed && present.length === 0 && checkouts.length === 0)
244
+ continue;
245
+ const pullRequest = pullRequestOf(store, row.run);
246
+ const key = `${repo}\u0000${row.run}`;
247
+ const seen = `${row.head}:${main}:${pullRequest?.merged === true}:${pullRequest?.headSha}:${row.completed}`;
248
+ if (options.memo === true && notLanded.get(key) === seen)
249
+ continue;
250
+ if (options.limit !== undefined && looked >= options.limit)
251
+ break;
252
+ looked++;
253
+ const proof = await landedProof(git, repo, row.head, pullRequest);
254
+ if (!proof.landed) {
255
+ if (options.memo === true)
256
+ notLanded.set(key, seen);
257
+ kept.push({ repo, what: row.id, why: proof.why });
258
+ continue;
259
+ }
260
+ notLanded.delete(key);
261
+ const assignment = assignmentOf(store, row.id, now, { principal: "operator", repos: null, includeUnplaced: true }, root);
262
+ const receipt = assignment?.receipt ?? null;
263
+ if (assignment === null || receipt === null || receipt.runId !== row.run || receipt.head !== row.head) {
264
+ kept.push({ repo, what: row.id, why: "its result couldn't be read" });
265
+ continue;
266
+ }
267
+ const completionHold = completionProblem(store, assignment, root);
268
+ if (completionHold !== null) {
269
+ kept.push({ repo, what: row.id, why: completionHold });
270
+ continue;
271
+ }
272
+ const targets = [], keeps = [];
273
+ for (const one of checkouts)
274
+ targets.push({ kind: "checkout", target: one.path, expect: row.head });
275
+ for (const name of present) {
276
+ const tip = branches.get(name).oid;
277
+ const inside = await within(git, repo, tip, row.head) || (main !== null && main !== "error" && await within(git, repo, tip, main));
278
+ const elsewhere = inUse.get(name);
279
+ if (!inside)
280
+ keeps.push({ repo, what: name, why: "it has commits that didn't land" });
281
+ else if (elsewhere !== undefined && !checkouts.some(one => real(one.path) === real(elsewhere)))
282
+ keeps.push({ repo, what: name, why: `it is checked out at ${elsewhere}` });
283
+ else
284
+ targets.push({ kind: "branch", target: name, expect: tip });
285
+ }
286
+ found.push({ repo, taskId: row.id, rootId: assignment.rootId, runId: row.run, receipt: receipt.digest, head: row.head, finishedAt: row.finishedAt,
287
+ completes: assignment.state === "ready-to-check", how: proof.how, reason: proof.reason, targets, keeps });
288
+ }
289
+ return { found, kept };
290
+ }
291
+ // ---- the intent ------------------------------------------------------------
292
+ /**
293
+ * Mark a landed result complete (unless it already is) and record its teardown, in one transaction, bound to the
294
+ * exact result read: refused when the result changed since. The completion's ledger entry says why. Returns the
295
+ * teardown's id; a result already taken returns its existing teardown.
296
+ */
297
+ export function recordLandedIntent(store, candidate, now, root, actor = LANDED_ACTOR) {
298
+ return store.transact(() => {
299
+ const existing = store.handle.prepare("SELECT id FROM landed_cleanup WHERE run_id = ?").get(candidate.runId);
300
+ if (existing !== undefined)
301
+ return { ok: true, id: Number(existing["id"]), completed: false };
302
+ const assignment = assignmentOf(store, candidate.taskId, now, { principal: "operator", repos: null, includeUnplaced: true }, root);
303
+ if (assignment === null || assignment.receipt?.digest !== candidate.receipt || assignment.receipt.runId !== candidate.runId || assignment.receipt.head !== candidate.head || assignment.repo !== candidate.repo || assignment.activeTaskId !== candidate.taskId) {
304
+ return { ok: false, why: "its result changed since it was read" };
305
+ }
306
+ const problem = resultProblem(store, candidate.runId, candidate.head) ?? completionProblem(store, assignment, root);
307
+ if (problem !== null)
308
+ return { ok: false, why: problem };
309
+ const completes = assignment.state === "ready-to-check";
310
+ if (completes) {
311
+ store.recordAction({ at: now.toISOString(), actor: LANDED_ACTOR, repo: candidate.repo, taskId: assignment.rootId, runId: candidate.runId,
312
+ action: COMPLETION_ACTION, outcome: candidate.receipt, source: "work", detail: candidate.reason });
313
+ const ref = store.lookupRef(assignment.rootId);
314
+ if (ref !== null)
315
+ store.noteTaskAct(ref.id, "completed", now, undefined, { account: LANDED_ACTOR, lead: false });
316
+ store.bumpWake();
317
+ }
318
+ const id = Number(store.handle.prepare(`INSERT INTO landed_cleanup (repo, task_id, run_id, receipt, head, how, reason, completed, actor, created_at)
319
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(candidate.repo, assignment.rootId, candidate.runId, candidate.receipt, candidate.head, candidate.how, candidate.reason, completes ? 1 : 0, actor, now.toISOString()).lastInsertRowid);
320
+ addItems(store, id, candidate.repo, candidate.targets);
321
+ if (completes)
322
+ noteAssignmentStatus(store, assignmentOf(store, assignment.rootId, now, { principal: "operator", repos: null, includeUnplaced: true }, root), now);
323
+ return { ok: true, id, completed: completes };
324
+ });
325
+ }
326
+ function addItems(store, cleanup, repo, targets) {
327
+ const insert = store.handle.prepare("INSERT OR IGNORE INTO landed_cleanup_item (cleanup, kind, repo, target, expect) VALUES (?, ?, ?, ?, ?)");
328
+ // Checkouts first: a branch goes only once nothing has it checked out.
329
+ for (const one of [...targets].sort((a, b) => (a.kind === "checkout" ? 0 : 1) - (b.kind === "checkout" ? 0 : 1)))
330
+ insert.run(cleanup, one.kind, repo, one.target, one.expect);
331
+ }
332
+ export function recordBranchIntent(store, branch, now, actor) {
333
+ return store.transact(() => {
334
+ const id = Number(store.handle.prepare(`INSERT INTO landed_cleanup (repo, task_id, run_id, receipt, head, how, reason, completed, actor, created_at)
335
+ VALUES (?, NULL, NULL, NULL, ?, ?, ?, 0, ?, ?)`).run(branch.repo, branch.oid, branch.how, branch.reason, actor, now.toISOString()).lastInsertRowid);
336
+ addItems(store, id, branch.repo, [...(branch.checkout === null ? [] : [{ kind: "checkout", target: branch.checkout, expect: branch.oid }]),
337
+ { kind: "branch", target: branch.branch, expect: branch.oid }]);
338
+ return id;
339
+ });
340
+ }
341
+ /** Every name prefix a task's own build or plan branch may have. */
342
+ const TASK_PREFIXES = ["toolroll/", "standing-orders/", "toolroll-plan/", "standing-orders-plan/"];
343
+ /** Who uses a branch, and how: what any task's runs recorded on it, or the task its name belongs to (a plan
344
+ * branch is named after its task too). The strongest reason to keep it wins; undefined when no task names it. */
345
+ function branchUser(store, statuses, name) {
346
+ let found = statuses.byBranch.get(name);
347
+ for (const prefix of TASK_PREFIXES) {
348
+ if (!name.startsWith(prefix))
349
+ continue;
350
+ const ref = store.lookupRef(name.slice(prefix.length));
351
+ const status = ref === null ? undefined : statuses.byRef.get(ref.id);
352
+ if (status !== undefined && (found === undefined || (found.keep === null && status.keep !== null)))
353
+ found = status;
354
+ }
355
+ return found;
356
+ }
357
+ /** Validate persisted targets too: an older intent may name an arbitrary run branch. */
358
+ function allowedBranch(store, item, name) {
359
+ if (name === "main" || name === "master")
360
+ return false;
361
+ if (item.taskId === null)
362
+ return item.runId === null && BACKLOG_PREFIXES.some(prefix => name.startsWith(prefix));
363
+ const family = store.taskFamilyOf(item.taskId, null, true);
364
+ return item.runId !== null && family !== null && family.current.repo === item.repo && familyBranches(family.versions).has(name);
365
+ }
366
+ /** Same local default as the builder protects: origin HEAD, then the parent checkout's branch. */
367
+ async function isDefaultBranch(git, repo, name) {
368
+ if (name === "main" || name === "master")
369
+ return true;
370
+ const origin = await git(repo, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
371
+ if (origin.code === 0) {
372
+ const ref = origin.stdout.trim();
373
+ return ref.startsWith("refs/remotes/origin/") ? name === ref.slice("refs/remotes/origin/".length) : null;
374
+ }
375
+ if (origin.code !== 1)
376
+ return null;
377
+ const parent = await git(repo, ["symbolic-ref", "--quiet", "HEAD"]);
378
+ const ref = parent.stdout.trim();
379
+ return parent.code === 0 && ref.startsWith("refs/heads/") ? name === ref.slice("refs/heads/".length) : null;
380
+ }
381
+ /** Whether a teardown's result is still its task's current result; null for a branch no task names. */
382
+ function stillCurrent(store, item) {
383
+ if (item.taskId === null || item.runId === null)
384
+ return item.taskId === null && item.runId === null;
385
+ const family = store.taskFamilyOf(item.taskId, null, true);
386
+ if (family === null)
387
+ return false;
388
+ const latest = store.handle.prepare("SELECT MAX(id) AS id FROM run WHERE task_ref = ? AND finished_at IS NOT NULL AND role IN ('builder','scout')").get(family.current.refId);
389
+ return family.current.state === "done" && Number(latest?.["id"]) === item.runId;
390
+ }
391
+ /** An intent is not a lasting landing proof: main and the recorded PR fact can both change. */
392
+ async function landingProblem(store, git, item) {
393
+ if (!stillCurrent(store, item))
394
+ return "its task has newer work";
395
+ if (item.runId !== null) {
396
+ const problem = resultProblem(store, item.runId, item.head);
397
+ if (problem !== null)
398
+ return problem;
399
+ }
400
+ const proof = await landedProof(git, item.repo, item.head, item.runId === null ? null : pullRequestOf(store, item.runId));
401
+ if (!proof.landed)
402
+ return proof.why;
403
+ // A family's older branch can carry commits beyond this result that were in main at preview time.
404
+ const inside = await within(git, item.repo, item.expect, item.head);
405
+ if (inside === null)
406
+ return "git couldn't compare its commits";
407
+ if (!inside) {
408
+ const targetProof = await landedProof(git, item.repo, item.expect);
409
+ if (!targetProof.landed)
410
+ return targetProof.why;
411
+ }
412
+ return null;
413
+ }
414
+ /** Read immediately before ref deletion, after all asynchronous Git reads. A lease may precede its run or checkout. */
415
+ function branchUseProblem(store, item, now) {
416
+ if (!stillCurrent(store, item))
417
+ return "its task has newer work";
418
+ if (store.listWorktrees().some(row => row.repo === item.repo && row.branch === item.target))
419
+ return "a recorded checkout uses it";
420
+ const refs = new Set(store.handle.prepare("SELECT DISTINCT task_ref FROM run WHERE branch = ?").all(item.target).map(row => Number(row["task_ref"])));
421
+ for (const prefix of TASK_PREFIXES) {
422
+ if (!item.target.startsWith(prefix))
423
+ continue;
424
+ const ref = store.lookupRef(item.target.slice(prefix.length));
425
+ if (ref !== null)
426
+ refs.add(ref.id);
427
+ }
428
+ if ([...refs].some(ref => store.currentLiveLease(ref, now) !== null))
429
+ return "a live lease uses it";
430
+ if (store.handle.prepare("SELECT 1 FROM run WHERE branch = ? AND finished_at IS NULL").get(item.target) !== undefined)
431
+ return "an unfinished run uses it";
432
+ const status = branchUser(store, taskStatuses(store, now), item.target);
433
+ return status !== undefined && status.keep !== null ? `${status.taskId} uses it (${whyWords({ why: status.keep })})` : null;
434
+ }
435
+ /**
436
+ * Finish what teardowns intended: pending items, oldest first (checkouts before branches). Each is checked again
437
+ * right before it goes, and recorded right after. Doubt leaves it pending for the next pass; a branch that moved, or
438
+ * a result that has newer work, is kept for good. `only` limits it to these teardowns.
439
+ */
440
+ export async function finishLanded(store, pool, git, now, options = {}) {
441
+ const items = store.handle.prepare(`SELECT i.id, i.cleanup, i.kind, i.repo, i.target, i.expect, c.task_id, c.run_id, c.head, c.reason, c.actor
442
+ FROM landed_cleanup_item i JOIN landed_cleanup c ON c.id = i.cleanup
443
+ WHERE i.state = 'pending' AND (? IS NULL OR i.repo = ?) ORDER BY i.cleanup, i.kind = 'branch', i.id`).all(options.repo ?? null, options.repo ?? null)
444
+ .map(row => ({ id: Number(row["id"]), cleanup: Number(row["cleanup"]), kind: row["kind"] === "branch" ? "branch" : "checkout",
445
+ repo: String(row["repo"]), target: String(row["target"]), expect: String(row["expect"]), taskId: row["task_id"] === null ? null : String(row["task_id"]),
446
+ runId: row["run_id"] === null ? null : Number(row["run_id"]), head: String(row["head"]), reason: String(row["reason"]), actor: String(row["actor"]) }))
447
+ .filter(item => options.only === undefined || options.only.has(item.cleanup));
448
+ const steps = [];
449
+ const touched = new Set();
450
+ const mark = (item, state, why) => {
451
+ store.handle.prepare("UPDATE landed_cleanup_item SET state = ?, why = ?, done_at = ? WHERE id = ? AND state = 'pending'")
452
+ .run(state, why, state === "pending" ? null : now().toISOString(), item.id);
453
+ touched.add(item.cleanup);
454
+ };
455
+ const source = (item) => item.actor === LANDED_ACTOR ? "work" : "request";
456
+ for (const item of items) {
457
+ if (options.limit !== undefined && steps.length >= options.limit)
458
+ break;
459
+ const step = (outcome, why) => {
460
+ steps.push({ kind: item.kind, repo: item.repo, target: item.target, taskId: item.taskId, outcome, why });
461
+ mark(item, outcome === "waits" ? "pending" : outcome === "kept" ? "kept" : "done", why);
462
+ };
463
+ if (!stillCurrent(store, item)) {
464
+ step("kept", "its task has newer work");
465
+ continue;
466
+ }
467
+ if (item.kind === "checkout") {
468
+ const outcome = await removeCheckout(store, pool, git, item, now);
469
+ if (outcome.outcome === "removed")
470
+ store.recordAction({ at: now().toISOString(), actor: item.actor, repo: item.repo, taskId: item.taskId, runId: null,
471
+ action: "checkout removed", outcome: "removed", source: source(item), detail: `${basename(item.target)}: its work landed (${item.reason})` });
472
+ step(outcome.outcome, outcome.why);
473
+ continue;
474
+ }
475
+ if (!allowedBranch(store, item, item.target)) {
476
+ step("kept", "it isn't a task build or plan branch");
477
+ continue;
478
+ }
479
+ const firstOut = store.handle.prepare("SELECT 1 FROM landed_cleanup_item WHERE cleanup = ? AND kind = 'checkout' AND state = 'pending'").get(item.cleanup);
480
+ if (firstOut !== undefined) {
481
+ step("waits", "its checkout goes first");
482
+ continue;
483
+ }
484
+ const outcome = await deleteBranch(store, git, item, now);
485
+ if (outcome.outcome === "removed")
486
+ store.recordAction({ at: now().toISOString(), actor: item.actor, repo: item.repo, taskId: item.taskId, runId: null,
487
+ action: "branch deleted", outcome: "removed", source: source(item), detail: `${item.target} at ${item.expect.slice(0, 12)}: its work landed (${item.reason})` });
488
+ step(outcome.outcome, outcome.why);
489
+ }
490
+ // A teardown with nothing pending is finished: one ledger entry says what it did.
491
+ for (const cleanup of touched) {
492
+ const left = store.handle.prepare("SELECT state, COUNT(*) AS n FROM landed_cleanup_item WHERE cleanup = ? GROUP BY state").all(cleanup);
493
+ const count = (state) => Number(left.find(row => row["state"] === state)?.["n"] ?? 0);
494
+ if (count("pending") > 0)
495
+ continue;
496
+ store.transact(() => {
497
+ const row = store.handle.prepare("SELECT repo, task_id, actor, reason FROM landed_cleanup WHERE id = ? AND finished_at IS NULL").get(cleanup);
498
+ if (row === undefined)
499
+ return;
500
+ store.handle.prepare("UPDATE landed_cleanup SET finished_at = ? WHERE id = ?").run(now().toISOString(), cleanup);
501
+ store.recordAction({ at: now().toISOString(), actor: String(row["actor"]), repo: String(row["repo"]), taskId: row["task_id"] === null ? null : String(row["task_id"]), runId: null,
502
+ action: LANDED_CLEANED_ACTION, outcome: count("kept") > 0 ? "partial" : "removed", source: String(row["actor"]) === LANDED_ACTOR ? "work" : "request",
503
+ detail: `${String(row["reason"])}: ${count("done")} removed${count("kept") > 0 ? `, ${count("kept")} kept` : ""}` });
504
+ });
505
+ }
506
+ return steps;
507
+ }
508
+ async function removeCheckout(store, pool, git, item, now) {
509
+ const row = store.getWorktree(item.target);
510
+ if (row === null)
511
+ return { outcome: "already gone", why: null };
512
+ if (row.repo !== item.repo || !allowedBranch(store, item, row.branch))
513
+ return { outcome: "kept", why: "it isn't a task build or plan branch" };
514
+ const protectedBranch = await isDefaultBranch(git, item.repo, row.branch);
515
+ if (protectedBranch === null)
516
+ return { outcome: "waits", why: "git couldn't read the project's default branch" };
517
+ if (protectedBranch)
518
+ return { outcome: "kept", why: "it is the project's default branch" };
519
+ if (row.releasedAt === null || row.runner !== null)
520
+ return { outcome: "waits", why: "in use" };
521
+ if (!existsSync(row.path)) {
522
+ store.transact(() => { const still = store.getWorktree(row.path); if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
523
+ store.forgetWorktree(row.path); });
524
+ return { outcome: "already gone", why: null };
525
+ }
526
+ const actual = await git(row.path, ["symbolic-ref", "--quiet", "HEAD"]);
527
+ if (actual.code !== 0 || actual.stdout.trim() !== `refs/heads/${row.branch}`)
528
+ return { outcome: "waits", why: "its checked-out branch changed" };
529
+ // Its HEAD must still be the landed work (or behind it): anything newer didn't land.
530
+ const head = await commitOf(git, row.path, "HEAD");
531
+ if (head === null || head === "error")
532
+ return { outcome: "waits", why: "git couldn't read it" };
533
+ const inside = await within(git, item.repo, head, item.expect);
534
+ if (inside === null)
535
+ return { outcome: "waits", why: "git couldn't read it" };
536
+ if (!inside)
537
+ return { outcome: "kept", why: "it has commits that didn't land" };
538
+ let landingHold = null;
539
+ const wanted = async (fresh, again) => {
540
+ if (!again)
541
+ return true;
542
+ // Prune calls this after inspecting the checkout, immediately before removal.
543
+ landingHold = await landingProblem(store, git, item);
544
+ const current = store.getWorktree(fresh.path);
545
+ return landingHold === null && current !== null && current.repo === item.repo && current.branch === fresh.branch
546
+ && current.releasedAt === fresh.releasedAt && allowedBranch(store, item, current.branch)
547
+ && checkoutKeepWhy(pool, current, taskStatuses(store, now()), now()) === null;
548
+ };
549
+ const first = checkoutKeepWhy(pool, row, taskStatuses(store, now()), now());
550
+ if (first !== null)
551
+ return { outcome: "waits", why: whyWords({ why: first }) };
552
+ const pruned = await pool.prune([row], wanted, 1);
553
+ if (pruned.removed.length === 1)
554
+ return { outcome: "removed", why: null };
555
+ const kept = pruned.kept[0];
556
+ return { outcome: "waits", why: landingHold ?? (kept === undefined ? "in use" : whyWords({ why: kept.why })) };
557
+ }
558
+ async function deleteBranch(store, git, item, now) {
559
+ if (!allowedBranch(store, item, item.target))
560
+ return { outcome: "kept", why: "it isn't a task build or plan branch" };
561
+ const ref = `refs/heads/${item.target}`;
562
+ const tip = await commitOf(git, item.repo, ref);
563
+ if (tip === null)
564
+ return { outcome: "already gone", why: null };
565
+ if (tip === "error")
566
+ return { outcome: "waits", why: "git couldn't read it" };
567
+ if (tip !== item.expect)
568
+ return { outcome: "kept", why: "it moved since" };
569
+ const protectedBranch = await isDefaultBranch(git, item.repo, item.target);
570
+ if (protectedBranch === null)
571
+ return { outcome: "waits", why: "git couldn't read the project's default branch" };
572
+ if (protectedBranch)
573
+ return { outcome: "kept", why: "it is the project's default branch" };
574
+ const firstOut = await checkedOut(git, item.repo);
575
+ if (firstOut === null)
576
+ return { outcome: "waits", why: "git couldn't read it" };
577
+ if (firstOut.has(item.target))
578
+ return { outcome: "waits", why: `it is checked out at ${firstOut.get(item.target)}` };
579
+ const landingHold = await landingProblem(store, git, item);
580
+ if (landingHold !== null)
581
+ return { outcome: "waits", why: landingHold };
582
+ const out = await checkedOut(git, item.repo);
583
+ if (out === null)
584
+ return { outcome: "waits", why: "git couldn't read it" };
585
+ if (out.has(item.target))
586
+ return { outcome: "waits", why: `it is checked out at ${out.get(item.target)}` };
587
+ const use = branchUseProblem(store, item, now());
588
+ if (use !== null)
589
+ return { outcome: "waits", why: use };
590
+ // Deleted only while it still names the expected commit: git checks that and the deletion in one step.
591
+ const gone = await git(item.repo, ["update-ref", "--no-deref", "-d", ref, item.expect]);
592
+ if (gone.code === 0)
593
+ return { outcome: "removed", why: null };
594
+ return (await commitOf(git, item.repo, ref)) === null ? { outcome: "already gone", why: null } : { outcome: "waits", why: "git refused" };
595
+ }
596
+ // ---- reconcile -------------------------------------------------------------
597
+ /**
598
+ * Reconcile's part: finish teardowns a crash cut short, then find newly landed results (a bounded few), mark them
599
+ * complete and tear them down.
600
+ */
601
+ export async function reconcileLanded(store, pool, git, repo, root, now) {
602
+ const steps = await finishLanded(store, pool, git, now, { repo, limit: AUTO_ITEMS });
603
+ const { found } = await landedResults(store, git, repo, root, now(), { limit: AUTO_LOOKS, memo: true });
604
+ const completed = [];
605
+ const fresh = new Set();
606
+ for (const one of found) {
607
+ const recorded = recordLandedIntent(store, one, now(), root);
608
+ if (!recorded.ok)
609
+ continue;
610
+ fresh.add(recorded.id);
611
+ if (recorded.completed)
612
+ completed.push(one.taskId);
613
+ }
614
+ if (fresh.size > 0)
615
+ steps.push(...await finishLanded(store, pool, git, now, { repo, limit: AUTO_ITEMS, only: fresh }));
616
+ return { completed, steps };
617
+ }
618
+ /** What landed-work teardowns did so far, and what they still wait on (Settings → Storage). Reads only. */
619
+ export function landedSummary(store) {
620
+ const count = (sql) => Number(store.handle.prepare(sql).get()?.["n"] ?? 0);
621
+ return {
622
+ completed: count("SELECT COUNT(*) AS n FROM landed_cleanup WHERE completed = 1"),
623
+ checkouts: count("SELECT COUNT(*) AS n FROM landed_cleanup_item WHERE kind = 'checkout' AND state = 'done'"),
624
+ branches: count("SELECT COUNT(*) AS n FROM landed_cleanup_item WHERE kind = 'branch' AND state = 'done'"),
625
+ waiting: store.handle.prepare("SELECT target, why FROM landed_cleanup_item WHERE state = 'pending' ORDER BY id LIMIT 50").all()
626
+ .map(row => ({ target: String(row["target"]), why: row["why"] === null ? null : String(row["why"]) })),
627
+ };
628
+ }
629
+ /**
630
+ * What `storage clean --landed` would do now, across every project: landed results finished at least a week ago,
631
+ * and Toolroll's own branches (toolroll/, toolroll-plan/, standing-orders/) no task uses whose last commit is at
632
+ * least a week old and landed in main. Reads only.
633
+ */
634
+ export async function landedBacklog(store, git, root, now, options = {}) {
635
+ const cutoff = new Date(now.getTime() - BACKLOG_DAYS * DAY_MS).toISOString();
636
+ const plan = { at: now.toISOString(), cutoff, results: [], branches: [], kept: [], pending: 0 };
637
+ plan.pending = Number(store.handle.prepare("SELECT COUNT(*) AS n FROM landed_cleanup_item WHERE state = 'pending'").get()?.["n"] ?? 0);
638
+ const statuses = taskStatuses(store, now);
639
+ const pendingTargets = new Set(store.handle.prepare("SELECT repo, target FROM landed_cleanup_item WHERE state = 'pending'").all().map(row => `${String(row["repo"])}\u0000${String(row["target"])}`));
640
+ for (const repo of (options.repos ?? store.knownRepos()).filter(one => existsSync(one))) {
641
+ const results = await landedResults(store, git, repo, root, now, { before: cutoff });
642
+ plan.results.push(...results.found);
643
+ plan.kept.push(...results.kept, ...results.found.flatMap(one => one.keeps));
644
+ const taken = new Set([...results.found.flatMap(one => [...one.targets.map(target => target.target), ...one.keeps.map(keep => keep.what)])]);
645
+ // A result that stays already says why: its branches don't say it again.
646
+ const staying = new Set(results.kept.map(one => one.what));
647
+ const [branches, out] = await Promise.all([localBranches(git, repo), checkedOut(git, repo)]);
648
+ if (branches === null || out === null) {
649
+ plan.kept.push({ repo, what: repo, why: "git couldn't read the project" });
650
+ continue;
651
+ }
652
+ const worktrees = store.listWorktrees().filter(row => row.repo === repo);
653
+ for (const [name, tip] of branches) {
654
+ if (!BACKLOG_PREFIXES.some(prefix => name.startsWith(prefix)) || taken.has(name) || pendingTargets.has(`${repo}\u0000${name}`))
655
+ continue;
656
+ const status = branchUser(store, statuses, name);
657
+ if (status !== undefined && staying.has(status.taskId))
658
+ continue;
659
+ if (status !== undefined && status.keep !== null) {
660
+ plan.kept.push({ repo, what: name, why: `${status.taskId} uses it (${whyWords({ why: status.keep })})` });
661
+ continue;
662
+ }
663
+ if (tip.at * 1000 > Date.parse(cutoff)) {
664
+ plan.kept.push({ repo, what: name, why: `its last commit is less than ${BACKLOG_DAYS} days old` });
665
+ continue;
666
+ }
667
+ const checkout = worktrees.find(row => row.branch === name) ?? null;
668
+ const elsewhere = out.get(name);
669
+ if (elsewhere !== undefined && (checkout === null || real(checkout.path) !== real(elsewhere))) {
670
+ plan.kept.push({ repo, what: name, why: `it is checked out at ${elsewhere}` });
671
+ continue;
672
+ }
673
+ const proof = await landedProof(git, repo, tip.oid);
674
+ if (!proof.landed) {
675
+ plan.kept.push({ repo, what: name, why: proof.why });
676
+ continue;
677
+ }
678
+ plan.branches.push({ repo, branch: name, oid: tip.oid, how: proof.how, reason: proof.reason, checkout: checkout?.path ?? null });
679
+ }
680
+ }
681
+ return plan;
682
+ }
683
+ /** Act on a preview: record every teardown first, then carry them out. Refuses nothing it previewed silently: what
684
+ * changed since is reported as kept. */
685
+ export async function cleanLandedBacklog(store, pool, git, root, now, plan, actor) {
686
+ const ids = new Set();
687
+ const completed = [];
688
+ const refused = [];
689
+ for (const one of plan.results) {
690
+ const recorded = recordLandedIntent(store, one, now(), root, actor);
691
+ if (!recorded.ok) {
692
+ refused.push({ repo: one.repo, what: one.taskId, why: recorded.why });
693
+ continue;
694
+ }
695
+ ids.add(recorded.id);
696
+ if (recorded.completed)
697
+ completed.push(one.taskId);
698
+ }
699
+ for (const one of plan.branches)
700
+ ids.add(recordBranchIntent(store, one, now(), actor));
701
+ // Earlier teardowns a crash cut short finish too.
702
+ const earlier = store.handle.prepare("SELECT DISTINCT cleanup FROM landed_cleanup_item WHERE state = 'pending'").all().map(row => Number(row["cleanup"]));
703
+ for (const id of earlier)
704
+ ids.add(id);
705
+ const steps = await finishLanded(store, pool, git, now, { only: ids });
706
+ return { completed, steps, refused };
707
+ }