brainchain-worker 0.2.1 → 0.2.3

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 CHANGED
@@ -54,17 +54,24 @@ Briefs are written by strangers, and some will try to get your agent to do somet
54
54
 
55
55
  ## Keep it running
56
56
 
57
- - **Stopping.** A `run` with no task in hand stops at once on Ctrl-C or SIGTERM. During a task, the first one stops `run` from taking new tasks and lets the current command finish and deliver; a second one, more than 1.5 seconds later, stops the command at once.
58
- - **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.json`, readable only by you: a result waiting to be uploaded or submitted, when each failed task is due again, and how far it has read the chain. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has.
57
+ - **Stopping.** A `run` with no task in hand stops at once on Ctrl-C or SIGTERM. During a task, the first one stops `run` from taking new tasks and lets the current command finish and deliver; a second one stops the command at once. SIGHUP, which a closed terminal or a dropped SSH session sends, stops the command and `run` at once.
58
+ - **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.json`, readable only by you: a result waiting to be uploaded or submitted, a submit already sent, when each failed task is due again, and how far it has read the chain. A restart, or the next `--once` run, picks up from there without running the command again for a result it already has, and waits for a sent submit before sending another. A command left running by a `run` that was killed is stopped when the next one starts. If the file is damaged, `run` moves it aside and starts without saved work.
59
59
  - **One run per worker.** A second `run` for the same address refuses to start while the first is alive.
60
+ - **Under a supervisor.** systemd sends SIGTERM to every process of a service by default, the command included, and every supervisor kills what is left after a timeout: 90 seconds for systemd, 10 for Docker and 30 for Kubernetes. For a stop that lets the current task deliver, signal the worker alone and allow the task timeout plus two minutes, 22 minutes with the default `--timeout`:
61
+
62
+ | Supervisor | Settings |
63
+ | --- | --- |
64
+ | systemd | `KillMode=mixed` and `TimeoutStopSec=22min` in `[Service]` |
65
+ | Docker | `stop_grace_period: 22m` in Compose, or `docker run --stop-timeout 1320` |
66
+ | Kubernetes | `terminationGracePeriodSeconds: 1320` in the pod spec |
60
67
 
61
68
  ## Commands
62
69
 
63
70
  | Command | |
64
71
  | --- | --- |
65
72
  | `new-key` | Print a fresh private key and its address. |
66
- | `join --name <name> --about <text> [--price <tUSDG>]` | Claim tBRAIN from the faucet if needed, stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
67
- | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once]` | Do every task the worker is hired for, earliest deadline first. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed, for a cron job. |
73
+ | `join --name <name> --about <text> [--price <tUSDG>]` | Claim tBRAIN from the faucet if needed, stake the minimum and apply. Jev reads the name and description to decide, and refuses an empty one. The name is at most 60 characters and the description at most 500. A new worker's price defaults to 0.20; a worker that has staked keeps its price unless `--price` changes it. An allowed worker whose stake fell below the minimum, after `leave` or a slash, stakes back up to it. Stake that is unstaking does not count, so the tBRAIN comes from the wallet or the faucet until `withdraw` returns it. |
74
+ | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once]` | Do every task the worker is hired for, earliest deadline first. `--timeout` sets how many minutes the command may spend on one task: 20 by default, at most 1440, fractions allowed. `--once` does one pass and exits, with code 1 if a task failed or was left too close to its deadline, for a cron job. |
68
75
  | `status` | Whether the worker can be hired, its stake, its tBRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs. |
69
76
  | `price <tUSDG>` | Change the asking price. |
70
77
  | `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
package/dist/chain.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isIP } from "node:net";
2
- import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, keccak256, parseAbi, TimeoutError, toBytes, } from "viem";
2
+ import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, keccak256, parseAbi, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
3
3
  import { erc20Errors, taskBoardAbi, workerStakingAbi } from "./abi.js";
4
4
  import { chainOf } from "./network.js";
5
5
  import { memoryStore } from "./state.js";
@@ -33,8 +33,10 @@ export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5,
33
33
  export const ApplicationState = { Open: 1, Returned: 2, Burned: 3 };
34
34
  const LOG_RANGE = 50000n;
35
35
  const MAX_BRIEF_BYTES = 256 * 1024;
36
- /** What the service answers when it will never take this result. */
36
+ /** What the service answers, with its own JSON error, when it will never take this result. */
37
37
  const REFUSED = [400, 403, 409, 413];
38
+ /** How long a transaction's receipt is waited for. */
39
+ export const RECEIPT_MS = 90_000;
38
40
  /** A failure that trying again cannot fix. */
39
41
  export class PermanentError extends Error {
40
42
  }
@@ -80,23 +82,51 @@ function unquote(text) {
80
82
  return text;
81
83
  }
82
84
  }
83
- /** A URL that is not an RPC, such as a web page, answers with a whole page; one short line of it is enough. */
84
- function excerpt(details) {
85
- const line = unquote(details)
85
+ /** The parts of a URL long enough to be an API key: the path, the query and each of their segments. */
86
+ function secretParts(url) {
87
+ if (!URL.canParse(url))
88
+ return [];
89
+ const { pathname, search } = new URL(url);
90
+ return [pathname + search, pathname, search, ...`${pathname}${search}`.split(/[/?&=]/)].filter((part) => part.length >= 8);
91
+ }
92
+ /**
93
+ * One short line of an answer, without the RPC URL's path or query: an answer
94
+ * can echo the path, which can hold an API key, and a web page is far longer
95
+ * than one line.
96
+ */
97
+ function excerpt(details, url) {
98
+ let line = unquote(details);
99
+ for (const part of secretParts(url))
100
+ line = line.replaceAll(part, "…");
101
+ line = line
86
102
  .replace(/\s+/g, " ")
87
103
  .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
88
104
  .trim();
89
105
  return line.length > 160 ? `${line.slice(0, 159)}…` : line;
90
106
  }
107
+ /** A request that failed, named by the RPC's host and the status it answered with. */
108
+ function requestFailure(request) {
109
+ const host = hostOf(request.url);
110
+ // viem passes on a 2xx answer that does not parse as JSON as the parser's error.
111
+ if (request.cause instanceof SyntaxError)
112
+ return `${host} answered, but not as an RPC`;
113
+ const details = request.details ? excerpt(request.details, request.url) : "";
114
+ const status = request instanceof TimeoutError ? undefined : request.status;
115
+ if (status === undefined)
116
+ return `Could not reach ${host}${details ? ` (${details})` : ""}`;
117
+ // A proxy or CDN in front of the RPC answers an outage or a rate limit with its own page.
118
+ if (details.startsWith("<"))
119
+ return `${host} answered ${status} with a web page${status === 404 || status === 405 ? ", not as an RPC" : ""}`;
120
+ return `${host} answered ${status}${details ? ` (${details})` : ""}`;
121
+ }
91
122
  /** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
92
123
  export function reason(error) {
93
124
  if (!(error instanceof BaseError))
94
125
  return error instanceof Error ? error.message : String(error);
95
- const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError);
96
- if (request instanceof HttpRequestError || request instanceof TimeoutError) {
97
- const details = request.details ? excerpt(request.details) : "";
98
- return `Could not reach ${hostOf(request.url)}${details ? ` (${details})` : ""}`;
99
- }
126
+ // An RPC error with an HTTP status is a provider refusing the request, such as a bad key or a rate limit.
127
+ const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError || (e instanceof RpcRequestError && e.status !== undefined));
128
+ if (request instanceof HttpRequestError || request instanceof RpcRequestError || request instanceof TimeoutError)
129
+ return requestFailure(request);
100
130
  const name = revertOf(error)?.errorName;
101
131
  return name ? `${error.shortMessage} (${name})` : error.shortMessage;
102
132
  }
@@ -105,9 +135,14 @@ export async function unhealthy(service, fetcher = fetch) {
105
135
  const host = new URL(service).host;
106
136
  const response = await fetcher(`${service}/health`, { signal: AbortSignal.timeout(10_000) }).catch(() => undefined);
107
137
  if (response === undefined)
108
- return `could not reach the BrainChain service at ${host}`;
138
+ return `could not reach the BrainChain service at ${host}; results are uploaded there, so none can be delivered until it answers`;
109
139
  await response.body?.cancel();
110
- return response.ok ? undefined : `the BrainChain service at ${host} answered its health check with ${response.status}`;
140
+ if (response.ok)
141
+ return undefined;
142
+ // The service answers 503 while its review rounds are late, which does not stop it taking results.
143
+ if (response.status === 503)
144
+ return `the BrainChain service at ${host} reports it is behind (503); results still upload, and grading may be late`;
145
+ return `the BrainChain service at ${host} answered its health check with ${response.status}; results are uploaded there, so delivery can fail until it recovers`;
111
146
  }
112
147
  export function createChain(network, account, store = memoryStore()) {
113
148
  const chain = chainOf(network);
@@ -119,9 +154,10 @@ export function createChain(network, account, store = memoryStore()) {
119
154
  const id = workerId(account.address);
120
155
  const board = { address: taskBoard, abi: taskBoardAbi };
121
156
  const origin = new URL(network.service).origin;
122
- async function send(request) {
157
+ async function send(request, sent) {
123
158
  const hash = await wallet.writeContract(request);
124
- const receipt = await client.waitForTransactionReceipt({ hash, timeout: 90_000 });
159
+ sent?.(hash);
160
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
125
161
  if (receipt.status !== "success")
126
162
  throw new Error(`Transaction ${hash} reverted`);
127
163
  return hash;
@@ -187,11 +223,13 @@ export function createChain(network, account, store = memoryStore()) {
187
223
  }).catch(() => {
188
224
  throw new Error(`Could not reach the BrainChain service at ${new URL(origin).host}`);
189
225
  });
190
- const body = (await response.json().catch(() => ({})));
191
- const why = `(${response.status}): ${body.error ?? "no reason given"}`;
192
- if (REFUSED.includes(response.status))
226
+ const body = (await response.json().catch(() => undefined));
227
+ // Only the service's own JSON error is a refusal: a CDN or firewall page with the same status is retried.
228
+ const said = typeof body?.error === "string" ? body.error : undefined;
229
+ const why = `(${response.status}): ${said ?? "no reason given"}`;
230
+ if (REFUSED.includes(response.status) && said !== undefined)
193
231
  throw new PermanentError(`The service refused the result ${why}`);
194
- if (!response.ok || !body.uri)
232
+ if (!response.ok || !body?.uri)
195
233
  throw new Error(`Uploading the result failed ${why}`);
196
234
  if (body.hash?.toLowerCase() !== hash)
197
235
  throw new Error("The service stored different bytes than were sent");
@@ -199,9 +237,18 @@ export function createChain(network, account, store = memoryStore()) {
199
237
  throw new Error(`The service returned a result address outside ${origin}`);
200
238
  return { uri: body.uri, hash };
201
239
  },
202
- submit: (task, resultHash, resultURI) => send({ ...board, functionName: "submit", args: [task, resultHash, resultURI] }).catch((error) => {
240
+ submit: (task, resultHash, resultURI, sent) => send({ ...board, functionName: "submit", args: [task, resultHash, resultURI] }, sent).catch((error) => {
203
241
  throw submitFailure(error);
204
242
  }),
243
+ async landed(tx, waitMs) {
244
+ // Without replacement checks a receipt is only ever this transaction's, never another sent with its nonce.
245
+ const receipt = await client.waitForTransactionReceipt({ hash: tx, timeout: waitMs, checkReplacement: false }).catch((error) => {
246
+ if (error instanceof WaitForTransactionReceiptTimeoutError)
247
+ return undefined;
248
+ throw error;
249
+ });
250
+ return receipt && receipt.status === "success";
251
+ },
205
252
  };
206
253
  return {
207
254
  ...work,
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { join as joinPath } from "node:path";
4
- import { formatEther, formatUnits, parseUnits } from "viem";
4
+ import { formatEther, parseUnits } from "viem";
5
5
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
- import { ApplicationState, createChain, faucetReopens, reason, unhealthy } from "./chain.js";
6
+ import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
7
7
  import { resolveNetwork } from "./network.js";
8
+ import { hireable, snapshot, stake, usdg, wallet, when } from "./standing.js";
8
9
  import { fileStore, lock, stateDir } from "./state.js";
9
- import { createWork, shellCommand, stopper } from "./work.js";
10
+ import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
10
11
  const { version } = createRequire(import.meta.url)("../package.json");
11
12
  const USAGE = `brainchain-worker ${version}
12
13
 
@@ -15,7 +16,8 @@ Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
15
16
  Usage:
16
17
  brainchain-worker new-key Print a fresh private key and its address.
17
18
  brainchain-worker join --name <name> --about <text> [--price <tUSDG>]
18
- Stake the minimum and apply to take tasks.
19
+ Stake the minimum and apply to take tasks. The name is at
20
+ most 60 characters, the description at most 500.
19
21
  brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
20
22
  Do every task Jev hires you for: the command reads the
21
23
  task on stdin and prints the deliverable on stdout, within
@@ -54,7 +56,7 @@ const POSITIONAL = { price: 1 };
54
56
  function parse(command, args) {
55
57
  const known = FLAGS[command];
56
58
  if (known === undefined)
57
- throw new UsageError(`Unknown command "${command}" (see brainchain-worker help)`);
59
+ throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
58
60
  const positional = [];
59
61
  const values = new Map();
60
62
  const switches = new Set();
@@ -67,7 +69,7 @@ function parse(command, args) {
67
69
  const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
68
70
  const isSwitch = known[name];
69
71
  if (isSwitch === undefined)
70
- throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker help)`);
72
+ throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker --help)`);
71
73
  if (isSwitch) {
72
74
  switches.add(name);
73
75
  continue;
@@ -78,7 +80,7 @@ function parse(command, args) {
78
80
  values.set(name, value);
79
81
  }
80
82
  if (positional.length > (POSITIONAL[command] ?? 0))
81
- throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker help)`);
83
+ throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker --help)`);
82
84
  return { positional, values, switches };
83
85
  }
84
86
  function signer() {
@@ -104,9 +106,6 @@ function price(value) {
104
106
  }
105
107
  return parseUnits(value, 6);
106
108
  }
107
- const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
108
- const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
109
- const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
110
109
  const RUN = '`npx brainchain-worker run --exec "<command>"`';
111
110
  function noGas(chain, network) {
112
111
  return new Error(`${chain.account} has no ETH on ${network.name} for gas. Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com ` +
@@ -116,45 +115,6 @@ async function needGas(chain, network) {
116
115
  if ((await chain.ethBalance()) === 0n)
117
116
  throw noGas(chain, network);
118
117
  }
119
- /** The worker's standing with the board and the staking contract, and what join would do about it. */
120
- async function snapshot(chain) {
121
- const [allowed, w, min, { applications, change }] = await Promise.all([chain.allowed(), chain.stake(), chain.minStake(), chain.applications()]);
122
- const pending = change !== undefined && !change.executed && !change.vetoed;
123
- const open = applications.find((a) => a.state === ApplicationState.Open);
124
- return {
125
- allowed,
126
- w,
127
- min,
128
- applications,
129
- change,
130
- pending,
131
- open,
132
- applying: !allowed && !pending && open === undefined,
133
- // Below the minimum after leave or a slash, an allowed worker cannot be hired until it stakes again.
134
- toStake: w.staked >= min ? 0n : min - w.staked,
135
- };
136
- }
137
- /** Why the worker can or cannot be hired right now, in one line. */
138
- function hireable({ allowed, w, min, applications, change, pending }, network) {
139
- if (allowed)
140
- return w.staked >= min ? "yes" : `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again.`;
141
- // An allowed application waiting out its timelock outranks anything filed after it.
142
- if (change && pending)
143
- return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
144
- const last = applications.at(-1);
145
- if (last === undefined)
146
- return "no: run `npx brainchain-worker join` to apply";
147
- if (last.state === ApplicationState.Open)
148
- return `no: application ${last.id} is waiting for Jev`;
149
- if (last.state === ApplicationState.Burned)
150
- return `no: Jev did not allow application ${last.id}, and its bond was burned. Run join to apply again.`;
151
- if (change?.vetoed) {
152
- // A vetoed call can never be queued again, so the veto still stands for applications filed after it.
153
- const vetoed = applications.find((a) => a.decisionId === change.decisionId);
154
- return `no: the guardian vetoed ${vetoed ? `application ${vetoed.id}` : "allowing this worker"}`;
155
- }
156
- return `no: application ${last.id} closed without a decision; its bond was returned. Run join to apply again.`;
157
- }
158
118
  async function join(chain, network, values) {
159
119
  const name = values.get("name")?.trim() ?? "";
160
120
  const about = values.get("about")?.trim() ?? "";
@@ -175,14 +135,19 @@ async function join(chain, network, values) {
175
135
  const [bond, balance] = await Promise.all([chain.bond(), chain.stakeBalance()]);
176
136
  const need = toStake + (applying ? bond : 0n);
177
137
  if (balance < need) {
138
+ const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
139
+ const closed = (opensAt) => {
140
+ const withdraw = now >= w.unlocksAt ? "run withdraw now" : `run withdraw after ${when(w.unlocksAt)}`;
141
+ const unstaking = w.unstaking > 0n ? `, or ${withdraw} and join with the ${stake(network, w.unstaking)} it returns` : "";
142
+ return new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs, and the faucet allows one claim a day: ` +
143
+ `this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
144
+ };
145
+ if (now < faucet.opensAt)
146
+ throw closed(faucet.opensAt);
178
147
  console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
179
148
  await chain.claimStakeToken().catch((error) => {
180
149
  const opensAt = faucetReopens(error);
181
- if (opensAt === undefined)
182
- throw error;
183
- const unstaking = w.unstaking > 0n ? `, or run withdraw after ${when(w.unlocksAt)} and join with the ${stake(network, w.unstaking)} it returns` : "";
184
- throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs, and the faucet allows one claim a day: ` +
185
- `this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
150
+ throw opensAt === undefined ? error : closed(opensAt);
186
151
  });
187
152
  }
188
153
  if (toStake > 0n) {
@@ -214,19 +179,6 @@ async function join(chain, network, values) {
214
179
  `Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
215
180
  "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
216
181
  }
217
- /** The wallet's tBRAIN and, below the minimum stake, where join would find what it needs. */
218
- function wallet(network, s, balance, bond, faucet, now) {
219
- const has = stake(network, balance);
220
- if (s.toStake === 0n)
221
- return has;
222
- const need = s.toStake + (s.applying ? bond : 0n);
223
- if (balance >= need)
224
- return `${has}, enough for the ${stake(network, need)} join needs`;
225
- if (now >= faucet.opensAt)
226
- return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
227
- const unstaking = s.w.unstaking > 0n ? `, and the ${stake(network, s.w.unstaking)} unstaking can be withdrawn ${now >= s.w.unlocksAt ? "now" : `after ${when(s.w.unlocksAt)}`}` : "";
228
- return `${has}; join needs ${stake(network, need)}. The faucet opens again for this wallet at ${when(faucet.opensAt)}${unstaking}`;
229
- }
230
182
  async function status(chain, network) {
231
183
  const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
232
184
  chain.ethBalance(),
@@ -236,13 +188,14 @@ async function status(chain, network) {
236
188
  chain.bountyBalance(),
237
189
  chain.stakeBalance(),
238
190
  chain.bond(),
239
- chain.faucet(),
191
+ // A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
192
+ chain.faucet().catch(() => undefined),
240
193
  chain.now(),
241
194
  ]);
242
195
  const { w, min } = s;
243
196
  const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
244
197
  console.log(`Worker ${chain.account} on ${network.name}
245
- Hireable ${hireable(s, network)}
198
+ Hireable ${hireable(s, network, "status")}
246
199
  Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
247
200
  Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
248
201
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
@@ -261,13 +214,16 @@ async function run(network, { key, account }, values, once) {
261
214
  if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
262
215
  throw new UsageError("--timeout is the most minutes the command may spend on one task: more than 0, at most 1440, and 20 by default");
263
216
  }
217
+ const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
264
218
  const file = joinPath(stateDir(), account.address);
265
219
  lock(`${file}.lock`);
266
- const store = fileStore(`${file}.json`, network.contracts.taskBoard);
220
+ const store = fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
221
+ // The lock rules out a live run, so a command still in the state was left by one that died.
222
+ stopLeftover(store, log);
267
223
  const chain = createChain(network, account, store);
268
- const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)}Z ${message}`);
269
224
  const command = shellCommand(exec);
270
- const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code) });
225
+ // An idle stop keeps the exit code of a task that failed earlier in a --once pass.
226
+ const stop = stopper({ busy: () => work.busy(), kill: command.kill, log, exit: (code) => process.exit(code || process.exitCode) });
271
227
  const work = createWork({
272
228
  chain,
273
229
  command,
@@ -277,9 +233,11 @@ async function run(network, { key, account }, values, once) {
277
233
  secrets: [key, key.slice(2)],
278
234
  store,
279
235
  stopping: stop.stopping,
236
+ // A cron job sees a failed task as a failed run.
237
+ failed: once ? () => void (process.exitCode = 1) : undefined,
280
238
  });
281
- process.on("SIGINT", stop.signal);
282
- process.on("SIGTERM", stop.signal);
239
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
240
+ process.on(signal, stop.signal);
283
241
  const gas = await chain.ethBalance().catch((error) => {
284
242
  log(`could not read the wallet's ETH balance: ${reason(error)}`);
285
243
  return undefined;
@@ -287,19 +245,16 @@ async function run(network, { key, account }, values, once) {
287
245
  if (gas === 0n)
288
246
  throw noGas(chain, network);
289
247
  const [line, sick] = await Promise.all([
290
- snapshot(chain).then((s) => hireable(s, network), (error) => `could not check (${reason(error)})`),
248
+ snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
291
249
  unhealthy(network.service),
292
250
  ]);
293
251
  log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
294
252
  if (sick)
295
- log(`warning: ${sick}; results are uploaded there, so none can be delivered until it answers`);
253
+ log(`warning: ${sick}`);
296
254
  if (once) {
297
255
  const result = await work.round();
298
- if (result.hired === 0)
256
+ if (result.hired === 0 && result.delivered === 0)
299
257
  log("no task is hired to this worker right now");
300
- // A cron job sees a failed task as a failed run.
301
- if (result.failed > 0)
302
- process.exitCode = 1;
303
258
  return;
304
259
  }
305
260
  log("waiting for tasks (Ctrl-C to stop)");
package/dist/network.js CHANGED
@@ -24,11 +24,10 @@ export function resolveNetwork(env) {
24
24
  throw new Error("Outside workers run on BrainChain testnet for now. Unset BRAINCHAIN_NETWORK or set it to testnet.");
25
25
  if (name !== "testnet")
26
26
  throw new Error(`BRAINCHAIN_NETWORK must be testnet, not "${env.BRAINCHAIN_NETWORK}"`);
27
- return {
28
- ...TESTNET,
29
- ...(env.BRAINCHAIN_RPC_URL ? { rpc: rpcURL(env.BRAINCHAIN_RPC_URL.trim()) } : {}),
30
- ...(env.BRAINCHAIN_SERVICE_URL ? { service: serviceURL(env.BRAINCHAIN_SERVICE_URL.trim()) } : {}),
31
- };
27
+ // Blank counts as unset, as for BRAINCHAIN_NETWORK.
28
+ const rpc = env.BRAINCHAIN_RPC_URL?.trim();
29
+ const service = env.BRAINCHAIN_SERVICE_URL?.trim();
30
+ return { ...TESTNET, ...(rpc ? { rpc: rpcURL(rpc) } : {}), ...(service ? { service: serviceURL(service) } : {}) };
32
31
  }
33
32
  /** The value is never echoed: an RPC URL can hold an API key, and a mistyped one still does. */
34
33
  function rpcURL(value) {
@@ -0,0 +1,64 @@
1
+ import { formatUnits } from "viem";
2
+ import { ApplicationState } from "./chain.js";
3
+ export const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
4
+ export const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
5
+ export const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
6
+ /** The worker's standing with the board and the staking contract, and what join would do about it. */
7
+ export async function snapshot(chain) {
8
+ const [allowed, w, min, { applications, change }] = await Promise.all([chain.allowed(), chain.stake(), chain.minStake(), chain.applications()]);
9
+ const pending = change !== undefined && !change.executed && !change.vetoed;
10
+ const open = applications.find((a) => a.state === ApplicationState.Open);
11
+ return {
12
+ allowed,
13
+ w,
14
+ min,
15
+ applications,
16
+ change,
17
+ pending,
18
+ open,
19
+ applying: !allowed && !pending && open === undefined,
20
+ // Below the minimum after leave or a slash, an allowed worker cannot be hired until it stakes again.
21
+ toStake: w.staked >= min ? 0n : min - w.staked,
22
+ };
23
+ }
24
+ /** Why the worker can or cannot be hired right now, in one line for `run` or for `status`, which shows the rest itself. */
25
+ export function hireable({ allowed, w, min, applications, change, pending }, network, from) {
26
+ if (allowed) {
27
+ if (w.staked >= min)
28
+ return "yes";
29
+ return `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again${from === "run" ? "; status shows what it needs" : ""}.`;
30
+ }
31
+ // An allowed application waiting out its timelock outranks anything filed after it.
32
+ if (change && pending)
33
+ return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
34
+ const last = applications.at(-1);
35
+ if (last === undefined)
36
+ return "no: run `npx brainchain-worker join` to apply";
37
+ if (last.state === ApplicationState.Open)
38
+ return `no: application ${last.id} is waiting for Jev`;
39
+ if (last.state === ApplicationState.Burned)
40
+ return `no: Jev did not allow application ${last.id}, and its bond was burned. Run join to apply again.`;
41
+ if (change?.vetoed) {
42
+ // A vetoed call can never be queued again, so the veto still stands for applications filed after it.
43
+ const vetoed = applications.find((a) => a.decisionId === change.decisionId);
44
+ return `no: the guardian vetoed ${vetoed ? `application ${vetoed.id}` : "allowing this worker"}`;
45
+ }
46
+ return `no: application ${last.id} closed without a decision; its bond was returned. Run join to apply again.`;
47
+ }
48
+ /**
49
+ * The wallet's stake token and, below the minimum stake, where join would find
50
+ * what it needs. A stake token without a faucet has no `faucet`.
51
+ */
52
+ export function wallet(network, s, balance, bond, faucet, now) {
53
+ const has = stake(network, balance);
54
+ if (s.toStake === 0n)
55
+ return has;
56
+ const need = s.toStake + (s.applying ? bond : 0n);
57
+ if (balance >= need)
58
+ return `${has}, enough for the ${stake(network, need)} join needs`;
59
+ if (faucet && now >= faucet.opensAt)
60
+ return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
61
+ const reopens = faucet ? `. The faucet opens again for this wallet at ${when(faucet.opensAt)}` : "";
62
+ const unstaking = s.w.unstaking > 0n ? `, and the ${stake(network, s.w.unstaking)} unstaking can be withdrawn ${now >= s.w.unlocksAt ? "now" : `after ${when(s.w.unlocksAt)}`}` : "";
63
+ return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}`;
64
+ }
package/dist/state.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  export const stateDir = () => join(homedir(), ".config", "brainchain-worker");
@@ -6,8 +6,17 @@ const empty = (board) => ({ board: board.toLowerCase(), tasks: {}, done: [] });
6
6
  export function memoryStore(board = "") {
7
7
  return { state: empty(board), save: () => undefined };
8
8
  }
9
+ function parse(text) {
10
+ try {
11
+ const value = JSON.parse(text);
12
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
9
18
  /** State in a 0600 JSON file, replaced whole on every save so a crash never leaves half a file. */
10
- export function fileStore(file, board) {
19
+ export function fileStore(file, board, warn = console.warn) {
11
20
  let state = empty(board);
12
21
  let text;
13
22
  try {
@@ -17,23 +26,29 @@ export function fileStore(file, board) {
17
26
  if (error.code !== "ENOENT")
18
27
  throw error;
19
28
  }
20
- if (text !== undefined) {
21
- let saved;
22
- try {
23
- saved = JSON.parse(text);
24
- }
25
- catch {
26
- throw new Error(`${file} is not valid JSON. Move it aside to start with no saved work.`);
27
- }
28
- if (saved.board === state.board)
29
- state = { ...state, ...saved, tasks: saved.tasks ?? {}, done: saved.done ?? [] };
29
+ const saved = text === undefined ? undefined : parse(text);
30
+ if (saved?.board === state.board)
31
+ state = { ...state, ...saved, tasks: saved.tasks ?? {}, done: saved.done ?? [] };
32
+ if (text !== undefined && saved === undefined) {
33
+ // Refusing to start would turn one bad file into a crash loop under a supervisor.
34
+ const aside = `${file}.corrupt-${new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "")}`;
35
+ renameSync(file, aside);
36
+ warn(`${file} was not valid JSON, so it was moved to ${aside} and this run starts with no saved work`);
30
37
  }
31
38
  return {
32
39
  state,
33
40
  save() {
34
41
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
35
42
  const temp = `${file}.${process.pid}.tmp`;
36
- writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
43
+ const fd = openSync(temp, "w", 0o600);
44
+ try {
45
+ writeFileSync(fd, JSON.stringify(state));
46
+ // On disk before the rename, so a power loss leaves the old file or the new one, never an empty one.
47
+ fsyncSync(fd);
48
+ }
49
+ finally {
50
+ closeSync(fd);
51
+ }
37
52
  renameSync(temp, file);
38
53
  },
39
54
  };
package/dist/work.js CHANGED
@@ -1,6 +1,6 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { toBytes } from "viem";
3
- import { fetchBrief, PermanentError, reason } from "./chain.js";
3
+ import { fetchBrief, PermanentError, reason, RECEIPT_MS } from "./chain.js";
4
4
  import { memoryStore } from "./state.js";
5
5
  /** The most the service accepts for one result. */
6
6
  export const MAX_RESULT_BYTES = 100_000;
@@ -79,22 +79,59 @@ function signalGroup(pid, signal) {
79
79
  throw error;
80
80
  }
81
81
  }
82
+ /** When a process started, in ms since the epoch, or undefined when it is gone or ps cannot tell. */
83
+ function startTime(pid) {
84
+ const ps = spawnSync("ps", ["-o", "etime=", "-p", String(pid)], { encoding: "utf8" });
85
+ const elapsed = /^\s*(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)\s*$/.exec(ps.stdout ?? "");
86
+ if (ps.status !== 0 || !elapsed)
87
+ return undefined;
88
+ const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
89
+ return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
90
+ }
91
+ /**
92
+ * Stops the command an earlier run left running when it died, such as on
93
+ * SIGKILL, so a restart never works on the same task twice at once.
94
+ */
95
+ export function stopLeftover(store, log) {
96
+ const left = store.state.command;
97
+ if (left === undefined)
98
+ return;
99
+ delete store.state.command;
100
+ store.save();
101
+ // A pid is not reused while its process lives, so a live process with it that started when the command did is the command.
102
+ // Its command line proves nothing: the shell execs a simple command, so it shows the program's arguments, not --exec.
103
+ const started = startTime(left.pgid);
104
+ if (started === undefined || Math.abs(started - left.startedAt) > 5_000)
105
+ return;
106
+ signalGroup(left.pgid, "SIGKILL");
107
+ log(`stopped the command an earlier run left running (process group ${left.pgid})`);
108
+ }
82
109
  /**
83
110
  * Runs a shell command with the input on stdin and takes its stdout as the
84
- * deliverable. A command that does not stop on SIGTERM is killed `graceMs` later.
111
+ * deliverable. A command that does not stop on SIGTERM is killed `graceMs` later,
112
+ * and none outlives the worker's exit.
85
113
  */
86
114
  export function shellCommand(command, graceMs = 10_000) {
87
115
  const running = new Set();
116
+ const kill = () => {
117
+ for (const pid of running)
118
+ signalGroup(pid, "SIGKILL");
119
+ };
88
120
  return {
89
- run(input, env, timeoutMs) {
121
+ run(input, env, timeoutMs, started) {
90
122
  return new Promise((resolve, reject) => {
91
123
  // Its own process group, so a stop reaches everything the command started, and Ctrl-C in the terminal reaches only the worker.
92
124
  // Stderr is forwarded rather than shared: a background group that touches the terminal is stopped by SIGTTOU.
93
125
  const child = spawn(command, { shell: true, detached: true, env: childEnv(env), stdio: ["pipe", "pipe", "pipe"] });
94
126
  child.stderr.on("data", (chunk) => process.stderr.write(chunk));
95
127
  const pid = child.pid;
96
- if (pid !== undefined)
128
+ if (pid !== undefined) {
129
+ // Its own group would outlive the worker otherwise, still working on a task the next run takes up again.
130
+ if (running.size === 0)
131
+ process.on("exit", kill);
97
132
  running.add(pid);
133
+ started?.(pid);
134
+ }
98
135
  const chunks = [];
99
136
  let size = 0;
100
137
  let stopped;
@@ -128,6 +165,8 @@ export function shellCommand(command, graceMs = 10_000) {
128
165
  clearTimeout(force);
129
166
  if (pid !== undefined)
130
167
  running.delete(pid);
168
+ if (running.size === 0)
169
+ process.off("exit", kill);
131
170
  if (stopped !== undefined)
132
171
  return reject(new Error(stopped));
133
172
  if (code !== 0)
@@ -144,29 +183,32 @@ export function shellCommand(command, graceMs = 10_000) {
144
183
  child.stdin.end(input);
145
184
  });
146
185
  },
147
- kill() {
148
- for (const pid of running)
149
- signalGroup(pid, "SIGKILL");
150
- },
186
+ kill,
151
187
  };
152
188
  }
153
- /** Signals closer together than this are one: npm passes the terminal's Ctrl-C on to the worker, which gets its own too. */
154
- const ECHO_MS = 1_500;
189
+ /** A repeat of one signal sooner than this is an echo: npm passes the terminal's Ctrl-C on to the worker, which gets its own too. */
190
+ const ECHO_MS = 250;
155
191
  /**
156
192
  * What Ctrl-C and SIGTERM do to `run`. An idle worker stops at once. A busy one
157
193
  * takes no new task and stops after the current one, unless a second signal
158
- * comes, which stops the command too.
194
+ * comes, which stops the command too. SIGHUP stops both at once: the terminal
195
+ * or session that would see the task through is gone.
159
196
  */
160
197
  export function stopper(options) {
161
198
  let stopping = false;
162
- let counted = -Infinity;
199
+ let last;
163
200
  return {
164
201
  stopping: () => stopping,
165
202
  signal(signal) {
203
+ if (signal === "SIGHUP") {
204
+ options.kill();
205
+ options.log("SIGHUP: stopping now");
206
+ return options.exit(129);
207
+ }
166
208
  const now = Date.now();
167
- if (now - counted < ECHO_MS)
209
+ if (last?.signal === signal && now - last.at < ECHO_MS)
168
210
  return;
169
- counted = now;
211
+ last = { signal, at: now };
170
212
  const name = signal === "SIGINT" ? "Ctrl-C" : signal;
171
213
  if (stopping) {
172
214
  options.kill();
@@ -211,16 +253,38 @@ export function createWork(options) {
211
253
  if ((await chain.now()) >= task.deadline)
212
254
  throw new PermanentError("The deadline passed before the result could be submitted");
213
255
  };
256
+ const remember = (pgid) => {
257
+ state.command = { pgid, startedAt: Date.now() };
258
+ store.save();
259
+ };
260
+ const forget = () => {
261
+ delete state.command;
262
+ store.save();
263
+ };
214
264
  return {
215
265
  /** True while a task is in hand, from running its command to submitting the result. */
216
266
  busy: () => busy,
217
267
  async round() {
218
268
  const hired = (await chain.hired()).sort((a, b) => (a.deadline < b.deadline ? -1 : a.deadline > b.deadline ? 1 : 0));
219
269
  const result = { hired: hired.length, delivered: 0, failed: 0 };
270
+ const fail = () => {
271
+ result.failed += 1;
272
+ options.failed?.();
273
+ };
274
+ const delivered = (key, text, tx) => {
275
+ result.delivered += 1;
276
+ log(`task ${key}: delivered (${toBytes(text).byteLength} bytes, ${tx}). Jev grades it next.`);
277
+ };
220
278
  const current = new Set(hired.map((t) => t.id.toString()));
221
- for (const key of Object.keys(state.tasks))
222
- if (!current.has(key))
223
- delete state.tasks[key];
279
+ for (const [key, attempt] of Object.entries(state.tasks)) {
280
+ if (current.has(key))
281
+ continue;
282
+ delete state.tasks[key];
283
+ // A submit that landed after the run that sent it stopped waiting, or stopped.
284
+ if (attempt.tx !== undefined && attempt.result !== undefined && (await chain.landed(attempt.tx, 5_000).catch(() => false))) {
285
+ delivered(key, attempt.result, attempt.tx);
286
+ }
287
+ }
224
288
  state.done = state.done.filter((key) => current.has(key));
225
289
  store.save();
226
290
  for (const task of hired) {
@@ -234,19 +298,33 @@ export function createWork(options) {
234
298
  const now = await chain.now();
235
299
  if (now < BigInt(attempt.nextAt))
236
300
  continue;
301
+ busy = true;
302
+ if (attempt.tx !== undefined && attempt.result !== undefined) {
303
+ // Sent before a restart or a receipt timeout: it may still land, and sending the result again would then only revert.
304
+ if (await chain.landed(attempt.tx, RECEIPT_MS)) {
305
+ finish(key);
306
+ delivered(key, attempt.result, attempt.tx);
307
+ continue;
308
+ }
309
+ delete attempt.tx;
310
+ store.save();
311
+ }
237
312
  if (now + MARGIN_SEC >= task.deadline) {
238
313
  finish(key);
314
+ fail();
239
315
  log(`task ${task.id}: too close to the deadline to deliver; the poster is refunded`);
240
316
  continue;
241
317
  }
242
- busy = true;
243
318
  if (attempt.result === undefined) {
244
319
  const brief = parseBrief(await read(task.briefURI, task.briefHash));
245
320
  await needGas();
321
+ // A stop asked for while the brief downloaded or the balance was read starts no command.
322
+ if (options.stopping?.())
323
+ continue;
246
324
  log(`task ${task.id}: working on "${brief.title}"`);
247
325
  const input = options.format === "json" ? JSON.stringify(taskInput(task, brief)) : taskPrompt(task, brief);
248
326
  const timeoutMs = Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, Number(task.deadline - MARGIN_SEC - now) * 1000);
249
- const output = stripAnsi(await command.run(input, { BRAINCHAIN_TASK_ID: key }, timeoutMs)).trim();
327
+ const output = stripAnsi(await command.run(input, { BRAINCHAIN_TASK_ID: key }, timeoutMs, remember).finally(forget)).trim();
250
328
  if (output === "")
251
329
  throw new Error("The command printed nothing");
252
330
  const lower = output.toLowerCase();
@@ -267,13 +345,15 @@ export function createWork(options) {
267
345
  }
268
346
  await needGas();
269
347
  await inTime(task);
270
- const tx = await chain.submit(task.id, attempt.stored.hash, attempt.stored.uri);
348
+ const tx = await chain.submit(task.id, attempt.stored.hash, attempt.stored.uri, (sent) => {
349
+ attempt.tx = sent;
350
+ store.save();
351
+ });
271
352
  finish(key);
272
- result.delivered += 1;
273
- log(`task ${task.id}: delivered (${toBytes(attempt.result).byteLength} bytes, ${tx}). Jev grades it next.`);
353
+ delivered(key, attempt.result, tx);
274
354
  }
275
355
  catch (error) {
276
- result.failed += 1;
356
+ fail();
277
357
  const why = reason(error);
278
358
  if (error instanceof PermanentError) {
279
359
  finish(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Earn on BrainChain: stake, apply, and do the tasks Jev hires your agent for, with any command that reads a task and prints the work.",
5
5
  "license": "MIT",
6
6
  "author": "BrainChain <hello@usebrainchain.com>",