toolroll 0.9.7 → 0.9.9
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 +48 -1
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-adapters.js +2 -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 +15 -0
- package/dist/assignment.js +56 -9
- 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/build-review.d.ts +38 -0
- package/dist/build-review.js +341 -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/check-levels-ui.d.ts +5 -0
- package/dist/check-levels-ui.js +16 -2
- package/dist/check-progress.js +4 -3
- package/dist/checkout-cleanup.d.ts +40 -2
- package/dist/checkout-cleanup.js +129 -9
- package/dist/cli.js +2 -0
- package/dist/desktop-update.d.ts +52 -0
- package/dist/desktop-update.js +147 -5
- package/dist/dispatch.js +2 -1
- package/dist/dispose.d.ts +4 -3
- package/dist/dispose.js +12 -4
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +8 -0
- package/dist/exec.d.ts +5 -0
- package/dist/exec.js +30 -4
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +8 -6
- package/dist/invoke.js +5 -0
- 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/modes.d.ts +3 -1
- package/dist/modes.js +3 -1
- 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 +231 -36
- package/dist/plane-review.js +4 -0
- package/dist/process-custody.js +8 -0
- package/dist/process-recovery-eligibility.d.ts +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/provider.js +16 -0
- package/dist/result-completion.d.ts +4 -0
- package/dist/result-completion.js +11 -0
- package/dist/retention-ui.d.ts +3 -1
- package/dist/retention-ui.js +4 -4
- package/dist/retention.d.ts +13 -6
- package/dist/retention.js +50 -12
- package/dist/review-context.d.ts +3 -0
- package/dist/review-context.js +45 -5
- package/dist/review-switch.d.ts +41 -0
- package/dist/review-switch.js +88 -0
- package/dist/reviewer.d.ts +24 -2
- package/dist/reviewer.js +49 -1
- 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 +158 -18
- 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/storage.js +2 -0
- package/dist/store.d.ts +72 -1
- package/dist/store.js +258 -31
- package/dist/surface.js +5 -1
- package/dist/task-status.d.ts +22 -5
- package/dist/task-status.js +45 -24
- package/dist/telegram-progress.js +2 -1
- package/dist/telegram-status.js +3 -3
- 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/work-summary.js +2 -1
- package/dist/workspace-ui.d.ts +3 -1
- package/dist/workspace-ui.js +10 -9
- package/dist/worktree.d.ts +16 -0
- package/dist/worktree.js +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const REVIEW_SCHEMA = `
|
|
2
|
+
-- A project's explicit review switch. No row: on while the project is under a hands-off mode, off otherwise.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS review_switch (
|
|
4
|
+
repo TEXT PRIMARY KEY,
|
|
5
|
+
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
|
6
|
+
changed_by TEXT NOT NULL,
|
|
7
|
+
changed_at TEXT NOT NULL
|
|
8
|
+
);
|
|
9
|
+
-- One row per reviewed build run: pending until its one reviewer run settles it.
|
|
10
|
+
CREATE TABLE IF NOT EXISTS build_review (
|
|
11
|
+
run INTEGER PRIMARY KEY,
|
|
12
|
+
task_id TEXT NOT NULL,
|
|
13
|
+
repo TEXT NOT NULL,
|
|
14
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'reviewed', 'not-reviewed')),
|
|
15
|
+
request INTEGER,
|
|
16
|
+
reviewer_run INTEGER,
|
|
17
|
+
findings_json TEXT,
|
|
18
|
+
reason TEXT,
|
|
19
|
+
sent_back_as TEXT,
|
|
20
|
+
queued_at TEXT NOT NULL,
|
|
21
|
+
finished_at TEXT
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS build_review_pending ON build_review (repo) WHERE state = 'pending';
|
|
24
|
+
CREATE INDEX IF NOT EXISTS build_review_sent_back ON build_review (sent_back_as) WHERE sent_back_as IS NOT NULL;
|
|
25
|
+
`;
|
|
26
|
+
/** How the policy log words a switch state. */
|
|
27
|
+
export function reviewSwitchWords(state) {
|
|
28
|
+
return state.source === "project" ? (state.on ? "on" : "off") : state.on ? "on (hands-off default)" : "off (default)";
|
|
29
|
+
}
|
|
30
|
+
export function buildReviewOf(store, runId) {
|
|
31
|
+
let row;
|
|
32
|
+
// A deploy reads this with the candidate's code over the INSTALLED runtime's file, before this version's migration
|
|
33
|
+
// made the table: no table reads as no review.
|
|
34
|
+
try {
|
|
35
|
+
row = store.handle.prepare("SELECT * FROM build_review WHERE run = ?").get(runId);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (/no such table: build_review/.test(String(error)))
|
|
39
|
+
return null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (row === undefined)
|
|
43
|
+
return null;
|
|
44
|
+
// The row was validated when it was written (parseBuildFindings).
|
|
45
|
+
let findings = [];
|
|
46
|
+
try {
|
|
47
|
+
const saved = row["findings_json"] === null ? null : JSON.parse(String(row["findings_json"]));
|
|
48
|
+
if (Array.isArray(saved?.findings))
|
|
49
|
+
findings = saved.findings;
|
|
50
|
+
}
|
|
51
|
+
catch { /* An unreadable saved row shows no findings; its state stays. */ }
|
|
52
|
+
return {
|
|
53
|
+
run: runId,
|
|
54
|
+
state: String(row["state"]),
|
|
55
|
+
reviewerRun: row["reviewer_run"] === null ? null : Number(row["reviewer_run"]),
|
|
56
|
+
reason: row["reason"] === null ? null : String(row["reason"]),
|
|
57
|
+
high: findings.filter(one => one.severity === "HIGH"),
|
|
58
|
+
followUps: findings.filter(one => one.severity !== "HIGH"),
|
|
59
|
+
sentBackAs: row["sent_back_as"] === null ? null : String(row["sent_back_as"]),
|
|
60
|
+
finishedAt: row["finished_at"] === null ? null : String(row["finished_at"]),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export const findingWords = (finding) => `${finding.severity} ${finding.file}:${finding.line} — ${finding.scenario}`;
|
|
64
|
+
/** The review's standing in one line, or null while nothing was asked. */
|
|
65
|
+
export function buildReviewHeadline(view) {
|
|
66
|
+
if (view === null)
|
|
67
|
+
return null;
|
|
68
|
+
if (view.state === "pending")
|
|
69
|
+
return "Reviewing — an automatic review is reading this result.";
|
|
70
|
+
if (view.state === "not-reviewed")
|
|
71
|
+
return `Not reviewed${view.reason === null ? "." : `: ${view.reason}`}`;
|
|
72
|
+
if (view.sentBackAs !== null)
|
|
73
|
+
return `Sent back as ${view.sentBackAs}: ${view.high.length} high finding${view.high.length === 1 ? "" : "s"}.`;
|
|
74
|
+
if (view.high.length > 0)
|
|
75
|
+
return `Reviewed: ${view.high.length} high finding${view.high.length === 1 ? "" : "s"} for you to decide.`;
|
|
76
|
+
return `Reviewed: no high findings${view.followUps.length === 0 ? "." : `; ${view.followUps.length} suggested follow-up${view.followUps.length === 1 ? "" : "s"}.`}`;
|
|
77
|
+
}
|
|
78
|
+
/** Plain lines for `task show` and `assignment show`. */
|
|
79
|
+
export function buildReviewLines(view) {
|
|
80
|
+
const headline = buildReviewHeadline(view);
|
|
81
|
+
if (view === null || headline === null)
|
|
82
|
+
return [];
|
|
83
|
+
return [
|
|
84
|
+
` review: ${headline}`,
|
|
85
|
+
...view.high.map(one => ` ${findingWords(one)}`),
|
|
86
|
+
...view.followUps.map(one => ` suggested follow-up: ${findingWords(one)}`),
|
|
87
|
+
];
|
|
88
|
+
}
|
package/dist/reviewer.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
/** Pure
|
|
2
|
-
*
|
|
1
|
+
/** Pure parsers for review payloads: retained historical reviews, and the one
|
|
2
|
+
* automatic build review's findings (build-review.ts runs it). This module
|
|
3
|
+
* cannot launch providers, create runs or retry work. */
|
|
3
4
|
import type { CriterionJudgementWord } from "./proof.js";
|
|
5
|
+
import type { BuildFinding } from "./review-switch.js";
|
|
4
6
|
/** The files the pass writes INTO the scratch directory for the agent —
|
|
5
7
|
* the patch always; the rubric, re-serialized proof, check log, and
|
|
6
8
|
* screenshots only when this run actually has one (v40) — a grandfathered
|
|
@@ -95,3 +97,23 @@ export declare function parseReview(raw: string, patchPaths: ReadonlySet<string>
|
|
|
95
97
|
ok: false;
|
|
96
98
|
problems: ReviewProblem[];
|
|
97
99
|
};
|
|
100
|
+
export type { BuildFinding, FindingSeverity } from "./review-switch.js";
|
|
101
|
+
export declare const FINDING_LIMITS: {
|
|
102
|
+
readonly findings: 40;
|
|
103
|
+
readonly file: 300;
|
|
104
|
+
readonly scenario: 500;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* The automatic build review's reply, strict and wholesale like parseReview:
|
|
108
|
+
* `{"version":1,"findings":[{"severity","file","line","scenario"}]}`. Any
|
|
109
|
+
* invalid finding refuses the whole reply — a review that did not answer
|
|
110
|
+
* what it was asked is "not reviewed", never a partial pass. Text is kept
|
|
111
|
+
* to one line so it can travel in a revision note and a terminal.
|
|
112
|
+
*/
|
|
113
|
+
export declare function parseBuildFindings(raw: string): {
|
|
114
|
+
ok: true;
|
|
115
|
+
findings: BuildFinding[];
|
|
116
|
+
} | {
|
|
117
|
+
ok: false;
|
|
118
|
+
problem: string;
|
|
119
|
+
};
|
package/dist/reviewer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { citesSuppliedProvenance } from "./review-context.js";
|
|
2
|
-
import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
|
|
2
|
+
import { normalizeStructuredJson, REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
|
|
3
3
|
/** The files the pass writes INTO the scratch directory for the agent —
|
|
4
4
|
* the patch always; the rubric, re-serialized proof, check log, and
|
|
5
5
|
* screenshots only when this run actually has one (v40) — a grandfathered
|
|
@@ -200,3 +200,51 @@ export function parseReview(raw, patchPaths, approvedCriteriaIds = new Set(), pr
|
|
|
200
200
|
return { ok: false, problems };
|
|
201
201
|
return { ok: true, comments, criteria, ...(payload["learning"] === undefined ? {} : { learning: payload["learning"] }), ...(payload["learningAssessment"] === undefined ? {} : { learningAssessment: payload["learningAssessment"] }) };
|
|
202
202
|
}
|
|
203
|
+
export const FINDING_LIMITS = { findings: 40, file: REVIEW_OUTPUT_LIMITS.path, scenario: REVIEW_OUTPUT_LIMITS.note };
|
|
204
|
+
/**
|
|
205
|
+
* The automatic build review's reply, strict and wholesale like parseReview:
|
|
206
|
+
* `{"version":1,"findings":[{"severity","file","line","scenario"}]}`. Any
|
|
207
|
+
* invalid finding refuses the whole reply — a review that did not answer
|
|
208
|
+
* what it was asked is "not reviewed", never a partial pass. Text is kept
|
|
209
|
+
* to one line so it can travel in a revision note and a terminal.
|
|
210
|
+
*/
|
|
211
|
+
export function parseBuildFindings(raw) {
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = JSON.parse(normalizeStructuredJson(raw).text);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return { ok: false, problem: "the reply is not JSON" };
|
|
218
|
+
}
|
|
219
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
220
|
+
return { ok: false, problem: "the reply is not an object" };
|
|
221
|
+
const payload = parsed;
|
|
222
|
+
if (payload["version"] !== 1)
|
|
223
|
+
return { ok: false, problem: "version must be 1" };
|
|
224
|
+
const list = payload["findings"];
|
|
225
|
+
if (!Array.isArray(list))
|
|
226
|
+
return { ok: false, problem: "findings must be an array" };
|
|
227
|
+
if (list.length > FINDING_LIMITS.findings)
|
|
228
|
+
return { ok: false, problem: `at most ${FINDING_LIMITS.findings} findings` };
|
|
229
|
+
const findings = [];
|
|
230
|
+
const oneLine = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, " ").replace(/\s+/g, " ").trim();
|
|
231
|
+
for (const [index, one] of list.entries()) {
|
|
232
|
+
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
233
|
+
return { ok: false, problem: `finding ${index}: not an object` };
|
|
234
|
+
const finding = one;
|
|
235
|
+
const severity = finding["severity"];
|
|
236
|
+
if (severity !== "HIGH" && severity !== "MEDIUM" && severity !== "LOW")
|
|
237
|
+
return { ok: false, problem: `finding ${index}: severity must be HIGH, MEDIUM or LOW` };
|
|
238
|
+
const file = typeof finding["file"] === "string" ? oneLine(finding["file"]) : "";
|
|
239
|
+
if (file.length === 0 || file.length > FINDING_LIMITS.file)
|
|
240
|
+
return { ok: false, problem: `finding ${index}: file must be a path of 1..${FINDING_LIMITS.file} characters` };
|
|
241
|
+
const line = finding["line"];
|
|
242
|
+
if (typeof line !== "number" || !Number.isInteger(line) || line < 1)
|
|
243
|
+
return { ok: false, problem: `finding ${index}: line must be a positive whole number` };
|
|
244
|
+
const scenario = typeof finding["scenario"] === "string" ? oneLine(finding["scenario"]) : "";
|
|
245
|
+
if (scenario.length === 0 || scenario.length > FINDING_LIMITS.scenario)
|
|
246
|
+
return { ok: false, problem: `finding ${index}: scenario must be one sentence of 1..${FINDING_LIMITS.scenario} characters` };
|
|
247
|
+
findings.push({ severity, file, line, scenario });
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, findings };
|
|
250
|
+
}
|
|
@@ -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
|