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/dist/state.js CHANGED
@@ -1,5 +1,6 @@
1
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
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
- writeFileSync(`${file}.tmp`, JSON.stringify({ seen: seen.toString() }), { mode: 0o600 });
19
- renameSync(`${file}.tmp`, file);
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 holder = (file) => (existsSync(file) ? Number(readFileSync(file, "utf8").trim()) : 0);
117
- const held = (pid, file) => new Error(`Another run (pid ${pid}) is already working for this worker. Stop it before starting another; ${file} holds its lock.`);
118
- /** Takes the lock in `file` for this process until it exits; refuses while a live process holds it. */
119
- export function lock(file) {
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, String(process.pid), { flag: "wx", mode: 0o600 });
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
- // The holder may have released it since the write failed; then the next try takes it.
131
- const pid = holder(file);
132
- if (alive(pid))
133
- throw held(pid, file);
134
- rmSync(file, { force: true });
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
- if (existsSync(file) && readFileSync(file, "utf8") === String(process.pid))
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 pid = holder(`${old}.lock`);
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 (existsSync(`${file}.json`))
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 (!options.veto)
167
- continue;
168
- if (!(await chain.open(change.changeId))) {
169
- log(`change ${change.changeId} is closed already, nothing to veto`);
170
- continue;
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
- catch (error) {
181
- log(`veto of change ${change.changeId} failed: ${reason(error)}`);
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
- log(`decision ${id} could not be checked: ${reason(error)}`);
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
- seenDecision = count;
222
- options.saw?.(count);
223
- if (!options.report || (checked.length === 0 && found.length === 0))
224
- return;
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
- log(await options.report(checked, found));
278
+ options.saw?.(reached);
227
279
  }
228
280
  catch (error) {
229
- log(`report not taken: ${reason(error)}`);
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
- await changes();
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 more than ${OUTPUT_CAP} bytes and was stopped; at most ${MAX_RESULT_BYTES} are accepted`);
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
- if (pid !== undefined)
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
- if (now + wait + MARGIN_SEC >= task.deadline) {
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.12.2",
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.3.1",
31
+ "brainchain-verify": "^0.4.0",
32
32
  "viem": "^2.0.0"
33
33
  },
34
34
  "devDependencies": {