brainchain-worker 0.2.2 → 0.2.4
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 +10 -3
- package/dist/chain.js +108 -25
- package/dist/index.js +42 -79
- package/dist/network.js +4 -5
- package/dist/standing.js +64 -0
- package/dist/state.js +53 -13
- package/dist/work.js +146 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,9 +54,16 @@ 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
|
|
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`. 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. |
|
|
66
|
+
| Kubernetes | `terminationGracePeriodSeconds: 1320` in the pod spec |
|
|
60
67
|
|
|
61
68
|
## Commands
|
|
62
69
|
|
|
@@ -64,7 +71,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
64
71
|
| --- | --- |
|
|
65
72
|
| `new-key` | Print a fresh private key and its address. |
|
|
66
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. |
|
|
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. |
|
|
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,28 +82,92 @@ function unquote(text) {
|
|
|
80
82
|
return text;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
/**
|
|
84
|
-
function
|
|
85
|
-
|
|
85
|
+
/** The Basic credentials viem sends for a URL's user and password, which a server that echoes requests answers with. */
|
|
86
|
+
function basicAuth(username, password) {
|
|
87
|
+
if (!username)
|
|
88
|
+
return [];
|
|
89
|
+
try {
|
|
90
|
+
return [btoa(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`)];
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** The parts of a URL long enough to be an API key: its credentials, the path, the query and each of their segments. */
|
|
97
|
+
function secretParts(url) {
|
|
98
|
+
if (!URL.canParse(url))
|
|
99
|
+
return [];
|
|
100
|
+
const { pathname, search, username, password } = new URL(url);
|
|
101
|
+
const path = pathname + search;
|
|
102
|
+
return [...basicAuth(username, password), username, password, path, pathname, search, ...path.split(/[/?&=]/)].filter((part) => part.length >= 8);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* One short line of an answer, without the RPC URL's credentials, path or
|
|
106
|
+
* query: an answer can echo them, and they can hold an API key. A web page is
|
|
107
|
+
* far longer than one line.
|
|
108
|
+
*/
|
|
109
|
+
function excerpt(details, url) {
|
|
110
|
+
let line = unquote(details);
|
|
111
|
+
for (const part of secretParts(url))
|
|
112
|
+
line = line.replaceAll(part, "…");
|
|
113
|
+
line = line
|
|
86
114
|
.replace(/\s+/g, " ")
|
|
87
115
|
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
88
116
|
.trim();
|
|
89
117
|
return line.length > 160 ? `${line.slice(0, 159)}…` : line;
|
|
90
118
|
}
|
|
119
|
+
/** A 2xx answer in JSON that is no JSON-RPC reply, such as a REST API's. */
|
|
120
|
+
class NotRpcError extends Error {
|
|
121
|
+
excerpt;
|
|
122
|
+
/** One short line of the answer, without the URL's secrets. */
|
|
123
|
+
constructor(excerpt) {
|
|
124
|
+
super("The answer is not a JSON-RPC reply");
|
|
125
|
+
this.excerpt = excerpt;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const isReply = (value) => typeof value === "object" && value !== null && ("result" in value || ("error" in value && typeof value.error === "object" && value.error !== null));
|
|
129
|
+
/** viem reads an answer without a result as an empty one, and fails later on whatever needed it, such as "Block could not be found." */
|
|
130
|
+
async function checkReply(response, url) {
|
|
131
|
+
if (!response.ok)
|
|
132
|
+
return;
|
|
133
|
+
const text = await response.clone().text();
|
|
134
|
+
let value;
|
|
135
|
+
try {
|
|
136
|
+
value = JSON.parse(text);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// viem names an answer that is not JSON itself.
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// The excerpt is cut here, where the URL still holds the credentials viem leaves out of its errors.
|
|
143
|
+
if (!(Array.isArray(value) ? value.every(isReply) : isReply(value)))
|
|
144
|
+
throw new NotRpcError(excerpt(text, url));
|
|
145
|
+
}
|
|
146
|
+
/** A request that failed, named by the RPC's host and the status it answered with. */
|
|
147
|
+
function requestFailure(request) {
|
|
148
|
+
const host = hostOf(request.url);
|
|
149
|
+
// viem passes on a 2xx answer that does not parse as JSON as the parser's error.
|
|
150
|
+
if (request.cause instanceof SyntaxError)
|
|
151
|
+
return `${host} answered, but not as an RPC`;
|
|
152
|
+
if (request.cause instanceof NotRpcError)
|
|
153
|
+
return `${host} answered, but not as an RPC (${request.cause.excerpt})`;
|
|
154
|
+
const details = request.details ? excerpt(request.details, request.url) : "";
|
|
155
|
+
const status = request instanceof TimeoutError ? undefined : request.status;
|
|
156
|
+
if (status === undefined)
|
|
157
|
+
return `Could not reach ${host}${details ? ` (${details})` : ""}`;
|
|
158
|
+
// A proxy or CDN in front of the RPC answers an outage or a rate limit with its own page.
|
|
159
|
+
if (details.startsWith("<"))
|
|
160
|
+
return `${host} answered ${status} with a web page${status === 404 || status === 405 ? ", not as an RPC" : ""}`;
|
|
161
|
+
return `${host} answered ${status}${details ? ` (${details})` : ""}`;
|
|
162
|
+
}
|
|
91
163
|
/** One readable line for an error. It names the RPC by host only: its URL can hold an API key. */
|
|
92
164
|
export function reason(error) {
|
|
93
165
|
if (!(error instanceof BaseError))
|
|
94
166
|
return error instanceof Error ? error.message : String(error);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (details.startsWith("<"))
|
|
100
|
-
return `${host} answered with a web page, not as an RPC`;
|
|
101
|
-
if (request instanceof HttpRequestError && request.status)
|
|
102
|
-
return `${host} answered ${request.status}${details ? ` (${details})` : ""}`;
|
|
103
|
-
return `Could not reach ${host}${details ? ` (${details})` : ""}`;
|
|
104
|
-
}
|
|
167
|
+
// An RPC error with an HTTP status is a provider refusing the request, such as a bad key or a rate limit.
|
|
168
|
+
const request = error.walk((e) => e instanceof HttpRequestError || e instanceof TimeoutError || (e instanceof RpcRequestError && e.status !== undefined));
|
|
169
|
+
if (request instanceof HttpRequestError || request instanceof RpcRequestError || request instanceof TimeoutError)
|
|
170
|
+
return requestFailure(request);
|
|
105
171
|
const name = revertOf(error)?.errorName;
|
|
106
172
|
return name ? `${error.shortMessage} (${name})` : error.shortMessage;
|
|
107
173
|
}
|
|
@@ -110,13 +176,18 @@ export async function unhealthy(service, fetcher = fetch) {
|
|
|
110
176
|
const host = new URL(service).host;
|
|
111
177
|
const response = await fetcher(`${service}/health`, { signal: AbortSignal.timeout(10_000) }).catch(() => undefined);
|
|
112
178
|
if (response === undefined)
|
|
113
|
-
return `could not reach the BrainChain service at ${host}`;
|
|
179
|
+
return `could not reach the BrainChain service at ${host}; results are uploaded there, so none can be delivered until it answers`;
|
|
114
180
|
await response.body?.cancel();
|
|
115
|
-
|
|
181
|
+
if (response.ok)
|
|
182
|
+
return undefined;
|
|
183
|
+
// The service answers 503 while its review rounds are late, which does not stop it taking results.
|
|
184
|
+
if (response.status === 503)
|
|
185
|
+
return `the BrainChain service at ${host} reports it is behind (503); results still upload, and grading may be late`;
|
|
186
|
+
return `the BrainChain service at ${host} answered its health check with ${response.status}; results are uploaded there, so delivery can fail until it recovers`;
|
|
116
187
|
}
|
|
117
188
|
export function createChain(network, account, store = memoryStore()) {
|
|
118
189
|
const chain = chainOf(network);
|
|
119
|
-
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
|
|
190
|
+
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3, onFetchResponse: (response) => checkReply(response, network.rpc) });
|
|
120
191
|
// BrainChain mines a block per transaction, so receipts are polled every second.
|
|
121
192
|
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
122
193
|
const wallet = createWalletClient({ account, chain, transport });
|
|
@@ -124,9 +195,10 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
124
195
|
const id = workerId(account.address);
|
|
125
196
|
const board = { address: taskBoard, abi: taskBoardAbi };
|
|
126
197
|
const origin = new URL(network.service).origin;
|
|
127
|
-
async function send(request) {
|
|
198
|
+
async function send(request, sent) {
|
|
128
199
|
const hash = await wallet.writeContract(request);
|
|
129
|
-
|
|
200
|
+
sent?.(hash);
|
|
201
|
+
const receipt = await client.waitForTransactionReceipt({ hash, timeout: RECEIPT_MS });
|
|
130
202
|
if (receipt.status !== "success")
|
|
131
203
|
throw new Error(`Transaction ${hash} reverted`);
|
|
132
204
|
return hash;
|
|
@@ -192,11 +264,13 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
192
264
|
}).catch(() => {
|
|
193
265
|
throw new Error(`Could not reach the BrainChain service at ${new URL(origin).host}`);
|
|
194
266
|
});
|
|
195
|
-
const body = (await response.json().catch(() =>
|
|
196
|
-
|
|
197
|
-
|
|
267
|
+
const body = (await response.json().catch(() => undefined));
|
|
268
|
+
// Only the service's own JSON error is a refusal: a CDN or firewall page with the same status is retried.
|
|
269
|
+
const said = typeof body?.error === "string" ? body.error : undefined;
|
|
270
|
+
const why = `(${response.status}): ${said ?? "no reason given"}`;
|
|
271
|
+
if (REFUSED.includes(response.status) && said !== undefined)
|
|
198
272
|
throw new PermanentError(`The service refused the result ${why}`);
|
|
199
|
-
if (!response.ok || !body
|
|
273
|
+
if (!response.ok || !body?.uri)
|
|
200
274
|
throw new Error(`Uploading the result failed ${why}`);
|
|
201
275
|
if (body.hash?.toLowerCase() !== hash)
|
|
202
276
|
throw new Error("The service stored different bytes than were sent");
|
|
@@ -204,9 +278,18 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
204
278
|
throw new Error(`The service returned a result address outside ${origin}`);
|
|
205
279
|
return { uri: body.uri, hash };
|
|
206
280
|
},
|
|
207
|
-
submit: (task, resultHash, resultURI) => send({ ...board, functionName: "submit", args: [task, resultHash, resultURI] }).catch((error) => {
|
|
281
|
+
submit: (task, resultHash, resultURI, sent) => send({ ...board, functionName: "submit", args: [task, resultHash, resultURI] }, sent).catch((error) => {
|
|
208
282
|
throw submitFailure(error);
|
|
209
283
|
}),
|
|
284
|
+
async landed(tx, waitMs) {
|
|
285
|
+
// Without replacement checks a receipt is only ever this transaction's, never another sent with its nonce.
|
|
286
|
+
const receipt = await client.waitForTransactionReceipt({ hash: tx, timeout: waitMs, checkReplacement: false }).catch((error) => {
|
|
287
|
+
if (error instanceof WaitForTransactionReceiptTimeoutError)
|
|
288
|
+
return undefined;
|
|
289
|
+
throw error;
|
|
290
|
+
});
|
|
291
|
+
return receipt && receipt.status === "success";
|
|
292
|
+
},
|
|
210
293
|
};
|
|
211
294
|
return {
|
|
212
295
|
...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,
|
|
4
|
+
import { formatEther, parseUnits } from "viem";
|
|
5
5
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
6
|
-
import {
|
|
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
|
|
|
@@ -68,7 +69,7 @@ function parse(command, args) {
|
|
|
68
69
|
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
69
70
|
const isSwitch = known[name];
|
|
70
71
|
if (isSwitch === undefined)
|
|
71
|
-
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)`);
|
|
72
73
|
if (isSwitch) {
|
|
73
74
|
switches.add(name);
|
|
74
75
|
continue;
|
|
@@ -79,7 +80,7 @@ function parse(command, args) {
|
|
|
79
80
|
values.set(name, value);
|
|
80
81
|
}
|
|
81
82
|
if (positional.length > (POSITIONAL[command] ?? 0))
|
|
82
|
-
throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker help)`);
|
|
83
|
+
throw new UsageError(`Unexpected "${positional.at(-1)}" (see brainchain-worker --help)`);
|
|
83
84
|
return { positional, values, switches };
|
|
84
85
|
}
|
|
85
86
|
function signer() {
|
|
@@ -105,9 +106,6 @@ function price(value) {
|
|
|
105
106
|
}
|
|
106
107
|
return parseUnits(value, 6);
|
|
107
108
|
}
|
|
108
|
-
const stake = (network, amount) => `${formatUnits(amount, 18)} ${network.stakeSymbol}`;
|
|
109
|
-
const usdg = (network, amount) => `${formatUnits(amount, 6)} ${network.bountySymbol}`;
|
|
110
|
-
const when = (seconds) => new Date(Number(seconds) * 1000).toISOString().replace(".000Z", "Z");
|
|
111
109
|
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
112
110
|
function noGas(chain, network) {
|
|
113
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 ` +
|
|
@@ -117,45 +115,6 @@ async function needGas(chain, network) {
|
|
|
117
115
|
if ((await chain.ethBalance()) === 0n)
|
|
118
116
|
throw noGas(chain, network);
|
|
119
117
|
}
|
|
120
|
-
/** The worker's standing with the board and the staking contract, and what join would do about it. */
|
|
121
|
-
async function snapshot(chain) {
|
|
122
|
-
const [allowed, w, min, { applications, change }] = await Promise.all([chain.allowed(), chain.stake(), chain.minStake(), chain.applications()]);
|
|
123
|
-
const pending = change !== undefined && !change.executed && !change.vetoed;
|
|
124
|
-
const open = applications.find((a) => a.state === ApplicationState.Open);
|
|
125
|
-
return {
|
|
126
|
-
allowed,
|
|
127
|
-
w,
|
|
128
|
-
min,
|
|
129
|
-
applications,
|
|
130
|
-
change,
|
|
131
|
-
pending,
|
|
132
|
-
open,
|
|
133
|
-
applying: !allowed && !pending && open === undefined,
|
|
134
|
-
// Below the minimum after leave or a slash, an allowed worker cannot be hired until it stakes again.
|
|
135
|
-
toStake: w.staked >= min ? 0n : min - w.staked,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
/** Why the worker can or cannot be hired right now, in one line. */
|
|
139
|
-
function hireable({ allowed, w, min, applications, change, pending }, network) {
|
|
140
|
-
if (allowed)
|
|
141
|
-
return w.staked >= min ? "yes" : `no: your stake is below the minimum of ${stake(network, min)}. Run join to stake again; status shows what it needs.`;
|
|
142
|
-
// An allowed application waiting out its timelock outranks anything filed after it.
|
|
143
|
-
if (change && pending)
|
|
144
|
-
return `no: Jev allowed this worker; it can be hired after ${when(change.eta)}`;
|
|
145
|
-
const last = applications.at(-1);
|
|
146
|
-
if (last === undefined)
|
|
147
|
-
return "no: run `npx brainchain-worker join` to apply";
|
|
148
|
-
if (last.state === ApplicationState.Open)
|
|
149
|
-
return `no: application ${last.id} is waiting for Jev`;
|
|
150
|
-
if (last.state === ApplicationState.Burned)
|
|
151
|
-
return `no: Jev did not allow application ${last.id}, and its bond was burned. Run join to apply again.`;
|
|
152
|
-
if (change?.vetoed) {
|
|
153
|
-
// A vetoed call can never be queued again, so the veto still stands for applications filed after it.
|
|
154
|
-
const vetoed = applications.find((a) => a.decisionId === change.decisionId);
|
|
155
|
-
return `no: the guardian vetoed ${vetoed ? `application ${vetoed.id}` : "allowing this worker"}`;
|
|
156
|
-
}
|
|
157
|
-
return `no: application ${last.id} closed without a decision; its bond was returned. Run join to apply again.`;
|
|
158
|
-
}
|
|
159
118
|
async function join(chain, network, values) {
|
|
160
119
|
const name = values.get("name")?.trim() ?? "";
|
|
161
120
|
const about = values.get("about")?.trim() ?? "";
|
|
@@ -176,12 +135,13 @@ async function join(chain, network, values) {
|
|
|
176
135
|
const [bond, balance] = await Promise.all([chain.bond(), chain.stakeBalance()]);
|
|
177
136
|
const need = toStake + (applying ? bond : 0n);
|
|
178
137
|
if (balance < need) {
|
|
138
|
+
const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
|
|
179
139
|
const closed = (opensAt) => {
|
|
180
|
-
const
|
|
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` : "";
|
|
181
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: ` +
|
|
182
143
|
`this wallet can claim again at ${when(opensAt)}. Run join again then${unstaking}.`);
|
|
183
144
|
};
|
|
184
|
-
const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
|
|
185
145
|
if (now < faucet.opensAt)
|
|
186
146
|
throw closed(faucet.opensAt);
|
|
187
147
|
console.log(`Claiming ${network.stakeSymbol} from the testnet faucet...`);
|
|
@@ -219,19 +179,6 @@ async function join(chain, network, values) {
|
|
|
219
179
|
`Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
|
|
220
180
|
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
221
181
|
}
|
|
222
|
-
/** The wallet's tBRAIN and, below the minimum stake, where join would find what it needs. */
|
|
223
|
-
function wallet(network, s, balance, bond, faucet, now) {
|
|
224
|
-
const has = stake(network, balance);
|
|
225
|
-
if (s.toStake === 0n)
|
|
226
|
-
return has;
|
|
227
|
-
const need = s.toStake + (s.applying ? bond : 0n);
|
|
228
|
-
if (balance >= need)
|
|
229
|
-
return `${has}, enough for the ${stake(network, need)} join needs`;
|
|
230
|
-
if (now >= faucet.opensAt)
|
|
231
|
-
return `${has}; join needs ${stake(network, need)} and claims the rest from the faucet, which gives ${stake(network, faucet.amount)} a day`;
|
|
232
|
-
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)}`}` : "";
|
|
233
|
-
return `${has}; join needs ${stake(network, need)}. The faucet opens again for this wallet at ${when(faucet.opensAt)}${unstaking}`;
|
|
234
|
-
}
|
|
235
182
|
async function status(chain, network) {
|
|
236
183
|
const [gas, s, held, record, bountyBalance, stakeBalance, bond, faucet, now] = await Promise.all([
|
|
237
184
|
chain.ethBalance(),
|
|
@@ -241,13 +188,14 @@ async function status(chain, network) {
|
|
|
241
188
|
chain.bountyBalance(),
|
|
242
189
|
chain.stakeBalance(),
|
|
243
190
|
chain.bond(),
|
|
244
|
-
|
|
191
|
+
// A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
|
|
192
|
+
chain.faucet().catch(() => undefined),
|
|
245
193
|
chain.now(),
|
|
246
194
|
]);
|
|
247
195
|
const { w, min } = s;
|
|
248
196
|
const unstaking = w.unstaking > 0n ? `, ${stake(network, w.unstaking)} unstaking until ${when(w.unlocksAt)}` : "";
|
|
249
197
|
console.log(`Worker ${chain.account} on ${network.name}
|
|
250
|
-
Hireable ${hireable(s, network)}
|
|
198
|
+
Hireable ${hireable(s, network, "status")}
|
|
251
199
|
Stake ${stake(network, w.staked)} (minimum ${stake(network, min)})${unstaking}
|
|
252
200
|
Wallet ${wallet(network, s, stakeBalance, bond, faucet, now)}
|
|
253
201
|
Price ${w.registered ? `${usdg(network, w.price)} per task` : "not set until you join"}
|
|
@@ -266,13 +214,16 @@ async function run(network, { key, account }, values, once) {
|
|
|
266
214
|
if (!/^\d{1,4}(\.\d+)?$/.test(minutes) || Number(minutes) <= 0 || Number(minutes) > 1440) {
|
|
267
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");
|
|
268
216
|
}
|
|
217
|
+
const log = (message) => console.log(`${new Date().toISOString().slice(0, 19)}Z ${message}`);
|
|
269
218
|
const file = joinPath(stateDir(), account.address);
|
|
270
219
|
lock(`${file}.lock`);
|
|
271
|
-
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
|
+
await stopLeftover(store, log);
|
|
272
223
|
const chain = createChain(network, account, store);
|
|
273
|
-
const log = (message) => console.log(`${new Date().toISOString().slice(11, 19)}Z ${message}`);
|
|
274
224
|
const command = shellCommand(exec);
|
|
275
|
-
|
|
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) });
|
|
276
227
|
const work = createWork({
|
|
277
228
|
chain,
|
|
278
229
|
command,
|
|
@@ -282,29 +233,41 @@ async function run(network, { key, account }, values, once) {
|
|
|
282
233
|
secrets: [key, key.slice(2)],
|
|
283
234
|
store,
|
|
284
235
|
stopping: stop.stopping,
|
|
236
|
+
// A cron job sees a failed task as a failed run.
|
|
237
|
+
failed: once ? () => void (process.exitCode = 1) : undefined,
|
|
285
238
|
});
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
239
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
240
|
+
process.on(signal, stop.signal);
|
|
241
|
+
let gas;
|
|
242
|
+
try {
|
|
243
|
+
gas = await chain.ethBalance();
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
// The rest of a single pass reads the same RPC, so it would only fail the same way twice more.
|
|
247
|
+
if (once) {
|
|
248
|
+
log(`could not read the chain: ${reason(error)}`);
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
289
252
|
log(`could not read the wallet's ETH balance: ${reason(error)}`);
|
|
290
|
-
|
|
291
|
-
});
|
|
253
|
+
}
|
|
292
254
|
if (gas === 0n)
|
|
293
255
|
throw noGas(chain, network);
|
|
294
256
|
const [line, sick] = await Promise.all([
|
|
295
|
-
snapshot(chain).then((s) => hireable(s, network), (error) => `could not check (${reason(error)})`),
|
|
257
|
+
snapshot(chain).then((s) => hireable(s, network, "run"), (error) => `could not check (${reason(error)})`),
|
|
296
258
|
unhealthy(network.service),
|
|
297
259
|
]);
|
|
298
260
|
log(`worker ${chain.account} on ${network.name}; hireable: ${line}`);
|
|
299
261
|
if (sick)
|
|
300
|
-
log(`warning: ${sick}
|
|
262
|
+
log(`warning: ${sick}`);
|
|
301
263
|
if (once) {
|
|
302
|
-
const result = await work.round()
|
|
303
|
-
|
|
304
|
-
log("no task is hired to this worker right now");
|
|
305
|
-
// A cron job sees a failed task as a failed run.
|
|
306
|
-
if (result.failed > 0)
|
|
264
|
+
const result = await work.round().catch((error) => {
|
|
265
|
+
log(`round failed: ${reason(error)}`);
|
|
307
266
|
process.exitCode = 1;
|
|
267
|
+
return undefined;
|
|
268
|
+
});
|
|
269
|
+
if (result?.hired === 0 && result.delivered === 0)
|
|
270
|
+
log("no task is hired to this worker right now");
|
|
308
271
|
return;
|
|
309
272
|
}
|
|
310
273
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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) {
|
package/dist/standing.js
ADDED
|
@@ -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,42 @@ 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
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
11
|
+
const isHash = (value) => typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value);
|
|
12
|
+
const isIds = (value) => Array.isArray(value) && value.every((id) => typeof id === "string" && /^\d+$/.test(id));
|
|
13
|
+
function isTask(value) {
|
|
14
|
+
if (!isObject(value) || !isCount(value.failures) || !isCount(value.nextAt))
|
|
15
|
+
return false;
|
|
16
|
+
if (value.result !== undefined && typeof value.result !== "string")
|
|
17
|
+
return false;
|
|
18
|
+
if (value.tx !== undefined && !isHash(value.tx))
|
|
19
|
+
return false;
|
|
20
|
+
return value.stored === undefined || (isObject(value.stored) && typeof value.stored.uri === "string" && isHash(value.stored.hash));
|
|
21
|
+
}
|
|
22
|
+
/** Whether a parsed file is a state this worker can go on from; any other shape fails every round that reads it. */
|
|
23
|
+
function isState(value) {
|
|
24
|
+
if (!isObject(value) || typeof value.board !== "string" || !isObject(value.tasks) || !isIds(value.done))
|
|
25
|
+
return false;
|
|
26
|
+
if (!Object.values(value.tasks).every(isTask))
|
|
27
|
+
return false;
|
|
28
|
+
const { hired, command } = value;
|
|
29
|
+
if (hired !== undefined && !(isObject(hired) && isCount(hired.block) && isIds(hired.tasks)))
|
|
30
|
+
return false;
|
|
31
|
+
// Group 0 is this process's own and -1 is every process, so only a real command's group is signalled.
|
|
32
|
+
return command === undefined || (isObject(command) && isCount(command.pgid) && command.pgid > 1 && isCount(command.startedAt));
|
|
33
|
+
}
|
|
34
|
+
function parse(text) {
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(text);
|
|
37
|
+
return isState(value) ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
9
43
|
/** 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) {
|
|
44
|
+
export function fileStore(file, board, warn = console.warn) {
|
|
11
45
|
let state = empty(board);
|
|
12
46
|
let text;
|
|
13
47
|
try {
|
|
@@ -17,23 +51,29 @@ export function fileStore(file, board) {
|
|
|
17
51
|
if (error.code !== "ENOENT")
|
|
18
52
|
throw error;
|
|
19
53
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
if (saved.board === state.board)
|
|
29
|
-
state = { ...state, ...saved, tasks: saved.tasks ?? {}, done: saved.done ?? [] };
|
|
54
|
+
const saved = text === undefined ? undefined : parse(text);
|
|
55
|
+
if (saved?.board === state.board)
|
|
56
|
+
state = saved;
|
|
57
|
+
if (text !== undefined && saved === undefined) {
|
|
58
|
+
// Refusing to start would turn one bad file into a crash loop under a supervisor.
|
|
59
|
+
const aside = `${file}.corrupt-${new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "")}`;
|
|
60
|
+
renameSync(file, aside);
|
|
61
|
+
warn(`${file} could not be read as saved worker state, so it was moved to ${aside} and this run starts with no saved work`);
|
|
30
62
|
}
|
|
31
63
|
return {
|
|
32
64
|
state,
|
|
33
65
|
save() {
|
|
34
66
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
35
67
|
const temp = `${file}.${process.pid}.tmp`;
|
|
36
|
-
|
|
68
|
+
const fd = openSync(temp, "w", 0o600);
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(fd, JSON.stringify(state));
|
|
71
|
+
// On disk before the rename, so a power loss leaves the old file or the new one, never an empty one.
|
|
72
|
+
fsyncSync(fd);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
}
|
|
37
77
|
renameSync(temp, file);
|
|
38
78
|
},
|
|
39
79
|
};
|
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,99 @@ function signalGroup(pid, signal) {
|
|
|
79
79
|
throw error;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
/** One field of `ps` for a process, or undefined when it is gone or ps cannot tell. */
|
|
83
|
+
function ps(pid, field) {
|
|
84
|
+
const result = spawnSync("ps", ["-o", `${field}=`, "-p", String(pid)], { encoding: "utf8" });
|
|
85
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
86
|
+
}
|
|
87
|
+
/** When a process started, in ms since the epoch, or undefined when it is gone or ps cannot tell. */
|
|
88
|
+
function startTime(pid) {
|
|
89
|
+
const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
|
|
90
|
+
if (!elapsed)
|
|
91
|
+
return undefined;
|
|
92
|
+
const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
|
|
93
|
+
return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
94
|
+
}
|
|
95
|
+
function exists(pid) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, 0);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return error.code === "EPERM";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Whether a process group still has a process this user can signal. */
|
|
105
|
+
function groupAlive(pgid) {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(-pgid, 0);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Whether a group lives on without its leader, as when the shell exits while
|
|
116
|
+
* what it started runs on. Its id is not reused while the group lives, so only
|
|
117
|
+
* this run's own group could match after a reuse, and ps rules that out.
|
|
118
|
+
*/
|
|
119
|
+
function orphaned(pgid) {
|
|
120
|
+
if (exists(pgid) || !groupAlive(pgid))
|
|
121
|
+
return false;
|
|
122
|
+
const own = ps(process.pid, "pgid");
|
|
123
|
+
return own !== undefined && own !== String(pgid);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Stops the command an earlier run left running when it died, such as on
|
|
127
|
+
* SIGKILL, so a restart never works on the same task twice at once. It gets
|
|
128
|
+
* SIGTERM first and SIGKILL `graceMs` later.
|
|
129
|
+
*/
|
|
130
|
+
export async function stopLeftover(store, log, graceMs = 3_000) {
|
|
131
|
+
const left = store.state.command;
|
|
132
|
+
if (left === undefined)
|
|
133
|
+
return;
|
|
134
|
+
delete store.state.command;
|
|
135
|
+
store.save();
|
|
136
|
+
const { pgid } = left;
|
|
137
|
+
// A pid is not reused while its process lives, so a live process with it that started when the command did is the command.
|
|
138
|
+
// Its command line proves nothing: the shell execs a simple command, so it shows the program's arguments, not --exec.
|
|
139
|
+
const started = startTime(pgid);
|
|
140
|
+
if (started === undefined ? !orphaned(pgid) : Math.abs(started - left.startedAt) > 5_000)
|
|
141
|
+
return;
|
|
142
|
+
signalGroup(pgid, "SIGTERM");
|
|
143
|
+
for (const end = Date.now() + graceMs; Date.now() < end && groupAlive(pgid);)
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
145
|
+
if (groupAlive(pgid))
|
|
146
|
+
signalGroup(pgid, "SIGKILL");
|
|
147
|
+
log(`stopped the command an earlier run left running (process group ${pgid})`);
|
|
148
|
+
}
|
|
82
149
|
/**
|
|
83
150
|
* 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
|
|
151
|
+
* deliverable. A command that does not stop on SIGTERM is killed `graceMs` later,
|
|
152
|
+
* and none outlives the worker's exit.
|
|
85
153
|
*/
|
|
86
154
|
export function shellCommand(command, graceMs = 10_000) {
|
|
87
155
|
const running = new Set();
|
|
156
|
+
const kill = () => {
|
|
157
|
+
for (const pid of running)
|
|
158
|
+
signalGroup(pid, "SIGKILL");
|
|
159
|
+
};
|
|
88
160
|
return {
|
|
89
|
-
run(input, env, timeoutMs) {
|
|
161
|
+
run(input, env, timeoutMs, started) {
|
|
90
162
|
return new Promise((resolve, reject) => {
|
|
91
163
|
// Its own process group, so a stop reaches everything the command started, and Ctrl-C in the terminal reaches only the worker.
|
|
92
164
|
// Stderr is forwarded rather than shared: a background group that touches the terminal is stopped by SIGTTOU.
|
|
93
165
|
const child = spawn(command, { shell: true, detached: true, env: childEnv(env), stdio: ["pipe", "pipe", "pipe"] });
|
|
94
166
|
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
95
167
|
const pid = child.pid;
|
|
96
|
-
if (pid !== undefined)
|
|
168
|
+
if (pid !== undefined) {
|
|
169
|
+
// Its own group would outlive the worker otherwise, still working on a task the next run takes up again.
|
|
170
|
+
if (running.size === 0)
|
|
171
|
+
process.on("exit", kill);
|
|
97
172
|
running.add(pid);
|
|
173
|
+
started?.(pid);
|
|
174
|
+
}
|
|
98
175
|
const chunks = [];
|
|
99
176
|
let size = 0;
|
|
100
177
|
let stopped;
|
|
@@ -128,6 +205,8 @@ export function shellCommand(command, graceMs = 10_000) {
|
|
|
128
205
|
clearTimeout(force);
|
|
129
206
|
if (pid !== undefined)
|
|
130
207
|
running.delete(pid);
|
|
208
|
+
if (running.size === 0)
|
|
209
|
+
process.off("exit", kill);
|
|
131
210
|
if (stopped !== undefined)
|
|
132
211
|
return reject(new Error(stopped));
|
|
133
212
|
if (code !== 0)
|
|
@@ -144,33 +223,37 @@ export function shellCommand(command, graceMs = 10_000) {
|
|
|
144
223
|
child.stdin.end(input);
|
|
145
224
|
});
|
|
146
225
|
},
|
|
147
|
-
kill
|
|
148
|
-
for (const pid of running)
|
|
149
|
-
signalGroup(pid, "SIGKILL");
|
|
150
|
-
},
|
|
226
|
+
kill,
|
|
151
227
|
};
|
|
152
228
|
}
|
|
153
|
-
/**
|
|
154
|
-
const ECHO_MS =
|
|
229
|
+
/** 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. */
|
|
230
|
+
const ECHO_MS = 250;
|
|
155
231
|
/**
|
|
156
232
|
* What Ctrl-C and SIGTERM do to `run`. An idle worker stops at once. A busy one
|
|
157
233
|
* takes no new task and stops after the current one, unless a second signal
|
|
158
|
-
* comes, which stops the command too.
|
|
234
|
+
* comes, which stops the command too. SIGHUP stops both at once: the terminal
|
|
235
|
+
* or session that would see the task through is gone.
|
|
159
236
|
*/
|
|
160
237
|
export function stopper(options) {
|
|
161
238
|
let stopping = false;
|
|
162
|
-
let
|
|
239
|
+
let last;
|
|
163
240
|
return {
|
|
164
241
|
stopping: () => stopping,
|
|
165
242
|
signal(signal) {
|
|
243
|
+
if (signal === "SIGHUP") {
|
|
244
|
+
options.kill();
|
|
245
|
+
options.log("SIGHUP: stopping now");
|
|
246
|
+
return options.exit(129);
|
|
247
|
+
}
|
|
166
248
|
const now = Date.now();
|
|
167
|
-
|
|
249
|
+
const previous = last;
|
|
250
|
+
if (previous?.signal === signal && now - previous.at < ECHO_MS)
|
|
168
251
|
return;
|
|
169
|
-
|
|
252
|
+
last = { signal, at: now };
|
|
170
253
|
const name = signal === "SIGINT" ? "Ctrl-C" : signal;
|
|
171
254
|
if (stopping) {
|
|
172
255
|
options.kill();
|
|
173
|
-
options.log(`${name} again: stopping now`);
|
|
256
|
+
options.log(`${name}${previous?.signal === signal ? " again" : ""}: stopping now`);
|
|
174
257
|
return options.exit(signal === "SIGINT" ? 130 : 143);
|
|
175
258
|
}
|
|
176
259
|
if (!options.busy()) {
|
|
@@ -211,16 +294,38 @@ export function createWork(options) {
|
|
|
211
294
|
if ((await chain.now()) >= task.deadline)
|
|
212
295
|
throw new PermanentError("The deadline passed before the result could be submitted");
|
|
213
296
|
};
|
|
297
|
+
const remember = (pgid) => {
|
|
298
|
+
state.command = { pgid, startedAt: Date.now() };
|
|
299
|
+
store.save();
|
|
300
|
+
};
|
|
301
|
+
const forget = () => {
|
|
302
|
+
delete state.command;
|
|
303
|
+
store.save();
|
|
304
|
+
};
|
|
214
305
|
return {
|
|
215
306
|
/** True while a task is in hand, from running its command to submitting the result. */
|
|
216
307
|
busy: () => busy,
|
|
217
308
|
async round() {
|
|
218
309
|
const hired = (await chain.hired()).sort((a, b) => (a.deadline < b.deadline ? -1 : a.deadline > b.deadline ? 1 : 0));
|
|
219
310
|
const result = { hired: hired.length, delivered: 0, failed: 0 };
|
|
311
|
+
const fail = () => {
|
|
312
|
+
result.failed += 1;
|
|
313
|
+
options.failed?.();
|
|
314
|
+
};
|
|
315
|
+
const delivered = (key, text, tx) => {
|
|
316
|
+
result.delivered += 1;
|
|
317
|
+
log(`task ${key}: delivered (${toBytes(text).byteLength} bytes, ${tx}). Jev grades it next.`);
|
|
318
|
+
};
|
|
220
319
|
const current = new Set(hired.map((t) => t.id.toString()));
|
|
221
|
-
for (const key of Object.
|
|
222
|
-
if (
|
|
223
|
-
|
|
320
|
+
for (const [key, attempt] of Object.entries(state.tasks)) {
|
|
321
|
+
if (current.has(key))
|
|
322
|
+
continue;
|
|
323
|
+
delete state.tasks[key];
|
|
324
|
+
// A submit that landed after the run that sent it stopped waiting, or stopped.
|
|
325
|
+
if (attempt.tx !== undefined && attempt.result !== undefined && (await chain.landed(attempt.tx, 5_000).catch(() => false))) {
|
|
326
|
+
delivered(key, attempt.result, attempt.tx);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
224
329
|
state.done = state.done.filter((key) => current.has(key));
|
|
225
330
|
store.save();
|
|
226
331
|
for (const task of hired) {
|
|
@@ -234,19 +339,33 @@ export function createWork(options) {
|
|
|
234
339
|
const now = await chain.now();
|
|
235
340
|
if (now < BigInt(attempt.nextAt))
|
|
236
341
|
continue;
|
|
342
|
+
busy = true;
|
|
343
|
+
if (attempt.tx !== undefined && attempt.result !== undefined) {
|
|
344
|
+
// Sent before a restart or a receipt timeout: it may still land, and sending the result again would then only revert.
|
|
345
|
+
if (await chain.landed(attempt.tx, RECEIPT_MS)) {
|
|
346
|
+
finish(key);
|
|
347
|
+
delivered(key, attempt.result, attempt.tx);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
delete attempt.tx;
|
|
351
|
+
store.save();
|
|
352
|
+
}
|
|
237
353
|
if (now + MARGIN_SEC >= task.deadline) {
|
|
238
354
|
finish(key);
|
|
355
|
+
fail();
|
|
239
356
|
log(`task ${task.id}: too close to the deadline to deliver; the poster is refunded`);
|
|
240
357
|
continue;
|
|
241
358
|
}
|
|
242
|
-
busy = true;
|
|
243
359
|
if (attempt.result === undefined) {
|
|
244
360
|
const brief = parseBrief(await read(task.briefURI, task.briefHash));
|
|
245
361
|
await needGas();
|
|
362
|
+
// A stop asked for while the brief downloaded or the balance was read starts no command.
|
|
363
|
+
if (options.stopping?.())
|
|
364
|
+
continue;
|
|
246
365
|
log(`task ${task.id}: working on "${brief.title}"`);
|
|
247
366
|
const input = options.format === "json" ? JSON.stringify(taskInput(task, brief)) : taskPrompt(task, brief);
|
|
248
367
|
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();
|
|
368
|
+
const output = stripAnsi(await command.run(input, { BRAINCHAIN_TASK_ID: key }, timeoutMs, remember).finally(forget)).trim();
|
|
250
369
|
if (output === "")
|
|
251
370
|
throw new Error("The command printed nothing");
|
|
252
371
|
const lower = output.toLowerCase();
|
|
@@ -267,13 +386,15 @@ export function createWork(options) {
|
|
|
267
386
|
}
|
|
268
387
|
await needGas();
|
|
269
388
|
await inTime(task);
|
|
270
|
-
const tx = await chain.submit(task.id, attempt.stored.hash, attempt.stored.uri)
|
|
389
|
+
const tx = await chain.submit(task.id, attempt.stored.hash, attempt.stored.uri, (sent) => {
|
|
390
|
+
attempt.tx = sent;
|
|
391
|
+
store.save();
|
|
392
|
+
});
|
|
271
393
|
finish(key);
|
|
272
|
-
|
|
273
|
-
log(`task ${task.id}: delivered (${toBytes(attempt.result).byteLength} bytes, ${tx}). Jev grades it next.`);
|
|
394
|
+
delivered(key, attempt.result, tx);
|
|
274
395
|
}
|
|
275
396
|
catch (error) {
|
|
276
|
-
|
|
397
|
+
fail();
|
|
277
398
|
const why = reason(error);
|
|
278
399
|
if (error instanceof PermanentError) {
|
|
279
400
|
finish(key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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>",
|