brainchain-worker 0.12.2 → 0.13.0
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/README.md +40 -17
- package/dist/chain.js +21 -3
- package/dist/cli.js +137 -65
- package/dist/dispute.js +1 -2
- package/dist/key.js +92 -0
- package/dist/network.js +3 -3
- package/dist/propose.js +1 -1
- package/dist/standing.js +47 -3
- package/dist/state.js +150 -19
- package/dist/watch.js +83 -25
- package/dist/work.js +61 -4
- package/package.json +2 -2
package/dist/state.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
|
|
5
6
|
/** The decision count a one-shot watch run stopped at, kept per decision log; undefined when there is none or it cannot be read. */
|
|
@@ -15,8 +16,10 @@ export function readWatched(log, dir = stateDir()) {
|
|
|
15
16
|
export function writeWatched(log, seen, dir = stateDir()) {
|
|
16
17
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
17
18
|
const file = join(dir, `watch.${log.toLowerCase()}.json`);
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
// Each write its own temp file: runs from cron overlap, and nothing else keeps them apart.
|
|
20
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
21
|
+
writeFileSync(temp, JSON.stringify({ seen: seen.toString() }), { mode: 0o600 });
|
|
22
|
+
renameSync(temp, file);
|
|
20
23
|
}
|
|
21
24
|
const empty = (board) => ({ board: board.toLowerCase(), tasks: {}, done: [] });
|
|
22
25
|
export function memoryStore(board = "") {
|
|
@@ -101,6 +104,26 @@ export function fileStore(file, board, warn = console.warn) {
|
|
|
101
104
|
},
|
|
102
105
|
};
|
|
103
106
|
}
|
|
107
|
+
/** How often a run touches its lock. */
|
|
108
|
+
const LOCK_BEAT_MS = 5_000;
|
|
109
|
+
/** With no touch for this long a lock was left behind, whatever process now has its pid. */
|
|
110
|
+
const LOCK_STALE_MS = 15_000;
|
|
111
|
+
/** How long a lock from another PID namespace is watched for a touch, a little over one beat. */
|
|
112
|
+
const LOCK_WATCH_MS = 7_000;
|
|
113
|
+
/** A takeover takes a few system calls, so one marker older than this was left by a crash. */
|
|
114
|
+
const TAKEOVER_STALE_MS = 5_000;
|
|
115
|
+
/**
|
|
116
|
+
* Where a pid means something. Containers sharing a config folder each have
|
|
117
|
+
* their own numbering, so a holder's pid says nothing outside its own space.
|
|
118
|
+
*/
|
|
119
|
+
function pidSpace() {
|
|
120
|
+
try {
|
|
121
|
+
return `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}/${readlinkSync("/proc/self/ns/pid")}`;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return hostname();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
104
127
|
function alive(pid) {
|
|
105
128
|
// A container restarts its process with the same pid, so our own pid in the file is a stale lock.
|
|
106
129
|
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
|
|
@@ -113,28 +136,132 @@ function alive(pid) {
|
|
|
113
136
|
return error.code === "EPERM";
|
|
114
137
|
}
|
|
115
138
|
}
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
const sleep = (ms) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
140
|
+
function readIfThere(file) {
|
|
141
|
+
try {
|
|
142
|
+
return readFileSync(file, "utf8");
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code === "ENOENT")
|
|
146
|
+
return undefined;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function touchedAt(file) {
|
|
151
|
+
try {
|
|
152
|
+
return statSync(file).mtimeMs;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Who a lock's text names. A bare pid is a lock from 0.12 or earlier; text that is neither is a lock still being written. */
|
|
159
|
+
function holderOf(text) {
|
|
160
|
+
try {
|
|
161
|
+
const value = JSON.parse(text);
|
|
162
|
+
if (typeof value === "number")
|
|
163
|
+
return { pid: value };
|
|
164
|
+
if (isObject(value) && typeof value.pid === "number" && typeof value.space === "string")
|
|
165
|
+
return { pid: value.pid, space: value.space };
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Handled below.
|
|
169
|
+
}
|
|
170
|
+
return { pid: 0, space: "" };
|
|
171
|
+
}
|
|
172
|
+
/** Whether the run that wrote a lock is still going. */
|
|
173
|
+
function isHeld(file, { pid, space }, watchMs) {
|
|
174
|
+
if (space === undefined)
|
|
175
|
+
return alive(pid);
|
|
176
|
+
const touched = touchedAt(file);
|
|
177
|
+
if (touched === undefined || Date.now() - touched >= LOCK_STALE_MS)
|
|
178
|
+
return false;
|
|
179
|
+
if (pid === 0)
|
|
180
|
+
return true;
|
|
181
|
+
if (space === pidSpace())
|
|
182
|
+
return alive(pid);
|
|
183
|
+
// A recent touch can be a run that just died, and a restarted container is one more pid namespace: a live run touches its lock again within a beat.
|
|
184
|
+
sleep(watchMs);
|
|
185
|
+
return touchedAt(file) !== touched;
|
|
186
|
+
}
|
|
187
|
+
const held = (pid, space, file) => new Error(`Another run (pid ${pid}${space === undefined || space === pidSpace() ? "" : " in another container or PID namespace"}) is already working for this worker. ` +
|
|
188
|
+
`Stop it before starting another; ${file} holds its lock.`);
|
|
189
|
+
/**
|
|
190
|
+
* Replaces a lock left behind. The marker lets one start at a time judge and
|
|
191
|
+
* replace it, since removing the file another start has just written would give
|
|
192
|
+
* the lock to both.
|
|
193
|
+
*/
|
|
194
|
+
function takeOver(file, mine, judged) {
|
|
195
|
+
const marker = `${file}.takeover`;
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(marker, "", { flag: "wx", mode: 0o600 });
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error.code !== "EEXIST")
|
|
201
|
+
throw error;
|
|
202
|
+
const at = touchedAt(marker);
|
|
203
|
+
if (at !== undefined && Date.now() - at >= TAKEOVER_STALE_MS)
|
|
204
|
+
rmSync(marker, { force: true });
|
|
205
|
+
sleep(20);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (readIfThere(file) !== judged)
|
|
210
|
+
return false;
|
|
211
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
212
|
+
writeFileSync(temp, mine, { mode: 0o600 });
|
|
213
|
+
renameSync(temp, file);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
rmSync(marker, { force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Takes the lock in `file` for this process until it exits; refuses while another
|
|
222
|
+
* run holds it, in this PID namespace or another sharing the folder. The run
|
|
223
|
+
* touches the lock every few seconds, and `lost` is called if another took it
|
|
224
|
+
* over, as can happen to a run frozen for longer than a lock lasts.
|
|
225
|
+
*/
|
|
226
|
+
export function lock(file, lost = () => process.exit(1), watchMs = LOCK_WATCH_MS) {
|
|
120
227
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
228
|
+
const mine = JSON.stringify({ pid: process.pid, space: pidSpace(), run: randomBytes(4).toString("hex") });
|
|
121
229
|
for (;;) {
|
|
122
230
|
try {
|
|
123
|
-
writeFileSync(file,
|
|
231
|
+
writeFileSync(file, mine, { flag: "wx", mode: 0o600 });
|
|
124
232
|
break;
|
|
125
233
|
}
|
|
126
234
|
catch (error) {
|
|
127
235
|
if (error.code !== "EEXIST")
|
|
128
236
|
throw error;
|
|
129
237
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
238
|
+
const text = readIfThere(file);
|
|
239
|
+
// The holder released it since the write failed, so the next try takes it.
|
|
240
|
+
if (text === undefined)
|
|
241
|
+
continue;
|
|
242
|
+
const holder = holderOf(text);
|
|
243
|
+
if (isHeld(file, holder, watchMs))
|
|
244
|
+
throw held(holder.pid, holder.space, file);
|
|
245
|
+
if (takeOver(file, mine, text))
|
|
246
|
+
break;
|
|
135
247
|
}
|
|
248
|
+
const beat = setInterval(() => {
|
|
249
|
+
try {
|
|
250
|
+
if (readIfThere(file) !== mine) {
|
|
251
|
+
clearInterval(beat);
|
|
252
|
+
return lost();
|
|
253
|
+
}
|
|
254
|
+
const now = new Date();
|
|
255
|
+
utimesSync(file, now, now);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// A disk that fails for a moment is no reason to drop a task; the next beat tries again.
|
|
259
|
+
}
|
|
260
|
+
}, LOCK_BEAT_MS);
|
|
261
|
+
beat.unref();
|
|
136
262
|
const release = () => {
|
|
137
|
-
|
|
263
|
+
clearInterval(beat);
|
|
264
|
+
if (readIfThere(file) === mine)
|
|
138
265
|
rmSync(file, { force: true });
|
|
139
266
|
};
|
|
140
267
|
process.once("exit", release);
|
|
@@ -158,10 +285,11 @@ function migrate(old, file, board) {
|
|
|
158
285
|
if (parse(text)?.board !== board.toLowerCase())
|
|
159
286
|
return false;
|
|
160
287
|
// A run from before the move may still be working on this network's tasks from the old file.
|
|
161
|
-
const
|
|
288
|
+
const left = readIfThere(`${old}.lock`);
|
|
289
|
+
const pid = left === undefined ? 0 : holderOf(left).pid;
|
|
162
290
|
if (alive(pid))
|
|
163
|
-
throw held(pid, `${old}.lock`);
|
|
164
|
-
if (
|
|
291
|
+
throw held(pid, undefined, `${old}.lock`);
|
|
292
|
+
if (readIfThere(`${file}.json`) !== undefined)
|
|
165
293
|
return false;
|
|
166
294
|
// Removed rather than moved: this run already holds the new lock.
|
|
167
295
|
rmSync(`${old}.lock`, { force: true });
|
|
@@ -176,7 +304,10 @@ function migrate(old, file, board) {
|
|
|
176
304
|
export function openStore(address, network, log = console.log, dir = stateDir()) {
|
|
177
305
|
const old = join(dir, address);
|
|
178
306
|
const file = `${old}.${network.id}`;
|
|
179
|
-
lock(`${file}.lock
|
|
307
|
+
lock(`${file}.lock`, () => {
|
|
308
|
+
log(`another run took over ${file}.lock, so this one stops`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
});
|
|
180
311
|
if (migrate(old, file, network.contracts.taskBoard))
|
|
181
312
|
log(`moved ${old}.json to ${file}.json`);
|
|
182
313
|
return fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
|
package/dist/watch.js
CHANGED
|
@@ -81,6 +81,7 @@ export function createWatchChain(network, account) {
|
|
|
81
81
|
decisions: () => client.readContract({ address: network.contracts.log, abi: logAbi, functionName: "count" }),
|
|
82
82
|
...(gate
|
|
83
83
|
? {
|
|
84
|
+
watcher: () => client.readContract({ address: gate, abi: gateAbi, functionName: "watcher" }),
|
|
84
85
|
async veto(changeId, why) {
|
|
85
86
|
const watcher = await client.readContract({ address: gate, abi: gateAbi, functionName: "watcher" });
|
|
86
87
|
if (watcher.toLowerCase() !== account.address.toLowerCase())
|
|
@@ -93,12 +94,29 @@ export function createWatchChain(network, account) {
|
|
|
93
94
|
: {}),
|
|
94
95
|
};
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Refuses a --veto run whose key is not the gate's watcher, which otherwise
|
|
99
|
+
* shows only when a veto is due. A read that fails does not stop the watch: a
|
|
100
|
+
* round outlasts a bad RPC, and each veto reads the watcher again.
|
|
101
|
+
*/
|
|
102
|
+
export async function requireWatcher(chain, log) {
|
|
103
|
+
const watcher = await chain.watcher?.().catch((error) => {
|
|
104
|
+
log(`could not read the gate's watcher (${reason(error)}); it is read again before any veto`);
|
|
105
|
+
return undefined;
|
|
106
|
+
});
|
|
107
|
+
if (watcher !== undefined && watcher.toLowerCase() !== chain.account.toLowerCase()) {
|
|
108
|
+
throw new Error(`This key cannot veto: the gate's watcher is ${watcher}, not ${chain.account}. Set BRAINCHAIN_WORKER_KEY to the watcher's key, or run watch without --veto.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
96
111
|
/** The service's prefix on a report; the worker signs the prefix and the report's JSON text. */
|
|
97
112
|
export const REPORT_PREFIX = "BrainChain watchtower report\n";
|
|
98
113
|
/** A report lists at most this many decisions checked. */
|
|
99
114
|
export const MAX_CHECKED = 200;
|
|
100
115
|
/** The proof a record was fetched: the hash of its bytes followed by the worker's address, which nothing on chain gives away. */
|
|
101
116
|
export const fetchProof = (bytes, worker) => keccak256(concat([bytes, toBytes(worker)]));
|
|
117
|
+
/** The service answered and will not take this report, so sending it again would not help. */
|
|
118
|
+
export class ReportRefused extends Error {
|
|
119
|
+
}
|
|
102
120
|
/**
|
|
103
121
|
* Sends a round's re-verified decisions to the service, signed by the worker,
|
|
104
122
|
* so a staked worker's watchtower counts toward its mark in hire questions.
|
|
@@ -123,6 +141,8 @@ export function createReporter(network, account, fetcher = fetch) {
|
|
|
123
141
|
signal: AbortSignal.timeout(20_000),
|
|
124
142
|
});
|
|
125
143
|
const body = (await response.json().catch(() => ({})));
|
|
144
|
+
if (response.status >= 400 && response.status < 500)
|
|
145
|
+
throw new ReportRefused(body.error ?? `${response.status} from ${network.service}`);
|
|
126
146
|
if (!response.ok)
|
|
127
147
|
throw new Error(body.error ?? `${response.status} from ${network.service}`);
|
|
128
148
|
return body.mark
|
|
@@ -141,7 +161,10 @@ export function createWatch(options) {
|
|
|
141
161
|
let nextBlock = options.fromBlock ?? network.fromBlock;
|
|
142
162
|
let seenDecision = options.seenDecision;
|
|
143
163
|
const judged = new Set();
|
|
164
|
+
/** Vetoes due and not yet landed; each is tried every round until it lands or its change closes. */
|
|
165
|
+
const owed = new Map();
|
|
144
166
|
let findings = 0;
|
|
167
|
+
let incomplete = false;
|
|
145
168
|
async function changes() {
|
|
146
169
|
const head = await chain.head();
|
|
147
170
|
if (head < nextBlock)
|
|
@@ -153,36 +176,49 @@ export function createWatch(options) {
|
|
|
153
176
|
const key = change.changeId.toString();
|
|
154
177
|
if (judged.has(key))
|
|
155
178
|
continue;
|
|
156
|
-
judged.add(key);
|
|
157
179
|
const { name, broken } = judge(change.data);
|
|
180
|
+
// A failed read leaves the change unjudged and the block range unread, so the next round comes back to it.
|
|
181
|
+
const open = await chain.open(change.changeId);
|
|
182
|
+
judged.add(key);
|
|
183
|
+
const why = broken.join("; ");
|
|
184
|
+
if (!open) {
|
|
185
|
+
log(`change ${change.changeId}: ${name} on ${change.target}${broken.length > 0 ? ` broke the constitution (${why})` : ""}; closed, it has run or was vetoed`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
158
188
|
const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 16);
|
|
159
189
|
if (broken.length === 0) {
|
|
160
190
|
log(`change ${change.changeId}: ${name} on ${change.target}, within the constitution, runs after ${runsAt} UTC`);
|
|
161
191
|
continue;
|
|
162
192
|
}
|
|
163
193
|
findings += 1;
|
|
164
|
-
const why = broken.join("; ");
|
|
165
194
|
log(`change ${change.changeId}: ${name} on ${change.target} breaks the constitution (${why}), runs after ${runsAt} UTC`);
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
195
|
+
if (options.veto)
|
|
196
|
+
owed.set(key, { id: change.changeId, why });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
nextBlock = head + 1n;
|
|
200
|
+
}
|
|
201
|
+
async function vetoes() {
|
|
202
|
+
for (const [key, { id, why }] of owed) {
|
|
203
|
+
try {
|
|
204
|
+
if (!(await chain.open(id))) {
|
|
205
|
+
owed.delete(key);
|
|
206
|
+
log(`change ${id} is closed already, nothing to veto`);
|
|
171
207
|
}
|
|
172
|
-
if (!chain.veto) {
|
|
208
|
+
else if (!chain.veto) {
|
|
209
|
+
owed.delete(key);
|
|
173
210
|
log(`cannot veto: ${network.name} has no guardian gate this worker can reach`);
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
try {
|
|
177
|
-
const hash = await chain.veto(change.changeId, `Breaks the constitution: ${why}`);
|
|
178
|
-
log(`vetoed change ${change.changeId} through the guardian gate: ${hash}`);
|
|
179
211
|
}
|
|
180
|
-
|
|
181
|
-
|
|
212
|
+
else {
|
|
213
|
+
const hash = await chain.veto(id, `Breaks the constitution: ${why}`);
|
|
214
|
+
owed.delete(key);
|
|
215
|
+
log(`vetoed change ${id} through the guardian gate: ${hash}`);
|
|
182
216
|
}
|
|
183
217
|
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
log(`veto of change ${id} failed, tried again next round: ${reason(error)}`);
|
|
220
|
+
}
|
|
184
221
|
}
|
|
185
|
-
nextBlock = head + 1n;
|
|
186
222
|
}
|
|
187
223
|
async function decisions() {
|
|
188
224
|
if (!verify)
|
|
@@ -200,6 +236,7 @@ export function createWatch(options) {
|
|
|
200
236
|
}
|
|
201
237
|
const checked = [];
|
|
202
238
|
const found = [];
|
|
239
|
+
let reached = seenDecision;
|
|
203
240
|
for (let id = seenDecision + 1n; id <= count; id++) {
|
|
204
241
|
try {
|
|
205
242
|
const { findings: wrong, record } = await verify(id);
|
|
@@ -213,27 +250,48 @@ export function createWatch(options) {
|
|
|
213
250
|
found.push({ id, checks: wrong });
|
|
214
251
|
log(`decision ${id} does not verify: ${wrong.join("; ")}`);
|
|
215
252
|
}
|
|
253
|
+
reached = id;
|
|
216
254
|
}
|
|
217
255
|
catch (error) {
|
|
218
|
-
|
|
256
|
+
// The place stays here, so this decision and the ones after it are checked next time.
|
|
257
|
+
log(`decision ${id} could not be checked, so it is tried again next round: ${reason(error)}`.replace(/\s*\n\s*/g, " "));
|
|
258
|
+
incomplete = true;
|
|
259
|
+
break;
|
|
219
260
|
}
|
|
220
261
|
}
|
|
221
|
-
|
|
222
|
-
options.
|
|
223
|
-
|
|
224
|
-
|
|
262
|
+
// Before the place is saved, so a save that fails does not lose the round's work.
|
|
263
|
+
if (options.report && (checked.length > 0 || found.length > 0)) {
|
|
264
|
+
try {
|
|
265
|
+
log(await options.report(checked, found));
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
log(`report not taken: ${reason(error)}`);
|
|
269
|
+
// Only an answer from the service settles a report; these decisions are checked and sent again otherwise.
|
|
270
|
+
if (!(error instanceof ReportRefused)) {
|
|
271
|
+
incomplete = true;
|
|
272
|
+
reached = seenDecision;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
seenDecision = reached;
|
|
225
277
|
try {
|
|
226
|
-
|
|
278
|
+
options.saw?.(reached);
|
|
227
279
|
}
|
|
228
280
|
catch (error) {
|
|
229
|
-
log(`
|
|
281
|
+
log(`warning: could not save how far this got, so the next run checks these decisions again: ${reason(error)}`);
|
|
230
282
|
}
|
|
231
283
|
}
|
|
232
284
|
return {
|
|
233
285
|
async round() {
|
|
234
|
-
|
|
286
|
+
incomplete = false;
|
|
287
|
+
try {
|
|
288
|
+
await changes();
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await vetoes();
|
|
292
|
+
}
|
|
235
293
|
await decisions();
|
|
236
|
-
return { findings };
|
|
294
|
+
return { findings, incomplete };
|
|
237
295
|
},
|
|
238
296
|
};
|
|
239
297
|
}
|
package/dist/work.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { toBytes } from "viem";
|
|
3
4
|
import { fetchBrief, PermanentError, reason, RECEIPT_MS } from "./chain.js";
|
|
4
5
|
import { memoryStore } from "./state.js";
|
|
@@ -10,6 +11,8 @@ const OUTPUT_CAP = MAX_RESULT_BYTES * 2;
|
|
|
10
11
|
const MARGIN_SEC = 300n;
|
|
11
12
|
const RETRY_SEC = [60n, 240n, 960n];
|
|
12
13
|
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
|
14
|
+
/** How long the pipes stay open after a command exits, for what it had already written. */
|
|
15
|
+
const DRAIN_MS = 2_000;
|
|
13
16
|
const ANSI = /\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
|
|
14
17
|
export const stripAnsi = (text) => text.replace(ANSI, "");
|
|
15
18
|
/** Text from strangers, made safe to print as one terminal line. */
|
|
@@ -133,6 +136,44 @@ function orphaned(pgid) {
|
|
|
133
136
|
const own = ps(process.pid, "pgid");
|
|
134
137
|
return own !== undefined && own !== String(pgid);
|
|
135
138
|
}
|
|
139
|
+
/** A Linux process's name and parent; undefined where /proc has no such process or there is no /proc. */
|
|
140
|
+
function procStat(pid) {
|
|
141
|
+
try {
|
|
142
|
+
const stat = /^\d+ \((.*)\) \S (\d+) /s.exec(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
143
|
+
return stat ? { comm: stat[1], ppid: Number(stat[2]) } : undefined;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** The npm process above a worker it started: its parent, or its grandparent where the shell npm runs the command in stays between them. */
|
|
150
|
+
function npmAbove() {
|
|
151
|
+
const parent = procStat(process.ppid);
|
|
152
|
+
return parent && /^(sh|dash)$/.test(parent.comm) ? parent.ppid : undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Calls `lost` once when what npx put above this process dies. npx passes a stop
|
|
156
|
+
* to the shell it runs the worker in, and dash dies without passing it on, so
|
|
157
|
+
* the worker never hears it and would run on with nothing above it.
|
|
158
|
+
*/
|
|
159
|
+
export function watchStarter(lost, intervalMs = 1_000) {
|
|
160
|
+
const parent = process.ppid;
|
|
161
|
+
const npm = npmAbove();
|
|
162
|
+
const timer = setInterval(() => {
|
|
163
|
+
if (process.ppid === parent && (npm === undefined || exists(npm)))
|
|
164
|
+
return;
|
|
165
|
+
clearInterval(timer);
|
|
166
|
+
lost();
|
|
167
|
+
}, intervalMs);
|
|
168
|
+
timer.unref();
|
|
169
|
+
}
|
|
170
|
+
/** Whether a supervisor started the npx above this process, as PID 1, as a child of PID 1 or as a systemd service. A stop from it can reach no further than npx. */
|
|
171
|
+
export function npxUnderSupervisor(env = process.env) {
|
|
172
|
+
if (procStat(process.ppid) === undefined)
|
|
173
|
+
return false;
|
|
174
|
+
const npm = npmAbove() ?? process.ppid;
|
|
175
|
+
return npm === 1 || procStat(npm)?.ppid === 1 || env.INVOCATION_ID !== undefined;
|
|
176
|
+
}
|
|
136
177
|
/**
|
|
137
178
|
* Stops the command an earlier run left running when it died, such as on
|
|
138
179
|
* SIGKILL, so a restart never works on the same task twice at once. It gets
|
|
@@ -200,22 +241,36 @@ export function shellCommand(command, graceMs = 10_000) {
|
|
|
200
241
|
}, graceMs);
|
|
201
242
|
};
|
|
202
243
|
const timer = setTimeout(() => stop(`The command ran past ${Math.round(timeoutMs / 1000)} seconds and was stopped`), timeoutMs);
|
|
244
|
+
let drain;
|
|
245
|
+
child.on("exit", () => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
// A background process can hold the pipes open long after the command has exited, and close would wait for it.
|
|
248
|
+
drain = setTimeout(() => {
|
|
249
|
+
child.stdout.destroy();
|
|
250
|
+
child.stderr.destroy();
|
|
251
|
+
}, DRAIN_MS);
|
|
252
|
+
});
|
|
203
253
|
child.stdout.on("data", (chunk) => {
|
|
204
254
|
size += chunk.byteLength;
|
|
205
255
|
if (size <= OUTPUT_CAP)
|
|
206
256
|
return void chunks.push(chunk);
|
|
207
|
-
stop(`The command printed
|
|
257
|
+
stop(`The command printed too much and was stopped; at most ${MAX_RESULT_BYTES} bytes are accepted`);
|
|
208
258
|
});
|
|
209
259
|
child.on("error", (error) => {
|
|
210
260
|
clearTimeout(timer);
|
|
211
261
|
clearTimeout(force);
|
|
262
|
+
clearTimeout(drain);
|
|
212
263
|
reject(error);
|
|
213
264
|
});
|
|
214
265
|
child.on("close", (code, signal) => {
|
|
215
266
|
clearTimeout(timer);
|
|
216
267
|
clearTimeout(force);
|
|
217
|
-
|
|
268
|
+
clearTimeout(drain);
|
|
269
|
+
if (pid !== undefined) {
|
|
270
|
+
// The task is over, and nothing a stranger's brief started should outlive it.
|
|
271
|
+
signalGroup(pid, "SIGKILL");
|
|
218
272
|
running.delete(pid);
|
|
273
|
+
}
|
|
219
274
|
if (running.size === 0)
|
|
220
275
|
process.off("exit", kill);
|
|
221
276
|
if (stopped !== undefined)
|
|
@@ -364,7 +419,7 @@ export function createWork(options) {
|
|
|
364
419
|
delete attempt.tx;
|
|
365
420
|
store.save();
|
|
366
421
|
}
|
|
367
|
-
if (now + MARGIN_SEC >= task.deadline) {
|
|
422
|
+
if (attempt.result === undefined && now + MARGIN_SEC >= task.deadline) {
|
|
368
423
|
finish(key);
|
|
369
424
|
fail();
|
|
370
425
|
log(`task ${task.id}: too close to the deadline to deliver; the poster is refunded`);
|
|
@@ -387,6 +442,7 @@ export function createWork(options) {
|
|
|
387
442
|
throw new PermanentError("The result contains the worker's private key, so it was not uploaded. Check what the command can read");
|
|
388
443
|
}
|
|
389
444
|
const bytes = toBytes(output).byteLength;
|
|
445
|
+
// Not permanent: a retry runs the command again, and a model's next answer can fit.
|
|
390
446
|
if (bytes > MAX_RESULT_BYTES)
|
|
391
447
|
throw new Error(`The result is ${bytes} bytes; at most ${MAX_RESULT_BYTES} are accepted`);
|
|
392
448
|
attempt.result = output;
|
|
@@ -419,7 +475,8 @@ export function createWork(options) {
|
|
|
419
475
|
const wait = RETRY_SEC[Math.min(attempt.failures - 1, RETRY_SEC.length - 1)];
|
|
420
476
|
// The attempt may have run for many minutes, so the retry is timed from now.
|
|
421
477
|
const now = await chain.now().catch(wall);
|
|
422
|
-
|
|
478
|
+
// A retry with the result in hand only has to deliver it, so the margin for running the command does not apply.
|
|
479
|
+
if (now + wait + (attempt.result === undefined ? MARGIN_SEC : 0n) >= task.deadline) {
|
|
423
480
|
finish(key);
|
|
424
481
|
log(`task ${task.id}: failed (${why}); no time left to retry, so the poster is refunded`);
|
|
425
482
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Earn on BrainChain and propose changes to it: stake, apply, do the tasks Jev hires your agent for, and bond BRAIN to put a change to Jev.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BrainChain <hello@usebrainchain.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"prepack": "npm run build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"brainchain-verify": "^0.
|
|
31
|
+
"brainchain-verify": "^0.4.0",
|
|
32
32
|
"viem": "^2.0.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|