toolroll 0.9.92 → 0.9.94
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 +87 -0
- package/dist/activity-line.d.ts +7 -4
- package/dist/activity-line.js +9 -7
- package/dist/agent-fence.d.ts +4 -0
- package/dist/agent-fence.js +4 -1
- package/dist/approval-authority.d.ts +7 -1
- package/dist/approval-authority.js +11 -2
- package/dist/approval-password.d.ts +31 -0
- package/dist/approval-password.js +26 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +3 -3
- package/dist/assignment.js +16 -3
- package/dist/boot-identity.d.ts +2 -1
- package/dist/boot-identity.js +6 -4
- package/dist/bounded-spawn.d.ts +57 -0
- package/dist/bounded-spawn.js +220 -0
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +26 -26
- package/dist/browser-crew.js +2 -2
- package/dist/browser-shell.d.ts +1 -1
- package/dist/browser-shell.js +2 -2
- package/dist/browser-workspace.d.ts +48 -7
- package/dist/browser-workspace.js +3 -3
- package/dist/builder-restart.d.ts +21 -0
- package/dist/builder-restart.js +50 -0
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +73 -25
- package/dist/chat-acceptance.js +1 -1
- package/dist/chat-actions.d.ts +12 -2
- package/dist/chat-actions.js +25 -2
- package/dist/chat-channel.js +11 -0
- package/dist/chat-decide.d.ts +7 -2
- package/dist/chat-decide.js +37 -9
- package/dist/chat-quiet.js +3 -3
- package/dist/chat-voice.d.ts +1 -1
- package/dist/chat-voice.js +2 -2
- package/dist/check-facts.d.ts +126 -0
- package/dist/check-facts.js +318 -0
- package/dist/check-failure.d.ts +22 -3
- package/dist/check-failure.js +57 -13
- package/dist/check-levels-ui.d.ts +4 -0
- package/dist/check-levels-ui.js +10 -1
- package/dist/check-load.d.ts +85 -0
- package/dist/check-load.js +147 -0
- package/dist/check-only-standing.d.ts +36 -0
- package/dist/check-only-standing.js +39 -0
- package/dist/check-only.d.ts +15 -0
- package/dist/check-only.js +99 -0
- package/dist/check-report.d.ts +7 -0
- package/dist/check-report.js +4 -3
- package/dist/check-stamp.d.ts +46 -0
- package/dist/check-stamp.js +93 -0
- package/dist/checkout-cleanup.d.ts +36 -5
- package/dist/checkout-cleanup.js +166 -14
- package/dist/cli-help.d.ts +4 -1
- package/dist/cli-help.js +47 -30
- package/dist/cli.js +16 -1
- package/dist/code-extract.d.ts +44 -0
- package/dist/code-extract.js +88 -0
- package/dist/code-map-acts.d.ts +24 -0
- package/dist/code-map-acts.js +53 -0
- package/dist/code-map-admission.d.ts +52 -0
- package/dist/code-map-admission.js +295 -0
- package/dist/code-map-build.d.ts +63 -0
- package/dist/code-map-build.js +415 -0
- package/dist/code-map-cli.d.ts +153 -0
- package/dist/code-map-cli.js +103 -0
- package/dist/code-map-main.d.ts +1 -0
- package/dist/code-map-main.js +48 -0
- package/dist/code-map-read.d.ts +106 -0
- package/dist/code-map-read.js +171 -0
- package/dist/code-map-report.d.ts +37 -0
- package/dist/code-map-report.js +95 -0
- package/dist/code-map-shape.d.ts +172 -0
- package/dist/code-map-shape.js +327 -0
- package/dist/code-map-store.d.ts +136 -0
- package/dist/code-map-store.js +233 -0
- package/dist/coding-provider.d.ts +2 -0
- package/dist/coding-provider.js +3 -2
- package/dist/coding-update.js +3 -2
- package/dist/coding-workspace.js +4 -4
- package/dist/console-chrome.js +2 -2
- package/dist/containment.d.ts +5 -0
- package/dist/containment.js +14 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +52 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +12 -5
- package/dist/contracts/console-api.js +12 -5
- package/dist/contracts/gateway-tools.d.ts +17 -0
- package/dist/contracts/gateway-tools.js +3 -1
- package/dist/contracts/lead-tools.d.ts +61 -0
- package/dist/contracts/lead-tools.js +10 -1
- package/dist/contracts/project-knowledge.d.ts +38 -5
- package/dist/contracts/project-knowledge.js +11 -5
- package/dist/contracts/project-memory.d.ts +3 -1
- package/dist/contracts/project-memory.js +3 -2
- package/dist/contracts/release-check.d.ts +25 -1
- package/dist/contracts/release-check.js +19 -0
- package/dist/contracts/storage.d.ts +14 -0
- package/dist/contracts/storage.js +3 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/youiyoux-request.d.ts +1 -1
- package/dist/contracts/youiyoux-request.js +1 -1
- package/dist/desktop-access.js +6 -2
- package/dist/desktop-update.d.ts +2 -2
- package/dist/desktop-update.js +13 -9
- package/dist/dispatch.js +18 -15
- package/dist/dispose.js +8 -2
- package/dist/easy-start.js +2 -3
- package/dist/eval-hooks.d.ts +55 -0
- package/dist/eval-hooks.js +227 -4
- package/dist/evals.js +2 -1
- package/dist/fast-start.d.ts +136 -0
- package/dist/fast-start.js +431 -0
- package/dist/fast-terms.d.ts +9 -0
- package/dist/fast-terms.js +23 -0
- package/dist/fenced-check-coverage.d.ts +62 -0
- package/dist/fenced-check-coverage.js +54 -0
- package/dist/first-run.js +1 -1
- package/dist/flaky-ci.d.ts +31 -0
- package/dist/flaky-ci.js +149 -0
- package/dist/flaky-cli.d.ts +60 -0
- package/dist/flaky-cli.js +60 -0
- package/dist/flaky.d.ts +83 -0
- package/dist/flaky.js +264 -0
- package/dist/flow-card-task.js +3 -0
- package/dist/flow-triggers.d.ts +1 -1
- package/dist/flow-triggers.js +1 -1
- package/dist/github-import.d.ts +62 -0
- package/dist/github-import.js +146 -0
- package/dist/guides.js +15 -10
- package/dist/invoke.d.ts +5 -1
- package/dist/invoke.js +49 -14
- package/dist/job-object-helper.ps1 +10 -3
- package/dist/keymap.d.ts +41 -0
- package/dist/keymap.js +51 -0
- package/dist/knowledge-cli.js +1 -1
- package/dist/knowledge-ui.d.ts +21 -4
- package/dist/knowledge-ui.js +75 -7
- package/dist/laptop-background.d.ts +16 -0
- package/dist/laptop-background.js +108 -2
- package/dist/lead-context.js +1 -1
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-progress.js +3 -0
- package/dist/lead-status.d.ts +2 -0
- package/dist/lead-status.js +6 -2
- package/dist/lead-tools.js +51 -2
- package/dist/lead.js +1 -1
- package/dist/lesson-notes.d.ts +81 -0
- package/dist/lesson-notes.js +336 -0
- package/dist/limits-ui.js +1 -1
- package/dist/local-owner.d.ts +1 -1
- package/dist/local-owner.js +11 -0
- package/dist/machine-check.d.ts +16 -0
- package/dist/machine-check.js +26 -0
- package/dist/machine-identity.d.ts +75 -0
- package/dist/machine-identity.js +170 -0
- package/dist/mcp-core.js +11 -1
- package/dist/memory-cli.d.ts +300 -1
- package/dist/memory-cli.js +62 -9
- package/dist/memory-pass.d.ts +16 -0
- package/dist/memory-pass.js +67 -3
- package/dist/memory-tidy.d.ts +181 -0
- package/dist/memory-tidy.js +523 -0
- package/dist/memory-use.d.ts +35 -0
- package/dist/memory-use.js +109 -0
- package/dist/merge-checklist.d.ts +55 -0
- package/dist/merge-checklist.js +182 -0
- package/dist/needs-you.d.ts +32 -13
- package/dist/needs-you.js +108 -49
- package/dist/nightly-github.d.ts +2 -0
- package/dist/nightly-github.js +4 -4
- package/dist/operate-remote-arguments.js +6 -1
- package/dist/operate-remote.js +3 -1
- package/dist/operate.d.ts +6 -1
- package/dist/operate.js +540 -70
- package/dist/phase-routing.js +2 -2
- package/dist/plain-schema.d.ts +23 -0
- package/dist/plain-schema.js +94 -0
- package/dist/plain-words.d.ts +20 -0
- package/dist/plain-words.js +66 -0
- package/dist/planner.js +2 -1
- package/dist/process-liveness.d.ts +2 -0
- package/dist/process-liveness.js +61 -17
- package/dist/project-delete-ui.d.ts +7 -2
- package/dist/project-delete-ui.js +12 -1
- package/dist/project-delete.js +13 -2
- package/dist/project-knowledge.d.ts +18 -11
- package/dist/project-knowledge.js +39 -9
- package/dist/project-learning.d.ts +2 -1
- package/dist/project-learning.js +15 -45
- package/dist/project-memory.d.ts +5 -0
- package/dist/project-memory.js +22 -2
- package/dist/proof.js +30 -5
- package/dist/provider-auth.js +1 -1
- package/dist/publish.js +9 -1
- package/dist/pull-request-flow.js +5 -0
- package/dist/push.d.ts +26 -3
- package/dist/push.js +55 -15
- package/dist/release-adapters.d.ts +9 -0
- package/dist/release-adapters.js +87 -1
- package/dist/release-approval.d.ts +191 -0
- package/dist/release-approval.js +382 -0
- package/dist/release-auto.js +26 -17
- package/dist/release-card.d.ts +74 -6
- package/dist/release-card.js +179 -23
- package/dist/release-carry.d.ts +7 -2
- package/dist/release-carry.js +39 -9
- package/dist/release-cli.d.ts +4 -0
- package/dist/release-cli.js +38 -23
- package/dist/release-coverage.js +1 -1
- package/dist/release-next.d.ts +48 -0
- package/dist/release-next.js +161 -0
- package/dist/release.d.ts +50 -1
- package/dist/release.js +118 -11
- package/dist/remote-destination.d.ts +36 -0
- package/dist/remote-destination.js +217 -0
- package/dist/remote-main.d.ts +48 -0
- package/dist/remote-main.js +84 -5
- package/dist/repository-context.d.ts +3 -2
- package/dist/repository-context.js +112 -70
- package/dist/restart-certification.d.ts +1 -0
- package/dist/restart-certification.js +8 -6
- package/dist/result-acts.d.ts +29 -21
- package/dist/result-acts.js +36 -25
- package/dist/result-follow-ups.d.ts +2 -2
- package/dist/result-follow-ups.js +18 -4
- package/dist/result-review.d.ts +1 -1
- package/dist/route-change-check.d.ts +4 -0
- package/dist/route-change-check.js +13 -0
- package/dist/route-change.d.ts +22 -0
- package/dist/route-change.js +89 -0
- package/dist/route-hooks.js +3 -2
- package/dist/route-results.d.ts +6 -1
- package/dist/route-results.js +14 -5
- package/dist/run-closeout.d.ts +1 -1
- package/dist/run-closeout.js +7 -5
- package/dist/runner-process.js +3 -2
- package/dist/runner.js +2 -1
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +22 -9
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +24 -18
- package/dist/server/chat.js +14 -12
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +26 -35
- package/dist/server/console.css +23 -0
- package/dist/server/guards.js +4 -0
- package/dist/server/home.d.ts +6 -0
- package/dist/server/home.js +21 -13
- package/dist/server/http.js +1 -1
- package/dist/server/kit.js +1 -1
- package/dist/server/merge-ready-view.d.ts +10 -0
- package/dist/server/merge-ready-view.js +30 -0
- package/dist/server/pages-assets.d.ts +1 -0
- package/dist/server/pages-assets.js +8 -5
- package/dist/server/pages.js +103 -82
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-code-map.d.ts +30 -0
- package/dist/server/render-code-map.js +137 -0
- package/dist/server/render-flaky.d.ts +20 -0
- package/dist/server/render-flaky.js +59 -0
- package/dist/server/render-history.js +1 -1
- package/dist/server/render-new-task.d.ts +26 -1
- package/dist/server/render-new-task.js +30 -5
- package/dist/server/render-pages.d.ts +1 -175
- package/dist/server/render-pages.js +38 -313
- package/dist/server/render-projects.d.ts +2 -1
- package/dist/server/render-projects.js +9 -7
- package/dist/server/render-release.d.ts +19 -0
- package/dist/server/render-release.js +84 -0
- package/dist/server/render-results.d.ts +2 -2
- package/dist/server/render-results.js +71 -73
- package/dist/server/render-tasks.d.ts +13 -2
- package/dist/server/render-tasks.js +156 -139
- package/dist/server/retired-routes.d.ts +17 -0
- package/dist/server/retired-routes.js +53 -0
- package/dist/server/route-table.d.ts +4 -1
- package/dist/server/route-table.js +52 -13
- package/dist/server/settings.js +129 -22
- package/dist/server/task-views.js +61 -26
- package/dist/server/tasks.d.ts +17 -6
- package/dist/server/tasks.js +273 -338
- package/dist/server/welcome.js +1 -1
- package/dist/settings-groups-ui.js +1 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.d.ts +67 -0
- package/dist/stop-proof.js +295 -0
- package/dist/storage-sweep-process.js +1 -1
- package/dist/storage-sweep.d.ts +5 -3
- package/dist/storage-sweep.js +30 -9
- package/dist/storage-ui.js +28 -17
- package/dist/storage.d.ts +27 -2
- package/dist/storage.js +39 -2
- package/dist/store.d.ts +148 -34
- package/dist/store.js +626 -177
- package/dist/surface.js +46 -5
- package/dist/sweep-quarantine.d.ts +1 -0
- package/dist/sweep-quarantine.js +6 -4
- package/dist/tamper-scan.d.ts +4 -0
- package/dist/tamper-scan.js +90 -0
- package/dist/task-aliases.d.ts +5 -0
- package/dist/task-aliases.js +9 -0
- package/dist/task-control.js +4 -1
- package/dist/task-outcome-cli.d.ts +2 -0
- package/dist/task-outcome-cli.js +36 -17
- package/dist/task-sizing.d.ts +5 -0
- package/dist/task-sizing.js +2 -0
- package/dist/task-source.d.ts +7 -0
- package/dist/task-source.js +14 -0
- package/dist/task-status.d.ts +9 -0
- package/dist/task-status.js +40 -49
- package/dist/teardown-git.d.ts +16 -0
- package/dist/teardown-git.js +63 -5
- package/dist/telegram-progress.js +5 -3
- package/dist/telegram-status.js +6 -4
- package/dist/test-infra.d.ts +8 -0
- package/dist/test-infra.js +18 -0
- package/dist/tidy.js +1 -1
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +18 -5
- package/dist/tui-contract.d.ts +24 -0
- package/dist/tui-source.js +67 -8
- package/dist/tui.js +51 -51
- package/dist/vanished-retry.d.ts +30 -0
- package/dist/vanished-retry.js +174 -0
- package/dist/work-index.d.ts +2 -0
- package/dist/work-index.js +82 -70
- package/dist/work-summary.d.ts +2 -2
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +28 -22
- package/dist/worktree.d.ts +12 -3
- package/dist/worktree.js +68 -13
- package/package.json +1 -1
- package/dist/server/render-next.d.ts +0 -63
- package/dist/server/render-next.js +0 -109
package/dist/server/welcome.js
CHANGED
|
@@ -87,7 +87,7 @@ ${listed.length === 0 ? "" : html `<div class="welcome-rows">${listed}</div>`}${
|
|
|
87
87
|
const taskLeads = lead === "task";
|
|
88
88
|
if (taskLeads)
|
|
89
89
|
primary = btn("Write a task", { variant: "primary", href: "/tasks/new" });
|
|
90
|
-
const taskBody = model.task.done ? html `<p class="k-note">Filed. Its plan waits for your approval in <a class="k-link" href="/
|
|
90
|
+
const taskBody = model.task.done ? html `<p class="k-note">Filed. Its plan waits for your approval in <a class="k-link" href="/tasks">Tasks</a>.</p>`
|
|
91
91
|
: !model.task.open ? html `<p class="k-note">Add a project first.</p>`
|
|
92
92
|
: html `<p class="k-note">Describe a change in a sentence or two. Nothing starts until you approve its plan.</p>\
|
|
93
93
|
${model.task.starters.length === 0 ? "" : html `<div class="k-ideas" aria-label="Ideas to start from" data-starters>${model.task.starters.map(one => html `<a class="k-btn k-btn-outline k-idea" href="${one.href}">${one.label}</a>`)}</div>`}\
|
|
@@ -465,7 +465,7 @@ export function systemGroup(facts, flags) {
|
|
|
465
465
|
const links = facts.operator ? settingsLinks("In this group", [
|
|
466
466
|
settingsLink({ href: "/settings/updates", title: "Updates", status: facts.updates === null ? "Toolroll’s own version" : `Toolroll ${facts.updates.current}` }),
|
|
467
467
|
settingsLink({ href: "/settings/backups", title: "Backups", status: facts.backup?.words ?? "When and where the database is copied", needs: facts.backup?.failed ? 1 : 0 }),
|
|
468
|
-
settingsLink({ href: "/settings/storage", title: "Storage", status: "
|
|
468
|
+
settingsLink({ href: "/settings/storage", title: "Storage", status: "Task folders on this computer, and cleaning them up" }),
|
|
469
469
|
settingsLink({ href: "/settings/retention", title: "Retention", status: "How long run logs and chats are kept" }),
|
|
470
470
|
settingsLink({ href: "/settings/monitoring", title: "Monitoring", status: "Send the audit stream and traces to your own systems" }),
|
|
471
471
|
settingsLink({ href: "/settings/data", title: "Data export", status: "Everything except secrets, as one download" }),
|
package/dist/setup-guide.d.ts
CHANGED
|
@@ -15,12 +15,12 @@ export declare function detectPreparation(repo: string): {
|
|
|
15
15
|
} | null;
|
|
16
16
|
/** Browser installation may write only this known file inside its project. */
|
|
17
17
|
export declare function previewProjectInstructions(repo: string): {
|
|
18
|
-
content?: never;
|
|
19
18
|
ok: false;
|
|
20
19
|
message: string;
|
|
21
20
|
plan?: never;
|
|
22
21
|
fingerprint?: never;
|
|
23
22
|
installed?: never;
|
|
23
|
+
content?: never;
|
|
24
24
|
} | {
|
|
25
25
|
message?: never;
|
|
26
26
|
ok: true;
|
|
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
|
|
|
30
30
|
installed: boolean;
|
|
31
31
|
};
|
|
32
32
|
export declare function addProjectInstructions(repo: string, fingerprint: string): {
|
|
33
|
-
content?: never;
|
|
34
33
|
ok: false;
|
|
35
34
|
message: string;
|
|
36
35
|
plan?: never;
|
|
37
36
|
fingerprint?: never;
|
|
38
37
|
installed?: never;
|
|
38
|
+
content?: never;
|
|
39
39
|
} | import("./skills.js").InstallResult;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type StopOptions } from "./run-closeout.js";
|
|
2
|
+
import { type Store } from "./store.js";
|
|
3
|
+
/** How long after a run finished its processes have to go on their own before Toolroll looks. */
|
|
4
|
+
export declare const STOP_PROOF_GRACE_MS = 60000;
|
|
5
|
+
export type StopProofOutcome =
|
|
6
|
+
/** Proven stopped and settled, in the ledger with its evidence. */
|
|
7
|
+
"settled"
|
|
8
|
+
/** Not yet: inside the grace period, a live child is still tracked here, or a leftover was just stopped. */
|
|
9
|
+
| "waiting"
|
|
10
|
+
/** This computer can't prove it (can't look, another computer's process, or something it didn't start is in the
|
|
11
|
+
* folder). Recorded once, in plain words. */
|
|
12
|
+
| "blocked"
|
|
13
|
+
/** Nothing for this proof to do: already stopped, still open, alive on its own record, or a native sandbox. */
|
|
14
|
+
| "skipped";
|
|
15
|
+
export type StopProofResult = {
|
|
16
|
+
run: number;
|
|
17
|
+
outcome: StopProofOutcome;
|
|
18
|
+
words: string;
|
|
19
|
+
stopped?: number;
|
|
20
|
+
};
|
|
21
|
+
export type StopProofOptions = StopOptions & {
|
|
22
|
+
/** How long after the run finished before Toolroll looks (STOP_PROOF_GRACE_MS). `graceMs` is the SIGTERM grace. */
|
|
23
|
+
afterFinishMs?: number;
|
|
24
|
+
/** Which processes have their working directory inside each path; must throw when it can't tell. */
|
|
25
|
+
cwds?: (paths: readonly string[]) => Map<string, number[]>;
|
|
26
|
+
/** Every process's command line, by pid; must throw when it can't tell. */
|
|
27
|
+
commandLines?: () => Map<number, string>;
|
|
28
|
+
/** The live service's own pid (its ancestors are never targets). */
|
|
29
|
+
self?: number;
|
|
30
|
+
/** The worktree note check (worktree.ts); throws when the note can't be read. */
|
|
31
|
+
occupancy?: (path: string) => {
|
|
32
|
+
held: true;
|
|
33
|
+
by: number;
|
|
34
|
+
} | {
|
|
35
|
+
held: false;
|
|
36
|
+
};
|
|
37
|
+
/** Called after processes were stopped: a cached look must be read again. */
|
|
38
|
+
refresh?: () => void;
|
|
39
|
+
};
|
|
40
|
+
/** One pass's shared look at this computer: the process table, folders and command lines are read once for every
|
|
41
|
+
* run the pass checks, and again after anything was stopped. */
|
|
42
|
+
export declare function sharedLook(options?: StopProofOptions): StopProofOptions;
|
|
43
|
+
/** Which processes work inside each path, from the OS: /proc on Linux, lsof on macOS. Unlike a best-effort listing,
|
|
44
|
+
* it throws when the listing can't be read: no answer is never "nobody". */
|
|
45
|
+
export declare function processesInStrict(paths: readonly string[], listing?: readonly {
|
|
46
|
+
pid: number;
|
|
47
|
+
cwd: string;
|
|
48
|
+
}[]): Map<string, number[]>;
|
|
49
|
+
/** Every process's working directory, from the OS; throws when the listing can't be read. */
|
|
50
|
+
export declare function processFolders(): {
|
|
51
|
+
pid: number;
|
|
52
|
+
cwd: string;
|
|
53
|
+
}[];
|
|
54
|
+
/** Every process's command line, by pid. Read to match a folder's path in memory only; never stored or shown. */
|
|
55
|
+
export declare function processCommandLines(): Map<number, string>;
|
|
56
|
+
/** Whether a command line names `path` or something inside it (a whole path, never a longer folder's prefix). */
|
|
57
|
+
export declare function namesPath(line: string, path: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Prove one finished run stopped, on this computer, without asking anyone (see the file's comment). Walks the run's
|
|
60
|
+
* owned runs one unproven stop at a time. Never signals a process that isn't proven the run's.
|
|
61
|
+
*/
|
|
62
|
+
export declare function proveRunStopped(store: Store, runId: number, clock: () => Date, options?: StopProofOptions): Promise<StopProofResult>;
|
|
63
|
+
/** The worker's pass (operate.ts reconcile): every finished run of this computer whose stop is unproven where
|
|
64
|
+
* Toolroll's own look can prove it. A batch a pass, oldest first; a blocked run never holds newer ones back. */
|
|
65
|
+
export declare function proveFinishedRunsStopped(store: Store, clock: () => Date, options?: StopProofOptions & {
|
|
66
|
+
batch?: number;
|
|
67
|
+
}): Promise<StopProofResult[]>;
|
|
@@ -0,0 +1,295 @@
|
|
|
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 { localWitnessSql, 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 local = localWitnessSql(store.handle);
|
|
227
|
+
const foreign = store.handle.prepare(`SELECT host FROM run_process WHERE run = ? AND exited_at IS NULL AND NOT ${local.sql} LIMIT 1`).get(subject, ...local.params);
|
|
228
|
+
if (foreign !== undefined)
|
|
229
|
+
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);
|
|
230
|
+
if (run.worktree !== null) {
|
|
231
|
+
try {
|
|
232
|
+
const held = (options.occupancy ?? worktreeProcessOccupancy)(run.worktree);
|
|
233
|
+
if (held.held)
|
|
234
|
+
return { run: subject, outcome: "waiting", words: `its folder is still held by process ${held.by}` };
|
|
235
|
+
}
|
|
236
|
+
catch { /* an unreadable note: the look below decides */ }
|
|
237
|
+
}
|
|
238
|
+
let seen;
|
|
239
|
+
try {
|
|
240
|
+
seen = look(store, subject, options);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
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);
|
|
244
|
+
}
|
|
245
|
+
if (seen.targets.length > 0) {
|
|
246
|
+
const done = await stopProcesses(seen.targets, options);
|
|
247
|
+
options.refresh?.();
|
|
248
|
+
stopped += done.stopped.length;
|
|
249
|
+
const ref = store.refById(run.taskRef);
|
|
250
|
+
store.recordAction({ at: clock().toISOString(), actor: "system", repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: subject,
|
|
251
|
+
action: "run processes stopped", outcome: "stopped", source: "work", detail: `run #${subject} ended (${run.outcome ?? "?"}); its stop check found and stopped ${stoppedWords(done, options.graceMs)}` });
|
|
252
|
+
try {
|
|
253
|
+
seen = look(store, subject, options);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return { run: subject, outcome: "waiting", words: "stopped what it left running; checking again", stopped };
|
|
257
|
+
}
|
|
258
|
+
if (seen.targets.length > 0)
|
|
259
|
+
return { run: subject, outcome: "waiting", words: "stopped what it left running; checking again", stopped };
|
|
260
|
+
}
|
|
261
|
+
if (seen.blockers.length > 0) {
|
|
262
|
+
const one = seen.blockers[0];
|
|
263
|
+
const others = seen.blockers.length - 1;
|
|
264
|
+
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);
|
|
265
|
+
}
|
|
266
|
+
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` : ""}`;
|
|
267
|
+
if (!settleRunStopByObservation(store, { runId: subject, cause, evidence }, clock()))
|
|
268
|
+
return { run: subject, outcome: "waiting", words: "its record changed while checking" };
|
|
269
|
+
}
|
|
270
|
+
return { run: runId, outcome: "waiting", words: "more to check" };
|
|
271
|
+
}
|
|
272
|
+
function block(store, runId, words, detail, now) {
|
|
273
|
+
recordStopProofBlocked(store, runId, words, detail, now);
|
|
274
|
+
return { run: runId, outcome: "blocked", words };
|
|
275
|
+
}
|
|
276
|
+
/** The worker's pass (operate.ts reconcile): every finished run of this computer whose stop is unproven where
|
|
277
|
+
* Toolroll's own look can prove it. A batch a pass, oldest first; a blocked run never holds newer ones back. */
|
|
278
|
+
export async function proveFinishedRunsStopped(store, clock, options = {}) {
|
|
279
|
+
const grace = options.afterFinishMs ?? STOP_PROOF_GRACE_MS;
|
|
280
|
+
const before = new Date(clock().getTime() - grace).toISOString();
|
|
281
|
+
const local = localWitnessSql(store.handle, "p");
|
|
282
|
+
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 (
|
|
283
|
+
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 ${local.sql})
|
|
284
|
+
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)))
|
|
285
|
+
ORDER BY r.id LIMIT ?`).all(before, ...local.params, options.batch ?? 50).map(row => Number(row["id"]));
|
|
286
|
+
const out = [];
|
|
287
|
+
const shared = sharedLook(options);
|
|
288
|
+
for (const runId of runs) {
|
|
289
|
+
try {
|
|
290
|
+
out.push(await proveRunStopped(store, runId, clock, shared));
|
|
291
|
+
}
|
|
292
|
+
catch { /* one run's failure never stops the pass; the next pass looks again */ }
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
@@ -15,7 +15,7 @@ export function sweepResponse(value, request) {
|
|
|
15
15
|
if (!object(value) || value.version !== 1 || !object(value.record))
|
|
16
16
|
return false;
|
|
17
17
|
const record = value.record;
|
|
18
|
-
const kinds = ["test temp", "orphans", ...(request.poolRoot === null ? [] : ["checkouts"]), "runtimes", "dependencies"];
|
|
18
|
+
const kinds = ["test temp", "orphans", ...(request.poolRoot === null ? [] : ["checkouts"]), "runtimes", "dependencies", "check history"];
|
|
19
19
|
if (!date(record.at) || record.source !== "automatic" || record.actor !== request.actor || !Array.isArray(record.parts) || record.parts.length !== kinds.length)
|
|
20
20
|
return false;
|
|
21
21
|
return record.parts.every((part, index) => object(part) && part.kind === kinds[index] &&
|
package/dist/storage-sweep.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { type Located } from "./orphan-sweep.js";
|
|
2
2
|
import { type ProcessRow } from "./run-closeout.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type Store } from "./store.js";
|
|
4
4
|
import { type WorktreePool } from "./worktree.js";
|
|
5
5
|
import { type SweepCompletion, type SweepExecutor } from "./storage-sweep-process.js";
|
|
6
6
|
export declare const SWEEP_CURSOR = "storage-sweep";
|
|
7
7
|
export declare const SWEEP_EVERY_MS = 86400000;
|
|
8
8
|
/** A sweep that failed somewhere, or left work for later, runs again this soon. */
|
|
9
9
|
export declare const RETRY_MS: number;
|
|
10
|
-
export type SweepKind = "test temp" | "orphans" | "checkouts" | "runtimes" | "dependencies";
|
|
10
|
+
export type SweepKind = "test temp" | "orphans" | "checkouts" | "runtimes" | "dependencies" | "check history";
|
|
11
11
|
export type SweepPart = {
|
|
12
12
|
kind: SweepKind;
|
|
13
13
|
count: number;
|
|
@@ -35,6 +35,8 @@ export type SweepDeps = {
|
|
|
35
35
|
inUse?: () => string[];
|
|
36
36
|
scripts?: (pids: readonly number[]) => string[];
|
|
37
37
|
graceMs?: number;
|
|
38
|
+
/** Which processes run from inside each path. */
|
|
39
|
+
cwds?: (paths: readonly string[]) => Map<string, number[]>;
|
|
38
40
|
};
|
|
39
41
|
export declare function storageSweepOff(env?: NodeJS.ProcessEnv): boolean;
|
|
40
42
|
export declare function sweepFailure(at: string, actor: string, withPool: boolean, reason: Exclude<SweepCompletion, {
|
|
@@ -69,7 +71,7 @@ export declare function lastSweep(store: Store, source?: SweepRecord["source"]):
|
|
|
69
71
|
/** When the next automatic sweep is due, or null when it is off. */
|
|
70
72
|
export declare function nextSweepAt(store: Store): string | null;
|
|
71
73
|
export declare function partWords(one: Pick<SweepPart, "kind" | "count">): string;
|
|
72
|
-
/** "Removed 120 test temp folders and 2
|
|
74
|
+
/** "Removed 120 test temp folders and 2 task folders, about 1.2 GB. Stopped 1 leftover process. 3 couldn't be removed." */
|
|
73
75
|
export declare function sweepWords(parts: readonly SweepPart[]): string;
|
|
74
76
|
/** The details a disclosure shows: each part's paths, problems and what's left for later, one line each. */
|
|
75
77
|
export declare function sweepDetails(parts: readonly SweepPart[]): string[];
|
package/dist/storage-sweep.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The storage sweep: the plane cleans up after itself without anyone running a command. About once a day, when nothing
|
|
3
3
|
* is running or waiting (storage-sweep-schedule.ts: one service takes it, the cursor moves in the same transaction),
|
|
4
4
|
* it removes test temp folders nothing touched for a day (test-temp.ts), stops orphans Toolroll left running (orphan-sweep.ts), removes finished checkouts the cleanup
|
|
5
|
-
* setting lets go (checkout-cleanup.ts), keeps the current, previous and newest staged runtimes (staged-runtimes.ts)
|
|
5
|
+
* setting lets go and idle ones past the idle limit (checkout-cleanup.ts), keeps the current, previous and newest staged runtimes (staged-runtimes.ts)
|
|
6
6
|
* and removes shared dependency installs no checkout has used for a week (shared-deps.ts).
|
|
7
7
|
*
|
|
8
8
|
* Nothing live work uses goes (`liveWork`): while a run is open, a lease current or a checkout in use, each removal
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* The everyday work stays where it happens: suites remove their own temp roots and processes, a run's processes stop
|
|
16
16
|
* when it ends and a finished checkout goes on the worker's next pass. This is the net for what slipped through.
|
|
17
17
|
*
|
|
18
|
+
* The last part is rows, not disk: test results and flaky-test observations past their 180 days (check history).
|
|
19
|
+
*
|
|
18
20
|
* What each sweep did is saved (the latest few) and in the ledger: `toolroll storage` and Settings → Storage show it.
|
|
19
21
|
* A clean-up by hand (`storage clean --yes`) is saved the same way. A part that failed, or had more than one pass
|
|
20
22
|
* takes, brings the next sweep forward to an hour later; it still waits for nothing to be running.
|
|
@@ -22,14 +24,18 @@
|
|
|
22
24
|
* it runs on).
|
|
23
25
|
*/
|
|
24
26
|
import { realpathSync } from "node:fs";
|
|
25
|
-
import { hostname } from "node:os";
|
|
26
27
|
import { dirname, join, resolve, sep } from "node:path";
|
|
28
|
+
import { pruneCheckResults } from "./check-facts.js";
|
|
27
29
|
import { cleanCheckouts } from "./checkout-cleanup.js";
|
|
30
|
+
import { pruneCiObservations } from "./flaky-ci.js";
|
|
31
|
+
import { pruneObservations } from "./flaky.js";
|
|
28
32
|
import { durationWords, locateProcesses, sweepOrphans, ownerRunning } from "./orphan-sweep.js";
|
|
29
33
|
import { processCensus } from "./run-closeout.js";
|
|
30
34
|
import { linkedKey, pruneUnusedShared, sharedDepsRoot, sharedUse, SHARED_UNUSED_MS } from "./shared-deps.js";
|
|
31
35
|
import { pruneStagedRuntimes, runtimesInUse, runtimeScriptPaths, stagePlan } from "./staged-runtimes.js";
|
|
32
36
|
import { bytesWords, diskBytes } from "./storage.js";
|
|
37
|
+
import { localWitnessSql } from "./store.js";
|
|
38
|
+
import { processesInStrict } from "./stop-proof.js";
|
|
33
39
|
import { removeStaleTestTemp } from "./test-temp.js";
|
|
34
40
|
import { worktreeProcessOccupancy } from "./worktree.js";
|
|
35
41
|
import { runSweepChild } from "./storage-sweep-process.js";
|
|
@@ -80,8 +86,9 @@ export function liveWork(store, depsRoot, now, where) {
|
|
|
80
86
|
}
|
|
81
87
|
if (tasks.size === 0 && paths.length === 0)
|
|
82
88
|
return { live: false, paths: [], blind: false };
|
|
89
|
+
const local = localWitnessSql(db, "p");
|
|
83
90
|
const witnessed = db.prepare(`SELECT p.pid AS pid FROM run_process p JOIN run r ON r.id = p.run
|
|
84
|
-
WHERE r.finished_at IS NULL AND p.exited_at IS NULL AND p.pid IS NOT NULL AND
|
|
91
|
+
WHERE r.finished_at IS NULL AND p.exited_at IS NULL AND p.pid IS NOT NULL AND ${local.sql}`).all(...local.params);
|
|
85
92
|
for (const row of witnessed)
|
|
86
93
|
pids.add(Number(row["pid"]));
|
|
87
94
|
const from = pids.size === 0 ? [] : where([...pids].sort((a, b) => a - b));
|
|
@@ -181,7 +188,7 @@ export async function sweepStorage(store, deps, now, execute = runSweepChild) {
|
|
|
181
188
|
return saveSweep(store, record);
|
|
182
189
|
}
|
|
183
190
|
export function sweepKinds(withPool) {
|
|
184
|
-
return ["test temp", "orphans", ...(withPool ? ["checkouts"] : []), "runtimes", "dependencies"];
|
|
191
|
+
return ["test temp", "orphans", ...(withPool ? ["checkouts"] : []), "runtimes", "dependencies", "check history"];
|
|
185
192
|
}
|
|
186
193
|
/** Child side (also a focused unit-test seam): every scan and removal, without saving the parent's result. */
|
|
187
194
|
export async function executeSweep(store, deps, now) {
|
|
@@ -221,8 +228,15 @@ export async function executeSweep(store, deps, now) {
|
|
|
221
228
|
if (deps.pool !== null) {
|
|
222
229
|
const pool = deps.pool;
|
|
223
230
|
await part("checkouts", async () => {
|
|
224
|
-
|
|
225
|
-
|
|
231
|
+
// Idle folders go here only: nothing records a person's shell or editor in one, so the OS is asked. Unreadable keeps it.
|
|
232
|
+
const occupied = (path) => { try {
|
|
233
|
+
return (deps.cwds ?? processesInStrict)([path]).size > 0;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return true;
|
|
237
|
+
} };
|
|
238
|
+
const done = await cleanCheckouts(store, pool, now, { manual: false, actor, spare, idle: { occupied } });
|
|
239
|
+
return { count: done.removed.length, bytes: done.freed, failed: done.kept.filter(one => one.why === "git refused").length, ...(done.more ? { more: true } : {}), items: capped(done.removed.map(one => one.path)) };
|
|
226
240
|
});
|
|
227
241
|
}
|
|
228
242
|
const stateDir = dirname(deps.databaseFile);
|
|
@@ -244,6 +258,12 @@ export async function executeSweep(store, deps, now) {
|
|
|
244
258
|
const done = await pruneUnusedShared(root, checkouts, now(), SHARED_UNUSED_MS, { spare });
|
|
245
259
|
return { count: done.removed.length, bytes: done.removed.reduce((sum, one) => sum + (sizes.get(one.dir) ?? 0), 0), failed: done.failed.length, items: capped(done.removed.map(one => one.dir)) };
|
|
246
260
|
});
|
|
261
|
+
// Learning rows past their retention (docs/plans/learning-loops.md): the only DELETE on them. A lesson note's tallies
|
|
262
|
+
// are written with each check and never pruned, so what they say doesn't change here.
|
|
263
|
+
await part("check history", async () => {
|
|
264
|
+
const count = store.transact(() => pruneCheckResults(store, now()) + pruneObservations(store, now()) + pruneCiObservations(store, now()));
|
|
265
|
+
return { count, bytes: 0, failed: 0, items: [] };
|
|
266
|
+
});
|
|
247
267
|
return { at: now().toISOString(), source: "automatic", actor, parts };
|
|
248
268
|
}
|
|
249
269
|
/** Save what a sweep or a clean-up by hand did (the latest few are kept), with one ledger entry saying it. */
|
|
@@ -285,19 +305,20 @@ export function nextSweepAt(store) {
|
|
|
285
305
|
const NOUNS = {
|
|
286
306
|
"test temp": ["test temp folder", "test temp folders"],
|
|
287
307
|
orphans: ["leftover process", "leftover processes"],
|
|
288
|
-
checkouts: ["
|
|
308
|
+
checkouts: ["task folder", "task folders"],
|
|
289
309
|
runtimes: ["staged runtime", "staged runtimes"],
|
|
290
310
|
dependencies: ["unused dependency install", "unused dependency installs"],
|
|
311
|
+
"check history": ["old test result", "old test results"],
|
|
291
312
|
};
|
|
292
313
|
/** What a part looks at, for "Couldn't check …". */
|
|
293
|
-
const CHECKED = { "test temp": "test temp folders", orphans: "processes", checkouts: "
|
|
314
|
+
const CHECKED = { "test temp": "test temp folders", orphans: "processes", checkouts: "task folders", runtimes: "staged runtimes", dependencies: "dependency installs", "check history": "old test results" };
|
|
294
315
|
export function partWords(one) {
|
|
295
316
|
return `${one.count.toLocaleString("en-US")} ${NOUNS[one.kind][one.count === 1 ? 0 : 1]}`;
|
|
296
317
|
}
|
|
297
318
|
function list(items) {
|
|
298
319
|
return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
299
320
|
}
|
|
300
|
-
/** "Removed 120 test temp folders and 2
|
|
321
|
+
/** "Removed 120 test temp folders and 2 task folders, about 1.2 GB. Stopped 1 leftover process. 3 couldn't be removed." */
|
|
301
322
|
export function sweepWords(parts) {
|
|
302
323
|
const removed = parts.filter(one => one.kind !== "orphans" && one.count > 0);
|
|
303
324
|
const stopped = parts.find(one => one.kind === "orphans" && one.count > 0);
|