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.
Files changed (85) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment-brief.d.ts +2 -0
  3. package/dist/assignment-brief.js +2 -1
  4. package/dist/assignment-presentation.js +3 -1
  5. package/dist/assignment-status.d.ts +1 -1
  6. package/dist/assignment-ui.js +5 -0
  7. package/dist/assignment.d.ts +9 -0
  8. package/dist/assignment.js +42 -8
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +18 -18
  11. package/dist/browser-crew.js +4 -0
  12. package/dist/browser-workspace.d.ts +40 -0
  13. package/dist/builder.d.ts +2 -0
  14. package/dist/builder.js +114 -8
  15. package/dist/chat-controls.d.ts +1 -1
  16. package/dist/chat-controls.js +1 -1
  17. package/dist/chat-delivery-state.d.ts +5 -0
  18. package/dist/chat-delivery.js +45 -8
  19. package/dist/chat-quiet.d.ts +10 -4
  20. package/dist/chat-quiet.js +87 -22
  21. package/dist/chat-voice.d.ts +68 -0
  22. package/dist/chat-voice.js +175 -0
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/desktop-update.d.ts +52 -0
  27. package/dist/desktop-update.js +147 -5
  28. package/dist/discord-chat.js +1 -0
  29. package/dist/evidence.d.ts +6 -0
  30. package/dist/evidence.js +8 -0
  31. package/dist/first-run.d.ts +1 -1
  32. package/dist/first-run.js +7 -5
  33. package/dist/lead-context.d.ts +1 -1
  34. package/dist/lead-context.js +4 -2
  35. package/dist/lead-status.d.ts +10 -1
  36. package/dist/lead-status.js +21 -24
  37. package/dist/needs-you.d.ts +55 -0
  38. package/dist/needs-you.js +83 -0
  39. package/dist/operate.d.ts +2 -0
  40. package/dist/operate.js +122 -15
  41. package/dist/plane-review.js +4 -0
  42. package/dist/process-recovery-eligibility.d.ts +1 -0
  43. package/dist/project-delete.js +1 -0
  44. package/dist/project-learning.d.ts +3 -3
  45. package/dist/project-learning.js +28 -17
  46. package/dist/proof.d.ts +3 -1
  47. package/dist/proof.js +4 -2
  48. package/dist/releases.d.ts +9 -0
  49. package/dist/releases.js +30 -0
  50. package/dist/result-completion.d.ts +4 -0
  51. package/dist/result-completion.js +11 -0
  52. package/dist/review-context.d.ts +3 -0
  53. package/dist/review-context.js +45 -5
  54. package/dist/run-closeout.d.ts +89 -0
  55. package/dist/run-closeout.js +302 -0
  56. package/dist/serve.d.ts +15 -1
  57. package/dist/serve.js +129 -14
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/shared-deps.d.ts +94 -0
  60. package/dist/shared-deps.js +365 -0
  61. package/dist/slack-chat.js +2 -0
  62. package/dist/storage.js +2 -0
  63. package/dist/store.d.ts +60 -0
  64. package/dist/store.js +207 -30
  65. package/dist/task-status.d.ts +18 -4
  66. package/dist/task-status.js +35 -18
  67. package/dist/teams-chat.js +1 -1
  68. package/dist/telegram-progress.d.ts +6 -1
  69. package/dist/telegram-progress.js +13 -5
  70. package/dist/telegram-status.d.ts +2 -1
  71. package/dist/telegram-status.js +6 -5
  72. package/dist/telegram.d.ts +1 -1
  73. package/dist/telegram.js +93 -43
  74. package/dist/test-temp.d.ts +19 -0
  75. package/dist/test-temp.js +98 -0
  76. package/dist/toolroll-update-ui.js +7 -2
  77. package/dist/toolroll-update.d.ts +21 -1
  78. package/dist/toolroll-update.js +135 -5
  79. package/dist/work-index.d.ts +11 -0
  80. package/dist/work-index.js +103 -16
  81. package/dist/work-summary.d.ts +3 -1
  82. package/dist/workspace-ui.js +1 -1
  83. package/dist/worktree.d.ts +16 -0
  84. package/dist/worktree.js +47 -0
  85. package/package.json +1 -1
@@ -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