@ricsam/r5d-worker 0.0.107 → 0.0.109

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.107",
3
+ "version": "0.0.109",
4
4
  "type": "module"
5
5
  }
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import picomatch from "picomatch";
4
+ import { getTasksSectionRange, PLAN_TASK_LINE_REGEX } from "./plan-parser.mjs";
5
+ import { PLAN_EXTENSION } from "./plan-paths.mjs";
6
+ import { PLAN_TASK_ID_REGEX } from "./plan-parser.mjs";
7
+ const LEGACY_SCHEMA_FIELD_REGEX = /^\s*(type|status|parent|phase|discovered_problem)\s*:/m;
8
+ function lintPlanContent(planId, content) {
9
+ const errors = [];
10
+ const lines = content.split(/\r?\n/);
11
+ if (/^---(?:\r?\n|$)/.test(content)) {
12
+ errors.push({
13
+ planId,
14
+ severity: "error",
15
+ message: "YAML frontmatter is not supported. Use plain Markdown with a ## Tasks checklist.",
16
+ line: 1
17
+ });
18
+ }
19
+ if (LEGACY_SCHEMA_FIELD_REGEX.test(content)) {
20
+ errors.push({
21
+ planId,
22
+ severity: "error",
23
+ message: "Legacy plan schema fields are not supported. Remove type/status/parent/phase/discovered_problem metadata."
24
+ });
25
+ }
26
+ const titleLineIndex = lines.findIndex((line) => /^# .+\S\s*$/.test(line));
27
+ if (titleLineIndex === -1) {
28
+ errors.push({
29
+ planId,
30
+ severity: "error",
31
+ message: "Plan must start with or contain an H1 title, for example '# Implementation Plan'."
32
+ });
33
+ }
34
+ const range = getTasksSectionRange(content);
35
+ if (!range) {
36
+ errors.push({
37
+ planId,
38
+ severity: "error",
39
+ message: "Plan is missing a ## Tasks section."
40
+ });
41
+ return errors;
42
+ }
43
+ const taskIds = /* @__PURE__ */ new Set();
44
+ let taskCount = 0;
45
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
46
+ const line = lines[index] ?? "";
47
+ if (line.trim() === "") continue;
48
+ const match = line.match(PLAN_TASK_LINE_REGEX);
49
+ if (!match || !match[3].trim()) {
50
+ errors.push({
51
+ planId,
52
+ severity: "error",
53
+ line: index + 1,
54
+ message: 'Malformed task line. Use "- [ ] task-id: Description" or "- [x] task-id: Description".'
55
+ });
56
+ continue;
57
+ }
58
+ taskCount += 1;
59
+ const taskId = match[2];
60
+ if (taskIds.has(taskId)) {
61
+ errors.push({
62
+ planId,
63
+ severity: "error",
64
+ line: index + 1,
65
+ message: `Duplicate task id "${taskId}".`
66
+ });
67
+ }
68
+ taskIds.add(taskId);
69
+ }
70
+ if (taskCount === 0) {
71
+ errors.push({
72
+ planId,
73
+ severity: "error",
74
+ message: "Plan must include at least one task in the ## Tasks section.",
75
+ line: range.startIndex + 1
76
+ });
77
+ }
78
+ return errors;
79
+ }
80
+ async function lintPlans(plansDir, glob) {
81
+ const errors = [];
82
+ let entries;
83
+ try {
84
+ entries = await fs.readdir(plansDir, { withFileTypes: true });
85
+ } catch (error) {
86
+ if (error.code !== "ENOENT") throw error;
87
+ entries = [];
88
+ }
89
+ const matches = glob ? picomatch(glob) : null;
90
+ let lintedPlanCount = 0;
91
+ for (const entry of entries) {
92
+ if (!entry.isFile() || !entry.name.endsWith(PLAN_EXTENSION)) continue;
93
+ const planId = entry.name.slice(0, -PLAN_EXTENSION.length);
94
+ if (!PLAN_TASK_ID_REGEX.test(planId) || matches && !matches(entry.name) && !matches(planId)) continue;
95
+ const content = await fs.readFile(path.join(plansDir, entry.name), "utf8");
96
+ lintedPlanCount += 1;
97
+ errors.push(...lintPlanContent(planId, content));
98
+ }
99
+ return {
100
+ type: "plan_lint",
101
+ errors,
102
+ errorCount: errors.filter((error) => error.severity === "error").length,
103
+ warningCount: errors.filter((error) => error.severity === "warning").length,
104
+ lintedPlanCount
105
+ };
106
+ }
107
+ function formatPlanLintErrors(errors) {
108
+ if (errors.length === 0) return "";
109
+ const lines = ["## Plan Lint Errors", ""];
110
+ const byPlan = /* @__PURE__ */ new Map();
111
+ for (const error of errors) {
112
+ byPlan.set(error.planId, [...byPlan.get(error.planId) ?? [], error]);
113
+ }
114
+ for (const [planId, planErrors] of byPlan) {
115
+ lines.push(`### ${planId}.plan.md`);
116
+ for (const error of planErrors) {
117
+ const location = error.line ? ` line ${error.line}` : "";
118
+ lines.push(`- ${error.severity.toUpperCase()}${location}: ${error.message}`);
119
+ }
120
+ lines.push("");
121
+ }
122
+ lines.push("Fix these issues by editing the Markdown plan file.");
123
+ return lines.join("\n");
124
+ }
125
+ export {
126
+ formatPlanLintErrors,
127
+ lintPlanContent,
128
+ lintPlans
129
+ };
@@ -0,0 +1,94 @@
1
+ const PLAN_TASK_ID_REGEX = /^[a-z0-9][a-z0-9-]*$/;
2
+ const PLAN_TASK_LINE_REGEX = /^- \[( |x)\] ([a-z0-9][a-z0-9-]*): (.+)$/;
3
+ const PLAN_TASKS_HEADING_REGEX = /^## Tasks\s*$/;
4
+ function getPlanMetadata(content) {
5
+ const title = content.split(/\r?\n/).map((line) => line.match(/^# (.+)$/)?.[1]?.trim()).find((value) => Boolean(value));
6
+ return { title: title || "Untitled Plan" };
7
+ }
8
+ function getTasksSectionRange(content) {
9
+ const lines = content.split(/\r?\n/);
10
+ const startIndex = lines.findIndex((line) => PLAN_TASKS_HEADING_REGEX.test(line));
11
+ if (startIndex === -1) return null;
12
+ let endIndex = lines.length;
13
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
14
+ if (/^##\s+/.test(lines[index] ?? "")) {
15
+ endIndex = index;
16
+ break;
17
+ }
18
+ }
19
+ return { startIndex, endIndex };
20
+ }
21
+ function parseMarkdownPlan(content, planId) {
22
+ const lines = content.split(/\r?\n/);
23
+ const { title } = getPlanMetadata(content);
24
+ const range = getTasksSectionRange(content);
25
+ const tasks = [];
26
+ if (range) {
27
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
28
+ const line = lines[index] ?? "";
29
+ const match = line.match(PLAN_TASK_LINE_REGEX);
30
+ if (!match) continue;
31
+ const description = match[3].trim();
32
+ if (!description) continue;
33
+ tasks.push({
34
+ id: match[2],
35
+ description,
36
+ checked: match[1] === "x",
37
+ lineNumber: index + 1
38
+ });
39
+ }
40
+ }
41
+ const currentTask = tasks.find((task) => !task.checked) ?? null;
42
+ return {
43
+ id: planId,
44
+ title,
45
+ content,
46
+ tasks,
47
+ currentTask,
48
+ complete: tasks.length > 0 && tasks.every((task) => task.checked),
49
+ tasksStartLine: range ? range.startIndex + 1 : null,
50
+ tasksEndLine: range ? range.endIndex : null
51
+ };
52
+ }
53
+ const parsePlan = parseMarkdownPlan;
54
+ function createPlanMarkdown(planId, title = formatPlanTitle(planId)) {
55
+ return `# ${title}
56
+
57
+ Brief implementation notes.
58
+
59
+ ## Tasks
60
+ `;
61
+ }
62
+ function updateTaskCheckbox(content, taskId, checked) {
63
+ if (!PLAN_TASK_ID_REGEX.test(taskId)) {
64
+ throw new Error(`Invalid task id: ${taskId}`);
65
+ }
66
+ const lines = content.split(/\r?\n/);
67
+ const range = getTasksSectionRange(content);
68
+ if (!range) {
69
+ throw new Error("Plan is missing a ## Tasks section.");
70
+ }
71
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
72
+ const line = lines[index] ?? "";
73
+ const match = line.match(PLAN_TASK_LINE_REGEX);
74
+ if (!match || match[2] !== taskId) continue;
75
+ lines[index] = `- [${checked ? "x" : " "}] ${match[2]}: ${match[3].trim()}`;
76
+ return lines.join("\n");
77
+ }
78
+ throw new Error(`Task not found in plan: ${taskId}`);
79
+ }
80
+ function formatPlanTitle(planId) {
81
+ return planId.split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
82
+ }
83
+ export {
84
+ PLAN_TASKS_HEADING_REGEX,
85
+ PLAN_TASK_ID_REGEX,
86
+ PLAN_TASK_LINE_REGEX,
87
+ createPlanMarkdown,
88
+ formatPlanTitle,
89
+ getPlanMetadata,
90
+ getTasksSectionRange,
91
+ parseMarkdownPlan,
92
+ parsePlan,
93
+ updateTaskCheckbox
94
+ };
@@ -0,0 +1,25 @@
1
+ import { PLAN_TASK_ID_REGEX } from "./plan-parser.mjs";
2
+ const PLAN_EXTENSION = ".plan.md";
3
+ function validatePlanId(planId) {
4
+ if (!PLAN_TASK_ID_REGEX.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
5
+ }
6
+ function assertPlanTarget(target) {
7
+ if (target.type === "workspace" && target.rootProfile !== "visible_projects") {
8
+ throw new Error("Ordinary plans are unavailable in canonical synchronization sessions");
9
+ }
10
+ }
11
+ function getWorkerPlansPath(target) {
12
+ assertPlanTarget(target);
13
+ return target.type === "project" ? "$R5D_ROOT/plans/$R5D_PROJECT_ID/$R5D_BRANCH_NAME" : "$R5D_ROOT/plans/workspace";
14
+ }
15
+ function getWorkerPlanPath(target, planId) {
16
+ validatePlanId(planId);
17
+ return `${getWorkerPlansPath(target)}/${planId}${PLAN_EXTENSION}`;
18
+ }
19
+ export {
20
+ PLAN_EXTENSION,
21
+ assertPlanTarget,
22
+ getWorkerPlanPath,
23
+ getWorkerPlansPath,
24
+ validatePlanId
25
+ };
@@ -0,0 +1,53 @@
1
+ const keyOf = (runId, stream) => `${runId}:${stream}`;
2
+ class WorkerOutputWindow {
3
+ constructor(windowBytes = 1024 * 1024) {
4
+ this.windowBytes = windowBytes;
5
+ }
6
+ windowBytes;
7
+ sent = /* @__PURE__ */ new Map();
8
+ acknowledged = /* @__PURE__ */ new Map();
9
+ outstanding = 0;
10
+ reset(cursors) {
11
+ this.sent.clear();
12
+ this.acknowledged.clear();
13
+ this.outstanding = 0;
14
+ for (const cursor of cursors) {
15
+ for (const stream of ["stdout", "stderr"]) {
16
+ const offset = stream === "stdout" ? cursor.stdoutBytes : cursor.stderrBytes;
17
+ this.sent.set(keyOf(cursor.runId, stream), offset);
18
+ this.acknowledged.set(keyOf(cursor.runId, stream), offset);
19
+ }
20
+ }
21
+ }
22
+ send(frame, send, bufferedAmount = 0) {
23
+ const key = keyOf(frame.runId, frame.stream);
24
+ const sent = this.sent.get(key) ?? 0;
25
+ const bytes = Buffer.from(frame.data, "utf8");
26
+ const end = frame.offset + bytes.length;
27
+ if (end <= sent) return true;
28
+ if (frame.offset > sent) return false;
29
+ let cursor = sent;
30
+ while (cursor < end) {
31
+ let count = Math.min(end - cursor, 64 * 1024, this.windowBytes - this.outstanding);
32
+ while (count > 0 && ((bytes[cursor - frame.offset + count] ?? 0) & 192) === 128) count -= 1;
33
+ if (count <= 0 || bufferedAmount >= this.windowBytes) return false;
34
+ const data = bytes.subarray(cursor - frame.offset, cursor - frame.offset + count).toString("utf8");
35
+ if (!send({ ...frame, offset: cursor, data })) return false;
36
+ cursor += count;
37
+ this.sent.set(key, cursor);
38
+ this.outstanding += count;
39
+ }
40
+ return true;
41
+ }
42
+ acknowledge(runId, stream, offset) {
43
+ if (!Number.isSafeInteger(offset) || offset < 0) return;
44
+ const key = keyOf(runId, stream);
45
+ const previous = this.acknowledged.get(key) ?? 0;
46
+ const next = Math.max(previous, Math.min(this.sent.get(key) ?? 0, offset));
47
+ this.acknowledged.set(key, next);
48
+ this.outstanding = Math.max(0, this.outstanding - (next - previous));
49
+ }
50
+ }
51
+ export {
52
+ WorkerOutputWindow
53
+ };
@@ -0,0 +1,6 @@
1
+ const WORKER_RESUMABLE_PROTOCOL = 1;
2
+ const WORKER_RECOVERY_TIMEOUT_MS = 5 * 60 * 1e3;
3
+ export {
4
+ WORKER_RECOVERY_TIMEOUT_MS,
5
+ WORKER_RESUMABLE_PROTOCOL
6
+ };
@@ -0,0 +1,194 @@
1
+ import { Database } from "bun:sqlite";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ function workerOperationDigest(message) {
6
+ const canonical = (value) => {
7
+ if (Array.isArray(value)) return value.map(canonical);
8
+ if (value && typeof value === "object")
9
+ return Object.fromEntries(
10
+ Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])
11
+ );
12
+ return value;
13
+ };
14
+ const { requestId: _requestId, ...identity } = message;
15
+ return createHash("sha256").update(JSON.stringify(canonical(identity))).digest("hex");
16
+ }
17
+ class WorkerRecoveryStore {
18
+ db;
19
+ ledgerId;
20
+ constructor(filename) {
21
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 448 });
22
+ this.db = new Database(filename, { create: true });
23
+ fs.chmodSync(filename, 384);
24
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
25
+ CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
26
+ CREATE TABLE IF NOT EXISTS operations (request_id TEXT PRIMARY KEY, run_id TEXT, session_id TEXT, request_type TEXT NOT NULL, digest TEXT NOT NULL, state TEXT NOT NULL, response TEXT);
27
+ CREATE UNIQUE INDEX IF NOT EXISTS operation_run_start ON operations(run_id) WHERE request_type='exec_start';
28
+ CREATE TABLE IF NOT EXISTS aliases (request_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL);
29
+ CREATE TABLE IF NOT EXISTS streams (run_id TEXT NOT NULL, stream TEXT NOT NULL, bytes INTEGER NOT NULL DEFAULT 0, acknowledged INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(run_id, stream));
30
+ CREATE TABLE IF NOT EXISTS output (run_id TEXT NOT NULL, stream TEXT NOT NULL, offset INTEGER NOT NULL, bytes INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(run_id, stream, offset));
31
+ CREATE TABLE IF NOT EXISTS terminals (run_id TEXT PRIMARY KEY, message TEXT NOT NULL);
32
+ CREATE TABLE IF NOT EXISTS cancelled_requests (request_id TEXT PRIMARY KEY);
33
+ `);
34
+ this.db.run("INSERT OR IGNORE INTO metadata(key,value) VALUES ('ledgerId',?)", [crypto.randomUUID()]);
35
+ this.ledgerId = this.db.query("SELECT value FROM metadata WHERE key='ledgerId'").get().value;
36
+ this.db.run("UPDATE operations SET state='unknown' WHERE state IN ('accepted','preparing','running')");
37
+ }
38
+ close() {
39
+ this.db.close();
40
+ }
41
+ row(requestId) {
42
+ return this.db.query("SELECT * FROM operations WHERE request_id=? OR request_id=(SELECT operation_id FROM aliases WHERE request_id=?)").get(requestId, requestId);
43
+ }
44
+ admit(message) {
45
+ const digest = workerOperationDigest(message);
46
+ let existing = this.row(message.requestId);
47
+ if (!existing && message.type === "exec_start" && typeof message.runId === "string")
48
+ existing = this.db.query("SELECT * FROM operations WHERE run_id=? AND request_type='exec_start'").get(message.runId);
49
+ if (existing) {
50
+ if (existing.digest !== digest) return "conflict";
51
+ this.db.run("INSERT OR IGNORE INTO aliases(request_id, operation_id) VALUES (?,?)", [message.requestId, existing.request_id]);
52
+ return existing.state === "unknown" ? "unknown" : "duplicate";
53
+ }
54
+ this.db.run("INSERT INTO operations(request_id, run_id, session_id, request_type, digest, state) VALUES (?,?,?,?,?,'accepted')", [
55
+ message.requestId,
56
+ typeof message.runId === "string" ? message.runId : null,
57
+ typeof message.sessionId === "string" ? message.sessionId : null,
58
+ message.type,
59
+ digest
60
+ ]);
61
+ return "new";
62
+ }
63
+ isUnknown(requestId) {
64
+ return this.row(requestId)?.state === "unknown";
65
+ }
66
+ unknown(requestId) {
67
+ const row = this.row(requestId);
68
+ if (row) this.db.run("UPDATE operations SET state='unknown' WHERE request_id=?", [row.request_id]);
69
+ }
70
+ unknownRun(runId) {
71
+ this.db.run("UPDATE operations SET state='unknown' WHERE run_id=? AND request_type IN ('exec_start','exec')", [runId]);
72
+ }
73
+ record(message) {
74
+ if (message.type === "exec_output" && typeof message.runId === "string" && (message.stream === "stdout" || message.stream === "stderr") && typeof message.data === "string") {
75
+ const runId = message.runId, stream = message.stream, data = message.data;
76
+ const offset = this.db.transaction(() => {
77
+ this.db.run("INSERT OR IGNORE INTO streams(run_id, stream) VALUES (?,?)", [runId, stream]);
78
+ const offset2 = this.db.query("SELECT bytes FROM streams WHERE run_id=? AND stream=?").get(runId, stream).bytes;
79
+ const bytes = Buffer.byteLength(data);
80
+ if (bytes) {
81
+ this.db.run("INSERT INTO output(run_id, stream, offset, bytes, data) VALUES (?,?,?,?,?)", [runId, stream, offset2, bytes, data]);
82
+ this.db.run("UPDATE streams SET bytes=bytes+? WHERE run_id=? AND stream=?", [bytes, runId, stream]);
83
+ }
84
+ return offset2;
85
+ })();
86
+ return { ...message, offset };
87
+ }
88
+ if ((message.type === "exec_exit" || message.type === "exec_error") && typeof message.runId === "string") {
89
+ const terminal = { ...message, ...this.lengths(message.runId) };
90
+ this.db.transaction(() => {
91
+ this.db.run("INSERT OR REPLACE INTO terminals(run_id,message) VALUES (?,?)", [message.runId, JSON.stringify(terminal)]);
92
+ this.db.run("UPDATE operations SET state='completed' WHERE run_id=? AND request_type='exec_start'", [message.runId]);
93
+ })();
94
+ return terminal;
95
+ }
96
+ if (typeof message.requestId === "string") {
97
+ const row = this.row(message.requestId);
98
+ if (row) {
99
+ const state = message.type === "exec_accepted" || message.type === "operation_queued" ? "preparing" : message.type === "exec_started" ? "running" : message.type === "operation_received" ? "accepted" : "completed";
100
+ if (message.type !== "operation_received")
101
+ this.db.run("UPDATE operations SET state=?, response=? WHERE request_id=?", [state, JSON.stringify(message), row.request_id]);
102
+ }
103
+ }
104
+ return message;
105
+ }
106
+ operations() {
107
+ return this.db.query(
108
+ "SELECT * FROM operations WHERE response IS NOT NULL OR state!='completed' UNION ALL SELECT aliases.request_id,operations.run_id,operations.session_id,operations.request_type,operations.digest,operations.state,operations.response FROM aliases JOIN operations ON operations.request_id=aliases.operation_id WHERE aliases.request_id!=operations.request_id AND (response IS NOT NULL OR state!='completed')"
109
+ ).all().map((row) => ({
110
+ requestId: row.request_id,
111
+ requestType: row.request_type,
112
+ state: row.state,
113
+ ...row.run_id ? { runId: row.run_id } : {},
114
+ ...row.session_id ? { sessionId: row.session_id } : {}
115
+ }));
116
+ }
117
+ response(requestId) {
118
+ const row = this.row(requestId);
119
+ return row?.response && row.state !== "unknown" ? { ...JSON.parse(row.response), requestId } : void 0;
120
+ }
121
+ responses(requestIds) {
122
+ const ids = /* @__PURE__ */ new Set([...requestIds, ...this.operations().map((operation) => operation.requestId)]);
123
+ return [...ids].flatMap((requestId) => {
124
+ const response = this.response(requestId);
125
+ return response ? [response] : [];
126
+ });
127
+ }
128
+ lengths(runId) {
129
+ const result = { stdoutBytes: 0, stderrBytes: 0 };
130
+ for (const row of this.db.query("SELECT stream,bytes FROM streams WHERE run_id=?").all(runId))
131
+ result[row.stream === "stdout" ? "stdoutBytes" : "stderrBytes"] = row.bytes;
132
+ return result;
133
+ }
134
+ outputCursors() {
135
+ const cursors = /* @__PURE__ */ new Map();
136
+ for (const stream of this.db.query("SELECT run_id,stream,acknowledged FROM streams").all()) {
137
+ const cursor = cursors.get(stream.run_id) ?? { runId: stream.run_id, stdoutBytes: 0, stderrBytes: 0 };
138
+ cursor[stream.stream === "stdout" ? "stdoutBytes" : "stderrBytes"] = stream.acknowledged;
139
+ cursors.set(stream.run_id, cursor);
140
+ }
141
+ return [...cursors.values()];
142
+ }
143
+ *output(offsets = []) {
144
+ const cursors = new Map(offsets.map((offset) => [offset.runId, offset]));
145
+ for (const stream of this.db.query("SELECT * FROM streams WHERE NOT EXISTS (SELECT 1 FROM operations WHERE operations.run_id=streams.run_id AND operations.state='unknown') ORDER BY rowid").all()) {
146
+ const supplied = cursors.get(stream.run_id);
147
+ const cursor = supplied ? stream.stream === "stdout" ? supplied.stdoutBytes : supplied.stderrBytes : stream.acknowledged;
148
+ if (cursor >= stream.bytes) continue;
149
+ const firstOffset = this.db.query("SELECT MAX(offset) AS offset FROM output WHERE run_id=? AND stream=? AND offset<=?").get(stream.run_id, stream.stream, cursor)?.offset ?? 0;
150
+ let nextChunk = firstOffset;
151
+ while (true) {
152
+ const row = this.db.query("SELECT offset,data FROM output WHERE run_id=? AND stream=? AND offset>=? AND offset<? AND offset+bytes>? ORDER BY offset LIMIT 1").get(stream.run_id, stream.stream, nextChunk, stream.bytes, cursor);
153
+ if (!row) break;
154
+ nextChunk = row.offset + 1;
155
+ yield { type: "exec_output", runId: stream.run_id, stream: stream.stream, offset: row.offset, data: row.data };
156
+ }
157
+ }
158
+ }
159
+ terminals() {
160
+ return this.db.query(
161
+ "SELECT message FROM terminals WHERE NOT EXISTS (SELECT 1 FROM operations WHERE operations.run_id=terminals.run_id AND operations.state='unknown')"
162
+ ).all().map((row) => JSON.parse(row.message));
163
+ }
164
+ acknowledgeOutput(runId, stream, offset) {
165
+ if (!Number.isSafeInteger(offset) || offset < 0) return;
166
+ this.db.run("UPDATE streams SET acknowledged=MAX(acknowledged,MIN(bytes,?)) WHERE run_id=? AND stream=?", [offset, runId, stream]);
167
+ }
168
+ acknowledgeResult(requestId) {
169
+ const row = this.row(requestId);
170
+ if (row && row.request_type !== "exec_start")
171
+ this.db.run("UPDATE operations SET response=NULL WHERE request_id=? AND state='completed'", [row.request_id]);
172
+ }
173
+ acknowledgeTerminal(runId) {
174
+ this.db.transaction(() => {
175
+ this.db.run("DELETE FROM terminals WHERE run_id=?", [runId]);
176
+ this.db.run("DELETE FROM output WHERE run_id=?", [runId]);
177
+ this.db.run("UPDATE streams SET acknowledged=bytes WHERE run_id=?", [runId]);
178
+ this.db.run("UPDATE operations SET response=NULL WHERE run_id=? AND request_type='exec_start' AND state='completed'", [runId]);
179
+ })();
180
+ }
181
+ cancelSession(sessionId) {
182
+ this.db.run("INSERT OR IGNORE INTO cancelled_requests SELECT request_id FROM operations WHERE session_id=? AND state!='completed'", [
183
+ sessionId
184
+ ]);
185
+ }
186
+ isRequestCancelled(requestId) {
187
+ const row = this.row(requestId);
188
+ return !!row && this.db.query("SELECT request_id FROM cancelled_requests WHERE request_id=?").get(row.request_id) !== null;
189
+ }
190
+ }
191
+ export {
192
+ WorkerRecoveryStore,
193
+ workerOperationDigest
194
+ };
@@ -6,6 +6,7 @@ const WORKER_RECONNECT_EXIT_CODE = 76;
6
6
  const WORKER_CREDENTIAL_RESTART_EXIT_CODE = 77;
7
7
  const WORKER_RECONNECT_DELAY_MS = 2e3;
8
8
  const WORKER_RECONNECT_JITTER_MS = 3e3;
9
+ const WORKER_RECONNECT_MAX_DELAY_MS = 3e4;
9
10
  const SUPERVISOR_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
10
11
  function defaultRuntimeSpawner(argv, options) {
11
12
  return Bun.spawn(argv, options);
@@ -13,9 +14,12 @@ function defaultRuntimeSpawner(argv, options) {
13
14
  function defaultRuntimeDelay(delayMs) {
14
15
  return new Promise((resolve) => setTimeout(resolve, delayMs));
15
16
  }
16
- function workerReconnectDelayMs(random = Math.random) {
17
+ function workerReconnectDelayMs(random = Math.random, attempt = 0) {
17
18
  const sample = Math.max(0, Math.min(1, random()));
18
- return WORKER_RECONNECT_DELAY_MS + Math.floor(sample * WORKER_RECONNECT_JITTER_MS);
19
+ const exponent = Math.min(20, Math.max(0, Math.floor(attempt)));
20
+ const ceiling = Math.min(WORKER_RECONNECT_MAX_DELAY_MS, (WORKER_RECONNECT_DELAY_MS + WORKER_RECONNECT_JITTER_MS) * 2 ** exponent);
21
+ const floor = Math.min(WORKER_RECONNECT_DELAY_MS * 2 ** exponent, ceiling * 0.4);
22
+ return floor + Math.floor(sample * (ceiling - floor));
19
23
  }
20
24
  async function defaultRuntimeReaper(runtime) {
21
25
  await terminateProcessTree(runtime);
@@ -29,10 +33,11 @@ function signalExitCode(signal) {
29
33
  function osSignalNumber(signal) {
30
34
  return osConstants.signals[signal];
31
35
  }
32
- async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = defaultRuntimeSpawner, waitBeforeReconnect = defaultRuntimeDelay, reapCredentialRuntime = defaultRuntimeReaper, reconnectDelay = workerReconnectDelayMs) {
36
+ async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = defaultRuntimeSpawner, waitBeforeReconnect = defaultRuntimeDelay, reapCredentialRuntime = defaultRuntimeReaper, reconnectDelay) {
33
37
  if (!process.argv[1]) {
34
38
  throw new Error("Cannot locate the r5d-worker entrypoint");
35
39
  }
40
+ let reconnectAttempt = 0;
36
41
  while (true) {
37
42
  const runtime = spawnRuntime([process.execPath, process.argv[1], ...argv], {
38
43
  stdin: "inherit",
@@ -81,7 +86,7 @@ async function superviseWorkerRuntime(argv, env = process.env, spawnRuntime = de
81
86
  continue;
82
87
  }
83
88
  if (exitCode === WORKER_RECONNECT_EXIT_CODE) {
84
- const reconnectDelayMs = reconnectDelay();
89
+ const reconnectDelayMs = reconnectDelay?.() ?? workerReconnectDelayMs(Math.random, reconnectAttempt++);
85
90
  process.stderr.write(`[r5d-worker] reconnecting in ${reconnectDelayMs}ms
86
91
  `);
87
92
  await waitBeforeReconnect(reconnectDelayMs);
@@ -100,6 +105,7 @@ export {
100
105
  WORKER_RECONNECT_DELAY_MS,
101
106
  WORKER_RECONNECT_EXIT_CODE,
102
107
  WORKER_RECONNECT_JITTER_MS,
108
+ WORKER_RECONNECT_MAX_DELAY_MS,
103
109
  WORKER_RELOAD_EXIT_CODE,
104
110
  WORKER_RUNTIME_ENV,
105
111
  isRetryableWorkerServerStatus,
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bun
2
+ import type { WorkerLintPlansResult, WorkerUpdatePlanTaskResult } from "./plan-paths";
3
+ import { WORKER_RESUMABLE_PROTOCOL, type WorkerRecoveryClientMessage, type WorkerRecoveryServerMessage } from "./recovery-protocol";
2
4
  import { Database } from "bun:sqlite";
3
5
  import type { WorkerGitIdentity } from "./git-identity";
4
6
  import { configureGitHubRegistryAuthFiles, type PreparedPrivateAuthFileGeneration } from "./registry-auth";
@@ -134,8 +136,11 @@ export type WorkerSessionTarget = {
134
136
  ownerUserId: string;
135
137
  rootProfile: "visible_projects" | "canonical_sync";
136
138
  };
137
- type WorkerClientMessage = {
139
+ type WorkerClientMessage = WorkerRecoveryClientMessage | {
138
140
  type: "hello";
141
+ resumableProtocol: typeof WORKER_RESUMABLE_PROTOCOL;
142
+ runtimeId: string;
143
+ ledgerId: string;
139
144
  hostInfo?: Record<string, unknown>;
140
145
  startupReady?: boolean;
141
146
  } | {
@@ -277,7 +282,7 @@ type WorkerClientMessage = {
277
282
  installedR5dctlVersion?: string;
278
283
  error?: string;
279
284
  };
280
- type WorkerServerMessage = {
285
+ type WorkerServerMessage = WorkerRecoveryServerMessage | {
281
286
  type: "connected";
282
287
  label: string;
283
288
  workerId: string;
@@ -414,16 +419,40 @@ type WorkerServerMessage = {
414
419
  }>;
415
420
  } | {
416
421
  type: "pty_input";
422
+ requestId: string;
417
423
  ptyId: string;
418
424
  data: string;
419
425
  } | {
420
426
  type: "pty_resize";
427
+ requestId: string;
421
428
  ptyId: string;
422
429
  cols: number;
423
430
  rows: number;
424
431
  } | {
425
432
  type: "pty_close";
433
+ requestId: string;
426
434
  ptyId: string;
435
+ } | {
436
+ type: "read_plan";
437
+ requestId: string;
438
+ target: WorkerSessionTarget;
439
+ sessionId?: string;
440
+ planId: string;
441
+ createIfMissing: boolean;
442
+ } | {
443
+ type: "lint_plans";
444
+ requestId: string;
445
+ target: WorkerSessionTarget;
446
+ sessionId?: string;
447
+ glob?: string;
448
+ } | {
449
+ type: "update_task_status";
450
+ requestId: string;
451
+ target: WorkerSessionTarget;
452
+ sessionId?: string;
453
+ planId: string;
454
+ taskId: string;
455
+ checked: boolean;
427
456
  } | {
428
457
  type: "read";
429
458
  requestId: string;
@@ -514,6 +543,11 @@ type WorkerGitHubCredential = {
514
543
  token: string;
515
544
  missingScopes: Array<"repo" | "read:org" | "gist" | "workflow" | "read:packages" | "write:packages">;
516
545
  };
546
+ type WorkerReadPlanResult = {
547
+ type: "read_plan";
548
+ read: WorkerReadFileResult;
549
+ created: boolean;
550
+ };
517
551
  type WorkerWriteFileResult = {
518
552
  type: "write";
519
553
  file: string;
@@ -587,7 +621,10 @@ type WorkerCodeReadResult = {
587
621
  mtime: string;
588
622
  base64: string;
589
623
  };
590
- type WorkerOperationResult = WorkerReadFileResult | WorkerWriteFileResult | WorkerEditFileResult | WorkerGrepResult | WorkerFindResult | WorkerLsResult | WorkerViewFileBytesResult | WorkerExecStdinAck | WorkerCreateProjectBranchResult | WorkerDeleteProjectBranchResult | WorkerCodeListResult | WorkerCodeReadResult;
624
+ type WorkerOperationResult = WorkerReadFileResult | WorkerReadPlanResult | WorkerLintPlansResult | WorkerUpdatePlanTaskResult | WorkerWriteFileResult | WorkerEditFileResult | WorkerGrepResult | WorkerFindResult | WorkerLsResult | WorkerViewFileBytesResult | WorkerExecStdinAck | WorkerCreateProjectBranchResult | WorkerDeleteProjectBranchResult | WorkerCodeListResult | WorkerCodeReadResult;
625
+ type WorkerOperationServerMessage = Extract<WorkerServerMessage, {
626
+ type: "read_plan" | "lint_plans" | "update_task_status" | "read" | "write" | "edit" | "grep" | "find" | "ls" | "view_file_bytes" | "code_list" | "code_read";
627
+ }>;
591
628
  declare class WorkspaceSyncAdmissionChangedError extends Error {
592
629
  readonly name = "WorkspaceSyncAdmissionChangedError";
593
630
  }
@@ -916,6 +953,7 @@ export declare const workerGitSecurityTestHarness: {
916
953
  workspaceSyncFailureHydrationIsSafe: typeof workspaceSyncFailureHydrationIsSafe;
917
954
  assertWorkerChildAdmission: typeof assertWorkerChildAdmission;
918
955
  executeWriteFileOperation: typeof executeWriteFileOperation;
956
+ executeOperation: typeof executeOperation;
919
957
  executeEditFileOperation: typeof executeEditFileOperation;
920
958
  terminateCredentialBearingChildren: typeof terminateCredentialBearingChildren;
921
959
  terminateCredentialBearingChildrenWithRetention: typeof terminateCredentialBearingChildrenWithRetention;
@@ -1010,6 +1048,14 @@ export declare function listWorkerDirectory(branchPath: string, inputPath?: stri
1010
1048
  export declare function listWorkerCodeDirectory(branchPath: string, inputPath: string): WorkerCodeListResult;
1011
1049
  export declare function readWorkerCodeFile(branchPath: string, inputPath: string): WorkerCodeReadResult;
1012
1050
  export declare function readWorkerImageFile(branchPath: string, filePath: string, builtInPaths?: WorkerBuiltInToolPaths): WorkerViewFileBytesResult;
1051
+ declare function executeOperation(input: {
1052
+ message: WorkerOperationServerMessage;
1053
+ resolvedTarget: ResolvedWorkerSessionTarget;
1054
+ baseUrl: string;
1055
+ token: string;
1056
+ rootDir: string;
1057
+ assertAdmission: () => void;
1058
+ }): Promise<WorkerOperationResult>;
1013
1059
  declare function targetProcessEnv(rootDir: string, target: WorkerSessionTarget): Record<string, string>;
1014
1060
  export declare function prepareShellEnvForTarget(input: {
1015
1061
  target: WorkerSessionTarget;