toolroll 0.9.8 → 0.9.10
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 +30 -0
- package/dist/assignment-brief.d.ts +2 -0
- package/dist/assignment-brief.js +2 -1
- package/dist/assignment-presentation.js +3 -1
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +5 -0
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +42 -8
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-crew.js +4 -0
- package/dist/browser-workspace.d.ts +40 -0
- package/dist/builder.d.ts +2 -0
- package/dist/builder.js +114 -8
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +45 -8
- package/dist/chat-quiet.d.ts +10 -4
- package/dist/chat-quiet.js +87 -22
- package/dist/chat-voice.d.ts +68 -0
- package/dist/chat-voice.js +175 -0
- package/dist/check-progress.js +4 -3
- package/dist/checkout-cleanup.d.ts +40 -2
- package/dist/checkout-cleanup.js +129 -9
- package/dist/desktop-update.d.ts +52 -0
- package/dist/desktop-update.js +147 -5
- package/dist/discord-chat.js +1 -0
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +8 -0
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +7 -5
- package/dist/lead-context.d.ts +1 -1
- package/dist/lead-context.js +4 -2
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +21 -24
- package/dist/needs-you.d.ts +55 -0
- package/dist/needs-you.js +83 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +122 -15
- package/dist/plane-review.js +4 -0
- package/dist/process-recovery-eligibility.d.ts +1 -0
- package/dist/project-delete.js +1 -0
- package/dist/project-learning.d.ts +3 -3
- package/dist/project-learning.js +28 -17
- package/dist/proof.d.ts +3 -1
- package/dist/proof.js +4 -2
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/result-completion.d.ts +4 -0
- package/dist/result-completion.js +11 -0
- package/dist/review-context.d.ts +3 -0
- package/dist/review-context.js +45 -5
- package/dist/run-closeout.d.ts +89 -0
- package/dist/run-closeout.js +302 -0
- package/dist/serve.d.ts +15 -1
- package/dist/serve.js +129 -14
- package/dist/setup-guide.d.ts +2 -2
- package/dist/shared-deps.d.ts +94 -0
- package/dist/shared-deps.js +365 -0
- package/dist/slack-chat.js +2 -0
- package/dist/storage.js +2 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.js +207 -30
- package/dist/task-status.d.ts +18 -4
- package/dist/task-status.js +35 -18
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +13 -5
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +6 -5
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +93 -43
- package/dist/test-temp.d.ts +19 -0
- package/dist/test-temp.js +98 -0
- package/dist/toolroll-update-ui.js +7 -2
- package/dist/toolroll-update.d.ts +21 -1
- package/dist/toolroll-update.js +135 -5
- package/dist/work-index.d.ts +11 -0
- package/dist/work-index.js +103 -16
- package/dist/work-summary.d.ts +3 -1
- package/dist/workspace-ui.js +1 -1
- package/dist/worktree.d.ts +16 -0
- package/dist/worktree.js +47 -0
- package/package.json +1 -1
package/dist/review-context.js
CHANGED
|
@@ -43,7 +43,7 @@ import { isVerificationReceipt } from "./verification-evidence.js";
|
|
|
43
43
|
*/
|
|
44
44
|
import { createHash } from "node:crypto";
|
|
45
45
|
import { hasForbiddenControls } from "./decision.js";
|
|
46
|
-
import { EVIDENCE_CAPS, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
46
|
+
import { EVIDENCE_CAPS, isBinaryAssetPath, isImagePath, readVerifiedArtifact, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
|
|
47
47
|
import { parseProof } from "./proof.js";
|
|
48
48
|
/** Explicit bounds. Content bytes are UTF-8 of the stored (possibly
|
|
49
49
|
* redacted) text; the aggregate is the sum over every stored item. */
|
|
@@ -420,7 +420,8 @@ export function parseReviewContext(raw) {
|
|
|
420
420
|
const c = one;
|
|
421
421
|
if (!str(c["id"], 64) || coverageIds.has(c["id"]) || (c["state"] !== "patch" && c["state"] !== "context" && c["state"] !== "gap") ||
|
|
422
422
|
typeof c["inherited"] !== "boolean" || !strList(c["paths"], 200) || !strList(c["items"], r["schema"] === 3 ? contentLimits.items : 64) || !strList(c["gaps"], r["schema"] === 3 ? 400 : 64) ||
|
|
423
|
-
(c["priorSupport"] !== "eligible" && c["priorSupport"] !== "invalid" && c["priorSupport"] !== "none")
|
|
423
|
+
(c["priorSupport"] !== "eligible" && c["priorSupport"] !== "invalid" && c["priorSupport"] !== "none") ||
|
|
424
|
+
(c["assets"] !== undefined && !str(c["assets"], 300))) {
|
|
424
425
|
return { ok: false, problem: `coverage ${index}: malformed` };
|
|
425
426
|
}
|
|
426
427
|
if (c["items"].some(id => !ids.has(id) || !items.find(item => item.id === id)?.criteria.includes(String(c["id"]))))
|
|
@@ -431,6 +432,7 @@ export function parseReviewContext(raw) {
|
|
|
431
432
|
coverage.push({
|
|
432
433
|
id: c["id"], state: c["state"], inherited: c["inherited"], paths: [...c["paths"]],
|
|
433
434
|
items: [...c["items"]], gaps: [...c["gaps"]], priorSupport: c["priorSupport"],
|
|
435
|
+
...(c["assets"] === undefined ? {} : { assets: c["assets"] }),
|
|
434
436
|
});
|
|
435
437
|
}
|
|
436
438
|
return {
|
|
@@ -499,6 +501,32 @@ async function treeEntries(git, cwd, revision, paths) {
|
|
|
499
501
|
}
|
|
500
502
|
return entries;
|
|
501
503
|
}
|
|
504
|
+
/** "12 images, 3.4 MB, in the run's evidence": every binary file at the head
|
|
505
|
+
* in one line. Files deleted by the change are not counted. */
|
|
506
|
+
async function assetSummary(git, cwd, head, assets) {
|
|
507
|
+
const sizes = new Map();
|
|
508
|
+
const paths = [...assets].filter(safeContextPath);
|
|
509
|
+
for (let at = 0; at < paths.length; at += 500) {
|
|
510
|
+
const listed = await treeEntries(git, cwd, head, paths.slice(at, at + 500));
|
|
511
|
+
for (const [path, entry] of listed ?? [])
|
|
512
|
+
if (regular(entry))
|
|
513
|
+
sizes.set(path, entry.size);
|
|
514
|
+
}
|
|
515
|
+
if (sizes.size === 0)
|
|
516
|
+
return null;
|
|
517
|
+
const images = [...sizes.keys()].filter(isImagePath).length;
|
|
518
|
+
const others = sizes.size - images;
|
|
519
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
520
|
+
const counted = [...(images > 0 ? [plural(images, "image")] : []), ...(others > 0 ? [plural(others, images > 0 ? "other binary file" : "binary file")] : [])].join(", ");
|
|
521
|
+
return `${counted}, ${assetBytesWords([...sizes.values()].reduce((sum, one) => sum + one, 0))}, in the run's evidence`;
|
|
522
|
+
}
|
|
523
|
+
function assetBytesWords(bytes) {
|
|
524
|
+
if (bytes >= 1024 * 1024)
|
|
525
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
526
|
+
if (bytes >= 1024)
|
|
527
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
528
|
+
return `${bytes} bytes`;
|
|
529
|
+
}
|
|
502
530
|
function regular(entry) {
|
|
503
531
|
return entry !== undefined && entry.type === "blob" && ["100644", "100755"].includes(entry.mode) && SHA1.test(entry.object) && Number.isSafeInteger(entry.size) && entry.size >= 0;
|
|
504
532
|
}
|
|
@@ -822,11 +850,22 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
822
850
|
gapAll("capture-failed", "the committed source paths in the approved scope could not be listed");
|
|
823
851
|
}
|
|
824
852
|
}
|
|
853
|
+
// Files named as images or other binaries are never text context: they are
|
|
854
|
+
// counted in one summary line, never listed as gaps or held against the
|
|
855
|
+
// bounds. A text-named file that turns out not to be text stays a gap.
|
|
856
|
+
const assets = new Set();
|
|
857
|
+
const textOnly = (paths) => paths.filter(path => {
|
|
858
|
+
if (!isBinaryAssetPath(path))
|
|
859
|
+
return true;
|
|
860
|
+
assets.add(path);
|
|
861
|
+
return false;
|
|
862
|
+
});
|
|
863
|
+
generalPaths.splice(0, generalPaths.length, ...textOnly(generalPaths));
|
|
825
864
|
const specificFor = new Map();
|
|
826
865
|
for (const criterion of sourceProof?.criteria ?? []) {
|
|
827
866
|
const cited = [...new Set(criterion.evidence.filter(one => one.kind === "changed-path").map(one => one.ref))].filter(safeContextPath).sort();
|
|
828
867
|
const inherited = parentContext?.coverage.find(one => one.id === criterion.id)?.paths ?? [];
|
|
829
|
-
const combined = [...new Set([...cited, ...inherited])].filter(safeContextPath).sort();
|
|
868
|
+
const combined = textOnly([...new Set([...cited, ...inherited])].filter(safeContextPath).sort());
|
|
830
869
|
if (combined.length > 0)
|
|
831
870
|
specificFor.set(criterion.id, combined);
|
|
832
871
|
}
|
|
@@ -852,7 +891,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
852
891
|
// A proof's path citations are relevance hints, not permission to omit other
|
|
853
892
|
// changed files. Deliver new revision files and tests in full as supplemental
|
|
854
893
|
// context without inventing criterion ownership or inheriting a judgement.
|
|
855
|
-
for (const path of args.patchPaths) {
|
|
894
|
+
for (const path of textOnly([...args.patchPaths])) {
|
|
856
895
|
if (safeContextPath(path) && !relevance.has(path))
|
|
857
896
|
relevance.set(path, { criteria: new Set(), why: new Set(["candidate-changed-path"]) });
|
|
858
897
|
}
|
|
@@ -965,6 +1004,7 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
965
1004
|
}
|
|
966
1005
|
}
|
|
967
1006
|
inventory.items = [...itemsByPath.values()];
|
|
1007
|
+
const assetLine = await assetSummary(git, args.worktree, args.head, assets);
|
|
968
1008
|
// 6. Prior review provenance — context, proved per criterion, never a verdict.
|
|
969
1009
|
const sourceDiff = sourceArtifacts.find(one => one.kind === "terminal-diff") ?? null;
|
|
970
1010
|
const sourceProofArtifact = sourceArtifacts.find(one => one.kind === "proof") ?? null;
|
|
@@ -1039,5 +1079,5 @@ export async function deriveReviewContext(store, git, args, lineage) {
|
|
|
1039
1079
|
const gapsFor = state === "gap" && relevantGaps.length === 0 ? [plan.paths.length === 0 ? "no relevant source paths are known" : "inherited context is incomplete"] : relevantGaps;
|
|
1040
1080
|
return { id: plan.id, state, inherited: plan.inherited && lineage.sourceRun !== args.runId, paths: plan.paths, items, gaps: gapsFor, priorSupport };
|
|
1041
1081
|
});
|
|
1042
|
-
return finish(coverage);
|
|
1082
|
+
return finish(assetLine === null ? coverage : coverage.map(one => ({ ...one, assets: assetLine })));
|
|
1043
1083
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Store, WorktreeRow } from "./store.js";
|
|
2
|
+
/** How long a process has after SIGTERM before SIGKILL. */
|
|
3
|
+
export declare const STOP_GRACE_MS = 10000;
|
|
4
|
+
export type ProcessRow = {
|
|
5
|
+
pid: number;
|
|
6
|
+
ppid: number;
|
|
7
|
+
pgid: number;
|
|
8
|
+
bornAt: number;
|
|
9
|
+
name: string;
|
|
10
|
+
terminal: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** Every process now: ids, when it was born (from its age) and its program's name — never its arguments, which can
|
|
13
|
+
* carry private provider inputs. */
|
|
14
|
+
export declare function processCensus(now?: number): ProcessRow[];
|
|
15
|
+
/** `ps -o pid=,ppid=,pgid=,etime=,tty=,comm=` rows; etime is [[dd-]hh:]mm:ss, tty `??` or `?` for none. Unreadable rows are left out. */
|
|
16
|
+
export declare function parseCensus(text: string, now: number): ProcessRow[];
|
|
17
|
+
/** This process, its ancestors, pid 1 and their process groups: the live service, never stopped. */
|
|
18
|
+
export declare function protectedProcesses(census: readonly ProcessRow[], self?: number): {
|
|
19
|
+
pids: Set<number>;
|
|
20
|
+
groups: Set<number>;
|
|
21
|
+
};
|
|
22
|
+
export type Witness = {
|
|
23
|
+
pid: number;
|
|
24
|
+
group: boolean;
|
|
25
|
+
observedAt: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* What of a run is still running: each witnessed process that is still the same process (born no later than the run
|
|
29
|
+
* saw it), and every member of each witnessed group whose leader is that same process or gone. Never anything
|
|
30
|
+
* protected.
|
|
31
|
+
*/
|
|
32
|
+
export declare function closeoutTargets(census: readonly ProcessRow[], witnesses: readonly Witness[], guard: {
|
|
33
|
+
pids: ReadonlySet<number>;
|
|
34
|
+
groups: ReadonlySet<number>;
|
|
35
|
+
}): ProcessRow[];
|
|
36
|
+
export type Stopped = {
|
|
37
|
+
stopped: ProcessRow[];
|
|
38
|
+
killed: ProcessRow[];
|
|
39
|
+
};
|
|
40
|
+
export type StopOptions = {
|
|
41
|
+
graceMs?: number;
|
|
42
|
+
census?: () => ProcessRow[];
|
|
43
|
+
signal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
44
|
+
sleep?: (ms: number) => Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
/** SIGTERM each target, wait up to the grace for them to go, then SIGKILL what is still the same process. */
|
|
47
|
+
export declare function stopProcesses(targets: readonly ProcessRow[], options?: StopOptions): Promise<Stopped>;
|
|
48
|
+
/** "node (4123), serve (4130); 1 needed SIGKILL after 10 s" — for the ledger. */
|
|
49
|
+
export declare function stoppedWords(done: Stopped, graceMs?: number): string;
|
|
50
|
+
export type CloseoutOptions = StopOptions & {
|
|
51
|
+
repo?: string;
|
|
52
|
+
actor?: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The worker's pass: every finished run of this host that still has a process witness open is closed out. A run
|
|
56
|
+
* whose transport here still holds a live child, or whose held supervisor hasn't shut down, is left to that road.
|
|
57
|
+
* Returns what each run had stopped.
|
|
58
|
+
*/
|
|
59
|
+
export declare function closeOutRuns(store: Store, clock: () => Date, options?: CloseoutOptions): Promise<{
|
|
60
|
+
runId: number;
|
|
61
|
+
done: Stopped;
|
|
62
|
+
}[]>;
|
|
63
|
+
/** Which processes run from inside each of `paths` (their working directory is there): lsof on macOS, /proc on Linux. */
|
|
64
|
+
export declare function processesIn(paths: readonly string[]): Map<string, number[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Of the processes running from a finished checkout (`inside`), the ones Toolroll left there: each descends from a
|
|
67
|
+
* process a finished run started (`roots`), or is an orphan (its parent is pid 1), or is the child of one that goes.
|
|
68
|
+
* Never one with a live parent Toolroll didn't start — a deploy the lead runs from the checkout, a person's agent or
|
|
69
|
+
* its tools — nor anything at a terminal or protected.
|
|
70
|
+
*/
|
|
71
|
+
export declare function checkoutTargets(census: readonly ProcessRow[], inside: ReadonlySet<number>, roots: ReadonlySet<number>, guard: {
|
|
72
|
+
pids: ReadonlySet<number>;
|
|
73
|
+
groups: ReadonlySet<number>;
|
|
74
|
+
}): ProcessRow[];
|
|
75
|
+
/**
|
|
76
|
+
* Completing or cancelling a task closes out its checkout: what Toolroll left running from it (`checkoutTargets`) is
|
|
77
|
+
* stopped (SIGTERM, then SIGKILL after the grace), in the ledger. `finished` lists the checkouts whose task is
|
|
78
|
+
* complete or cancelled, with when it finished; each is looked at once per finish.
|
|
79
|
+
*/
|
|
80
|
+
export declare function closeOutCheckouts(store: Store, clock: () => Date, finished: readonly {
|
|
81
|
+
row: WorktreeRow;
|
|
82
|
+
taskId: string | null;
|
|
83
|
+
finishedAt: string;
|
|
84
|
+
}[], options?: CloseoutOptions & {
|
|
85
|
+
find?: typeof processesIn;
|
|
86
|
+
}): Promise<{
|
|
87
|
+
path: string;
|
|
88
|
+
done: Stopped;
|
|
89
|
+
}[]>;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing out what a run started. When a run ends, whatever its outcome, anything still running in its process
|
|
3
|
+
* groups — the provider's own group and every process or group the run was seen to start — is stopped: SIGTERM, then
|
|
4
|
+
* SIGKILL for whatever is still there after 10 s. Completing or cancelling a task does the same for what Toolroll left
|
|
5
|
+
* running from its checkout: what a finished run started (descends from one of its processes) or an orphan (its parent
|
|
6
|
+
* gone), never one with a live parent Toolroll did not start (a deploy, a person's agent or shell). Each stop is in
|
|
7
|
+
* the ledger, naming what was stopped.
|
|
8
|
+
*
|
|
9
|
+
* Never the live service: this process, its ancestors and their process groups are never targets.
|
|
10
|
+
*
|
|
11
|
+
* A pid saved in the database is never signalled on its word alone (the OS reuses pids): a target must be alive in a
|
|
12
|
+
* fresh process listing and born no later than the run saw it. A group counts when its leader is that same process,
|
|
13
|
+
* or when the leader is gone and the group still has members (the OS never hands a live group's id to a new
|
|
14
|
+
* process). The worker's pass runs this (operate.ts reconcile), so every road a run ends by is covered.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { existsSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
18
|
+
import { hostname } from "node:os";
|
|
19
|
+
import { basename, sep } from "node:path";
|
|
20
|
+
import { ownedProcessCount, runOwnerTag } from "./exec.js";
|
|
21
|
+
/** How long a process has after SIGTERM before SIGKILL. */
|
|
22
|
+
export const STOP_GRACE_MS = 10_000;
|
|
23
|
+
/** `ps` reports age in whole seconds: a process born this close after the run saw its pid is still that process. */
|
|
24
|
+
const BIRTH_SLACK_MS = 2_000;
|
|
25
|
+
/** Every process now: ids, when it was born (from its age) and its program's name — never its arguments, which can
|
|
26
|
+
* carry private provider inputs. */
|
|
27
|
+
export function processCensus(now = Date.now()) {
|
|
28
|
+
if (process.platform === "win32")
|
|
29
|
+
return [];
|
|
30
|
+
const text = execFileSync("/bin/ps", ["-axo", "pid=,ppid=,pgid=,etime=,tty=,comm="], { encoding: "utf8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024 });
|
|
31
|
+
return parseCensus(text, now);
|
|
32
|
+
}
|
|
33
|
+
/** `ps -o pid=,ppid=,pgid=,etime=,tty=,comm=` rows; etime is [[dd-]hh:]mm:ss, tty `??` or `?` for none. Unreadable rows are left out. */
|
|
34
|
+
export function parseCensus(text, now) {
|
|
35
|
+
const rows = [];
|
|
36
|
+
for (const line of text.split("\n")) {
|
|
37
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
38
|
+
if (match === null)
|
|
39
|
+
continue;
|
|
40
|
+
const [, pid, ppid, pgid, days, hours, minutes, seconds, tty, comm] = match;
|
|
41
|
+
const age = ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds);
|
|
42
|
+
rows.push({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), bornAt: now - age * 1000, name: basename(comm.trim()) || "?", terminal: !/^(\?+|-)$/.test(tty) });
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
/** This process, its ancestors, pid 1 and their process groups: the live service, never stopped. */
|
|
47
|
+
export function protectedProcesses(census, self = process.pid) {
|
|
48
|
+
const byPid = new Map(census.map(row => [row.pid, row]));
|
|
49
|
+
const pids = new Set([1, self, process.ppid]);
|
|
50
|
+
for (let at = byPid.get(self), hops = 0; at !== undefined && hops < 64; at = byPid.get(at.ppid), hops++) {
|
|
51
|
+
pids.add(at.pid);
|
|
52
|
+
if (at.ppid <= 1)
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
const groups = new Set();
|
|
56
|
+
for (const pid of pids) {
|
|
57
|
+
const row = byPid.get(pid);
|
|
58
|
+
if (row !== undefined)
|
|
59
|
+
groups.add(row.pgid);
|
|
60
|
+
}
|
|
61
|
+
return { pids, groups };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What of a run is still running: each witnessed process that is still the same process (born no later than the run
|
|
65
|
+
* saw it), and every member of each witnessed group whose leader is that same process or gone. Never anything
|
|
66
|
+
* protected.
|
|
67
|
+
*/
|
|
68
|
+
export function closeoutTargets(census, witnesses, guard) {
|
|
69
|
+
const byPid = new Map(census.map(row => [row.pid, row]));
|
|
70
|
+
const groups = new Set();
|
|
71
|
+
const pids = new Set();
|
|
72
|
+
for (const one of witnesses) {
|
|
73
|
+
const now = byPid.get(one.pid);
|
|
74
|
+
const same = now !== undefined && now.bornAt <= one.observedAt + BIRTH_SLACK_MS;
|
|
75
|
+
if (same)
|
|
76
|
+
pids.add(one.pid);
|
|
77
|
+
if (one.group && (same || now === undefined))
|
|
78
|
+
groups.add(one.pid);
|
|
79
|
+
}
|
|
80
|
+
return census.filter(row => (pids.has(row.pid) || groups.has(row.pgid)) && !guard.pids.has(row.pid) && !guard.groups.has(row.pgid));
|
|
81
|
+
}
|
|
82
|
+
/** SIGTERM each target, wait up to the grace for them to go, then SIGKILL what is still the same process. */
|
|
83
|
+
export async function stopProcesses(targets, options = {}) {
|
|
84
|
+
if (targets.length === 0)
|
|
85
|
+
return { stopped: [], killed: [] };
|
|
86
|
+
const graceMs = options.graceMs ?? STOP_GRACE_MS;
|
|
87
|
+
const census = options.census ?? (() => processCensus());
|
|
88
|
+
const signal = options.signal ?? ((pid, name) => process.kill(pid, name));
|
|
89
|
+
const sleep = options.sleep ?? (ms => new Promise(done => setTimeout(done, ms)));
|
|
90
|
+
const send = (rows, name) => {
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
try {
|
|
93
|
+
signal(row.pid, name);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error.code !== "ESRCH" && error.code !== "EPERM")
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
// Still there: the same pid, born at the same time (a reused pid is somebody else).
|
|
102
|
+
const remaining = () => {
|
|
103
|
+
let rows;
|
|
104
|
+
try {
|
|
105
|
+
rows = census();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [...targets];
|
|
109
|
+
}
|
|
110
|
+
const alive = new Map(rows.map(row => [row.pid, row]));
|
|
111
|
+
return targets.filter(one => { const now = alive.get(one.pid); return now !== undefined && Math.abs(now.bornAt - one.bornAt) <= BIRTH_SLACK_MS; });
|
|
112
|
+
};
|
|
113
|
+
send(targets, "SIGTERM");
|
|
114
|
+
const deadline = Date.now() + graceMs;
|
|
115
|
+
let left = remaining();
|
|
116
|
+
while (left.length > 0 && Date.now() < deadline) {
|
|
117
|
+
await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
|
|
118
|
+
left = remaining();
|
|
119
|
+
}
|
|
120
|
+
if (left.length > 0)
|
|
121
|
+
send(left, "SIGKILL");
|
|
122
|
+
return { stopped: [...targets], killed: left };
|
|
123
|
+
}
|
|
124
|
+
/** "node (4123), serve (4130); 1 needed SIGKILL after 10 s" — for the ledger. */
|
|
125
|
+
export function stoppedWords(done, graceMs = STOP_GRACE_MS) {
|
|
126
|
+
const names = done.stopped.map(one => `${one.name} (${one.pid})`).join(", ");
|
|
127
|
+
const groups = new Set(done.stopped.map(one => one.pgid)).size;
|
|
128
|
+
const killed = done.killed.length === 0 ? "" : `; ${done.killed.length} needed SIGKILL after ${Number((graceMs / 1000).toFixed(1))} s`;
|
|
129
|
+
return `${done.stopped.length} process${done.stopped.length === 1 ? "" : "es"} in ${groups} group${groups === 1 ? "" : "s"}: ${names}${killed}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The worker's pass: every finished run of this host that still has a process witness open is closed out. A run
|
|
133
|
+
* whose transport here still holds a live child, or whose held supervisor hasn't shut down, is left to that road.
|
|
134
|
+
* Returns what each run had stopped.
|
|
135
|
+
*/
|
|
136
|
+
export async function closeOutRuns(store, clock, options = {}) {
|
|
137
|
+
if (process.platform === "win32")
|
|
138
|
+
return [];
|
|
139
|
+
const db = store.handle;
|
|
140
|
+
const runs = db.prepare(`SELECT DISTINCT p.run AS run FROM run_process p JOIN run r ON r.id = p.run JOIN task_ref t ON t.id = r.task_ref
|
|
141
|
+
WHERE p.exited_at IS NULL AND p.pid IS NOT NULL AND p.containment IS NULL AND p.container IS NULL AND p.host = ?
|
|
142
|
+
AND r.outcome IS NOT NULL${options.repo === undefined ? "" : " AND t.repo = ?"} ORDER BY p.run`)
|
|
143
|
+
.all(...[hostname(), ...(options.repo === undefined ? [] : [options.repo])]).map(row => Number(row["run"]));
|
|
144
|
+
const out = [];
|
|
145
|
+
if (runs.length === 0)
|
|
146
|
+
return out;
|
|
147
|
+
let census = null;
|
|
148
|
+
for (const runId of runs) {
|
|
149
|
+
store.recordRunProcessExits(runId, clock());
|
|
150
|
+
if (ownedProcessCount(runOwnerTag(store, runId)) > 0)
|
|
151
|
+
continue;
|
|
152
|
+
const held = store.heldSessionOf(runId);
|
|
153
|
+
if (held !== null && held.endedAt === null)
|
|
154
|
+
continue;
|
|
155
|
+
const witnesses = db.prepare(`SELECT pid, process_group, observed_at FROM run_process
|
|
156
|
+
WHERE run = ? AND exited_at IS NULL AND pid IS NOT NULL AND containment IS NULL AND container IS NULL AND host = ?`).all(runId, hostname())
|
|
157
|
+
.map(row => ({ pid: Number(row["pid"]), group: Number(row["process_group"]) === 1, observedAt: Date.parse(String(row["observed_at"])) }))
|
|
158
|
+
.filter(one => Number.isFinite(one.observedAt));
|
|
159
|
+
if (witnesses.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
census ??= (options.census ?? (() => processCensus()))();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
const targets = closeoutTargets(census, witnesses, protectedProcesses(census));
|
|
168
|
+
if (targets.length === 0)
|
|
169
|
+
continue;
|
|
170
|
+
const done = await stopProcesses(targets, options);
|
|
171
|
+
census = null;
|
|
172
|
+
const run = store.getRun(runId);
|
|
173
|
+
const ref = run === null ? null : store.refById(run.taskRef);
|
|
174
|
+
store.recordAction({ at: clock().toISOString(), actor: options.actor ?? "worker", repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId,
|
|
175
|
+
action: "run processes stopped", outcome: "stopped", source: "work", detail: `run #${runId} ended (${run?.outcome ?? "?"}); stopped ${stoppedWords(done, options.graceMs)}` });
|
|
176
|
+
store.recordRunProcessExits(runId, clock());
|
|
177
|
+
out.push({ runId, done });
|
|
178
|
+
}
|
|
179
|
+
store.settleQuiescentStops(clock());
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/** Which processes run from inside each of `paths` (their working directory is there): lsof on macOS, /proc on Linux. */
|
|
183
|
+
export function processesIn(paths) {
|
|
184
|
+
const found = new Map();
|
|
185
|
+
if (paths.length === 0 || process.platform === "win32")
|
|
186
|
+
return found;
|
|
187
|
+
const roots = paths.map(path => { let real = path; try {
|
|
188
|
+
real = realpathSync(path);
|
|
189
|
+
}
|
|
190
|
+
catch { /* gone */ } return { path, real }; });
|
|
191
|
+
const note = (pid, cwd) => {
|
|
192
|
+
for (const one of roots) {
|
|
193
|
+
if ([one.path, one.real].some(root => cwd === root || cwd.startsWith(root + sep)))
|
|
194
|
+
found.set(one.path, [...(found.get(one.path) ?? []), pid]);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
if (process.platform === "linux" && existsSync("/proc")) {
|
|
198
|
+
for (const name of readdirSync("/proc")) {
|
|
199
|
+
if (!/^\d+$/.test(name))
|
|
200
|
+
continue;
|
|
201
|
+
try {
|
|
202
|
+
note(Number(name), readlinkSync(`/proc/${name}/cwd`));
|
|
203
|
+
}
|
|
204
|
+
catch { /* gone, or not ours */ }
|
|
205
|
+
}
|
|
206
|
+
return found;
|
|
207
|
+
}
|
|
208
|
+
let text = "";
|
|
209
|
+
try {
|
|
210
|
+
text = execFileSync("lsof", ["-nP", "-d", "cwd", "-Fpn"], { encoding: "utf8", timeout: 20_000, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
text = String(error.stdout ?? "");
|
|
214
|
+
}
|
|
215
|
+
let pid = 0;
|
|
216
|
+
for (const line of text.split("\n")) {
|
|
217
|
+
if (line.startsWith("p"))
|
|
218
|
+
pid = Number(line.slice(1));
|
|
219
|
+
else if (line.startsWith("n") && pid > 0)
|
|
220
|
+
note(pid, line.slice(1));
|
|
221
|
+
}
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Of the processes running from a finished checkout (`inside`), the ones Toolroll left there: each descends from a
|
|
226
|
+
* process a finished run started (`roots`), or is an orphan (its parent is pid 1), or is the child of one that goes.
|
|
227
|
+
* Never one with a live parent Toolroll didn't start — a deploy the lead runs from the checkout, a person's agent or
|
|
228
|
+
* its tools — nor anything at a terminal or protected.
|
|
229
|
+
*/
|
|
230
|
+
export function checkoutTargets(census, inside, roots, guard) {
|
|
231
|
+
const byPid = new Map(census.map(row => [row.pid, row]));
|
|
232
|
+
const fromRun = (row) => {
|
|
233
|
+
for (let at = row, hops = 0; at !== undefined && at.pid > 1 && hops < 64; at = byPid.get(at.ppid), hops++) {
|
|
234
|
+
if (roots.has(at.pid))
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
};
|
|
239
|
+
const verdict = new Map();
|
|
240
|
+
const left = (row) => {
|
|
241
|
+
const known = verdict.get(row.pid);
|
|
242
|
+
if (known !== undefined)
|
|
243
|
+
return known;
|
|
244
|
+
verdict.set(row.pid, false);
|
|
245
|
+
const parent = byPid.get(row.ppid);
|
|
246
|
+
const ours = inside.has(row.pid) && !row.terminal && !guard.pids.has(row.pid) && !guard.groups.has(row.pgid)
|
|
247
|
+
&& (row.ppid === 1 || fromRun(row) || (parent !== undefined && left(parent)));
|
|
248
|
+
verdict.set(row.pid, ours);
|
|
249
|
+
return ours;
|
|
250
|
+
};
|
|
251
|
+
return census.filter(left);
|
|
252
|
+
}
|
|
253
|
+
/** The processes finished runs on this host started that are still the same process: what a leftover may descend from. */
|
|
254
|
+
function finishedRunProcesses(store, census) {
|
|
255
|
+
const byPid = new Map(census.map(row => [row.pid, row]));
|
|
256
|
+
const roots = new Set();
|
|
257
|
+
const rows = store.handle.prepare(`SELECT p.pid AS pid, p.observed_at AS observed_at FROM run_process p JOIN run r ON r.id = p.run
|
|
258
|
+
WHERE p.exited_at IS NULL AND p.pid IS NOT NULL AND p.host = ? AND r.outcome IS NOT NULL`).all(hostname());
|
|
259
|
+
for (const row of rows) {
|
|
260
|
+
const now = byPid.get(Number(row["pid"]));
|
|
261
|
+
const observedAt = Date.parse(String(row["observed_at"]));
|
|
262
|
+
if (now !== undefined && Number.isFinite(observedAt) && now.bornAt <= observedAt + BIRTH_SLACK_MS)
|
|
263
|
+
roots.add(now.pid);
|
|
264
|
+
}
|
|
265
|
+
return roots;
|
|
266
|
+
}
|
|
267
|
+
/** Checkouts this process already closed out since their task finished (path and when). */
|
|
268
|
+
const closed = new Set();
|
|
269
|
+
/**
|
|
270
|
+
* Completing or cancelling a task closes out its checkout: what Toolroll left running from it (`checkoutTargets`) is
|
|
271
|
+
* stopped (SIGTERM, then SIGKILL after the grace), in the ledger. `finished` lists the checkouts whose task is
|
|
272
|
+
* complete or cancelled, with when it finished; each is looked at once per finish.
|
|
273
|
+
*/
|
|
274
|
+
export async function closeOutCheckouts(store, clock, finished, options = {}) {
|
|
275
|
+
const fresh = finished.filter(one => !closed.has(`${one.row.path}\u0000${one.finishedAt}`) && existsSync(one.row.path) && one.row.runner === null);
|
|
276
|
+
const out = [];
|
|
277
|
+
if (fresh.length === 0 || process.platform === "win32")
|
|
278
|
+
return out;
|
|
279
|
+
const inside = (options.find ?? processesIn)(fresh.map(one => one.row.path));
|
|
280
|
+
let census = null;
|
|
281
|
+
for (const one of fresh) {
|
|
282
|
+
closed.add(`${one.row.path}\u0000${one.finishedAt}`);
|
|
283
|
+
const pids = new Set(inside.get(one.row.path) ?? []);
|
|
284
|
+
if (pids.size === 0)
|
|
285
|
+
continue;
|
|
286
|
+
try {
|
|
287
|
+
census ??= (options.census ?? (() => processCensus()))();
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
const targets = checkoutTargets(census, pids, finishedRunProcesses(store, census), protectedProcesses(census));
|
|
293
|
+
if (targets.length === 0)
|
|
294
|
+
continue;
|
|
295
|
+
const done = await stopProcesses(targets, options);
|
|
296
|
+
census = null;
|
|
297
|
+
store.recordAction({ at: clock().toISOString(), actor: options.actor ?? "worker", repo: one.row.repo, taskId: one.taskId, runId: null,
|
|
298
|
+
action: "checkout processes stopped", outcome: "stopped", source: "work", detail: `${basename(one.row.path)}: its task finished; stopped ${stoppedWords(done, options.graceMs)}` });
|
|
299
|
+
out.push({ path: one.row.path, done });
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
package/dist/serve.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { type BrowserWorkspace, type BrowserSignIn, type BrowserUpdateNotice } from './browser-workspace.js';
|
|
1
|
+
import { type BrowserWorkspace, type BrowserNeedAction, type BrowserSignIn, type BrowserUpdateNotice } from './browser-workspace.js';
|
|
2
2
|
import { CodingWorkspace } from './coding-workspace.js';
|
|
3
3
|
import { type FetchLike } from "./flow-share.js";
|
|
4
4
|
import { type MailSender } from "./flow-actions.js";
|
|
5
5
|
import { type TriggerIo } from "./flow-triggers.js";
|
|
6
6
|
import { type AssignmentSnapshot } from './assignment.js';
|
|
7
7
|
import type { PublishExec } from './publish.js';
|
|
8
|
+
import { type TaskStatus } from './task-status.js';
|
|
8
9
|
import { type VersionRunner } from "./model-catalog.js";
|
|
9
10
|
import { type Server } from "node:http";
|
|
10
11
|
import { type ProofVerdict, type CriterionMatrixRow } from "./proof.js";
|
|
@@ -266,6 +267,10 @@ type Chrome = {
|
|
|
266
267
|
signIn?: BrowserSignIn[];
|
|
267
268
|
/** A newer Toolroll: a quiet notice for an operator until they dismiss this version. */
|
|
268
269
|
update?: BrowserUpdateNotice;
|
|
270
|
+
/** An update waiting on something, what, and the action that clears it: every page, for an operator. */
|
|
271
|
+
updateWaiting?: {
|
|
272
|
+
words: string;
|
|
273
|
+
};
|
|
269
274
|
/** The chat tab renders only where chat could ever be allowed. */
|
|
270
275
|
chat?: boolean;
|
|
271
276
|
code?: boolean;
|
|
@@ -541,6 +546,15 @@ export declare function orderChangedFiles(files: readonly ReviewFileRow[], proof
|
|
|
541
546
|
/** Older machine-checked results saved a mechanical sentence; say the same
|
|
542
547
|
* facts in plain words (the version id stays, shortened). */
|
|
543
548
|
export declare function plainConclusionOf(conclusion: string): string;
|
|
549
|
+
/** A Needs you result's one action (needs-you.ts): a link to the act that resolves it, or Confirm it
|
|
550
|
+
* stopped behind the password. Null under every other headline. */
|
|
551
|
+
export declare function needActionOf(assignment: AssignmentSnapshot | null, status: TaskStatus | null, csrf: string, returnTo: string, result?: {
|
|
552
|
+
accepted: boolean;
|
|
553
|
+
humanReview: boolean;
|
|
554
|
+
run: number;
|
|
555
|
+
action: string;
|
|
556
|
+
rebuildable?: boolean;
|
|
557
|
+
}): BrowserNeedAction | null;
|
|
544
558
|
/** The facts region alone, for the open-run poll (A4). A finished run's
|
|
545
559
|
* fragment says so instead of quietly growing forms (finding 5), and a run
|
|
546
560
|
* that stopped being the task's live claim says so too — both carry the
|