toolroll 0.9.92 → 0.9.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +2 -2
- package/dist/assignment.js +11 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +30 -30
- package/dist/browser-crew.js +2 -2
- package/dist/browser-workspace.d.ts +3 -5
- package/dist/builder.js +21 -2
- package/dist/check-facts.d.ts +124 -0
- package/dist/check-facts.js +272 -0
- package/dist/check-report.d.ts +5 -0
- package/dist/check-report.js +4 -3
- package/dist/cli.js +5 -0
- package/dist/contracts/console-api.d.ts +2 -2
- package/dist/contracts/console-api.js +2 -2
- package/dist/contracts/release-check.d.ts +25 -1
- package/dist/contracts/release-check.js +19 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/desktop-update.js +13 -9
- package/dist/eval-hooks.d.ts +55 -0
- package/dist/eval-hooks.js +227 -4
- package/dist/flaky.d.ts +17 -0
- package/dist/flaky.js +9 -0
- package/dist/needs-you.d.ts +7 -7
- package/dist/needs-you.js +12 -10
- package/dist/operate.js +138 -18
- package/dist/project-delete.js +5 -1
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/push.d.ts +10 -3
- package/dist/push.js +22 -6
- package/dist/release-adapters.js +11 -1
- package/dist/release-approval.d.ts +191 -0
- package/dist/release-approval.js +382 -0
- package/dist/release-auto.js +13 -4
- package/dist/release-card.d.ts +73 -4
- package/dist/release-card.js +168 -23
- package/dist/release-cli.js +9 -1
- package/dist/release.d.ts +30 -0
- package/dist/release.js +42 -3
- package/dist/result-acts.d.ts +3 -3
- package/dist/result-acts.js +1 -1
- package/dist/server/guards.js +1 -0
- package/dist/server/pages-assets.js +1 -1
- package/dist/server/render-release.d.ts +17 -0
- package/dist/server/render-release.js +84 -0
- package/dist/server/render-results.d.ts +0 -2
- package/dist/server/render-results.js +4 -6
- package/dist/server/render-tasks.js +3 -3
- package/dist/server/route-table.d.ts +1 -1
- package/dist/server/route-table.js +7 -1
- package/dist/server/tasks.d.ts +15 -0
- package/dist/server/tasks.js +102 -20
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.d.ts +67 -0
- package/dist/stop-proof.js +293 -0
- package/dist/store.d.ts +70 -31
- package/dist/store.js +335 -95
- package/dist/surface.js +1 -1
- package/dist/task-outcome-cli.js +19 -0
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +1 -0
- package/dist/work-index.js +7 -9
- package/dist/work-summary.d.ts +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolroll's own proof that a finished build stopped. Nobody is ever asked to "confirm it stopped": a person can't see
|
|
3
|
+
* whether a process exited, and asking defeats unattended builds and releases.
|
|
4
|
+
*
|
|
5
|
+
* When a finished run's process record is missing or incomplete (a spawn whose process ID was never written, a lost
|
|
6
|
+
* process-tree snapshot, an agent start with no witness, a workspace note that can't be read), Toolroll looks at this
|
|
7
|
+
* computer itself, a grace period after the run finished:
|
|
8
|
+
*
|
|
9
|
+
* - no process the run is known to have started is still that process (pid and birth time, never a pid alone);
|
|
10
|
+
* - no process works in the run's folder (its working directory) or names it in its command line;
|
|
11
|
+
* - this process still tracks no live child for the run.
|
|
12
|
+
*
|
|
13
|
+
* A leftover it finds is stopped (SIGTERM, then SIGKILL) only when it is proven the run's: in the run's folder, born
|
|
14
|
+
* while the run ran, not this service or its ancestors, and orphaned or descended from the run's
|
|
15
|
+
* own processes — or it descends from such a leftover. A pid now held by a process born after the run ended is never
|
|
16
|
+
* the run's. Anything else in the folder (a person's shell or editor) is left alone and holds the proof, in plain
|
|
17
|
+
* words. When this computer can't look (the process table or the folder listing can't be read, or the run's
|
|
18
|
+
* processes belong to another computer), the stop stays unproven and the reason is shown; there is still nothing to
|
|
19
|
+
* vouch for.
|
|
20
|
+
*
|
|
21
|
+
* Command lines are read only to match the folder's path in memory; they are never stored or shown.
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { existsSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
25
|
+
import { hostname } from "node:os";
|
|
26
|
+
import { sep } from "node:path";
|
|
27
|
+
import { ownedProcessCount, runOwnerTag } from "./exec.js";
|
|
28
|
+
import { closeoutTargets, processCensus, protectedProcesses, stopProcesses, stoppedWords } from "./run-closeout.js";
|
|
29
|
+
import { recordStopProofBlocked, settleRunStopByObservation } from "./store.js";
|
|
30
|
+
import { worktreeProcessOccupancy } from "./worktree.js";
|
|
31
|
+
/** How long after a run finished its processes have to go on their own before Toolroll looks. */
|
|
32
|
+
export const STOP_PROOF_GRACE_MS = 60_000;
|
|
33
|
+
/** `ps` gives ages in whole seconds: a process born this close to the run's start or end still counts as during it. */
|
|
34
|
+
const BIRTH_SLACK_MS = 2_000;
|
|
35
|
+
/** One pass's shared look at this computer: the process table, folders and command lines are read once for every
|
|
36
|
+
* run the pass checks, and again after anything was stopped. */
|
|
37
|
+
export function sharedLook(options = {}) {
|
|
38
|
+
let census = null, folders = null, lines = null;
|
|
39
|
+
return {
|
|
40
|
+
...options,
|
|
41
|
+
census: () => (census ??= (options.census ?? (() => processCensus()))()),
|
|
42
|
+
cwds: paths => options.cwds !== undefined ? options.cwds(paths) : processesInStrict(paths, folders ??= processFolders()),
|
|
43
|
+
commandLines: () => (lines ??= (options.commandLines ?? processCommandLines)()),
|
|
44
|
+
refresh: () => { census = null; folders = null; lines = null; options.refresh?.(); },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Which processes work inside each path, from the OS: /proc on Linux, lsof on macOS. Unlike a best-effort listing,
|
|
48
|
+
* it throws when the listing can't be read: no answer is never "nobody". */
|
|
49
|
+
export function processesInStrict(paths, listing = processFolders()) {
|
|
50
|
+
const found = new Map();
|
|
51
|
+
const roots = paths.map(path => { let real = path; try {
|
|
52
|
+
real = realpathSync(path);
|
|
53
|
+
}
|
|
54
|
+
catch { /* gone */ } return { path, real }; });
|
|
55
|
+
for (const { pid, cwd } of listing) {
|
|
56
|
+
for (const one of roots)
|
|
57
|
+
if ([one.path, one.real].some(root => cwd === root || cwd.startsWith(root + sep)))
|
|
58
|
+
found.set(one.path, [...(found.get(one.path) ?? []), pid]);
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
/** Every process's working directory, from the OS; throws when the listing can't be read. */
|
|
63
|
+
export function processFolders() {
|
|
64
|
+
const found = [];
|
|
65
|
+
const note = (pid, cwd) => { found.push({ pid, cwd }); };
|
|
66
|
+
if (process.platform === "linux" && existsSync("/proc")) {
|
|
67
|
+
for (const name of readdirSync("/proc")) {
|
|
68
|
+
if (!/^\d+$/.test(name))
|
|
69
|
+
continue;
|
|
70
|
+
// Gone, or another user's (never a process this user's run started).
|
|
71
|
+
try {
|
|
72
|
+
note(Number(name), readlinkSync(`/proc/${name}/cwd`));
|
|
73
|
+
}
|
|
74
|
+
catch { /* not readable */ }
|
|
75
|
+
}
|
|
76
|
+
if (found.length === 0)
|
|
77
|
+
throw new Error("no process folder could be read");
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
if (process.platform !== "darwin")
|
|
81
|
+
throw new Error("this computer's process folders can't be listed");
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = execFileSync("lsof", ["-nP", "-d", "cwd", "-Fpn"], { encoding: "utf8", timeout: 20_000, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// lsof exits 1 when some process can't be inspected (another user's) yet still lists every one it can.
|
|
88
|
+
// A timeout, a signal or an overflow leaves a partial listing, which is no answer at all.
|
|
89
|
+
const failure = error;
|
|
90
|
+
if (failure.status !== 1 || failure.signal != null || typeof failure.code === "string")
|
|
91
|
+
throw new Error("the process folder listing could not be read");
|
|
92
|
+
text = String(failure.stdout ?? "");
|
|
93
|
+
}
|
|
94
|
+
if (!/^p\d+$/m.test(text))
|
|
95
|
+
throw new Error("the process folder listing was empty");
|
|
96
|
+
let pid = 0;
|
|
97
|
+
for (const line of text.split("\n")) {
|
|
98
|
+
if (line.startsWith("p"))
|
|
99
|
+
pid = Number(line.slice(1));
|
|
100
|
+
else if (line.startsWith("n") && pid > 0)
|
|
101
|
+
note(pid, line.slice(1));
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
/** Every process's command line, by pid. Read to match a folder's path in memory only; never stored or shown. */
|
|
106
|
+
export function processCommandLines() {
|
|
107
|
+
if (process.platform === "win32")
|
|
108
|
+
throw new Error("command lines can't be read here");
|
|
109
|
+
const text = execFileSync("/bin/ps", ["-axww", "-o", "pid=,args="], { encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
110
|
+
const lines = new Map();
|
|
111
|
+
for (const line of text.split("\n")) {
|
|
112
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
113
|
+
if (match !== null)
|
|
114
|
+
lines.set(Number(match[1]), match[2]);
|
|
115
|
+
}
|
|
116
|
+
if (lines.size === 0)
|
|
117
|
+
throw new Error("the process list was empty");
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
/** Whether a command line names `path` or something inside it (a whole path, never a longer folder's prefix). */
|
|
121
|
+
export function namesPath(line, path) {
|
|
122
|
+
for (let at = line.indexOf(path); at >= 0; at = line.indexOf(path, at + 1)) {
|
|
123
|
+
const before = at === 0 ? " " : line[at - 1], after = line[at + path.length];
|
|
124
|
+
if (/[\s"'=:]/.test(before) && (after === undefined || after === sep || /[\s"':]/.test(after)))
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* One look at this computer for one finished run: which processes are its leftovers (`targets`) and which are in its
|
|
131
|
+
* folder without being proven its (`blockers`). Throws when something can't be read.
|
|
132
|
+
*/
|
|
133
|
+
function look(store, runId, options) {
|
|
134
|
+
const run = store.getRun(runId);
|
|
135
|
+
const census = (options.census ?? (() => processCensus()))();
|
|
136
|
+
if (census.length === 0)
|
|
137
|
+
throw new Error("the process table was empty");
|
|
138
|
+
const guard = protectedProcesses(census, options.self);
|
|
139
|
+
const byPid = new Map(census.map(row => [row.pid, row]));
|
|
140
|
+
// Its recorded processes still open: still the same process (pid and birth), or a member of its open group.
|
|
141
|
+
const open = store.handle.prepare(`SELECT pid, process_group, observed_at FROM run_process
|
|
142
|
+
WHERE run = ? AND exited_at IS NULL AND pid IS NOT NULL AND containment IS NULL AND container IS NULL`).all(runId)
|
|
143
|
+
.map(row => ({ pid: Number(row["pid"]), group: Number(row["process_group"]) === 1, observedAt: Date.parse(String(row["observed_at"])) }))
|
|
144
|
+
.filter(one => Number.isFinite(one.observedAt));
|
|
145
|
+
const recorded = new Set(closeoutTargets(census, open, guard).map(row => row.pid));
|
|
146
|
+
// Its folder: working directory inside it, or named on the command line.
|
|
147
|
+
const folder = new Set();
|
|
148
|
+
if (run.worktree !== null) {
|
|
149
|
+
const paths = [run.worktree];
|
|
150
|
+
try {
|
|
151
|
+
const real = realpathSync(run.worktree);
|
|
152
|
+
if (real !== run.worktree)
|
|
153
|
+
paths.push(real);
|
|
154
|
+
}
|
|
155
|
+
catch { /* gone: only its name can match */ }
|
|
156
|
+
for (const pids of (options.cwds ?? processesInStrict)(paths).values())
|
|
157
|
+
for (const pid of pids)
|
|
158
|
+
folder.add(pid);
|
|
159
|
+
for (const [pid, line] of (options.commandLines ?? processCommandLines)())
|
|
160
|
+
if (paths.some(path => namesPath(line, path)))
|
|
161
|
+
folder.add(pid);
|
|
162
|
+
}
|
|
163
|
+
const started = Date.parse(run.startedAt) - BIRTH_SLACK_MS, ended = Date.parse(run.finishedAt) + BIRTH_SLACK_MS;
|
|
164
|
+
const during = (row) => row.bornAt >= started && row.bornAt <= ended;
|
|
165
|
+
const unguarded = (row) => !guard.pids.has(row.pid) && !guard.groups.has(row.pgid);
|
|
166
|
+
// A leftover in the folder: born while the run ran and orphaned (its starter gone). A terminal proves nothing either
|
|
167
|
+
// way here: a worker started from a terminal hands its controlling terminal to everything its runs start.
|
|
168
|
+
const orphans = new Set();
|
|
169
|
+
for (const pid of folder) {
|
|
170
|
+
const row = byPid.get(pid);
|
|
171
|
+
if (row !== undefined && during(row) && unguarded(row) && row.ppid === 1)
|
|
172
|
+
orphans.add(pid);
|
|
173
|
+
}
|
|
174
|
+
// And whatever descends from a leftover, wherever it works: from the run's own recorded processes (proven by pid
|
|
175
|
+
// and birth) or from an orphan found in its folder.
|
|
176
|
+
const descendsFrom = (roots, row) => {
|
|
177
|
+
for (let at = row, hops = 0; at !== undefined && at.pid > 1 && hops < 64; at = byPid.get(at.ppid), hops++)
|
|
178
|
+
if (roots.has(at.pid))
|
|
179
|
+
return true;
|
|
180
|
+
return false;
|
|
181
|
+
};
|
|
182
|
+
const targets = census.filter(row => unguarded(row) && (descendsFrom(recorded, row) || descendsFrom(orphans, row)));
|
|
183
|
+
const chosen = new Set(targets.map(row => row.pid));
|
|
184
|
+
// In the folder while the run ran but not proven its (a person's shell or editor: a live parent Toolroll didn't start):
|
|
185
|
+
// left alone, and the proof waits for it. Born before the run or after it ended (and not a leftover's child) can't
|
|
186
|
+
// be the run's, so it neither blocks nor is touched.
|
|
187
|
+
const blockers = [...folder].map(pid => byPid.get(pid)).filter((row) => row !== undefined && !chosen.has(row.pid) && during(row) && !guard.pids.has(row.pid));
|
|
188
|
+
// A run with no folder (a reviewer's): nothing names its processes but their groups. An orphan born while it ran
|
|
189
|
+
// in one of its recorded process groups may be its own, or a newer group that took the number: never signalled,
|
|
190
|
+
// it holds the proof.
|
|
191
|
+
if (run.worktree === null) {
|
|
192
|
+
const groups = new Set(store.handle.prepare("SELECT pid FROM run_process WHERE run = ? AND pid IS NOT NULL AND process_group = 1").all(runId).map(row => Number(row["pid"])));
|
|
193
|
+
for (const row of census)
|
|
194
|
+
if (!chosen.has(row.pid) && unguarded(row) && row.ppid === 1 && during(row) && groups.has(row.pgid))
|
|
195
|
+
blockers.push(row);
|
|
196
|
+
}
|
|
197
|
+
return { census, targets, blockers, inFolder: folder.size };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Prove one finished run stopped, on this computer, without asking anyone (see the file's comment). Walks the run's
|
|
201
|
+
* owned runs one unproven stop at a time. Never signals a process that isn't proven the run's.
|
|
202
|
+
*/
|
|
203
|
+
export async function proveRunStopped(store, runId, clock, options = {}) {
|
|
204
|
+
let stopped = 0;
|
|
205
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
206
|
+
const fact = store.stopQuiescenceFact(runId);
|
|
207
|
+
if (fact === null)
|
|
208
|
+
return { run: runId, outcome: pass === 0 ? "skipped" : "settled", words: "stopped", ...(stopped > 0 ? { stopped } : {}) };
|
|
209
|
+
// Already proven by the record (an unrecorded spawn whose run's every process is proven gone): settle it now.
|
|
210
|
+
if (fact.kind === "settling" && store.settleUnspawnedWitnesses(clock(), fact.run) > 0) {
|
|
211
|
+
store.settleQuiescentStops(clock());
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const cause = fact.cause;
|
|
215
|
+
if (cause === undefined || (fact.kind !== "unprovable" && fact.kind !== "unknown"))
|
|
216
|
+
return { run: fact.run, outcome: pass === 0 ? "skipped" : "waiting", words: fact.problem };
|
|
217
|
+
const subject = fact.run, run = store.getRun(subject);
|
|
218
|
+
const finished = Date.parse(run.finishedAt ?? "");
|
|
219
|
+
const now = clock();
|
|
220
|
+
if (!Number.isFinite(finished) || now.getTime() < finished + (options.afterFinishMs ?? STOP_PROOF_GRACE_MS))
|
|
221
|
+
return { run: subject, outcome: "waiting", words: "finishing" };
|
|
222
|
+
if (ownedProcessCount(runOwnerTag(store, subject)) > 0)
|
|
223
|
+
return { run: subject, outcome: "waiting", words: "a process of it is still tracked here" };
|
|
224
|
+
if (process.platform === "win32")
|
|
225
|
+
return block(store, subject, "can't be checked on this computer: Windows processes can't be listed by folder", "no folder listing on Windows", now);
|
|
226
|
+
const foreign = store.handle.prepare("SELECT host FROM run_process WHERE run = ? AND exited_at IS NULL AND host <> ? LIMIT 1").get(subject, hostname());
|
|
227
|
+
if (foreign !== undefined)
|
|
228
|
+
return block(store, subject, `ran on another computer (${String(foreign["host"])}); only that computer can check it stopped`, `an open witness belongs to ${String(foreign["host"])}`, now);
|
|
229
|
+
if (run.worktree !== null) {
|
|
230
|
+
try {
|
|
231
|
+
const held = (options.occupancy ?? worktreeProcessOccupancy)(run.worktree);
|
|
232
|
+
if (held.held)
|
|
233
|
+
return { run: subject, outcome: "waiting", words: `its folder is still held by process ${held.by}` };
|
|
234
|
+
}
|
|
235
|
+
catch { /* an unreadable note: the look below decides */ }
|
|
236
|
+
}
|
|
237
|
+
let seen;
|
|
238
|
+
try {
|
|
239
|
+
seen = look(store, subject, options);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return block(store, subject, "can't be checked on this computer: its process list could not be read", `look failed: ${error instanceof Error ? error.message : String(error)}`, now);
|
|
243
|
+
}
|
|
244
|
+
if (seen.targets.length > 0) {
|
|
245
|
+
const done = await stopProcesses(seen.targets, options);
|
|
246
|
+
options.refresh?.();
|
|
247
|
+
stopped += done.stopped.length;
|
|
248
|
+
const ref = store.refById(run.taskRef);
|
|
249
|
+
store.recordAction({ at: clock().toISOString(), actor: "system", repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: subject,
|
|
250
|
+
action: "run processes stopped", outcome: "stopped", source: "work", detail: `run #${subject} ended (${run.outcome ?? "?"}); its stop check found and stopped ${stoppedWords(done, options.graceMs)}` });
|
|
251
|
+
try {
|
|
252
|
+
seen = look(store, subject, options);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return { run: subject, outcome: "waiting", words: "stopped what it left running; checking again", stopped };
|
|
256
|
+
}
|
|
257
|
+
if (seen.targets.length > 0)
|
|
258
|
+
return { run: subject, outcome: "waiting", words: "stopped what it left running; checking again", stopped };
|
|
259
|
+
}
|
|
260
|
+
if (seen.blockers.length > 0) {
|
|
261
|
+
const one = seen.blockers[0];
|
|
262
|
+
const others = seen.blockers.length - 1;
|
|
263
|
+
return block(store, subject, `can't be proven stopped: ${one.name} (process ${one.pid})${others > 0 ? ` and ${others} more` : ""} is ${run.worktree === null ? "in one of its process groups" : "using its folder"}, and Toolroll didn't start it, so it left it alone`, `in its folder: ${seen.blockers.slice(0, 8).map(row => `${row.name} ${row.pid}`).join(", ")}`, now);
|
|
264
|
+
}
|
|
265
|
+
const evidence = `${hostname()}: ${seen.census.length} processes checked ${Math.round((now.getTime() - finished) / 1000)} s after it finished; none in ${run.worktree ?? "no folder"} or naming it (${seen.inFolder} seen there, none from the run), none of its recorded processes running${stopped > 0 ? `; stopped ${stopped} it left running` : ""}`;
|
|
266
|
+
if (!settleRunStopByObservation(store, { runId: subject, cause, evidence }, clock()))
|
|
267
|
+
return { run: subject, outcome: "waiting", words: "its record changed while checking" };
|
|
268
|
+
}
|
|
269
|
+
return { run: runId, outcome: "waiting", words: "more to check" };
|
|
270
|
+
}
|
|
271
|
+
function block(store, runId, words, detail, now) {
|
|
272
|
+
recordStopProofBlocked(store, runId, words, detail, now);
|
|
273
|
+
return { run: runId, outcome: "blocked", words };
|
|
274
|
+
}
|
|
275
|
+
/** The worker's pass (operate.ts reconcile): every finished run of this computer whose stop is unproven where
|
|
276
|
+
* Toolroll's own look can prove it. A batch a pass, oldest first; a blocked run never holds newer ones back. */
|
|
277
|
+
export async function proveFinishedRunsStopped(store, clock, options = {}) {
|
|
278
|
+
const grace = options.afterFinishMs ?? STOP_PROOF_GRACE_MS;
|
|
279
|
+
const before = new Date(clock().getTime() - grace).toISOString();
|
|
280
|
+
const runs = store.handle.prepare(`SELECT id FROM run r WHERE r.outcome IS NOT NULL AND r.finished_at IS NOT NULL AND r.finished_at <= ? AND (
|
|
281
|
+
EXISTS (SELECT 1 FROM run_process p WHERE p.run = r.id AND p.exited_at IS NULL AND p.pid IS NULL AND p.containment IS NULL AND p.container IS NULL AND p.host = ?)
|
|
282
|
+
OR (r.provider_started_at IS NOT NULL AND NOT EXISTS (SELECT 1 FROM run_process p WHERE p.run = r.id) AND NOT EXISTS (SELECT 1 FROM run_process_summary s WHERE s.run = r.id)))
|
|
283
|
+
ORDER BY r.id LIMIT ?`).all(before, hostname(), options.batch ?? 50).map(row => Number(row["id"]));
|
|
284
|
+
const out = [];
|
|
285
|
+
const shared = sharedLook(options);
|
|
286
|
+
for (const runId of runs) {
|
|
287
|
+
try {
|
|
288
|
+
out.push(await proveRunStopped(store, runId, clock, shared));
|
|
289
|
+
}
|
|
290
|
+
catch { /* one run's failure never stops the pass; the next pass looks again */ }
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -59,6 +59,15 @@ export declare const LEAD_SWEEP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS lead_swee
|
|
|
59
59
|
export declare const LEAD_COMMITMENT_SCHEMA = "\nCREATE TABLE IF NOT EXISTS lead_commitment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n owner TEXT NOT NULL,\n repo TEXT,\n thread INTEGER NOT NULL,\n turn INTEGER,\n channel TEXT NOT NULL DEFAULT 'chat',\n what TEXT NOT NULL,\n condition_json TEXT NOT NULL,\n check_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'done', 'cancelled', 'expired')),\n created_at TEXT NOT NULL,\n checked_at TEXT,\n closed_at TEXT,\n closed_by TEXT,\n outcome TEXT\n);\nCREATE INDEX IF NOT EXISTS lead_commitment_due ON lead_commitment (state, check_at);\nCREATE INDEX IF NOT EXISTS lead_commitment_owner ON lead_commitment (owner, state, id);\n";
|
|
60
60
|
/** What stops a finished run reading stopped (Store.stopQuiescenceFact). */
|
|
61
61
|
export type StopFactKind = "open" | "alive" | "elsewhere" | "unprovable" | "unknown" | "settling";
|
|
62
|
+
/** Why a finished run's stop is unproven where Toolroll's own process check (stop-proof.ts) can prove it: a run that
|
|
63
|
+
* started its agent with no process witness, a spawn witness with no pid, or a workspace note that can't be read. */
|
|
64
|
+
export type StopProofCause = "no-witness" | "incomplete-spawn" | "workspace-unreadable";
|
|
65
|
+
export type StopFact = {
|
|
66
|
+
run: number;
|
|
67
|
+
kind: StopFactKind;
|
|
68
|
+
problem: string;
|
|
69
|
+
cause?: StopProofCause;
|
|
70
|
+
};
|
|
62
71
|
export type TaskAct = "filed" | "approved" | "cancelled" | "completed" | "asked";
|
|
63
72
|
/** Store.stopQuiescenceFact, read safely: a deploy proves completion with this code over the INSTALLED runtime's store,
|
|
64
73
|
* which may predate the method. There the older stopQuiescenceProblem decides, and its words give the kind. */
|
|
@@ -234,7 +243,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
234
243
|
hash: string;
|
|
235
244
|
} | null;
|
|
236
245
|
};
|
|
237
|
-
export declare const SCHEMA_VERSION =
|
|
246
|
+
export declare const SCHEMA_VERSION = 149;
|
|
238
247
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
239
248
|
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
240
249
|
/**
|
|
@@ -426,7 +435,7 @@ export declare const V147_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
|
|
|
426
435
|
}>>>>;
|
|
427
436
|
/** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
|
|
428
437
|
export declare const V147_STEPS: readonly ["epoch", "local-mode", "password-set", "web-session", "console-instance"];
|
|
429
|
-
export type MigrationStep = (typeof V147_STEPS)[number] | (typeof V148_STEPS)[number];
|
|
438
|
+
export type MigrationStep = (typeof V147_STEPS)[number] | (typeof V148_STEPS)[number] | (typeof V149_STEPS)[number];
|
|
430
439
|
/**
|
|
431
440
|
* v148 (docs/plans/results-routing.md): every table the results-routing arc uses, landed at once because a current
|
|
432
441
|
* store runs no DDL. Slice 1 writes run_cohort, route_record and models_generation; route_rule, route_exposure* and
|
|
@@ -458,6 +467,33 @@ export declare const V148_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
|
|
|
458
467
|
}>>>>;
|
|
459
468
|
/** The v148 migration's steps after v147's, in order (OpenOptions.migrationStep may stop after each). */
|
|
460
469
|
export declare const V148_STEPS: readonly ["results-tables", "exec-class", "revision-origin", "suggest-models"];
|
|
470
|
+
/**
|
|
471
|
+
* v149 (docs/plans/learning-loops.md): every table the learning-loops arc uses, landed at once because a current store
|
|
472
|
+
* runs no DDL. Slice 1 writes check_fact, check_result and learning_watch; the rest are written from later slices.
|
|
473
|
+
* Each table has one writer module (the migration aside), which release-auto's learning scan (eval-hooks.ts
|
|
474
|
+
* learningWriteProblem) holds a release to: check_fact and check_result → check-facts.ts; local_observation → flaky.ts;
|
|
475
|
+
* ci_flaky_observation and ci_pairing_rejected → flaky-ci.ts; learning_watch → flaky.ts and lesson-notes.ts;
|
|
476
|
+
* lesson_note and lesson_note_tally → lesson-notes.ts; memory_use → memory-use.ts; memory_tidy_proposal and
|
|
477
|
+
* decision_merge → memory-tidy.ts.
|
|
478
|
+
*
|
|
479
|
+
* Nothing here is ever read for authority: a check's outcome, a verdict and an approval never change because of it.
|
|
480
|
+
*
|
|
481
|
+
* Deletion follows today's semantics (runs are never deleted by ordinary work; removing a project keeps its results).
|
|
482
|
+
* The tables are keyed by repo path with no foreign key to `project`; their run keys are plain REFERENCES with no ON
|
|
483
|
+
* DELETE action. check_fact, learning_watch, lesson_note, lesson_note_tally, memory_use, memory_tidy_proposal and
|
|
484
|
+
* decision_merge are kept and never deleted (their triggers allow only the columns their one writer sets: a watch's
|
|
485
|
+
* `until`, a tally's counters, a proposal's decision, a merge's undo). check_result, local_observation,
|
|
486
|
+
* ci_flaky_observation and ci_pairing_rejected are pruned after 180 days by the storage sweep's own step (a later
|
|
487
|
+
* slice), the only DELETE on them; their rows never change. The whole-project deletion (project-delete.ts), which
|
|
488
|
+
* lifts every guard for its one transaction, takes them with the project as it takes everything else.
|
|
489
|
+
*/
|
|
490
|
+
export declare const V149_SCHEMA = "\nCREATE TABLE IF NOT EXISTS check_fact (\n run INTEGER PRIMARY KEY REFERENCES run(id),\n repo TEXT,\n task_ref INTEGER NOT NULL,\n head TEXT,\n tree TEXT,\n command_digest TEXT,\n access TEXT,\n harness_digest TEXT,\n lockfile TEXT,\n conditions_digest TEXT,\n conditions_class TEXT,\n purpose TEXT NOT NULL CHECK (purpose IN ('ordinary','release','nightly')),\n learn_state TEXT NOT NULL CHECK (learn_state IN ('recorded','not-recorded','not-asked')),\n learn_why TEXT,\n recorded_at TEXT NOT NULL,\n CHECK ((learn_state = 'not-recorded') = (learn_why IS NOT NULL)),\n CHECK ((conditions_digest IS NULL) = (conditions_class IS NULL)),\n CHECK (conditions_digest IS NULL OR tree IS NOT NULL)\n);\nCREATE INDEX IF NOT EXISTS check_fact_by_repo ON check_fact (repo, recorded_at);\nCREATE INDEX IF NOT EXISTS check_fact_by_tree ON check_fact (tree, conditions_digest);\nCREATE TABLE IF NOT EXISTS check_result (\n run INTEGER NOT NULL REFERENCES check_fact(run),\n identity TEXT NOT NULL CHECK (length(identity) BETWEEN 1 AND 2600),\n state TEXT NOT NULL CHECK (state IN ('failed','passed','skipped','passed-on-retry')),\n source TEXT NOT NULL CHECK (source IN ('receipt','report','log','learn')),\n recorded_at TEXT NOT NULL,\n PRIMARY KEY (run, identity, source)\n);\nCREATE INDEX IF NOT EXISTS check_result_by_identity ON check_result (identity, run);\nCREATE TABLE IF NOT EXISTS local_observation (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n repo TEXT NOT NULL,\n identity TEXT NOT NULL,\n failed_run INTEGER NOT NULL REFERENCES run(id),\n passed_run INTEGER NOT NULL REFERENCES run(id),\n tree TEXT NOT NULL,\n conditions_digest TEXT NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('regate','same-tree','in-check-retry')),\n observed_at TEXT NOT NULL,\n UNIQUE (identity, failed_run, passed_run),\n CHECK ((kind = 'in-check-retry') = (failed_run = passed_run))\n);\nCREATE INDEX IF NOT EXISTS local_observation_by_repo ON local_observation (repo, identity, observed_at);\nCREATE TABLE IF NOT EXISTS ci_flaky_observation (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n repo TEXT NOT NULL,\n provider TEXT NOT NULL CHECK (provider = 'github'),\n repository TEXT NOT NULL,\n workflow TEXT NOT NULL,\n lane_id TEXT NOT NULL,\n lane_name TEXT NOT NULL,\n workflow_run_id INTEGER NOT NULL CHECK (workflow_run_id >= 1),\n head_sha TEXT NOT NULL,\n failed_attempt INTEGER NOT NULL CHECK (failed_attempt >= 1),\n failed_job_id INTEGER NOT NULL,\n failed_conclusion TEXT NOT NULL,\n passed_attempt INTEGER NOT NULL,\n passed_job_id INTEGER NOT NULL,\n passed_conclusion TEXT NOT NULL,\n observed_at TEXT NOT NULL,\n UNIQUE (provider, repository, workflow_run_id, lane_id, failed_attempt, passed_attempt),\n CHECK (passed_attempt > failed_attempt)\n);\nCREATE INDEX IF NOT EXISTS ci_flaky_observation_by_repo ON ci_flaky_observation (repo, lane_id, observed_at);\nCREATE TABLE IF NOT EXISTS ci_pairing_rejected (\n provider TEXT NOT NULL CHECK (provider = 'github'),\n repository TEXT NOT NULL,\n workflow_run_id INTEGER NOT NULL CHECK (workflow_run_id >= 1),\n attempt INTEGER NOT NULL CHECK (attempt >= 1),\n lane_id TEXT,\n why TEXT NOT NULL,\n at TEXT NOT NULL,\n PRIMARY KEY (provider, repository, workflow_run_id, attempt, why)\n);\nCREATE TABLE IF NOT EXISTS learning_watch (\n repo TEXT NOT NULL,\n identity TEXT NOT NULL CHECK (length(identity) BETWEEN 1 AND 2600),\n reason TEXT NOT NULL CHECK (reason IN ('note','observation')),\n source_ref TEXT NOT NULL,\n since TEXT NOT NULL,\n until TEXT,\n PRIMARY KEY (repo, identity, reason, source_ref),\n CHECK (until IS NULL OR until >= since)\n);\nCREATE INDEX IF NOT EXISTS learning_watch_open ON learning_watch (repo, since) WHERE until IS NULL;\nCREATE TABLE IF NOT EXISTS lesson_note (\n proposal INTEGER PRIMARY KEY REFERENCES memory_proposal(id),\n repo TEXT NOT NULL,\n failed_run INTEGER NOT NULL REFERENCES run(id),\n passing_run INTEGER NOT NULL REFERENCES run(id),\n repair_chain INTEGER REFERENCES repair_chain(id),\n revision_origin TEXT CHECK (revision_origin IS NULL OR revision_origin IN ('person','automatic-review','flow','lead','machine')),\n identities_json TEXT NOT NULL,\n changed_paths_json TEXT NOT NULL,\n test_files_changed_json TEXT NOT NULL,\n facts_digest TEXT NOT NULL,\n created_at TEXT NOT NULL,\n CHECK (failed_run <> passing_run)\n);\nCREATE INDEX IF NOT EXISTS lesson_note_by_repo ON lesson_note (repo, created_at);\nCREATE TABLE IF NOT EXISTS lesson_note_tally (\n note INTEGER NOT NULL REFERENCES lesson_note(proposal),\n month TEXT NOT NULL CHECK (month GLOB '[0-9][0-9][0-9][0-9]-[0-1][0-9]'),\n executions INTEGER NOT NULL DEFAULT 0 CHECK (executions >= 0),\n failures INTEGER NOT NULL DEFAULT 0 CHECK (failures >= 0 AND failures <= executions),\n mixed_failures INTEGER NOT NULL DEFAULT 0 CHECK (mixed_failures >= 0),\n received_executions INTEGER NOT NULL DEFAULT 0 CHECK (received_executions >= 0 AND received_executions <= executions),\n received_failures INTEGER NOT NULL DEFAULT 0 CHECK (received_failures >= 0 AND received_failures <= received_executions AND received_failures <= failures),\n unreported_checks INTEGER NOT NULL DEFAULT 0 CHECK (unreported_checks >= 0),\n sources_json TEXT NOT NULL DEFAULT '[]',\n partial INTEGER NOT NULL DEFAULT 0 CHECK (partial IN (0,1)),\n PRIMARY KEY (note, month)\n);\nCREATE TABLE IF NOT EXISTS memory_use (\n run INTEGER NOT NULL REFERENCES run(id),\n repo TEXT,\n kind TEXT NOT NULL CHECK (kind IN ('lesson','reference','decision')),\n item TEXT NOT NULL,\n version INTEGER,\n phase TEXT NOT NULL,\n included_at TEXT,\n PRIMARY KEY (run, kind, item)\n);\nCREATE INDEX IF NOT EXISTS memory_use_by_item ON memory_use (repo, kind, item, run);\nCREATE TABLE IF NOT EXISTS memory_tidy_proposal (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n repo TEXT NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('retire','merge','contradiction','note-failing','restore','unmerge')),\n items_json TEXT NOT NULL,\n after_text TEXT,\n rationale TEXT NOT NULL,\n evidence_json TEXT NOT NULL,\n source TEXT NOT NULL CHECK (source IN ('usage','duplicate','model','note','person')),\n status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','rejected','stale')),\n created_at TEXT NOT NULL,\n decided_by TEXT,\n decided_proof TEXT,\n decided_at TEXT,\n CHECK ((status = 'pending') = (decided_at IS NULL)),\n CHECK (status IN ('accepted','rejected') OR (decided_by IS NULL AND decided_proof IS NULL)),\n CHECK (status NOT IN ('accepted','rejected') OR decided_by IS NOT NULL)\n);\nCREATE INDEX IF NOT EXISTS memory_tidy_proposal_pending ON memory_tidy_proposal (repo, status, kind);\nCREATE TABLE IF NOT EXISTS decision_merge (\n merge INTEGER NOT NULL,\n successor INTEGER NOT NULL REFERENCES project_decision(id),\n successor_revision INTEGER NOT NULL CHECK (successor_revision >= 1),\n predecessor INTEGER NOT NULL REFERENCES project_decision(id),\n predecessor_revision INTEGER NOT NULL CHECK (predecessor_revision >= 1),\n at TEXT NOT NULL,\n by TEXT NOT NULL,\n undone_at TEXT,\n undone_by TEXT,\n PRIMARY KEY (merge, predecessor),\n CHECK (successor <> predecessor),\n CHECK ((undone_at IS NULL) = (undone_by IS NULL))\n);\nCREATE INDEX IF NOT EXISTS decision_merge_by_successor ON decision_merge (successor);\nCREATE INDEX IF NOT EXISTS decision_merge_by_predecessor ON decision_merge (predecessor);\nCREATE TRIGGER IF NOT EXISTS check_fact_no_update BEFORE UPDATE ON check_fact BEGIN SELECT RAISE(ABORT,'check facts are recorded once'); END;\nCREATE TRIGGER IF NOT EXISTS check_fact_no_delete BEFORE DELETE ON check_fact BEGIN SELECT RAISE(ABORT,'check facts are kept'); END;\nCREATE TRIGGER IF NOT EXISTS check_result_no_update BEFORE UPDATE ON check_result BEGIN SELECT RAISE(ABORT,'check results are recorded once'); END;\nCREATE TRIGGER IF NOT EXISTS local_observation_no_update BEFORE UPDATE ON local_observation BEGIN SELECT RAISE(ABORT,'observations are recorded once'); END;\nCREATE TRIGGER IF NOT EXISTS ci_flaky_observation_no_update BEFORE UPDATE ON ci_flaky_observation BEGIN SELECT RAISE(ABORT,'observations are recorded once'); END;\nCREATE TRIGGER IF NOT EXISTS ci_pairing_rejected_no_update BEFORE UPDATE ON ci_pairing_rejected BEGIN SELECT RAISE(ABORT,'rejected pairings are recorded once'); END;\nCREATE TRIGGER IF NOT EXISTS learning_watch_only_ends BEFORE UPDATE ON learning_watch\n WHEN NEW.repo IS NOT OLD.repo OR NEW.identity IS NOT OLD.identity OR NEW.reason IS NOT OLD.reason OR NEW.source_ref IS NOT OLD.source_ref\n OR NEW.since IS NOT OLD.since OR OLD.until IS NOT NULL OR NEW.until IS NULL\n BEGIN SELECT RAISE(ABORT,'a tracked identity only ends, once'); END;\nCREATE TRIGGER IF NOT EXISTS learning_watch_no_delete BEFORE DELETE ON learning_watch BEGIN SELECT RAISE(ABORT,'tracked identities are kept'); END;\nCREATE TRIGGER IF NOT EXISTS lesson_note_no_update BEFORE UPDATE ON lesson_note BEGIN SELECT RAISE(ABORT,'lesson note facts are recorded once'); END;\nCREATE TRIGGER IF NOT EXISTS lesson_note_no_delete BEFORE DELETE ON lesson_note BEGIN SELECT RAISE(ABORT,'lesson notes are kept'); END;\nCREATE TRIGGER IF NOT EXISTS lesson_note_tally_only_counts BEFORE UPDATE ON lesson_note_tally\n WHEN NEW.note IS NOT OLD.note OR NEW.month IS NOT OLD.month OR NEW.executions < OLD.executions OR NEW.failures < OLD.failures\n OR NEW.mixed_failures < OLD.mixed_failures OR NEW.received_executions < OLD.received_executions OR NEW.received_failures < OLD.received_failures\n OR NEW.unreported_checks < OLD.unreported_checks OR NEW.partial < OLD.partial\n BEGIN SELECT RAISE(ABORT,'a tally only counts up'); END;\nCREATE TRIGGER IF NOT EXISTS lesson_note_tally_no_delete BEFORE DELETE ON lesson_note_tally BEGIN SELECT RAISE(ABORT,'tallies are never pruned'); END;\nCREATE TRIGGER IF NOT EXISTS memory_use_no_update BEFORE UPDATE ON memory_use BEGIN SELECT RAISE(ABORT,'memory use is recorded once'); END;\nCREATE TRIGGER IF NOT EXISTS memory_use_no_delete BEFORE DELETE ON memory_use BEGIN SELECT RAISE(ABORT,'memory use is kept'); END;\nCREATE TRIGGER IF NOT EXISTS memory_tidy_proposal_only_decides BEFORE UPDATE ON memory_tidy_proposal\n WHEN NEW.id IS NOT OLD.id OR NEW.repo IS NOT OLD.repo OR NEW.kind IS NOT OLD.kind OR NEW.items_json IS NOT OLD.items_json\n OR NEW.after_text IS NOT OLD.after_text OR NEW.rationale IS NOT OLD.rationale OR NEW.evidence_json IS NOT OLD.evidence_json\n OR NEW.source IS NOT OLD.source OR NEW.created_at IS NOT OLD.created_at OR OLD.status <> 'pending'\n BEGIN SELECT RAISE(ABORT,'a tidy-up proposal is decided once'); END;\nCREATE TRIGGER IF NOT EXISTS memory_tidy_proposal_no_delete BEFORE DELETE ON memory_tidy_proposal BEGIN SELECT RAISE(ABORT,'tidy-up proposals are kept'); END;\nCREATE TRIGGER IF NOT EXISTS decision_merge_only_undone BEFORE UPDATE ON decision_merge\n WHEN NEW.merge IS NOT OLD.merge OR NEW.successor IS NOT OLD.successor OR NEW.successor_revision IS NOT OLD.successor_revision\n OR NEW.predecessor IS NOT OLD.predecessor OR NEW.predecessor_revision IS NOT OLD.predecessor_revision OR NEW.at IS NOT OLD.at\n OR NEW.by IS NOT OLD.by OR OLD.undone_at IS NOT NULL\n BEGIN SELECT RAISE(ABORT,'a merge is undone once, never rewritten'); END;\nCREATE TRIGGER IF NOT EXISTS decision_merge_no_delete BEFORE DELETE ON decision_merge BEGIN SELECT RAISE(ABORT,'merges are kept'); END;\n";
|
|
491
|
+
/** The tables V149_SCHEMA adds, in the order a test drops them to make the v148 shape (dependents first). */
|
|
492
|
+
export declare const V149_TABLES: readonly ["check_result", "check_fact", "local_observation", "ci_flaky_observation", "ci_pairing_rejected", "learning_watch", "lesson_note_tally", "lesson_note", "memory_use", "memory_tidy_proposal", "decision_merge"];
|
|
493
|
+
/** The v149 tables a whole-project deletion clears by repo (the rest hang off these or a deleted run or decision). */
|
|
494
|
+
export declare const V149_REPO_TABLES: readonly ["check_fact", "local_observation", "ci_flaky_observation", "learning_watch", "lesson_note", "memory_use", "memory_tidy_proposal"];
|
|
495
|
+
/** The v149 migration's steps after v148's, in order (OpenOptions.migrationStep may stop after each). */
|
|
496
|
+
export declare const V149_STEPS: readonly ["learning-tables"];
|
|
461
497
|
/**
|
|
462
498
|
* v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
|
|
463
499
|
* release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
|
|
@@ -6873,17 +6909,14 @@ export declare class Store {
|
|
|
6873
6909
|
* what kind of answer it is (needs-you.ts reads it): `alive` while
|
|
6874
6910
|
* something of the run may still be running, `elsewhere` when only
|
|
6875
6911
|
* another computer can tell, `unprovable` when nothing is known to run
|
|
6876
|
-
* but its exit was never recorded
|
|
6877
|
-
*
|
|
6878
|
-
*
|
|
6879
|
-
*
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
} | null;
|
|
6885
|
-
/** `run settle` recorded an approver's word that a run whose workspace
|
|
6886
|
-
* note couldn't be read has stopped. */
|
|
6912
|
+
* but its exit was never recorded (Toolroll's own look, stop-proof.ts,
|
|
6913
|
+
* settles it), `unknown` when this computer can't check, or that look
|
|
6914
|
+
* was blocked. `settling` already meets the automatic reconciliation
|
|
6915
|
+
* proof. Nobody is ever asked to confirm a build stopped. `cause` names
|
|
6916
|
+
* what stop-proof.ts can prove. */
|
|
6917
|
+
stopQuiescenceFact(runId: number): StopFact | null;
|
|
6918
|
+
/** A run whose workspace note couldn't be read was proven free: by Toolroll's own look (stop-proof.ts), or, in
|
|
6919
|
+
* ledgers from before it, by an approver's recorded word (still read, never written now). */
|
|
6887
6920
|
private workspaceConfirmedByApprover;
|
|
6888
6921
|
reserveRunProcess(runId: number, now: Date, group?: boolean): number;
|
|
6889
6922
|
/** The run's process witnesses not yet proved exited — after first saving whatever exit evidence is available. */
|
|
@@ -6915,29 +6948,12 @@ export declare class Store {
|
|
|
6915
6948
|
* here, every other witness of the run has exited and none of the run's
|
|
6916
6949
|
* process groups is alive, it settles as never started, with a ledger
|
|
6917
6950
|
* entry. A sole pid-less witness has no group to prove gone and stays for
|
|
6918
|
-
*
|
|
6951
|
+
* Toolroll's own look (stop-proof.ts). Native witnesses keep their OS-only road. */
|
|
6919
6952
|
settleUnspawnedWitnesses(now: Date, onlyRun?: number): number;
|
|
6920
6953
|
/** The existing automatic settlement proof, shared with its read-only
|
|
6921
6954
|
* status. Unknown-only, foreign, native and still-running custody keep
|
|
6922
6955
|
* their fences; only an observed single-process exit avoids a new probe. */
|
|
6923
6956
|
private settleableUnspawnedWitnesses;
|
|
6924
|
-
/** `run settle` (an approver's last resort): every witness of a finished
|
|
6925
|
-
* run that cannot be proven either way is recorded settled under the
|
|
6926
|
-
* approver's reason, in the ledger. Refuses while anything of the run is
|
|
6927
|
-
* alive or could be checked only from another host. Never signals. */
|
|
6928
|
-
settleRunWitnessesByApprover(args: {
|
|
6929
|
-
runId: number;
|
|
6930
|
-
by: string;
|
|
6931
|
-
why: string;
|
|
6932
|
-
}, now: Date): {
|
|
6933
|
-
ok: true;
|
|
6934
|
-
witnesses: number[];
|
|
6935
|
-
repeated: boolean;
|
|
6936
|
-
} | {
|
|
6937
|
-
ok: false;
|
|
6938
|
-
reason: "unknown-run" | "still-running" | "alive";
|
|
6939
|
-
detail: string;
|
|
6940
|
-
};
|
|
6941
6957
|
recordRunProcess(runId: number, pid: number, now: Date, group?: boolean, witness?: number): void;
|
|
6942
6958
|
/** The native OS object a witnessed spawn ran inside (v53) — recorded
|
|
6943
6959
|
* with the spawn, before the target can execute. */
|
|
@@ -8400,3 +8416,26 @@ export declare function recordLaptopPort(store: Store, port: number, now: Date):
|
|
|
8400
8416
|
* results-routing rows go (route-store.ts forgetProjectRoutes). One of the routing scan's named phase_* writers
|
|
8401
8417
|
* (route-hooks.ts). How many rows went. */
|
|
8402
8418
|
export declare function forgetProjectModels(store: Store, repo: string, now: Date): number;
|
|
8419
|
+
/** The ledger words of Toolroll's own stop proof. Nobody is asked to vouch that a build stopped: Toolroll looks at
|
|
8420
|
+
* this computer's processes itself, and a person's older recorded word (`run settle` before this) is still read. */
|
|
8421
|
+
export declare const STOP_PROOF_SETTLED = "run stop proven automatically";
|
|
8422
|
+
export declare const STOP_PROOF_BLOCKED = "automatic stop check blocked";
|
|
8423
|
+
export declare const STOP_PROOF_WORKSPACE_FREE = "workspace proven free automatically";
|
|
8424
|
+
/** Why Toolroll's own stop check is blocked for a run, in words that follow "run #N " (the stop fact's problem), or
|
|
8425
|
+
* null when its newest word is not a block (never checked, or proven since). */
|
|
8426
|
+
export declare function stopProofVerdictOf(store: Store, runId: number): string | null;
|
|
8427
|
+
/** Record that Toolroll's own stop check could not prove a run stopped (`words` follow "run #N "; `detail` is the
|
|
8428
|
+
* evidence). The same block twice in a row is recorded once. True when recorded. */
|
|
8429
|
+
export declare function recordStopProofBlocked(store: Store, runId: number, words: string, detail: string, now: Date): boolean;
|
|
8430
|
+
/**
|
|
8431
|
+
* Settle a finished run's unproven stop on Toolroll's own observation of this computer (stop-proof.ts): the caller
|
|
8432
|
+
* looked at every process here and found none of the run's. Applies only while the run's stop is still unproven for
|
|
8433
|
+
* exactly `cause` (re-read in this transaction): a pid-less spawn witness is recorded ended, a run with no witness gets
|
|
8434
|
+
* one ended witness, an unreadable workspace note is recorded proven free. The evidence goes in the ledger. Never
|
|
8435
|
+
* signals. True when this call settled it.
|
|
8436
|
+
*/
|
|
8437
|
+
export declare function settleRunStopByObservation(store: Store, args: {
|
|
8438
|
+
runId: number;
|
|
8439
|
+
cause: StopProofCause;
|
|
8440
|
+
evidence: string;
|
|
8441
|
+
}, now: Date): boolean;
|