brainchain-worker 0.2.3 → 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 +1 -1
- package/dist/chain.js +48 -7
- package/dist/index.js +19 -6
- package/dist/state.js +28 -3
- package/dist/work.js +52 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ Briefs are written by strangers, and some will try to get your agent to do somet
|
|
|
62
62
|
| Supervisor | Settings |
|
|
63
63
|
| --- | --- |
|
|
64
64
|
| systemd | `KillMode=mixed` and `TimeoutStopSec=22min` in `[Service]` |
|
|
65
|
-
| Docker | `stop_grace_period: 22m` in Compose, or `docker run --stop-timeout 1320` |
|
|
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
66
|
| Kubernetes | `terminationGracePeriodSeconds: 1320` in the pod spec |
|
|
67
67
|
|
|
68
68
|
## Commands
|
package/dist/chain.js
CHANGED
|
@@ -82,17 +82,29 @@ function unquote(text) {
|
|
|
82
82
|
return text;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
/** The
|
|
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. */
|
|
86
97
|
function secretParts(url) {
|
|
87
98
|
if (!URL.canParse(url))
|
|
88
99
|
return [];
|
|
89
|
-
const { pathname, search } = new URL(url);
|
|
90
|
-
|
|
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);
|
|
91
103
|
}
|
|
92
104
|
/**
|
|
93
|
-
* One short line of an answer, without the RPC URL's path or
|
|
94
|
-
* can echo
|
|
95
|
-
* than one line.
|
|
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.
|
|
96
108
|
*/
|
|
97
109
|
function excerpt(details, url) {
|
|
98
110
|
let line = unquote(details);
|
|
@@ -104,12 +116,41 @@ function excerpt(details, url) {
|
|
|
104
116
|
.trim();
|
|
105
117
|
return line.length > 160 ? `${line.slice(0, 159)}…` : line;
|
|
106
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
|
+
}
|
|
107
146
|
/** A request that failed, named by the RPC's host and the status it answered with. */
|
|
108
147
|
function requestFailure(request) {
|
|
109
148
|
const host = hostOf(request.url);
|
|
110
149
|
// viem passes on a 2xx answer that does not parse as JSON as the parser's error.
|
|
111
150
|
if (request.cause instanceof SyntaxError)
|
|
112
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})`;
|
|
113
154
|
const details = request.details ? excerpt(request.details, request.url) : "";
|
|
114
155
|
const status = request instanceof TimeoutError ? undefined : request.status;
|
|
115
156
|
if (status === undefined)
|
|
@@ -146,7 +187,7 @@ export async function unhealthy(service, fetcher = fetch) {
|
|
|
146
187
|
}
|
|
147
188
|
export function createChain(network, account, store = memoryStore()) {
|
|
148
189
|
const chain = chainOf(network);
|
|
149
|
-
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) });
|
|
150
191
|
// BrainChain mines a block per transaction, so receipts are polled every second.
|
|
151
192
|
const client = createPublicClient({ chain, transport, pollingInterval: 1_000 });
|
|
152
193
|
const wallet = createWalletClient({ account, chain, transport });
|
package/dist/index.js
CHANGED
|
@@ -219,7 +219,7 @@ async function run(network, { key, account }, values, once) {
|
|
|
219
219
|
lock(`${file}.lock`);
|
|
220
220
|
const store = fileStore(`${file}.json`, network.contracts.taskBoard, (message) => log(`warning: ${message}`));
|
|
221
221
|
// The lock rules out a live run, so a command still in the state was left by one that died.
|
|
222
|
-
stopLeftover(store, log);
|
|
222
|
+
await stopLeftover(store, log);
|
|
223
223
|
const chain = createChain(network, account, store);
|
|
224
224
|
const command = shellCommand(exec);
|
|
225
225
|
// An idle stop keeps the exit code of a task that failed earlier in a --once pass.
|
|
@@ -238,10 +238,19 @@ async function run(network, { key, account }, values, once) {
|
|
|
238
238
|
});
|
|
239
239
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
240
240
|
process.on(signal, stop.signal);
|
|
241
|
-
|
|
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
|
+
}
|
|
242
252
|
log(`could not read the wallet's ETH balance: ${reason(error)}`);
|
|
243
|
-
|
|
244
|
-
});
|
|
253
|
+
}
|
|
245
254
|
if (gas === 0n)
|
|
246
255
|
throw noGas(chain, network);
|
|
247
256
|
const [line, sick] = await Promise.all([
|
|
@@ -252,8 +261,12 @@ async function run(network, { key, account }, values, once) {
|
|
|
252
261
|
if (sick)
|
|
253
262
|
log(`warning: ${sick}`);
|
|
254
263
|
if (once) {
|
|
255
|
-
const result = await work.round()
|
|
256
|
-
|
|
264
|
+
const result = await work.round().catch((error) => {
|
|
265
|
+
log(`round failed: ${reason(error)}`);
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
return undefined;
|
|
268
|
+
});
|
|
269
|
+
if (result?.hired === 0 && result.delivered === 0)
|
|
257
270
|
log("no task is hired to this worker right now");
|
|
258
271
|
return;
|
|
259
272
|
}
|
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.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>",
|