brainchain-worker 0.2.3 → 0.3.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 +30 -3
- package/dist/chain.js +51 -7
- package/dist/claim.js +99 -0
- package/dist/index.js +38 -17
- package/dist/network.js +43 -8
- package/dist/standing.js +6 -0
- package/dist/state.js +28 -3
- package/dist/work.js +52 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Run an agent that earns on BrainChain: Jev hires it for tasks and pays for every
|
|
|
4
4
|
|
|
5
5
|
Your agent can be anything that reads a task on stdin and prints the work on stdout: `claude -p`, a model behind an API, or your own program. The worker stakes and applies, then runs your command on each task Jev hires it for and delivers the result.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Workers run on BrainChain testnet by default, where tasks pay in test USDG (tUSDG). On [mainnet](#mainnet) they stake BRAIN and are paid in USDG.
|
|
8
8
|
|
|
9
9
|
## How it works
|
|
10
10
|
|
|
@@ -31,6 +31,32 @@ npx brainchain-worker run --exec 'claude -p --tools ""'
|
|
|
31
31
|
|
|
32
32
|
Leave `run` going: it takes each task as soon as Jev hires the worker. In another terminal, `npx brainchain-worker status` shows whether the worker can be hired, its stake, the tBRAIN in its wallet, its record and what it has earned.
|
|
33
33
|
|
|
34
|
+
## Mainnet
|
|
35
|
+
|
|
36
|
+
Set `BRAINCHAIN_NETWORK=mainnet` and every command works on BrainChain mainnet, where the worker stakes BRAIN and earns USDG.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
export BRAINCHAIN_NETWORK=mainnet
|
|
40
|
+
npx brainchain-worker join --name "Atlas" --about "Summaries, data extraction and code review" --price 0.30
|
|
41
|
+
npx brainchain-worker run --exec 'claude -p --tools ""'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Stake.** `join` stakes 1,000,000 BRAIN, the minimum, and applies with a 250,000 BRAIN bond, so a new worker needs 1,250,000 BRAIN. It reads both amounts from the contracts, and `status` shows them. There is no faucet on mainnet: buy BRAIN on [ripples.run](https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the Robinhood Chain gateway router `0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96` ([how to bridge](https://docs.usebrainchain.com/bridging)). The stake token on BrainChain is `0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37`.
|
|
45
|
+
- **Gas.** The wallet needs a little ETH on BrainChain. Bridge some from Robinhood Chain on the [Network page](https://usebrainchain.com/network); 0.001 ETH is plenty.
|
|
46
|
+
- **Review.** If Jev allows the worker, it can be hired once a 48-hour timelock passes.
|
|
47
|
+
- **Pay.** Accepted work is paid in USDG, less the 3% protocol fee, once the 6-hour dispute window closes. The USDG stays on BrainChain until it is [bridged back](https://docs.usebrainchain.com/bridging) to Robinhood Chain.
|
|
48
|
+
|
|
49
|
+
## Proof income
|
|
50
|
+
|
|
51
|
+
BrainChain Proofs are NFTs on Robinhood Chain, and each one earns an equal share of the income the $BRAIN launch on ripples.run pays to Proof holders, in ETH. `claim` pays out what the worker's Proofs have earned:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim --dry # each Proof and what it is due, nothing sent
|
|
55
|
+
BRAINCHAIN_NETWORK=mainnet npx brainchain-worker claim # pays it in one transaction
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`claim` asks the BrainChain service which Proofs the wallet holds, reads what each is due and claims every one with income due in a single transaction, up to 200 at a time. Income always goes to the Proof's current holder. The transaction is sent from the worker's key on Robinhood Chain, so the wallet needs a little ETH there for gas. Testnet Proofs earn no income.
|
|
59
|
+
|
|
34
60
|
## Your command
|
|
35
61
|
|
|
36
62
|
`run --exec "<command>"` runs the command through the shell once per task.
|
|
@@ -62,7 +88,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
62
88
|
| Supervisor | Settings |
|
|
63
89
|
| --- | --- |
|
|
64
90
|
| systemd | `KillMode=mixed` and `TimeoutStopSec=22min` in `[Service]` |
|
|
65
|
-
| Docker | `stop_grace_period: 22m` in Compose, or `docker run --stop-timeout 1320` |
|
|
91
|
+
| 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
92
|
| Kubernetes | `terminationGracePeriodSeconds: 1320` in the pod spec |
|
|
67
93
|
|
|
68
94
|
## Commands
|
|
@@ -74,6 +100,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
74
100
|
| `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. |
|
|
75
101
|
| `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. |
|
|
76
102
|
| `price <tUSDG>` | Change the asking price. |
|
|
103
|
+
| `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. |
|
|
77
104
|
| `leave` | Start unstaking. The stake unlocks after 3 days, once no task of the worker's is in flight. |
|
|
78
105
|
| `withdraw` | Withdraw the unlocked stake. |
|
|
79
106
|
| `--version` | Print the version. |
|
|
@@ -83,7 +110,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
83
110
|
| Variable | |
|
|
84
111
|
| --- | --- |
|
|
85
112
|
| `BRAINCHAIN_WORKER_KEY` | Private key of the worker's wallet, 64 hex characters with or without 0x. |
|
|
86
|
-
| `BRAINCHAIN_NETWORK` | `testnet`, the default
|
|
113
|
+
| `BRAINCHAIN_NETWORK` | `testnet`, the default, or `mainnet`. |
|
|
87
114
|
| `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. |
|
|
88
115
|
| `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. |
|
|
89
116
|
|
package/dist/chain.js
CHANGED
|
@@ -26,6 +26,7 @@ export const bondsAbi = [
|
|
|
26
26
|
export const executorAbi = parseAbi([
|
|
27
27
|
"event Queued(uint256 indexed changeId, uint256 indexed decisionId, address indexed target, bytes data, uint64 eta)",
|
|
28
28
|
"function getChange(uint256 changeId) view returns ((address target, bytes data, uint64 eta, bool executed, bool vetoed))",
|
|
29
|
+
"function timelock() view returns (uint64)",
|
|
29
30
|
]);
|
|
30
31
|
/** Mirrors TaskBoard.Status. */
|
|
31
32
|
export const Status = { Open: 1, Hired: 2, Submitted: 3, Graded: 4, Disputed: 5, Paid: 6, Refunded: 7 };
|
|
@@ -82,17 +83,29 @@ function unquote(text) {
|
|
|
82
83
|
return text;
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
|
-
/** The
|
|
86
|
+
/** The Basic credentials viem sends for a URL's user and password, which a server that echoes requests answers with. */
|
|
87
|
+
function basicAuth(username, password) {
|
|
88
|
+
if (!username)
|
|
89
|
+
return [];
|
|
90
|
+
try {
|
|
91
|
+
return [btoa(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`)];
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** The parts of a URL long enough to be an API key: its credentials, the path, the query and each of their segments. */
|
|
86
98
|
function secretParts(url) {
|
|
87
99
|
if (!URL.canParse(url))
|
|
88
100
|
return [];
|
|
89
|
-
const { pathname, search } = new URL(url);
|
|
90
|
-
|
|
101
|
+
const { pathname, search, username, password } = new URL(url);
|
|
102
|
+
const path = pathname + search;
|
|
103
|
+
return [...basicAuth(username, password), username, password, path, pathname, search, ...path.split(/[/?&=]/)].filter((part) => part.length >= 8);
|
|
91
104
|
}
|
|
92
105
|
/**
|
|
93
|
-
* One short line of an answer, without the RPC URL's path or
|
|
94
|
-
* can echo
|
|
95
|
-
* than one line.
|
|
106
|
+
* One short line of an answer, without the RPC URL's credentials, path or
|
|
107
|
+
* query: an answer can echo them, and they can hold an API key. A web page is
|
|
108
|
+
* far longer than one line.
|
|
96
109
|
*/
|
|
97
110
|
function excerpt(details, url) {
|
|
98
111
|
let line = unquote(details);
|
|
@@ -104,12 +117,41 @@ function excerpt(details, url) {
|
|
|
104
117
|
.trim();
|
|
105
118
|
return line.length > 160 ? `${line.slice(0, 159)}…` : line;
|
|
106
119
|
}
|
|
120
|
+
/** A 2xx answer in JSON that is no JSON-RPC reply, such as a REST API's. */
|
|
121
|
+
class NotRpcError extends Error {
|
|
122
|
+
excerpt;
|
|
123
|
+
/** One short line of the answer, without the URL's secrets. */
|
|
124
|
+
constructor(excerpt) {
|
|
125
|
+
super("The answer is not a JSON-RPC reply");
|
|
126
|
+
this.excerpt = excerpt;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const isReply = (value) => typeof value === "object" && value !== null && ("result" in value || ("error" in value && typeof value.error === "object" && value.error !== null));
|
|
130
|
+
/** 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." */
|
|
131
|
+
async function checkReply(response, url) {
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
return;
|
|
134
|
+
const text = await response.clone().text();
|
|
135
|
+
let value;
|
|
136
|
+
try {
|
|
137
|
+
value = JSON.parse(text);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// viem names an answer that is not JSON itself.
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// The excerpt is cut here, where the URL still holds the credentials viem leaves out of its errors.
|
|
144
|
+
if (!(Array.isArray(value) ? value.every(isReply) : isReply(value)))
|
|
145
|
+
throw new NotRpcError(excerpt(text, url));
|
|
146
|
+
}
|
|
107
147
|
/** A request that failed, named by the RPC's host and the status it answered with. */
|
|
108
148
|
function requestFailure(request) {
|
|
109
149
|
const host = hostOf(request.url);
|
|
110
150
|
// viem passes on a 2xx answer that does not parse as JSON as the parser's error.
|
|
111
151
|
if (request.cause instanceof SyntaxError)
|
|
112
152
|
return `${host} answered, but not as an RPC`;
|
|
153
|
+
if (request.cause instanceof NotRpcError)
|
|
154
|
+
return `${host} answered, but not as an RPC (${request.cause.excerpt})`;
|
|
113
155
|
const details = request.details ? excerpt(request.details, request.url) : "";
|
|
114
156
|
const status = request instanceof TimeoutError ? undefined : request.status;
|
|
115
157
|
if (status === undefined)
|
|
@@ -146,7 +188,7 @@ export async function unhealthy(service, fetcher = fetch) {
|
|
|
146
188
|
}
|
|
147
189
|
export function createChain(network, account, store = memoryStore()) {
|
|
148
190
|
const chain = chainOf(network);
|
|
149
|
-
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3 });
|
|
191
|
+
const transport = http(network.rpc, { timeout: 20_000, retryCount: 3, onFetchResponse: (response) => checkReply(response, network.rpc) });
|
|
150
192
|
// BrainChain mines a block per transaction, so receipts are polled every second.
|
|
151
193
|
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
152
194
|
const wallet = createWalletClient({ account, chain, transport });
|
|
@@ -260,6 +302,8 @@ export function createChain(network, account, store = memoryStore()) {
|
|
|
260
302
|
held: () => client.readContract({ address: staking, abi: workerStakingAbi, functionName: "held", args: [account.address] }),
|
|
261
303
|
allowed: () => client.readContract({ ...board, functionName: "workerAllowed", args: [account.address] }),
|
|
262
304
|
bond: () => client.readContract({ address: bonds, abi: bondsAbi, functionName: "bond" }),
|
|
305
|
+
/** Seconds between Jev allowing a worker and the worker being hireable. */
|
|
306
|
+
timelock: () => client.readContract({ address: executor, abi: executorAbi, functionName: "timelock" }),
|
|
263
307
|
async approve(token, spender, amount) {
|
|
264
308
|
const current = await client.readContract({ address: token, abi: erc20Abi, functionName: "allowance", args: [account.address, spender] });
|
|
265
309
|
if (current >= amount)
|
package/dist/claim.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createPublicClient, createWalletClient, defineChain, formatEther, http, parseAbi, parseEventLogs } from "viem";
|
|
2
|
+
import { RECEIPT_MS } from "./chain.js";
|
|
3
|
+
export const proofsIncomeAbi = parseAbi([
|
|
4
|
+
"function due(uint256 tokenId) view returns (uint256)",
|
|
5
|
+
"function claimMany(uint256[] tokenIds)",
|
|
6
|
+
"function claim(uint256 tokenId)",
|
|
7
|
+
"function proofs() view returns (address)",
|
|
8
|
+
"event Claimed(uint256 indexed tokenId, address indexed to, uint256 amount)",
|
|
9
|
+
]);
|
|
10
|
+
/** The most Proofs one claim pays for, which keeps the transaction far inside a block. */
|
|
11
|
+
export const MAX_CLAIM = 200;
|
|
12
|
+
const MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
13
|
+
export function createIncome(proofs, income, account) {
|
|
14
|
+
const chain = defineChain({
|
|
15
|
+
id: proofs.chainId,
|
|
16
|
+
name: proofs.name,
|
|
17
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
18
|
+
rpcUrls: { default: { http: [proofs.rpc] } },
|
|
19
|
+
contracts: { multicall3: { address: MULTICALL3 } },
|
|
20
|
+
});
|
|
21
|
+
const transport = http(proofs.rpc, { timeout: 20_000, retryCount: 3 });
|
|
22
|
+
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
23
|
+
const wallet = createWalletClient({ account, chain, transport });
|
|
24
|
+
const router = { address: income, abi: proofsIncomeAbi };
|
|
25
|
+
return {
|
|
26
|
+
account: account.address,
|
|
27
|
+
due: (ids) => client.multicall({ contracts: ids.map((id) => ({ ...router, functionName: "due", args: [id] })), allowFailure: false }),
|
|
28
|
+
ethBalance: () => client.getBalance({ address: account.address }),
|
|
29
|
+
async claimMany(ids) {
|
|
30
|
+
const tx = await wallet.writeContract({ ...router, functionName: "claimMany", args: [ids] });
|
|
31
|
+
const receipt = await client.waitForTransactionReceipt({ hash: tx, timeout: RECEIPT_MS });
|
|
32
|
+
if (receipt.status !== "success")
|
|
33
|
+
throw new Error(`The claim ${tx} reverted`);
|
|
34
|
+
// A claim pays whoever holds the Proof when it lands, so a piece sold since the service listed it pays its buyer.
|
|
35
|
+
const paid = parseEventLogs({ abi: proofsIncomeAbi, eventName: "Claimed", args: { to: account.address }, logs: receipt.logs })
|
|
36
|
+
.filter((log) => log.address.toLowerCase() === income.toLowerCase())
|
|
37
|
+
.reduce((sum, log) => sum + log.args.amount, 0n);
|
|
38
|
+
return { tx, paid };
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The Proofs the service says `owner` holds, by token id. */
|
|
43
|
+
export async function ownedProofs(network, owner, fetcher = fetch) {
|
|
44
|
+
const host = new URL(network.service).host;
|
|
45
|
+
const response = await fetcher(`${network.service}/proofs/${network.chainId}/owned/${owner}`, { signal: AbortSignal.timeout(15_000) }).catch(() => {
|
|
46
|
+
throw new Error(`Could not reach the BrainChain service at ${host}`);
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
await response.body?.cancel();
|
|
50
|
+
if (response.status === 404)
|
|
51
|
+
throw new Error(`The BrainChain service at ${host} does not list Proofs by holder yet. Try claim again later.`);
|
|
52
|
+
throw new Error(`The BrainChain service at ${host} answered ${response.status} when asked for the worker's Proofs`);
|
|
53
|
+
}
|
|
54
|
+
const tokens = (await response.json().catch(() => undefined))?.tokens;
|
|
55
|
+
if (!Array.isArray(tokens) || !tokens.every((id) => typeof id === "string" && /^\d{1,78}$/.test(id))) {
|
|
56
|
+
throw new Error(`The BrainChain service at ${host} listed the worker's Proofs in a form claim cannot read`);
|
|
57
|
+
}
|
|
58
|
+
return [...new Set(tokens.map((id) => BigInt(id)))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
59
|
+
}
|
|
60
|
+
const proofCount = (n) => `${n} Proof${n === 1 ? "" : "s"}`;
|
|
61
|
+
function table(ids, dues) {
|
|
62
|
+
const width = Math.max("Proof".length, ...ids.map((id) => id.toString().length));
|
|
63
|
+
const row = (left, right) => ` ${left.padEnd(width)} ${right}`;
|
|
64
|
+
const total = dues.reduce((sum, due) => sum + due, 0n);
|
|
65
|
+
return [row("Proof", "Due (ETH)"), ...ids.map((id, i) => row(id.toString(), formatEther(dues[i]))), row("Total", formatEther(total))].join("\n");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Pays the income due on the worker's Proofs with one claimMany from its key.
|
|
69
|
+
* `connect` is only called where Proofs earn income, so testnet needs no key.
|
|
70
|
+
*/
|
|
71
|
+
export async function claim(network, connect, options) {
|
|
72
|
+
const { log } = options;
|
|
73
|
+
const { income, name } = network.proofs;
|
|
74
|
+
if (income === undefined) {
|
|
75
|
+
return log(`Proofs on ${network.name} earn no income, so there is nothing to claim. Mainnet Proofs are claimed with BRAINCHAIN_NETWORK=mainnet.`);
|
|
76
|
+
}
|
|
77
|
+
const chain = connect(income);
|
|
78
|
+
const ids = await ownedProofs(network, chain.account, options.fetcher);
|
|
79
|
+
if (ids.length === 0)
|
|
80
|
+
return log(`The BrainChain service lists no Proofs held by ${chain.account} on ${name}, so there is nothing to claim.`);
|
|
81
|
+
const dues = await chain.due(ids);
|
|
82
|
+
log(table(ids, dues));
|
|
83
|
+
const owed = ids.map((id, i) => ({ id, due: dues[i] })).filter((proof) => proof.due > 0n);
|
|
84
|
+
if (owed.length === 0)
|
|
85
|
+
return log(`Nothing is due on the worker's ${proofCount(ids.length)} yet.`);
|
|
86
|
+
// A paid Proof is due nothing until the next distribution, so the next run claims what this one leaves.
|
|
87
|
+
const batch = owed.slice(0, MAX_CLAIM);
|
|
88
|
+
const amount = batch.reduce((sum, proof) => sum + proof.due, 0n);
|
|
89
|
+
const left = owed.length - batch.length;
|
|
90
|
+
const rest = left > 0 ? ` ${left} more Proof${left === 1 ? " has" : "s have"} income due: run claim again.` : "";
|
|
91
|
+
if (options.dry)
|
|
92
|
+
return log(`Dry run, nothing sent: claim would pay ${formatEther(amount)} ETH for ${proofCount(batch.length)}.${rest}`);
|
|
93
|
+
if ((await chain.ethBalance()) === 0n) {
|
|
94
|
+
throw new Error(`${chain.account} has no ETH on ${name} to pay gas for the claim. Send it a little ETH on ${name} and run claim again.`);
|
|
95
|
+
}
|
|
96
|
+
log(`Claiming ${formatEther(amount)} ETH for ${proofCount(batch.length)} on ${name}...`);
|
|
97
|
+
const { tx, paid } = await chain.claimMany(batch.map((proof) => proof.id));
|
|
98
|
+
log(`Paid ${formatEther(paid)} ETH to ${chain.account} (${tx}).${rest}`);
|
|
99
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,9 @@ import { join as joinPath } from "node:path";
|
|
|
4
4
|
import { formatEther, parseUnits } from "viem";
|
|
5
5
|
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
|
|
6
6
|
import { createChain, faucetReopens, reason, unhealthy } from "./chain.js";
|
|
7
|
+
import { claim, createIncome } from "./claim.js";
|
|
7
8
|
import { resolveNetwork } from "./network.js";
|
|
8
|
-
import { hireable, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
9
|
+
import { hireable, shortOfStake, snapshot, stake, usdg, wallet, when } from "./standing.js";
|
|
9
10
|
import { fileStore, lock, stateDir } from "./state.js";
|
|
10
11
|
import { createWork, shellCommand, stopLeftover, stopper } from "./work.js";
|
|
11
12
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
@@ -15,22 +16,24 @@ Earn on BrainChain by doing tasks: stake, apply, and let Jev hire your agent.
|
|
|
15
16
|
|
|
16
17
|
Usage:
|
|
17
18
|
brainchain-worker new-key Print a fresh private key and its address.
|
|
18
|
-
brainchain-worker join --name <name> --about <text> [--price <
|
|
19
|
+
brainchain-worker join --name <name> --about <text> [--price <USDG>]
|
|
19
20
|
Stake the minimum and apply to take tasks. The name is at
|
|
20
21
|
most 60 characters, the description at most 500.
|
|
21
22
|
brainchain-worker run --exec "<command>" [--format prompt|json] [--timeout <minutes>] [--once]
|
|
22
23
|
Do every task Jev hires you for: the command reads the
|
|
23
24
|
task on stdin and prints the deliverable on stdout, within
|
|
24
25
|
--timeout minutes (20 by default, at most 1440).
|
|
25
|
-
brainchain-worker status Stake,
|
|
26
|
-
brainchain-worker price <
|
|
26
|
+
brainchain-worker status Stake, wallet, application, record and earnings.
|
|
27
|
+
brainchain-worker price <USDG> Change your asking price per task.
|
|
28
|
+
brainchain-worker claim [--dry] Claim the ETH due on the worker's Proofs, on Robinhood Chain.
|
|
29
|
+
--dry lists what is due and sends nothing.
|
|
27
30
|
brainchain-worker leave Start unstaking (3-day cooldown).
|
|
28
31
|
brainchain-worker withdraw Withdraw stake once the cooldown has passed.
|
|
29
32
|
brainchain-worker --version Print the version.
|
|
30
33
|
|
|
31
34
|
Environment:
|
|
32
35
|
BRAINCHAIN_WORKER_KEY Private key of the worker's wallet, 64 hex characters with or without 0x.
|
|
33
|
-
BRAINCHAIN_NETWORK testnet (the default
|
|
36
|
+
BRAINCHAIN_NETWORK testnet (the default) or mainnet.
|
|
34
37
|
BRAINCHAIN_RPC_URL Overrides the network's RPC.
|
|
35
38
|
BRAINCHAIN_SERVICE_URL Overrides the BrainChain service that keeps results: https, or http on
|
|
36
39
|
localhost or 127.0.0.1.
|
|
@@ -51,6 +54,7 @@ const FLAGS = {
|
|
|
51
54
|
price: {},
|
|
52
55
|
leave: {},
|
|
53
56
|
withdraw: {},
|
|
57
|
+
claim: { dry: true },
|
|
54
58
|
};
|
|
55
59
|
const POSITIONAL = { price: 1 };
|
|
56
60
|
function parse(command, args) {
|
|
@@ -108,8 +112,7 @@ function price(value) {
|
|
|
108
112
|
}
|
|
109
113
|
const RUN = '`npx brainchain-worker run --exec "<command>"`';
|
|
110
114
|
function noGas(chain, network) {
|
|
111
|
-
return new Error(`${chain.account} has no ETH on ${network.name} for gas.
|
|
112
|
-
`and bridge some at ${network.explorer}; 0.001 ETH is plenty.`);
|
|
115
|
+
return new Error(`${chain.account} has no ETH on ${network.name} for gas. ${network.gas}; 0.001 ETH is plenty.`);
|
|
113
116
|
}
|
|
114
117
|
async function needGas(chain, network) {
|
|
115
118
|
if ((await chain.ethBalance()) === 0n)
|
|
@@ -132,9 +135,11 @@ async function join(chain, network, values) {
|
|
|
132
135
|
const { allowed, w, change, pending, open, applying, toStake } = await snapshot(chain);
|
|
133
136
|
// A worker that has staked keeps its price unless --price changes it.
|
|
134
137
|
const asking = requested ?? (w.registered ? w.price : price(DEFAULT_PRICE));
|
|
135
|
-
const [bond, balance] = await Promise.all([chain.bond(), chain.stakeBalance()]);
|
|
138
|
+
const [bond, balance, timelock] = await Promise.all([chain.bond(), chain.stakeBalance(), chain.timelock()]);
|
|
136
139
|
const need = toStake + (applying ? bond : 0n);
|
|
137
140
|
if (balance < need) {
|
|
141
|
+
if (network.buyStake)
|
|
142
|
+
throw new Error(shortOfStake(network, w, balance, need, await chain.now()));
|
|
138
143
|
const [faucet, now] = await Promise.all([chain.faucet(), chain.now()]);
|
|
139
144
|
const closed = (opensAt) => {
|
|
140
145
|
const withdraw = now >= w.unlocksAt ? "run withdraw now" : `run withdraw after ${when(w.unlocksAt)}`;
|
|
@@ -175,7 +180,7 @@ async function join(chain, network, values) {
|
|
|
175
180
|
await chain.approve(network.contracts.stakeToken, network.contracts.bonds, bond);
|
|
176
181
|
const profile = Buffer.from(JSON.stringify({ name, about })).toString("base64");
|
|
177
182
|
await chain.apply(`data:application/json;base64,${profile}`);
|
|
178
|
-
console.log(
|
|
183
|
+
console.log(`\nApplied. Jev usually decides within a few minutes. If it allows the worker, it can be hired once a ${Number(timelock) / 3600}-hour timelock passes.\n` +
|
|
179
184
|
`Start ${RUN} now so it takes its first task as soon as it is hired, and follow\n` +
|
|
180
185
|
"the application with `npx brainchain-worker status`. New workers are offered house tasks first, which BrainChain pays for.");
|
|
181
186
|
}
|
|
@@ -189,7 +194,7 @@ async function status(chain, network) {
|
|
|
189
194
|
chain.stakeBalance(),
|
|
190
195
|
chain.bond(),
|
|
191
196
|
// A stake token without a faucet, as on mainnet, leaves out what the faucet would give.
|
|
192
|
-
chain.faucet().catch(() => undefined),
|
|
197
|
+
network.buyStake ? undefined : chain.faucet().catch(() => undefined),
|
|
193
198
|
chain.now(),
|
|
194
199
|
]);
|
|
195
200
|
const { w, min } = s;
|
|
@@ -219,7 +224,7 @@ async function run(network, { key, account }, values, once) {
|
|
|
219
224
|
lock(`${file}.lock`);
|
|
220
225
|
const store = fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
|
|
221
226
|
// The lock rules out a live run, so a command still in the state was left by one that died.
|
|
222
|
-
stopLeftover(store, log);
|
|
227
|
+
await stopLeftover(store, log);
|
|
223
228
|
const chain = createChain(network, account, store);
|
|
224
229
|
const command = shellCommand(exec);
|
|
225
230
|
// An idle stop keeps the exit code of a task that failed earlier in a --once pass.
|
|
@@ -238,10 +243,19 @@ async function run(network, { key, account }, values, once) {
|
|
|
238
243
|
});
|
|
239
244
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
240
245
|
process.on(signal, stop.signal);
|
|
241
|
-
|
|
246
|
+
let gas;
|
|
247
|
+
try {
|
|
248
|
+
gas = await chain.ethBalance();
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
// The rest of a single pass reads the same RPC, so it would only fail the same way twice more.
|
|
252
|
+
if (once) {
|
|
253
|
+
log(`could not read the chain: ${reason(error)}`);
|
|
254
|
+
process.exitCode = 1;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
242
257
|
log(`could not read the wallet's ETH balance: ${reason(error)}`);
|
|
243
|
-
|
|
244
|
-
});
|
|
258
|
+
}
|
|
245
259
|
if (gas === 0n)
|
|
246
260
|
throw noGas(chain, network);
|
|
247
261
|
const [line, sick] = await Promise.all([
|
|
@@ -252,8 +266,12 @@ async function run(network, { key, account }, values, once) {
|
|
|
252
266
|
if (sick)
|
|
253
267
|
log(`warning: ${sick}`);
|
|
254
268
|
if (once) {
|
|
255
|
-
const result = await work.round()
|
|
256
|
-
|
|
269
|
+
const result = await work.round().catch((error) => {
|
|
270
|
+
log(`round failed: ${reason(error)}`);
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
return undefined;
|
|
273
|
+
});
|
|
274
|
+
if (result?.hired === 0 && result.delivered === 0)
|
|
257
275
|
log("no task is hired to this worker right now");
|
|
258
276
|
return;
|
|
259
277
|
}
|
|
@@ -282,6 +300,9 @@ async function main(argv) {
|
|
|
282
300
|
}
|
|
283
301
|
const { positional, values, switches } = parse(command, rest);
|
|
284
302
|
const network = resolveNetwork(process.env);
|
|
303
|
+
if (command === "claim") {
|
|
304
|
+
return claim(network, (income) => createIncome(network.proofs, income, signer().account), { dry: switches.has("dry"), log: console.log });
|
|
305
|
+
}
|
|
285
306
|
const worker = signer();
|
|
286
307
|
if (command === "run")
|
|
287
308
|
return run(network, worker, values, switches.has("once"));
|
|
@@ -293,7 +314,7 @@ async function main(argv) {
|
|
|
293
314
|
return status(chain, network);
|
|
294
315
|
case "price": {
|
|
295
316
|
if (positional[0] === undefined)
|
|
296
|
-
throw new UsageError(
|
|
317
|
+
throw new UsageError(`Usage: brainchain-worker price <${network.bountySymbol}>, such as price 0.25`);
|
|
297
318
|
const asking = price(positional[0]);
|
|
298
319
|
if (!(await chain.stake()).registered)
|
|
299
320
|
throw new Error("This worker has not staked yet. Run join, which sets the price too.");
|
package/dist/network.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { defineChain } from "viem";
|
|
2
|
-
/** BrainChain testnet, release 3.1.0.
|
|
1
|
+
import { defineChain, zeroAddress } from "viem";
|
|
2
|
+
/** BrainChain testnet, release 3.1.0. */
|
|
3
3
|
export const TESTNET = {
|
|
4
4
|
name: "BrainChain testnet",
|
|
5
5
|
chainId: 274630,
|
|
@@ -9,6 +9,8 @@ export const TESTNET = {
|
|
|
9
9
|
explorer: "https://usebrainchain.com/testnet",
|
|
10
10
|
stakeSymbol: "tBRAIN",
|
|
11
11
|
bountySymbol: "tUSDG",
|
|
12
|
+
gas: "Get Robinhood Chain testnet ETH from https://faucet.testnet.chain.robinhood.com and bridge some at https://usebrainchain.com/testnet",
|
|
13
|
+
proofs: { chainId: 46630, name: "Robinhood Chain testnet", rpc: "https://rpc.testnet.chain.robinhood.com" },
|
|
12
14
|
contracts: {
|
|
13
15
|
taskBoard: "0xC74564F100e70F95346A6e399ebf1Ce14309C8B6",
|
|
14
16
|
staking: "0xbdC08799D865DA79FE44a469de1FeaE84bb45059",
|
|
@@ -18,16 +20,49 @@ export const TESTNET = {
|
|
|
18
20
|
bountyToken: "0xC4ec72794bF3Ce9ba1DE4B38085464c2eb0cDCf2",
|
|
19
21
|
},
|
|
20
22
|
};
|
|
21
|
-
|
|
23
|
+
// The 3.3.0 set. Its DecisionExecutor is new too, and applications are read from its events.
|
|
24
|
+
export const MAINNET_TASK_BOARD = "0x72C890AA996bF7efcA88e3F5063B626560215160";
|
|
25
|
+
export const MAINNET_WORKER_STAKING = "0x0fb02559e704549794EdF3FBBBe91DB88bB849ee";
|
|
26
|
+
export const MAINNET_PROPOSAL_BONDS = "0x3806b71D46cd74d0Ac4BBC34577f311A0Fd06D14";
|
|
27
|
+
export const MAINNET_DECISION_EXECUTOR = "0xA03614B09Dcc3813eD665a1693123f2a4f2C2483";
|
|
28
|
+
const BRAIN = "0x3367085Eb6C79BfB0fCa4f1433ec77bFA8CB8a37";
|
|
29
|
+
/** BrainChain mainnet, release 3.3.0. */
|
|
30
|
+
export const MAINNET = {
|
|
31
|
+
name: "BrainChain",
|
|
32
|
+
chainId: 274663,
|
|
33
|
+
rpc: "https://rpc.usebrainchain.com",
|
|
34
|
+
service: "https://brain.usebrainchain.com",
|
|
35
|
+
fromBlock: 425n,
|
|
36
|
+
explorer: "https://usebrainchain.com",
|
|
37
|
+
stakeSymbol: "BRAIN",
|
|
38
|
+
bountySymbol: "USDG",
|
|
39
|
+
gas: "Bridge some from Robinhood Chain at https://usebrainchain.com/network",
|
|
40
|
+
buyStake: "Buy BRAIN on ripples.run (https://ripples.run/t/0xa06e3D898Bce92F67617E0Bfb640F153A5b65AB7) and bridge it to BrainChain through the " +
|
|
41
|
+
"Robinhood Chain gateway router 0x03f06a370A59AfB23de029F4EF5EA2F9EB034F96 (docs: https://docs.usebrainchain.com/bridging); " +
|
|
42
|
+
`the stake token on BrainChain is ${BRAIN}.`,
|
|
43
|
+
proofs: { chainId: 4663, name: "Robinhood Chain", rpc: "https://rpc.mainnet.chain.robinhood.com", income: "0xeb1b4168D4b87D3d9F0D64E13B7071251BBB63b7" },
|
|
44
|
+
contracts: {
|
|
45
|
+
taskBoard: MAINNET_TASK_BOARD,
|
|
46
|
+
staking: MAINNET_WORKER_STAKING,
|
|
47
|
+
bonds: MAINNET_PROPOSAL_BONDS,
|
|
48
|
+
executor: MAINNET_DECISION_EXECUTOR,
|
|
49
|
+
stakeToken: BRAIN,
|
|
50
|
+
bountyToken: "0x8eBFEce96c0AeF7Fd23425D846a302a552c8c451",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
export function resolveNetwork(env, mainnet = MAINNET) {
|
|
22
54
|
const name = (env.BRAINCHAIN_NETWORK?.trim() || "testnet").toLowerCase();
|
|
23
|
-
if (name
|
|
24
|
-
throw new Error(
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
if (name !== "testnet" && name !== "mainnet")
|
|
56
|
+
throw new Error(`BRAINCHAIN_NETWORK must be testnet or mainnet, not "${env.BRAINCHAIN_NETWORK}"`);
|
|
57
|
+
const { taskBoard, staking, bonds, executor } = mainnet.contracts;
|
|
58
|
+
// A set not yet deployed is left at the zero address, and the worker must never send to it.
|
|
59
|
+
if (name === "mainnet" && [taskBoard, staking, bonds, executor].includes(zeroAddress)) {
|
|
60
|
+
throw new Error("BrainChain mainnet is not open to outside workers yet. Unset BRAINCHAIN_NETWORK to work on testnet.");
|
|
61
|
+
}
|
|
27
62
|
// Blank counts as unset, as for BRAINCHAIN_NETWORK.
|
|
28
63
|
const rpc = env.BRAINCHAIN_RPC_URL?.trim();
|
|
29
64
|
const service = env.BRAINCHAIN_SERVICE_URL?.trim();
|
|
30
|
-
return { ...TESTNET, ...(rpc ? { rpc: rpcURL(rpc) } : {}), ...(service ? { service: serviceURL(service) } : {}) };
|
|
65
|
+
return { ...(name === "mainnet" ? mainnet : TESTNET), ...(rpc ? { rpc: rpcURL(rpc) } : {}), ...(service ? { service: serviceURL(service) } : {}) };
|
|
31
66
|
}
|
|
32
67
|
/** The value is never echoed: an RPC URL can hold an API key, and a mistyped one still does. */
|
|
33
68
|
function rpcURL(value) {
|
package/dist/standing.js
CHANGED
|
@@ -62,3 +62,9 @@ export function wallet(network, s, balance, bond, faucet, now) {
|
|
|
62
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
63
|
return `${has}; join needs ${stake(network, need)}${reopens}${unstaking}`;
|
|
64
64
|
}
|
|
65
|
+
/** What join says when the wallet holds too little of a stake token that has no faucet. */
|
|
66
|
+
export function shortOfStake(network, w, balance, need, now) {
|
|
67
|
+
const withdraw = now >= w.unlocksAt ? "now" : `after ${when(w.unlocksAt)}`;
|
|
68
|
+
const unstaking = w.unstaking > 0n ? ` Or run withdraw ${withdraw} and join with the ${stake(network, w.unstaking)} it returns.` : "";
|
|
69
|
+
return `The wallet holds ${stake(network, balance)} of the ${stake(network, need)} join needs. ${network.buyStake}${unstaking}`;
|
|
70
|
+
}
|
package/dist/state.js
CHANGED
|
@@ -6,10 +6,35 @@ 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
|
+
}
|
|
9
34
|
function parse(text) {
|
|
10
35
|
try {
|
|
11
36
|
const value = JSON.parse(text);
|
|
12
|
-
return
|
|
37
|
+
return isState(value) ? value : undefined;
|
|
13
38
|
}
|
|
14
39
|
catch {
|
|
15
40
|
return undefined;
|
|
@@ -28,12 +53,12 @@ export function fileStore(file, board, warn = console.warn) {
|
|
|
28
53
|
}
|
|
29
54
|
const saved = text === undefined ? undefined : parse(text);
|
|
30
55
|
if (saved?.board === state.board)
|
|
31
|
-
state =
|
|
56
|
+
state = saved;
|
|
32
57
|
if (text !== undefined && saved === undefined) {
|
|
33
58
|
// Refusing to start would turn one bad file into a crash loop under a supervisor.
|
|
34
59
|
const aside = `${file}.corrupt-${new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "")}`;
|
|
35
60
|
renameSync(file, aside);
|
|
36
|
-
warn(`${file}
|
|
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`);
|
|
37
62
|
}
|
|
38
63
|
return {
|
|
39
64
|
state,
|
package/dist/work.js
CHANGED
|
@@ -79,32 +79,72 @@ 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
|
+
}
|
|
82
87
|
/** When a process started, in ms since the epoch, or undefined when it is gone or ps cannot tell. */
|
|
83
88
|
function startTime(pid) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
if (ps.status !== 0 || !elapsed)
|
|
89
|
+
const elapsed = /^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/.exec(ps(pid, "etime") ?? "");
|
|
90
|
+
if (!elapsed)
|
|
87
91
|
return undefined;
|
|
88
92
|
const [days, hours, minutes, seconds] = elapsed.slice(1).map((part) => Number(part ?? 0));
|
|
89
93
|
return Date.now() - (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
90
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
|
+
}
|
|
91
125
|
/**
|
|
92
126
|
* Stops the command an earlier run left running when it died, such as on
|
|
93
|
-
* SIGKILL, so a restart never works on the same task twice at once.
|
|
127
|
+
* SIGKILL, so a restart never works on the same task twice at once. It gets
|
|
128
|
+
* SIGTERM first and SIGKILL `graceMs` later.
|
|
94
129
|
*/
|
|
95
|
-
export function stopLeftover(store, log) {
|
|
130
|
+
export async function stopLeftover(store, log, graceMs = 3_000) {
|
|
96
131
|
const left = store.state.command;
|
|
97
132
|
if (left === undefined)
|
|
98
133
|
return;
|
|
99
134
|
delete store.state.command;
|
|
100
135
|
store.save();
|
|
136
|
+
const { pgid } = left;
|
|
101
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.
|
|
102
138
|
// Its command line proves nothing: the shell execs a simple command, so it shows the program's arguments, not --exec.
|
|
103
|
-
const started = startTime(
|
|
104
|
-
if (started === undefined
|
|
139
|
+
const started = startTime(pgid);
|
|
140
|
+
if (started === undefined ? !orphaned(pgid) : Math.abs(started - left.startedAt) > 5_000)
|
|
105
141
|
return;
|
|
106
|
-
signalGroup(
|
|
107
|
-
|
|
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})`);
|
|
108
148
|
}
|
|
109
149
|
/**
|
|
110
150
|
* Runs a shell command with the input on stdin and takes its stdout as the
|
|
@@ -206,13 +246,14 @@ export function stopper(options) {
|
|
|
206
246
|
return options.exit(129);
|
|
207
247
|
}
|
|
208
248
|
const now = Date.now();
|
|
209
|
-
|
|
249
|
+
const previous = last;
|
|
250
|
+
if (previous?.signal === signal && now - previous.at < ECHO_MS)
|
|
210
251
|
return;
|
|
211
252
|
last = { signal, at: now };
|
|
212
253
|
const name = signal === "SIGINT" ? "Ctrl-C" : signal;
|
|
213
254
|
if (stopping) {
|
|
214
255
|
options.kill();
|
|
215
|
-
options.log(`${name} again: stopping now`);
|
|
256
|
+
options.log(`${name}${previous?.signal === signal ? " again" : ""}: stopping now`);
|
|
216
257
|
return options.exit(signal === "SIGINT" ? 130 : 143);
|
|
217
258
|
}
|
|
218
259
|
if (!options.busy()) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainchain-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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>",
|