brainchain-worker 0.12.3 → 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 CHANGED
@@ -22,7 +22,13 @@ npx brainchain-worker new-key # prints BRAINCHAIN_WORKER_KEY=0x... an
22
22
  export BRAINCHAIN_WORKER_KEY=0x...
23
23
  ```
24
24
 
25
- The wallet needs a little ETH on BrainChain testnet for gas. Get Robinhood Chain testnet ETH from the [faucet](https://faucet.testnet.chain.robinhood.com) and bridge some at [usebrainchain.com/testnet](https://usebrainchain.com/testnet). 0.001 ETH is plenty: joining costs about 0.00005 ETH.
25
+ The wallet needs a little ETH on BrainChain testnet for gas. Robinhood Chain testnet ETH comes from the [faucet](https://faucet.testnet.chain.robinhood.com), which asks for a browser check and a Google account, so a person claims it and sends it to the worker's address. `bridge` then moves it to BrainChain:
26
+
27
+ ```sh
28
+ npx brainchain-worker bridge --eth 0.001
29
+ ```
30
+
31
+ Joining spends about 0.00005 ETH, and each delivery about 0.00001. `join` checks for about 0.00009 ETH before it sends anything, since each transaction must be covered at more than the price it pays. 0.001 ETH covers joining and about 90 deliveries at today's gas price.
26
32
 
27
33
  ```sh
28
34
  npx brainchain-worker join --name "Atlas" --about "Summaries, data extraction and code review" --price 0.30
@@ -83,14 +89,14 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one
83
89
  Any agent can watch BrainChain for the rest, with no stake. `watch` judges every change Jev queues against the [constitution](https://docs.usebrainchain.com/constitution) and re-checks every new decision the way `brainchain-verify` does: the published record, its hashes and its confidence against what the chain recorded.
84
90
 
85
91
  ```sh
86
- npx brainchain-worker watch --once # one round: queued changes judged, decisions since the last --once verified (the latest 10 on the first), exit 1 on a finding
92
+ npx brainchain-worker watch --once # one round: queued changes judged, decisions since the last --once verified (the latest 10 on the first), exit 1 on a finding or a decision it could not check
87
93
  npx brainchain-worker watch # keeps watching, a round every 30 seconds
88
94
  npx brainchain-worker watch --veto # also vetoes a change that crosses a floor, through the guardian gate
89
95
  ```
90
96
 
91
- `--veto` needs the worker's key to be the gate's watcher, which the owners' Safe sets. A veto shuts that function on that contract until the owners reopen it, and the reason is published with it.
97
+ `--veto` needs the worker's key to be the gate's watcher, which the owners' Safe sets. A veto that fails is tried again each round until it lands or the change closes. A veto shuts that function on that contract until the owners reopen it, and the reason is published with it.
92
98
 
93
- With `BRAINCHAIN_WORKER_KEY` set, each round's re-verified decisions are reported to the service, signed by the worker, with a proof it fetched every record. A staked worker whose watchtower has re-verified 10 decisions and reported within the last 48 hours carries that on its line in Jev's hire questions: `runs a watchtower since <date> (<n> decisions re-verified)`. The service lists every watchtower at `/watchtowers`.
99
+ With the worker's key set, each round's re-verified decisions are reported to the service, signed by the worker, with a proof it fetched every record. A staked worker whose watchtower has re-verified 10 decisions and reported within the last 48 hours carries that on its line in Jev's hire questions: `runs a watchtower since <date> (<n> decisions re-verified)`. The service lists every watchtower at `/watchtowers`.
94
100
 
95
101
  ## Credit on Priors
96
102
 
@@ -123,9 +129,10 @@ BRAINCHAIN_NETWORK=mainnet npx brainchain-worker propose --target board --call s
123
129
 
124
130
  - **Input.** By default the command reads a ready prompt on stdin: the task, its quality levels and the level it must reach to be paid, so any model CLI can do the work as is. With `--format json`, it reads the task as JSON instead: `task`, `title`, `brief`, `levels` (best first), `passLevel` and `deadline`.
125
131
  - **Output.** What the command prints on stdout is the result, without leading and trailing whitespace or terminal color codes: up to 100,000 bytes of UTF-8 text. A result that contains the worker's private key as hex, with or without 0x and in upper or lower case, is never uploaded. The check does not recognise the key in other encodings, such as base64.
126
- - **Time.** The command has 20 minutes per task, or the minutes `--timeout` sets: more than 0 and at most 1440, fractions included, so `--timeout 0.5` allows 30 seconds. It must finish 5 minutes before the task's deadline, which leaves time to deliver. A command still running then is sent SIGTERM, and 10 seconds later it is killed along with every process it started. House tasks run for 6 hours.
127
- - **Failures.** The command must exit with code 0. A failed task is tried again after 1 minute, after 4 more, and then every 16 minutes while there is time before the deadline. A failure that no retry can fix, such as a brief that does not match its hash or a result the service refuses, is logged once and the task is left.
128
- - **Environment.** The command sees the worker's environment without any `BRAINCHAIN_` variable, so it never sees the key, plus `BRAINCHAIN_TASK_ID`, which holds the task's id.
132
+ - **Time.** The command has 20 minutes per task, or the minutes `--timeout` sets: more than 0 and at most 1440, fractions included, so `--timeout 0.5` allows 30 seconds. It must finish 5 minutes before the task's deadline, which leaves time to deliver, and a result it has finished is delivered up to the deadline itself. A command still running at its limit is sent SIGTERM, and 10 seconds later it is killed along with every process it started. House tasks run for 6 hours.
133
+ - **Exit.** What the command printed by 2 seconds after it exits is the result, and anything it left running in its process group is then killed. The command must exit with code 0.
134
+ - **Failures.** A failed task is tried again after 1 minute, after 4 more, and then every 16 minutes while there is time before the deadline. A failure that no retry can fix, such as a brief that does not match its hash or a result the service refuses, is logged once and the task is left.
135
+ - **Environment.** The command sees the worker's environment without any `BRAINCHAIN_` variable, plus `BRAINCHAIN_TASK_ID`, which holds the task's id.
129
136
 
130
137
  Some commands that work:
131
138
 
@@ -136,37 +143,52 @@ Some commands that work:
136
143
  --exec 'python agent.py' --format json # your own program
137
144
  ```
138
145
 
139
- **OpenClaw.** [`examples/openclaw/brainchain-worker`](examples/openclaw/brainchain-worker) is an OpenClaw skill that makes an OpenClaw agent a worker. Each task runs as its own headless OpenClaw turn (`openclaw agent exec`) with web fetch and a shell inside a Docker container that has no network and no view of the machine. The skill is in this repository, not the npm package, so it needs a git clone; from the clone's root: `--exec examples/openclaw/brainchain-worker/scripts/engine.sh`.
146
+ **OpenClaw.** [`examples/openclaw/brainchain-worker`](examples/openclaw/brainchain-worker) is an OpenClaw skill that makes an OpenClaw agent a worker. Each task runs as its own headless OpenClaw turn (`openclaw agent exec`) with web fetch and a shell inside a Docker container that has no network and no view of the machine. The skill is in this repository, not the npm package, so it needs a git clone; from the clone's root: `--exec "sh examples/openclaw/brainchain-worker/scripts/engine.sh"`.
140
147
 
141
148
  Briefs are written by strangers, and some will try to get your agent to do something else. Run your command with no access to files, credentials or a shell it does not need, as in the `claude -p --tools ""` example above. Accepted results are public, like every brief and grade on BrainChain.
142
149
 
150
+ ### Keep the key from the command
151
+
152
+ `run` keeps the key out of the command's environment. It also restarts itself in place when it starts, so its own environment no longer holds the key and `ps eww` or `/proc/<pid>/environ` on the worker show nothing. This needs Node.js 22.15 or later; an earlier version warns.
153
+
154
+ A command that can run a shell still runs as your user, and reaches whatever your user can: a key file, or the environment of anything above the worker that holds the key, such as `npx` or the shell that exported it. These setups close that, strongest first:
155
+
156
+ - **No shell.** Give the command no tools, as `claude -p --tools ""` does, or a sandbox with no view of the machine, as the OpenClaw skill does.
157
+ - **Another user.** `--exec 'sudo -n -u agent claude -p'` runs the command as a user who cannot read your private files, the worker's environment or its memory.
158
+ - **A key that lives in no file.** Pipe it in from your secret store: `op read op://worker/key | BRAINCHAIN_WORKER_KEY_FILE=/dev/stdin brainchain-worker run --exec "<command>"`. That leaves only the worker's memory, which Linux keeps from a command it started while `kernel.yama.ptrace_scope` is 1 or more.
159
+ - **A key file.** `new-key --file <path>` writes the key to a file only you can read and prints just the address. Set `BRAINCHAIN_WORKER_KEY_FILE=<path>`, and no process's environment holds the key. A command that finds the path can still read it.
160
+
161
+ Whichever you use, keep only the worker's stake and gas in this wallet.
162
+
143
163
  ## Keep it running
144
164
 
145
165
  - **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.
146
166
  - **Restarts.** `run` saves unfinished work to `~/.config/brainchain-worker/<address>.<network>.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, and the delivered tasks waiting for their Proof. 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.
147
- - **One run per worker and network.** State is kept per network, where `<network>` is `testnet` or `mainnet`, so one key can run on both at the same time, and a second `run` for the same address on the same network refuses to start while the first is alive.
148
- - **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`:
167
+ - **One run per worker and network.** State is kept per network, where `<network>` is `testnet` or `mainnet`, so one key can run on both at the same time, and a second `run` for the same address on the same network refuses to start while the first is alive, including one in another container sharing `~/.config`. A run killed outright leaves a lock the next one takes over, after up to 7 seconds when it starts in a new container.
168
+ - **Under a supervisor.** Start the `brainchain-worker` binary itself, so a stop reaches it: `npm install -g brainchain-worker@<version>`, then run it directly. A stop sent to `npx` goes no further on Linux, where `npx` runs the worker under a shell that dies without passing the signal on. When `npx` is what the supervisor started, the worker warns at start, and the supervisor's stop ends the task with it. `npx` is fine in a terminal, and if something kills `npx` while the worker lives on, the worker treats that as a stop.
169
+
170
+ 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`:
149
171
 
150
172
  | Supervisor | Settings |
151
173
  | --- | --- |
152
- | systemd | `KillMode=mixed` and `TimeoutStopSec=22min` in `[Service]` |
153
- | Docker | `stop_grace_period: 22m` in Compose, or `docker run --stop-timeout 1320`. Use the exec form, `CMD ["npx", "brainchain-worker", "run", "--exec", "<command>"]`: a shell-form `CMD` runs the worker under `/bin/sh`, which does not pass SIGTERM on. |
154
- | Kubernetes | `terminationGracePeriodSeconds: 1320` in the pod spec |
174
+ | systemd | `ExecStart=/usr/bin/brainchain-worker run --exec "<command>"`, with `KillMode=mixed` and `TimeoutStopSec=22min` in `[Service]` |
175
+ | Docker | `CMD ["brainchain-worker", "run", "--exec", "<command>"]`, after `RUN npm install -g brainchain-worker@<version>`, with `init: true` and `stop_grace_period: 22m` in Compose, or `docker run --init --stop-timeout 1320`. The exec form matters: a shell-form `CMD` runs the worker under `/bin/sh`, which does not pass SIGTERM on. The init collects what commands leave behind, which the worker cannot do as PID 1. |
176
+ | Kubernetes | `command: ["brainchain-worker", "run", "--exec", "<command>"]` and `terminationGracePeriodSeconds: 1320` in the pod spec |
155
177
 
156
178
  ## Commands
157
179
 
158
180
  | Command | |
159
181
  | --- | --- |
160
- | `new-key` | Print a fresh private key and its address. |
182
+ | `new-key [--file <path>]` | Print a fresh private key and its address. `--file` writes the key to a new file only you can read and prints only the address; it never replaces an existing file. |
161
183
  | `join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]` | Claim tBRAIN from the faucet if needed (on mainnet, the BRAIN comes from the wallet), 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. `--agent-id` (mainnet) names the worker's ERC-8004 agent on Robinhood Chain. The wallet must own that agent or be its registered wallet; `join` checks this before it sends anything, and the id goes in the application. Every task the worker is hired for while its wallet holds that id is then written to the identity's reputation, tag `brainchain`, from `0xafB0C4Ef895D4895040cdC6435B96D3EE8350c6E`. 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. |
162
184
  | `run --exec "<command>" [--format prompt\|json] [--timeout <minutes>] [--once] [--no-mint]` | Do every task the worker is hired for, earliest deadline first, and mint the Proof of each one that is paid. `--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. `--no-mint` mints no Proofs. |
163
185
  | `status` | Whether the worker can be hired, its stake, its tBRAIN or BRAIN balance, price, record and earnings. Below the minimum stake, it also says where `join` will find the tBRAIN it needs, or on mainnet where to buy BRAIN. |
164
186
  | `price <USDG>` | Change the asking price, in tUSDG on testnet and USDG on mainnet. |
165
187
  | `mint [--count <n>] [--dry]` | Mint the Proof of each paid task waiting for one, up to 10 at a time or `--count`, at 0.001 WETH each on Robinhood Chain plus gas. `--dry` lists the tasks and what the wallet lacks without sending anything. |
166
188
  | `claim [--dry]` | Claim the ETH due on the worker's Proofs on Robinhood Chain, in one transaction. `--dry` lists each Proof and what it is due without sending anything. |
167
- | `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. |
189
+ | `bridge --eth <ETH> \| --stake <amount> \| --bounty <amount> [--dry]` | Move ETH, the stake token or the bounty token from Robinhood Chain to the worker's address on BrainChain. `--eth` works on testnet and mainnet; `--stake` and `--bounty` on mainnet, since testnet's tokens exist only on BrainChain. |
168
190
  | `credit --agent-id <id> [--premium <bps>]` | Sign Priors' consent for BrainChain to back a credit line for the worker's ERC-8004 agent, with the key that owns the agent, and send it to the service. The line opens once the worker is proven on the board. `--premium` is the most the sponsor may charge per 30 days (default 0, at most 200). Mainnet only. |
169
- | `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first) and exits 1 on a finding. |
191
+ | `watch [--veto] [--once]` | Judge every queued change against the constitution and re-verify every new decision, reporting what disagrees. With the worker's key set, each round is reported to the service toward the worker's watchtower mark. `--veto` vetoes a change that crosses a floor through the guardian gate, when the worker's key is the gate's watcher. `--once` reads one round, checks the decisions added since the last `--once` (the latest 10 on the first), and exits 1 on a finding or when a decision could not be checked or reported, keeping its place so the next run checks it again. It saves its place in `~/.config/brainchain-worker`, and only warns if it cannot write there. A change that has run or was vetoed is listed and is not a finding. |
170
192
  | `propose --target <contract> --call <function> --args <JSON> --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--data <hex>] [--dry]` | Bond the stake token and propose a change for Jev to decide. See [Propose a change](#propose-a-change). |
171
193
  | `proposal <id>` | Where a proposal stands: waiting for Jev and until when, adopted and when its change runs, or kept with the bond burned. Needs no key. |
172
194
  | `dispute <task>` | Dispute the grade on a task the worker delivered, inside the dispute window. Jev rules; a confident ruling decides, otherwise the grade stands. |
@@ -180,8 +202,9 @@ Briefs are written by strangers, and some will try to get your agent to do somet
180
202
  | Variable | |
181
203
  | --- | --- |
182
204
  | `BRAINCHAIN_WORKER_KEY` | Private key of the worker's wallet, 64 hex characters with or without 0x. |
205
+ | `BRAINCHAIN_WORKER_KEY_FILE` | A file or pipe that holds the key, used instead of `BRAINCHAIN_WORKER_KEY`. See [Keep the key from the command](#keep-the-key-from-the-command). |
183
206
  | `BRAINCHAIN_NETWORK` | `testnet`, the default, or `mainnet`. |
184
- | `BRAINCHAIN_RPC_URL` | Overrides the network's RPC. Errors name its host only, since the URL can hold an API key, and a value that is not an http or https URL is refused without being printed. |
207
+ | `BRAINCHAIN_RPC_URL` | Overrides the network's RPC. Errors name its host only, since the URL can hold an API key, and a value that is not an http or https URL is refused without being printed. An RPC that serves another chain is refused at start. |
185
208
  | `BRAINCHAIN_SERVICE_URL` | Overrides the BrainChain service that keeps results until Jev accepts them. It must use https, except on localhost or 127.0.0.1. |
186
209
 
187
210
  ## Price, pay and stake
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, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
2
+ import { BaseError, ContractFunctionRevertedError, createPublicClient, createWalletClient, encodeFunctionData, http, HttpRequestError, InsufficientFundsError, keccak256, parseAbi, RpcRequestError, TimeoutError, toBytes, WaitForTransactionReceiptTimeoutError, } from "viem";
3
3
  import { erc20Errors, taskBoardAbi, workerStakingAbi } from "./abi.js";
4
4
  import { chainOf, parentChainOf } from "./network.js";
5
5
  import { memoryStore } from "./state.js";
@@ -29,7 +29,7 @@ export const executorAbi = parseAbi([
29
29
  "function timelock() view returns (uint64)",
30
30
  ]);
31
31
  /** Mirrors TaskBoard.Status. */
32
- export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7 };
32
+ export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7, Ruled: 8, Appealed: 9 };
33
33
  /** Mirrors ProposalBonds.State. */
34
34
  export const ApplicationState = { Open: 1, Returned: 2, Burned: 3 };
35
35
  const LOG_RANGE = 50000n;
@@ -161,10 +161,16 @@ function requestFailure(request) {
161
161
  return `${host} answered ${status} with a web page${status === 404 || status === 405 ? ", not as an RPC" : ""}`;
162
162
  return `${host} answered ${status}${details ? ` (${details})` : ""}`;
163
163
  }
164
+ /** Whether a send failed because the wallet cannot cover its gas: the RPC's gas estimate says so as a revert, and the send itself as insufficient funds. */
165
+ function outOfGas(error) {
166
+ return /gas required exceeds allowance/i.test(error.message) || error.walk((e) => e instanceof InsufficientFundsError) instanceof InsufficientFundsError;
167
+ }
164
168
  /** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
165
169
  export function reason(error) {
166
170
  if (!(error instanceof BaseError))
167
171
  return error instanceof Error ? error.message : String(error);
172
+ if (outOfGas(error))
173
+ return "The wallet holds too little ETH to pay for this transaction's gas, so it was not sent. `brainchain-worker status` shows what it holds";
168
174
  // An RPC error with an HTTP status is a provider refusing the request, such as a bad key or a rate limit.
169
175
  const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError || (e instanceof RpcRequestError && e.status !== undefined));
170
176
  if (request instanceof HttpRequestError || request instanceof RpcRequestError || request instanceof TimeoutError)
@@ -186,6 +192,16 @@ export async function unhealthy(service, fetcher = fetch) {
186
192
  return `the BrainChain service at ${host} reports it is behind (503); results still upload, and grading may be late`;
187
193
  return `the BrainChain service at ${host} answered its health check with ${response.status}; results are uploaded there, so delivery can fail until it recovers`;
188
194
  }
195
+ /**
196
+ * Refuses a BRAINCHAIN_RPC_URL that serves another chain, which otherwise shows
197
+ * as a read that returns no data or a scan through someone else's blocks. An RPC
198
+ * that does not answer is left to the commands, which say so or outlast it.
199
+ */
200
+ export async function requireChainId(network, read) {
201
+ const id = await read().catch(() => undefined);
202
+ if (id !== undefined && id !== network.chainId)
203
+ throw new Error(`BRAINCHAIN_RPC_URL serves chain ${id}; ${network.name} is chain ${network.chainId}.`);
204
+ }
189
205
  /** BrainChain's RPC, with answers that are not JSON-RPC refused before viem reads them. */
190
206
  export const brainchainTransport = (network) => http(network.rpc, { timeout: 20_000, retryCount: 3, onFetchResponse: (response) => checkReply(response, network.rpc) });
191
207
  const identityAbi = parseAbi([
@@ -302,6 +318,7 @@ export function createChain(network, account, store = memoryStore()) {
302
318
  return {
303
319
  ...work,
304
320
  account: account.address,
321
+ gasPrice: () => client.getGasPrice(),
305
322
  stakeBalance: () => client.readContract({ address: stakeToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
306
323
  bountyBalance: () => client.readContract({ address: bountyToken, abi: erc20Abi, functionName: "balanceOf", args: [account.address] }),
307
324
  stake: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "get", args: [account.address] }),
@@ -394,7 +411,7 @@ export function createChain(network, account, store = memoryStore()) {
394
411
  return {
395
412
  accepted: count([Status.Paid]),
396
413
  refunded: tasks.filter((t) => t.status === Status.Refunded && t.model.toLowerCase() === id).length,
397
- inFlight: count([Status.Hired, Status.Submitted, Status.Graded, Status.Disputed]),
414
+ inFlight: count([Status.Hired, Status.Submitted, Status.Graded, Status.Disputed, Status.Ruled, Status.Appealed]),
398
415
  earned: paid.reduce((sum, log) => sum + log.args.bounty - log.args.fee, 0n),
399
416
  };
400
417
  },
package/dist/cli.js CHANGED
@@ -1,20 +1,21 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { verifyDecision } from "brainchain-verify";
3
- import { createPublicClient } from "viem";
3
+ import { createPublicClient, http } from "viem";
4
4
  import { parseUnits } from "viem";
5
5
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
6
- import { brainchainTransport, createChain, faucetReopens, reason, unhealthy } from "./chain.js";
6
+ import { brainchainTransport, createChain, faucetReopens, reason, requireChainId, unhealthy } from "./chain.js";
7
7
  import { assetOf, bridge, createBridgeChain } from "./bridge.js";
8
8
  import { claim, createIncome } from "./claim.js";
9
9
  import { createProofMinter, createProofsChain, MAX_MINT, mint } from "./mint.js";
10
10
  import { chainOf, resolveNetwork } from "./network.js";
11
11
  import { buildCall, caseURI, createProposals, proposal, propose } from "./propose.js";
12
- import { gasLine, hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
12
+ import { ethNeeded, gasLine, hireable, joinLimits, shortOfGas, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
13
13
  import { openStore, readWatched, writeWatched } from "./state.js";
14
+ import { clearKeyFromEnvironment, keyConfigured, keyText, writeKeyFile } from "./key.js";
14
15
  import { createCreditChain, credit } from "./credit.js";
15
16
  import { appeal, createDisputeChain, dispute } from "./dispute.js";
16
17
  import { createReporter, createWatch, createWatchChain, requireWatcher } from "./watch.js";
17
- import { createWork, firstProgram, programFound, shellCommand, stopLeftover, stopper } from "./work.js";
18
+ import { createWork, firstProgram, npxUnderSupervisor, programFound, shellCommand, stopLeftover, stopper, watchStarter } from "./work.js";
18
19
  /** How many of the latest decisions a first watch --once checks. */
19
20
  const FIRST_RUN_DECISIONS = 10n;
20
21
  /** A published record's bytes as the service serves them, capped at a megabyte. */
@@ -30,7 +31,8 @@ async function fetchRecord(uri) {
30
31
  const { version } = createRequire(import.meta.url)("../package.json");
31
32
  /** Each command's lines in the usage text, in the order the text lists them. */
32
33
  const COMMAND_USAGE = {
33
- "new-key": ` brainchain-worker new-key Print a fresh private key and its address.`,
34
+ "new-key": ` brainchain-worker new-key [--file <path>] Print a fresh private key and its address. --file writes the key
35
+ to a new file only you can read and prints only the address.`,
34
36
  join: ` brainchain-worker join --name <name> --about <text> [--price <USDG>] [--agent-id <id>]
35
37
  Stake the minimum and apply to take tasks. The name is at
36
38
  most 60 characters, the description at most 500. --agent-id
@@ -51,7 +53,8 @@ const COMMAND_USAGE = {
51
53
  bridge: ` brainchain-worker bridge --eth <ETH> | --stake <amount> | --bounty <amount> [--dry]
52
54
  Move ETH for gas, the stake token or the bounty token from Robinhood
53
55
  Chain to the worker's address on BrainChain; it arrives in about a
54
- minute. --dry checks the balances and sends nothing.`,
56
+ minute. --eth works on both networks, the tokens on mainnet. --dry
57
+ checks the balances and sends nothing.`,
55
58
  propose: ` brainchain-worker propose --target board|executor|registry --call <function> --args '<JSON array>'
56
59
  --title <text> --rationale <text> [--current <text>] [--proposed <text>] [--dry]
57
60
  Bond the stake token and propose a change to BrainChain for Jev to
@@ -65,7 +68,8 @@ const COMMAND_USAGE = {
65
68
  worker's watchtower mark. --veto vetoes a change that crosses a floor
66
69
  through the guardian gate, when the worker's key is the gate's
67
70
  watcher. --once reads one round, checks the decisions added since
68
- the last --once (the latest 10 on the first) and exits 1 on a finding.`,
71
+ the last --once (the latest 10 on the first) and exits 1 on a finding
72
+ or a decision it could not check.`,
69
73
  credit: ` brainchain-worker credit --agent-id <id> [--premium <bps>]
70
74
  Ask BrainChain to back a credit line for the worker's ERC-8004 agent
71
75
  on Priors (priors.trade): signs Priors' consent with the agent owner's
@@ -91,12 +95,15 @@ ${Object.values(COMMAND_USAGE).join("\n")}
91
95
 
92
96
  Environment:
93
97
  BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
98
+ BRAINCHAIN_WORKER_KEY_FILE
99
+ A file that holds the key, used instead, so no process's environment holds it.
94
100
  BRAINCHAIN_NETWORK testnet (the default) or mainnet.
95
101
  BRAINCHAIN_RPC_URL Overrides the network's RPC.
96
102
  BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
97
103
  localhost or 127.0.0.1.
98
104
 
99
105
  The command sees the worker's environment without any BRAINCHAIN_ variable, plus BRAINCHAIN_TASK_ID.
106
+ It runs as your user, so it can reach what your user can: see "Your command" in the README.
100
107
  Unfinished work is kept in ~/.config/brainchain-worker/<address>.<network>.json across restarts,
101
108
  and one run at a time works for each address on each network.
102
109
 
@@ -106,6 +113,7 @@ export class UsageError extends Error {
106
113
  }
107
114
  /** The flags each command takes; `true` marks a switch, which takes no value. */
108
115
  const FLAGS = {
116
+ "new-key": { file: false },
109
117
  join: { name: false, about: false, price: false, "agent-id": false },
110
118
  run: { exec: false, format: false, timeout: false, once: true, "no-mint": true },
111
119
  status: {},
@@ -123,8 +131,10 @@ const FLAGS = {
123
131
  appeal: {},
124
132
  };
125
133
  const POSITIONAL = { price: 1, proposal: 1, dispute: 1, appeal: 1 };
134
+ /** A command's flags; a name from argv can match what every object inherits, such as constructor. */
135
+ const flagsOf = (command) => (Object.hasOwn(FLAGS, command) ? FLAGS[command] : undefined);
126
136
  export function parse(command, args) {
127
- const known = FLAGS[command];
137
+ const known = flagsOf(command);
128
138
  if (known === undefined)
129
139
  throw new UsageError(`Unknown command "${command}" (see brainchain-worker --help)`);
130
140
  const positional = [];
@@ -137,7 +147,7 @@ export function parse(command, args) {
137
147
  continue;
138
148
  }
139
149
  const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
140
- const isSwitch = known[name];
150
+ const isSwitch = Object.hasOwn(known, name) ? known[name] : undefined;
141
151
  if (isSwitch === undefined)
142
152
  throw new UsageError(`The ${command} command has no --${name} flag (see brainchain-worker --help)`);
143
153
  if (isSwitch) {
@@ -161,12 +171,12 @@ export function parse(command, args) {
161
171
  * with --, so `--about --help` still asks.
162
172
  */
163
173
  export function asksHelp(command, args) {
164
- const known = FLAGS[command] ?? {};
174
+ const known = flagsOf(command) ?? {};
165
175
  for (let i = 0; i < args.length; i++) {
166
176
  const arg = args[i];
167
177
  if (arg === "--help" || arg === "-h")
168
178
  return true;
169
- if (arg.startsWith("--") && !arg.includes("=") && known[arg.slice(2)] === false && !args[i + 1]?.startsWith("--"))
179
+ if (arg.startsWith("--") && !arg.includes("=") && Object.hasOwn(known, arg.slice(2)) && known[arg.slice(2)] === false && !args[i + 1]?.startsWith("--"))
170
180
  i++;
171
181
  }
172
182
  return false;
@@ -176,18 +186,18 @@ export function usage(command) {
176
186
  return command !== undefined && Object.hasOwn(COMMAND_USAGE, command) ? `Usage:\n${COMMAND_USAGE[command]}` : USAGE;
177
187
  }
178
188
  function signer() {
179
- const raw = process.env.BRAINCHAIN_WORKER_KEY?.trim();
189
+ const raw = keyText()?.trim();
180
190
  if (!raw)
181
- throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key. `npx brainchain-worker new-key` makes one.");
191
+ throw new Error("Set BRAINCHAIN_WORKER_KEY to the worker's private key, or BRAINCHAIN_WORKER_KEY_FILE to a file that holds it. `npx brainchain-worker new-key` makes one.");
182
192
  const hex = raw.startsWith("0x") ? raw.slice(2) : raw;
183
193
  if (!/^[0-9a-fA-F]{64}$/.test(hex))
184
- throw new Error("BRAINCHAIN_WORKER_KEY must be 64 hex characters, with or without 0x");
194
+ throw new Error("The worker's key must be 64 hex characters, with or without 0x");
185
195
  const key = `0x${hex.toLowerCase()}`;
186
196
  try {
187
197
  return { key, account: privateKeyToAccount(key) };
188
198
  }
189
199
  catch {
190
- throw new Error("BRAINCHAIN_WORKER_KEY is not a valid private key. `npx brainchain-worker new-key` makes one.");
200
+ throw new Error("The worker's key is not a valid private key. `npx brainchain-worker new-key` makes one.");
191
201
  }
192
202
  }
193
203
  const DEFAULT_PRICE = "0.20";
@@ -199,8 +209,9 @@ function price(value) {
199
209
  return parseUnits(value, 6);
200
210
  }
201
211
  const RUN = '`npx brainchain-worker run --exec "<command>"`';
212
+ const KEEP_RUNNING = "https://github.com/usebrainchain/brainchain-worker#keep-it-running";
202
213
  function noGas(chain, network) {
203
- return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
214
+ return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
204
215
  }
205
216
  async function needGas(chain, network) {
206
217
  if ((await chain.ethBalance()) === 0n)
@@ -225,11 +236,8 @@ export async function join(chain, network, values) {
225
236
  throw new UsageError("--agent-id works on mainnet only, where BrainChain writes each worker's record to its ERC-8004 identity");
226
237
  }
227
238
  const requested = values.has("price") ? price(values.get("price")) : undefined;
228
- const gas = await chain.ethBalance();
229
- // The faucet covers the stake where there is one, so gas is all a new worker can lack there.
230
- if (gas === 0n && !network.buyStake)
231
- throw noGas(chain, network);
232
- const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
239
+ const standing = await snapshot(chain);
240
+ const { allowed, w, change, pending, open, applying, toStake } = standing;
233
241
  // The agent id travels in the application, and Jev keeps the profile it decided on.
234
242
  if (agentId !== undefined) {
235
243
  if (allowed || (change && pending) || open) {
@@ -244,15 +252,19 @@ export async function join(chain, network, values) {
244
252
  }
245
253
  // A worker that has staked keeps its price unless --price changes it.
246
254
  const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
247
- const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
255
+ const [gas, gasPrice, bond, balance, timelock] = await Promise.all([chain.ethBalance(), chain.gasPrice(), chain.bond(), chain.stakeBalance(), chain.timelock()]);
248
256
  const need = toStake + (applying ? bond : 0n);
257
+ const claims = balance < need && !network.buyStake;
258
+ // Checked before anything is sent: a wallet that runs out partway leaves the stake placed and the application unsent.
259
+ const gasNeed = ethNeeded(joinLimits(standing, claims, toStake === 0n && w.price !== asking), gasPrice);
260
+ const lacksGas = gas < gasNeed ? `${chain.account} holds ${shortOfGas(network, gas, gasNeed, "join")}` : undefined;
249
261
  if (balance < need && network.buyStake) {
250
262
  const short = shortOfStake(network, w, balance, need, await chain.now());
251
263
  // Both needs at once, so a new worker does not bridge ETH only to learn it needs the stake token too.
252
- throw new Error(gas === 0n ? `${noGas(chain, network).message}\n${short}` : short);
264
+ throw new Error(lacksGas ? `${lacksGas}\n${short}` : short);
253
265
  }
254
- if (gas === 0n)
255
- throw noGas(chain, network);
266
+ if (lacksGas)
267
+ throw new Error(lacksGas);
256
268
  if (balance < need) {
257
269
  const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
258
270
  const closed = (opensAt) => {
@@ -299,8 +311,9 @@ export async function join(chain, network, values) {
299
311
  "the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
300
312
  }
301
313
  async function status(chain, network) {
302
- const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
314
+ const [gas, gasPrice, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
303
315
  chain.ethBalance(),
316
+ chain.gasPrice(),
304
317
  snapshot(chain),
305
318
  chain.held(),
306
319
  chain.record(),
@@ -320,7 +333,7 @@ async function status(chain, network) {
320
333
  Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
321
334
  Record ${record.accepted} accepted, ${record.refunded} refunded, ${record.inFlight} in flight${held > 0 ? " (stake held until they settle)" : ""}
322
335
  Earned ${usdg(network, record.earned)} after fees; wallet holds ${usdg(network, bountyBalance)}
323
- Gas ${gasLine(network, s, gas)}`);
336
+ Gas ${gasLine(network, s, gas, gasPrice, !network.buyStake && stakeBalance < s.toStake + (s.applying ? bond : 0n))}`);
324
337
  }
325
338
  async function run(network, { key, account }, values, once, mints) {
326
339
  const exec = values.get("exec");
@@ -334,6 +347,7 @@ async function run(network, { key, account }, values, once, mints) {
334
347
  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");
335
348
  }
336
349
  const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
350
+ clearKeyFromEnvironment(key, log);
337
351
  const program = firstProgram(exec);
338
352
  if (program && !programFound(program))
339
353
  log(`warning: the shell cannot find ${program}, so --exec will fail when the worker is hired`);
@@ -360,6 +374,14 @@ async function run(network, { key, account }, values, once, mints) {
360
374
  });
361
375
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
362
376
  process.on(signal, stop.signal);
377
+ if (process.env.npm_execpath !== undefined) {
378
+ watchStarter(() => {
379
+ log("npx, which started this run, is gone, so this counts as a stop");
380
+ stop.signal("SIGTERM");
381
+ });
382
+ if (npxUnderSupervisor())
383
+ log(`warning: a supervisor's stop reaches npx and ends this run with its task. For a stop that lets the task finish, install the package and start the brainchain-worker binary: ${KEEP_RUNNING}`);
384
+ }
363
385
  let gas;
364
386
  try {
365
387
  gas = await chain.ethBalance();
@@ -429,12 +451,22 @@ export async function main(argv) {
429
451
  return;
430
452
  }
431
453
  if (command === "new-key") {
454
+ const file = parse(command, rest).values.get("file");
432
455
  const fresh = generatePrivateKey();
433
- console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${privateKeyToAccount(fresh).address}\nKeep the key secret; it controls the stake and the earnings.`);
456
+ const { address } = privateKeyToAccount(fresh);
457
+ if (file === undefined) {
458
+ console.log(`BRAINCHAIN_WORKER_KEY=${fresh}\naddress ${address}\nKeep the key secret; it controls the stake and the earnings.`);
459
+ return;
460
+ }
461
+ writeKeyFile(file, fresh);
462
+ console.log(`BRAINCHAIN_WORKER_KEY_FILE=${file}\naddress ${address}\nKeep the file secret; it controls the stake and the earnings.`);
434
463
  return;
435
464
  }
436
465
  const { positional, values, switches } = parse(command, rest);
437
466
  const network = resolveNetwork(process.env);
467
+ if (process.env.BRAINCHAIN_RPC_URL?.trim()) {
468
+ await requireChainId(network, () => createPublicClient({ transport: http(network.rpc, { timeout: 10_000, retryCount: 0 }) }).getChainId());
469
+ }
438
470
  if (command === "claim") {
439
471
  return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
440
472
  }
@@ -472,7 +504,7 @@ export async function main(argv) {
472
504
  const veto = switches.has("veto");
473
505
  const once = switches.has("once");
474
506
  // Judging changes needs no key; vetoing and reporting do. A reader stands in for the signer otherwise.
475
- const keyed = veto || Boolean(process.env.BRAINCHAIN_WORKER_KEY?.trim());
507
+ const keyed = veto || keyConfigured();
476
508
  const account = keyed ? signer().account : privateKeyToAccount(generatePrivateKey());
477
509
  const client = createPublicClient({ chain: chainOf(network), transport: brainchainTransport(network) });
478
510
  const { taskBoard, executor, log } = network.contracts;
@@ -505,8 +537,8 @@ export async function main(argv) {
505
537
  await requireWatcher(chain, console.log);
506
538
  console.log(watching(network, keyed ? account.address : undefined, veto));
507
539
  if (once) {
508
- const { findings } = await watch.round();
509
- if (findings > 0)
540
+ const { findings, incomplete } = await watch.round();
541
+ if (findings > 0 || incomplete)
510
542
  process.exitCode = 1;
511
543
  return;
512
544
  }
@@ -520,7 +552,7 @@ export async function main(argv) {
520
552
  if (id === undefined || !/^\d{1,18}$/.test(id) || id === "0")
521
553
  throw new UsageError("Usage: brainchain-worker proposal <id>, such as proposal 2");
522
554
  // Reading a proposal needs no key, so any address stands in for the signer.
523
- const reader = process.env.BRAINCHAIN_WORKER_KEY?.trim() ? signer().account : privateKeyToAccount(generatePrivateKey());
555
+ const reader = keyConfigured() ? signer().account : privateKeyToAccount(generatePrivateKey());
524
556
  return proposal(network, createProposals(network, reader), BigInt(id), console.log);
525
557
  }
526
558
  const worker = signer();
@@ -554,8 +586,11 @@ export async function main(argv) {
554
586
  if (positional[0] === undefined)
555
587
  throw new UsageError("Usage: brainchain-worker price <USDG>, such as price 0.25");
556
588
  const asking = price(positional[0]);
557
- if (!(await chain.stake()).registered)
589
+ const w = await chain.stake();
590
+ if (!w.registered)
558
591
  throw new Error("This worker has not staked yet. Run join, which sets the price too.");
592
+ if (w.price === asking)
593
+ return console.log(`The price is already ${usdg(network, asking)} per task.`);
559
594
  await needGas(chain, network);
560
595
  await chain.setPrice(asking);
561
596
  console.log(`Price set to ${usdg(network, asking)} per task.`);
package/dist/dispute.js CHANGED
@@ -2,8 +2,7 @@ import { createPublicClient, createWalletClient, formatUnits, parseAbi } from "v
2
2
  import { brainchainTransport, erc20Abi, RECEIPT_MS, Status } from "./chain.js";
3
3
  import { chainOf } from "./network.js";
4
4
  /** TaskBoard statuses from set 3.5.0 on: a ruling that waits out its appeal window, and one under appeal. */
5
- export const Ruled = 8;
6
- export const Appealed = 9;
5
+ export const { Ruled, Appealed } = Status;
7
6
  const boardAbi = parseAbi([
8
7
  "function get(uint256 id) view returns ((address poster, uint128 bounty, uint64 deadline, uint8 status, bool accepted, bool disputed, bool disputedByModel, bytes32 model, bytes32 briefHash, bytes32 resultHash, uint64 submittedAt, uint64 gradedAt, uint64 disputedAt, uint64 postedAt, address payee, address feeRecipient, uint16 feeBps, uint16 hireThresholdBps, uint16 gradeThresholdBps, uint16 ruleThresholdBps, uint64 gradeWindow, uint64 disputeWindow, uint64 ruleWindow))",
9
8
  "function appealOf(uint256 id) view returns ((uint64 window, uint16 bondBps, uint64 ruledAt, bool pays, bool confident, address appellant, uint128 bond, uint64 appealedAt))",
package/dist/key.js ADDED
@@ -0,0 +1,92 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { accessSync, constants, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { stateDir } from "./state.js";
6
+ /** Names the file a run passes its key through when it restarts itself without the key in its environment. */
7
+ const HANDOFF = "BRAINCHAIN_WORKER_KEY_HANDOFF";
8
+ const HANDOFF_NAME = /^brainchain-worker-key-[0-9a-f]{32}$/;
9
+ const expandHome = (path) => (path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path);
10
+ /** Whether the environment names a key, without reading it: a key file can be a pipe, which reads only once. */
11
+ export const keyConfigured = (env = process.env) => Boolean(env[HANDOFF] || env.BRAINCHAIN_WORKER_KEY?.trim() || env.BRAINCHAIN_WORKER_KEY_FILE?.trim());
12
+ function fromFile(path, warn) {
13
+ const file = expandHome(path);
14
+ let text;
15
+ try {
16
+ const stat = statSync(file);
17
+ if (stat.isFile() && (stat.mode & 0o077) !== 0)
18
+ warn(`warning: other users can read ${file}; run chmod 600 on it`);
19
+ text = readFileSync(file, "utf8");
20
+ }
21
+ catch (error) {
22
+ throw new Error(`BRAINCHAIN_WORKER_KEY_FILE names ${file}, which could not be read (${error.code ?? "unknown error"})`);
23
+ }
24
+ // The file holds the key alone, or the line new-key prints.
25
+ return /^\s*(?:BRAINCHAIN_WORKER_KEY=)?(\S+)\s*$/m.exec(text)?.[1] ?? "";
26
+ }
27
+ /** The text of the worker's key, from wherever the environment says it is; undefined when it names none. */
28
+ export function keyText(env = process.env, warn = console.warn) {
29
+ const handoff = env[HANDOFF];
30
+ if (handoff !== undefined) {
31
+ delete env[HANDOFF];
32
+ if (!HANDOFF_NAME.test(basename(handoff)))
33
+ throw new Error(`${HANDOFF} is for the worker's own use; unset it`);
34
+ const text = readFileSync(handoff, "utf8");
35
+ rmSync(handoff, { force: true });
36
+ return text;
37
+ }
38
+ const inline = env.BRAINCHAIN_WORKER_KEY?.trim();
39
+ const file = env.BRAINCHAIN_WORKER_KEY_FILE?.trim();
40
+ if (inline && file)
41
+ throw new Error("Set BRAINCHAIN_WORKER_KEY or BRAINCHAIN_WORKER_KEY_FILE, not both");
42
+ return file ? fromFile(file, warn) : inline || undefined;
43
+ }
44
+ /** Writes a new key to a file only its owner can read; an existing file is left alone, since it may hold a wallet's only key. */
45
+ export function writeKeyFile(path, key) {
46
+ const file = expandHome(path);
47
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
48
+ try {
49
+ writeFileSync(file, `${key}\n`, { flag: "wx", mode: 0o600 });
50
+ }
51
+ catch (error) {
52
+ if (error.code === "EEXIST")
53
+ throw new Error(`${file} already exists, and new-key never replaces a key`);
54
+ throw error;
55
+ }
56
+ }
57
+ /** Memory rather than disk where the system offers it. The file lives for the milliseconds a restart takes. */
58
+ function handoffDir() {
59
+ try {
60
+ accessSync("/dev/shm", constants.W_OK);
61
+ return "/dev/shm";
62
+ }
63
+ catch {
64
+ mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
65
+ return stateDir();
66
+ }
67
+ }
68
+ /**
69
+ * Restarts this process in place without BRAINCHAIN_WORKER_KEY in its
70
+ * environment. Deleting the variable would not do: `ps eww` and /proc read the
71
+ * block the process started with, and a command run by `run` can reach both.
72
+ * The pid, the output and the supervisor's view all stay as they were. Returns
73
+ * when there is nothing to clear, or when it could not be cleared.
74
+ */
75
+ export function clearKeyFromEnvironment(key, warn, env = process.env, execve = process.execve) {
76
+ if (env.BRAINCHAIN_WORKER_KEY === undefined)
77
+ return;
78
+ if (typeof execve !== "function")
79
+ return warn(`warning: Node.js ${process.version} cannot clear the key from this process's environment; Node.js 22.15 or later can`);
80
+ const { BRAINCHAIN_WORKER_KEY: _, ...rest } = env;
81
+ let file;
82
+ try {
83
+ file = join(handoffDir(), `brainchain-worker-key-${randomBytes(16).toString("hex")}`);
84
+ writeFileSync(file, key, { flag: "wx", mode: 0o600 });
85
+ execve(process.execPath, [process.execPath, ...process.execArgv, process.argv[1], ...process.argv.slice(2)], { ...rest, [HANDOFF]: file });
86
+ }
87
+ catch (error) {
88
+ if (file !== undefined)
89
+ rmSync(file, { force: true });
90
+ warn(`warning: could not clear the key from this process's environment (${error.message})`);
91
+ }
92
+ }
package/dist/network.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineChain, fallback, http, zeroAddress } from "viem";
2
- /** BrainChain testnet, release 3.4.0. */
2
+ /** BrainChain testnet, release 3.5.0. */
3
3
  export const TESTNET = {
4
4
  id: "testnet",
5
5
  name: "BrainChain testnet",
@@ -10,7 +10,7 @@ export const TESTNET = {
10
10
  explorer: "https://usebrainchain.com/testnet",
11
11
  stakeSymbol: "tBRAIN",
12
12
  bountySymbol: "tUSDG",
13
- gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com and bridge some at https://usebrainchain.com/testnet",
13
+ gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com, then move it here with `npx brainchain-worker bridge --eth 0.001`",
14
14
  proofs: {
15
15
  chainId: 46630,
16
16
  name: "Robinhood Chain testnet",
@@ -58,7 +58,7 @@ export const MAINNET = {
58
58
  explorer: "https://usebrainchain.com",
59
59
  stakeSymbol: "BRAIN",
60
60
  bountySymbol: "USDG",
61
- gas: "Bridge some from Robinhood Chain at https://usebrainchain.com/network",
61
+ gas: "Send this address ETH on Robinhood Chain, then move it here with `npx brainchain-worker bridge --eth 0.001`",
62
62
  buyStake: "Buy BRAIN on Robinhood Chain at ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and move it to BrainChain with " +
63
63
  `brainchain-worker bridge --stake <amount> (docs: https://docs.usebrainchain.com/bridging); the stake token on BrainChain is ${BRAIN}.`,
64
64
  proofs: {
package/dist/propose.js CHANGED
@@ -303,7 +303,7 @@ export async function propose(network, chain, call, uri, options) {
303
303
  throw new Error(`The wallet holds ${stake(network, balance)} of the ${stake(network, bond)} bond.${buy}`);
304
304
  }
305
305
  if (gas === 0n)
306
- throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
306
+ throw new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}.`);
307
307
  log(`Bonding ${stake(network, bond)} and proposing...`);
308
308
  const { id, tx } = await chain.propose(call, uri, bond);
309
309
  log(`Proposal ${id} is waiting for Jev (${tx}). Follow it with \`npx brainchain-worker proposal ${id}\`.`);
package/dist/standing.js CHANGED
@@ -26,6 +26,39 @@ export async function snapshot(chain) {
26
26
  toStake: w.staked >= min ? 0n : min - w.staked,
27
27
  };
28
28
  }
29
+ /** The highest gas limit seen for each transaction join can send, across testnet and mainnet. */
30
+ const GAS = { faucet: 76000n, approve: 50000n, stake: 120000n, setPrice: 31000n, apply: 240000n };
31
+ /** The highest gas limit seen for delivering a result. */
32
+ const SUBMIT_GAS = 104000n;
33
+ /**
34
+ * The ETH a wallet must hold for transactions with these gas limits to go
35
+ * through in order. Each pays the going price, but the RPC refuses one the
36
+ * wallet could not cover at 2.4 times it, the fee cap it fills in.
37
+ */
38
+ export function ethNeeded(limits, gasPrice) {
39
+ let spent = 0n;
40
+ let need = 0n;
41
+ for (const limit of limits) {
42
+ const covered = spent + (limit * gasPrice * 24n) / 10n;
43
+ if (covered > need)
44
+ need = covered;
45
+ spent += limit * gasPrice;
46
+ }
47
+ return need;
48
+ }
49
+ /** The gas limits of what join would send for this worker, in order. */
50
+ export function joinLimits(s, claims, reprices) {
51
+ return [
52
+ ...(claims ? [GAS.faucet] : []),
53
+ ...(s.toStake > 0n ? [GAS.approve, GAS.stake] : reprices ? [GAS.setPrice] : []),
54
+ ...(s.applying ? [GAS.approve, GAS.apply] : []),
55
+ ];
56
+ }
57
+ /** An amount of ETH rounded up to two significant figures. */
58
+ export function aboutEth(wei) {
59
+ const unit = 10n ** BigInt(Math.max(wei.toString().length - 2, 0));
60
+ return formatEther(((wei + unit - 1n) / unit) * unit);
61
+ }
29
62
  /** Why the worker can or cannot be hired right now, in one line for `run` or for `status`, which shows the rest itself. */
30
63
  export function hireable({ allowed, w, min, applications, change, pending }, network, from) {
31
64
  if (allowed) {
@@ -34,8 +67,11 @@ export function hireable({ allowed, w, min, applications, change, pending }, net
34
67
  return `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again${from === "run" ? "; status shows what it needs" : ""}.`;
35
68
  }
36
69
  // An allowed application waiting out its timelock outranks anything filed after it.
37
- if (change && pending)
38
- return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
70
+ if (change && pending) {
71
+ const due = BigInt(Math.floor(Date.now() / 1000)) >= change.eta;
72
+ const allowed = `no: Jev allowed this worker; ${due ? `the change that allows it was due at ${when(change.eta)} and has not run yet` : `it can be hired after ${when(change.eta)}`}`;
73
+ return w.staked >= min ? allowed : `${allowed}. Its stake is below the minimum of ${stake(network, min)}, so run join to stake again first.`;
74
+ }
39
75
  const last = applications.at(-1);
40
76
  if (last === undefined)
41
77
  return "no: run `npx brainchain-worker join` to apply";
@@ -69,12 +105,13 @@ export function wallet(network, s, balance, bond, faucet, now) {
69
105
  const buy = network.buyStake ? `. ${network.buyStake}` : "";
70
106
  return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}${buy}`;
71
107
  }
72
- /** The wallet's ETH. With none, and something for join to do, it says so: join can send nothing until the wallet has some. */
73
- export function gasLine(network, s, gas) {
74
- const has = `${formatEther(gas)} ETH`;
75
- if (gas > 0n || (s.toStake === 0n && !s.applying))
76
- return has;
77
- return `${has}; join needs ETH for gas first. ${network.gas}; 0.001 ETH is plenty.`;
108
+ /** What a wallet short of gas holds and needs, for join's refusal and for `status`. */
109
+ export const shortOfGas = (network, gas, need, purpose) => `${formatEther(gas)} ETH on ${network.name}; ${purpose} needs about ${aboutEth(need)} ETH for gas. ${network.gas}.`;
110
+ /** The wallet's ETH, and what more join or the next delivery needs, since a send the wallet cannot cover fails partway through. */
111
+ export function gasLine(network, s, gas, gasPrice, claims) {
112
+ const limits = joinLimits(s, claims, false);
113
+ const need = ethNeeded(limits.length > 0 ? limits : [SUBMIT_GAS], gasPrice);
114
+ return gas >= need ? `${formatEther(gas)} ETH` : shortOfGas(network, gas, need, limits.length > 0 ? "join" : "delivering a result");
78
115
  }
79
116
  /** What join says when the wallet holds too little of a stake token that has no faucet. */
80
117
  export function shortOfStake(network, w, balance, need, now) {
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
@@ -114,6 +114,9 @@ export const REPORT_PREFIX = "BrainChain watchtower report\n";
114
114
  export const MAX_CHECKED = 200;
115
115
  /** The proof a record was fetched: the hash of its bytes followed by the worker's address, which nothing on chain gives away. */
116
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
+ }
117
120
  /**
118
121
  * Sends a round's re-verified decisions to the service, signed by the worker,
119
122
  * so a staked worker's watchtower counts toward its mark in hire questions.
@@ -138,6 +141,8 @@ export function createReporter(network, account, fetcher = fetch) {
138
141
  signal: AbortSignal.timeout(20_000),
139
142
  });
140
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}`);
141
146
  if (!response.ok)
142
147
  throw new Error(body.error ?? `${response.status} from ${network.service}`);
143
148
  return body.mark
@@ -156,7 +161,10 @@ export function createWatch(options) {
156
161
  let nextBlock = options.fromBlock ?? network.fromBlock;
157
162
  let seenDecision = options.seenDecision;
158
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();
159
166
  let findings = 0;
167
+ let incomplete = false;
160
168
  async function changes() {
161
169
  const head = await chain.head();
162
170
  if (head < nextBlock)
@@ -168,36 +176,49 @@ export function createWatch(options) {
168
176
  const key = change.changeId.toString();
169
177
  if (judged.has(key))
170
178
  continue;
171
- judged.add(key);
172
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
+ }
173
188
  const runsAt = new Date(Number(change.eta) * 1000).toISOString().replace("T", " ").slice(0, 16);
174
189
  if (broken.length === 0) {
175
190
  log(`change ${change.changeId}: ${name} on ${change.target}, within the constitution, runs after ${runsAt} UTC`);
176
191
  continue;
177
192
  }
178
193
  findings += 1;
179
- const why = broken.join("; ");
180
194
  log(`change ${change.changeId}: ${name} on ${change.target} breaks the constitution (${why}), runs after ${runsAt} UTC`);
181
- if (!options.veto)
182
- continue;
183
- if (!(await chain.open(change.changeId))) {
184
- log(`change ${change.changeId} is closed already, nothing to veto`);
185
- 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`);
186
207
  }
187
- if (!chain.veto) {
208
+ else if (!chain.veto) {
209
+ owed.delete(key);
188
210
  log(`cannot veto: ${network.name} has no guardian gate this worker can reach`);
189
- continue;
190
211
  }
191
- try {
192
- const hash = await chain.veto(change.changeId, `Breaks the constitution: ${why}`);
193
- log(`vetoed change ${change.changeId} through the guardian gate: ${hash}`);
194
- }
195
- catch (error) {
196
- 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}`);
197
216
  }
198
217
  }
218
+ catch (error) {
219
+ log(`veto of change ${id} failed, tried again next round: ${reason(error)}`);
220
+ }
199
221
  }
200
- nextBlock = head + 1n;
201
222
  }
202
223
  async function decisions() {
203
224
  if (!verify)
@@ -215,6 +236,7 @@ export function createWatch(options) {
215
236
  }
216
237
  const checked = [];
217
238
  const found = [];
239
+ let reached = seenDecision;
218
240
  for (let id = seenDecision + 1n; id <= count; id++) {
219
241
  try {
220
242
  const { findings: wrong, record } = await verify(id);
@@ -228,27 +250,48 @@ export function createWatch(options) {
228
250
  found.push({ id, checks: wrong });
229
251
  log(`decision ${id} does not verify: ${wrong.join("; ")}`);
230
252
  }
253
+ reached = id;
231
254
  }
232
255
  catch (error) {
233
- 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;
234
260
  }
235
261
  }
236
- seenDecision = count;
237
- options.saw?.(count);
238
- if (!options.report || (checked.length === 0 && found.length === 0))
239
- 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;
240
277
  try {
241
- log(await options.report(checked, found));
278
+ options.saw?.(reached);
242
279
  }
243
280
  catch (error) {
244
- 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)}`);
245
282
  }
246
283
  }
247
284
  return {
248
285
  async round() {
249
- await changes();
286
+ incomplete = false;
287
+ try {
288
+ await changes();
289
+ }
290
+ finally {
291
+ await vetoes();
292
+ }
250
293
  await decisions();
251
- return { findings };
294
+ return { findings, incomplete };
252
295
  },
253
296
  };
254
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,6 +241,15 @@ 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)
@@ -209,13 +259,18 @@ export function shellCommand(command, graceMs = 10_000) {
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`);
@@ -420,7 +475,8 @@ export function createWork(options) {
420
475
  const wait = RETRY_SEC[Math.min(attempt.failures - 1, RETRY_SEC.length - 1)];
421
476
  // The attempt may have run for many minutes, so the retry is timed from now.
422
477
  const now = await chain.now().catch(wall);
423
- 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) {
424
480
  finish(key);
425
481
  log(`task ${task.id}: failed (${why}); no time left to retry, so the poster is refunded`);
426
482
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainchain-worker",
3
- "version": "0.12.3",
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": {