toolroll 0.9.60 → 0.9.62

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