handsel-worker 0.1.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 +27 -0
- package/handsel-worker.mjs +1561 -0
- package/package.json +34 -0
|
@@ -0,0 +1,1561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Handsel local worker — sell your locally-hosted AI's labor.
|
|
4
|
+
*
|
|
5
|
+
* Runs next to your model — local or a cloud API you already pay for.
|
|
6
|
+
* Connects OUTBOUND to the platform (polling), so there is nothing to
|
|
7
|
+
* expose: no webhook URL, no ngrok, no port forwarding. Zero dependencies —
|
|
8
|
+
* Node 18+ only.
|
|
9
|
+
*
|
|
10
|
+
* node handsel-worker.mjs --login # FIRST RUN, no dashboard:
|
|
11
|
+
* # email + password register (or
|
|
12
|
+
* # reconnect) the agent and save the
|
|
13
|
+
* # token to ~/.handsel/worker-token —
|
|
14
|
+
* # after that, plain
|
|
15
|
+
* # `node handsel-worker.mjs` suffices
|
|
16
|
+
* node handsel-worker.mjs --logout # forget the saved token
|
|
17
|
+
* node handsel-worker.mjs --token <TOKEN> # Ollama (default)
|
|
18
|
+
* node handsel-worker.mjs --token <TOKEN> --model llama3.2
|
|
19
|
+
* node handsel-worker.mjs --token <TOKEN> \
|
|
20
|
+
* --openai http://localhost:1234/v1 --model qwen2.5 # LM Studio / llama.cpp / vLLM
|
|
21
|
+
* node handsel-worker.mjs --token <TOKEN> \
|
|
22
|
+
* --openai https://api.your-cloud-host.com/v1 \ # any OpenAI-compatible
|
|
23
|
+
* --api-key sk-... --model your-model # cloud API — Groq, Together,
|
|
24
|
+
* # Fireworks, OpenRouter, a
|
|
25
|
+
* # custom hosted endpoint, etc.
|
|
26
|
+
* node handsel-worker.mjs --token <TOKEN> --concurrency 3 # run up to 3 jobs at once
|
|
27
|
+
* node handsel-worker.mjs --token <TOKEN> \
|
|
28
|
+
* --workdir ~/code/my-repo # WORK ON REAL SOURCE
|
|
29
|
+
* node handsel-worker.mjs --token <TOKEN> \
|
|
30
|
+
* --workdir ~/code/my-repo --allow-bash # …and let it run commands
|
|
31
|
+
* node handsel-worker.mjs --token <TOKEN> \
|
|
32
|
+
* --workdir ~/code/my-repo --harness claude # …or hand it to a REAL harness
|
|
33
|
+
* node handsel-worker.mjs --token <TOKEN> \
|
|
34
|
+
* --workdir ~/code/my-repo --harness-cmd "mytool run" # …or any other one
|
|
35
|
+
*
|
|
36
|
+
* --workdir turns this from "answer a question" into "do the work": the
|
|
37
|
+
* model gets list/read/write tools scoped to that directory and loops until
|
|
38
|
+
* it says it is done. --allow-bash additionally lets it run commands there
|
|
39
|
+
* (tests, build, git diff). Both are OFF by default, and this matters:
|
|
40
|
+
* without --workdir the worker cannot touch your disk at all, which is the
|
|
41
|
+
* behaviour every existing install keeps.
|
|
42
|
+
*
|
|
43
|
+
* READ THIS BEFORE ENABLING EITHER. Tasks can come from strangers — an
|
|
44
|
+
* outside customer who paid for an office commission is one. --workdir lets
|
|
45
|
+
* their task's model rewrite any file under that directory; --allow-bash
|
|
46
|
+
* lets it execute commands as you. Point it at a scratch checkout you can
|
|
47
|
+
* throw away, never at your home directory, and never at anything holding
|
|
48
|
+
* credentials. Paths are confined to the directory (../ and absolute paths
|
|
49
|
+
* are refused) but a command you allow can do whatever your shell can.
|
|
50
|
+
*
|
|
51
|
+
* --harness is the third mode, and the one to reach for on engineering work.
|
|
52
|
+
* Instead of this file's own agent loop, the task is handed to a coding
|
|
53
|
+
* harness that already exists and is maintained by people who do nothing
|
|
54
|
+
* else — Claude Code, Codex, OpenCode, Cline, Gemini CLI — and whatever it
|
|
55
|
+
* writes to .handsel/deliverable-<task>.md is submitted. With no --harness
|
|
56
|
+
* flag the worker looks for one on PATH and uses it; with none installed it
|
|
57
|
+
* falls back to the built-in loop, so nothing about an existing install
|
|
58
|
+
* changes. Mirrored from lib/worker-harness.ts (tests/worker-harness.test.ts).
|
|
59
|
+
*
|
|
60
|
+
* READ THIS TOO: --harness is strictly MORE permissive than --allow-bash. A
|
|
61
|
+
* headless harness that stops to ask a human never answers, so every adapter
|
|
62
|
+
* passes that harness's auto-approval flag — it can edit and run whatever it
|
|
63
|
+
* likes in the working directory. Same rule as above, more so: a scratch
|
|
64
|
+
* checkout you can throw away, never your home directory, never anything
|
|
65
|
+
* holding credentials.
|
|
66
|
+
*
|
|
67
|
+
* --openai isn't "local-only" — it's any OpenAI-compatible /chat/completions
|
|
68
|
+
* endpoint, on your machine or in the cloud. --api-key (or OPENAI_API_KEY)
|
|
69
|
+
* is sent as a Bearer token; omit it for endpoints that don't need one.
|
|
70
|
+
*
|
|
71
|
+
* --concurrency K (default 1) runs K jobs in parallel: a single poll driver
|
|
72
|
+
* pulls queued tasks and feeds K executor slots. Keep the driver single so the
|
|
73
|
+
* platform's on-chain accepts (which share this agent's account nonce) stay
|
|
74
|
+
* serial; the parallelism is in EXECUTION. Match K to what your model server
|
|
75
|
+
* can actually run at once (Ollama/LM Studio queue extra requests).
|
|
76
|
+
*
|
|
77
|
+
* Get your TOKEN from the agent's Runtime card on the dashboard
|
|
78
|
+
* ("Connect a local worker"). It bundles the agent id, its secret, and the
|
|
79
|
+
* platform URL — treat it like a password.
|
|
80
|
+
*
|
|
81
|
+
* Or skip the dashboard entirely: `--login` prompts for email + password and
|
|
82
|
+
* calls POST /api/agents/register — the same endpoint the desktop Miner uses.
|
|
83
|
+
* Same account + same agent name RECONNECTS to that agent (rotating its
|
|
84
|
+
* secret) rather than creating a new one, so logging in again from a new
|
|
85
|
+
* machine keeps the agent's credit and balance. The token is saved to
|
|
86
|
+
* ~/.handsel/worker-token (chmod 600 — it is a password; delete with
|
|
87
|
+
* --logout), so every later run needs no token at all.
|
|
88
|
+
*
|
|
89
|
+
* Token resolution order: --token wins, then --login, then the saved file;
|
|
90
|
+
* with none of those on an interactive terminal, first-time login starts by
|
|
91
|
+
* itself. A --token run does NOT save the token unless --remember is passed —
|
|
92
|
+
* existing installs keep their exact behavior. Deliberately NOT saved:
|
|
93
|
+
* --workdir and --harness. Granting file access is a per-run decision, and a
|
|
94
|
+
* remembered one would quietly re-grant it on a machine whose scratch dir has
|
|
95
|
+
* since become something else.
|
|
96
|
+
*
|
|
97
|
+
* Loop: warm up the model once (absorbs first-load latency before any task
|
|
98
|
+
* is at risk) → poll for a queued task → run it → post the result back.
|
|
99
|
+
* Your model's output is submitted as the agent's real work; the platform's
|
|
100
|
+
* independent graders (Proving Ground answers, job acceptance tests) — not
|
|
101
|
+
* your machine — decide what it's worth.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
import { promises as fs } from 'node:fs'
|
|
105
|
+
import path from 'node:path'
|
|
106
|
+
import os from 'node:os'
|
|
107
|
+
import readline from 'node:readline'
|
|
108
|
+
import { execFile, spawn } from 'node:child_process'
|
|
109
|
+
import { promisify } from 'node:util'
|
|
110
|
+
|
|
111
|
+
const execFileAsync = promisify(execFile)
|
|
112
|
+
|
|
113
|
+
const args = process.argv.slice(2)
|
|
114
|
+
const flag = (name) => {
|
|
115
|
+
const i = args.indexOf(`--${name}`)
|
|
116
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ── Login / saved token ──────────────────────────────────────────────────
|
|
120
|
+
* The token file holds exactly the base64url token --token takes, nothing
|
|
121
|
+
* else — so the two paths cannot drift, and a user can always fall back to
|
|
122
|
+
* pasting the saved value as --token on a machine with no home directory. */
|
|
123
|
+
const TOKEN_DIR = path.join(os.homedir(), '.handsel')
|
|
124
|
+
const TOKEN_FILE = path.join(TOKEN_DIR, 'worker-token')
|
|
125
|
+
|
|
126
|
+
async function readSavedToken() {
|
|
127
|
+
try {
|
|
128
|
+
return (await fs.readFile(TOKEN_FILE, 'utf8')).trim() || null
|
|
129
|
+
} catch {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function saveToken(tok) {
|
|
135
|
+
await fs.mkdir(TOKEN_DIR, { recursive: true, mode: 0o700 })
|
|
136
|
+
// 0o600: the token is a password (agent id + secret + platform).
|
|
137
|
+
await fs.writeFile(TOKEN_FILE, tok + '\n', { mode: 0o600 })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One interactive prompt. `mask` echoes * per keystroke — readline has no
|
|
141
|
+
* public masking, and pulling a dependency for it would break this file's
|
|
142
|
+
* zero-dependency contract, so this leans on _writeToOutput like every
|
|
143
|
+
* zero-dep CLI does. */
|
|
144
|
+
function ask(question, mask = false) {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
147
|
+
if (mask) {
|
|
148
|
+
const orig = rl._writeToOutput.bind(rl)
|
|
149
|
+
rl._writeToOutput = (s) => (s.includes(question) ? orig(s) : orig('*'))
|
|
150
|
+
}
|
|
151
|
+
rl.question(question, (answer) => {
|
|
152
|
+
rl.close()
|
|
153
|
+
if (mask) process.stdout.write('\n')
|
|
154
|
+
resolve(answer.trim())
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Email+password → POST /api/agents/register → token, saved. The endpoint
|
|
160
|
+
* reconnects on same account + same agent name (rotating the secret), so
|
|
161
|
+
* running --login twice is "log back in", never "make a duplicate agent". */
|
|
162
|
+
async function loginFlow() {
|
|
163
|
+
const platform = (
|
|
164
|
+
flag('platform') ??
|
|
165
|
+
((await ask('Platform URL [https://handsel-main.vercel.app]: ')) || 'https://handsel-main.vercel.app')
|
|
166
|
+
).replace(/\/+$/, '')
|
|
167
|
+
const email = flag('email') ?? (await ask('Email: '))
|
|
168
|
+
const password = process.env.HANDSEL_PASSWORD ?? (await ask('Password (a new account is created if none exists): ', true))
|
|
169
|
+
const defaultName = `${os.hostname()} worker`
|
|
170
|
+
const name = flag('agent-name') ?? ((await ask(`Agent name [${defaultName}]: `)) || defaultName)
|
|
171
|
+
// Declare file/code capability only when this run can actually touch disk —
|
|
172
|
+
// the matcher would otherwise route file work to a worker that refuses it.
|
|
173
|
+
const capabilities = flag('workdir') || flag('harness') || flag('harness-cmd') ? ['text', 'code', 'file'] : ['text']
|
|
174
|
+
|
|
175
|
+
const res = await fetch(`${platform}/api/agents/register`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'content-type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ email, password, name, auto_mine: true, capabilities }),
|
|
179
|
+
}).catch((e) => {
|
|
180
|
+
console.error(`Could not reach ${platform}: ${e?.message ?? e}`)
|
|
181
|
+
process.exit(1)
|
|
182
|
+
})
|
|
183
|
+
const body = await res.json().catch(() => null)
|
|
184
|
+
if (!res.ok || !body?.agent_id || !body?.secret) {
|
|
185
|
+
console.error(`Login failed (${res.status}): ${body?.error ?? 'unexpected response'}`)
|
|
186
|
+
process.exit(1)
|
|
187
|
+
}
|
|
188
|
+
const tok = Buffer.from(
|
|
189
|
+
JSON.stringify({ a: body.agent_id, s: body.secret, u: (body.platform_url ?? platform).replace(/\/+$/, '') }),
|
|
190
|
+
).toString('base64url')
|
|
191
|
+
await saveToken(tok)
|
|
192
|
+
console.log(
|
|
193
|
+
body.reconnected
|
|
194
|
+
? `Reconnected to existing agent "${name}" (worker secret rotated).`
|
|
195
|
+
: `Registered agent "${name}"${body.smart_account_address ? ` with wallet ${body.smart_account_address}` : ''}.`,
|
|
196
|
+
)
|
|
197
|
+
console.log(`Token saved to ${TOKEN_FILE} — from now on, plain \`node handsel-worker.mjs\` is enough.`)
|
|
198
|
+
return tok
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (args.includes('--logout')) {
|
|
202
|
+
await fs.rm(TOKEN_FILE, { force: true })
|
|
203
|
+
console.log(`Removed ${TOKEN_FILE}.`)
|
|
204
|
+
process.exit(0)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let token = flag('token')
|
|
208
|
+
if (!token && args.includes('--login')) token = await loginFlow()
|
|
209
|
+
if (!token) token = await readSavedToken()
|
|
210
|
+
if (!token && process.stdin.isTTY) {
|
|
211
|
+
console.log('No token found — starting first-time login (Ctrl-C to abort, or pass --token <TOKEN>).')
|
|
212
|
+
token = await loginFlow()
|
|
213
|
+
}
|
|
214
|
+
if (!token) {
|
|
215
|
+
console.error(
|
|
216
|
+
'Missing --token. Run with --login on an interactive terminal, or get a token from your agent\'s Runtime card ("Connect a local worker").',
|
|
217
|
+
)
|
|
218
|
+
process.exit(1)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let cfg
|
|
222
|
+
try {
|
|
223
|
+
cfg = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'))
|
|
224
|
+
if (!cfg.a || !cfg.s || !cfg.u) throw new Error('incomplete')
|
|
225
|
+
} catch {
|
|
226
|
+
console.error('Invalid --token (could not decode). Copy the full command from the dashboard again.')
|
|
227
|
+
process.exit(1)
|
|
228
|
+
}
|
|
229
|
+
// Opt-in persistence for a pasted token; --login already saved its own.
|
|
230
|
+
if (flag('token') && args.includes('--remember')) await saveToken(token)
|
|
231
|
+
|
|
232
|
+
const AGENT_ID = cfg.a
|
|
233
|
+
const SECRET = cfg.s
|
|
234
|
+
const PLATFORM = cfg.u.replace(/\/+$/, '')
|
|
235
|
+
const MODEL = flag('model') ?? 'llama3.2'
|
|
236
|
+
const OPENAI_BASE = flag('openai') // e.g. http://localhost:1234/v1 (LM Studio)
|
|
237
|
+
const OLLAMA_BASE = (flag('ollama') ?? 'http://localhost:11434').replace(/\/+$/, '')
|
|
238
|
+
const API_KEY = flag('api-key') ?? process.env.OPENAI_API_KEY ?? 'not-needed'
|
|
239
|
+
const POLL_MS = 3000
|
|
240
|
+
// How many jobs this worker runs in parallel. Bounded [1,8]: the parallelism
|
|
241
|
+
// is in local execution; on-chain accepts stay serial on the platform side.
|
|
242
|
+
const CONCURRENCY = Math.max(1, Math.min(parseInt(flag('concurrency') ?? '1', 10) || 1, 8))
|
|
243
|
+
|
|
244
|
+
const WORKDIR_RAW = flag('workdir') ?? process.env.HANDSEL_WORKDIR ?? ''
|
|
245
|
+
const ALLOW_BASH = args.includes('--allow-bash')
|
|
246
|
+
const WORKDIR = WORKDIR_RAW ? path.resolve(WORKDIR_RAW.replace(/^~(?=$|\/)/, os.homedir())) : ''
|
|
247
|
+
|
|
248
|
+
const HARNESS_ID = flag('harness') ?? null
|
|
249
|
+
const HARNESS_CMD = flag('harness-cmd') ?? null
|
|
250
|
+
/** Where a user-defined harness is told to write its finished work. Only
|
|
251
|
+
* meaningful with --harness-cmd; the built-in adapters own their own. */
|
|
252
|
+
const HARNESS_DELIVERABLE = flag('harness-deliverable') ?? null
|
|
253
|
+
/** Pipe the brief in rather than passing it as an argument. Implied when the
|
|
254
|
+
* --harness-cmd template contains no {brief}. */
|
|
255
|
+
const HARNESS_STDIN = args.includes('--harness-stdin')
|
|
256
|
+
const NO_HARNESS = args.includes('--no-harness')
|
|
257
|
+
// A harness gets a hard wall-clock limit because it is a process on someone
|
|
258
|
+
// else's machine that we do not control: a run that hangs holds a slot, and
|
|
259
|
+
// with --concurrency it holds one of very few. Generous by default — real
|
|
260
|
+
// engineering work is slow — and overridable for jobs that are slower still.
|
|
261
|
+
const HARNESS_TIMEOUT_MS = Math.max(60, parseInt(flag('harness-timeout') ?? '1800', 10) || 1800) * 1000
|
|
262
|
+
|
|
263
|
+
/* ── Harness mode ─────────────────────────────────────────────────────────
|
|
264
|
+
* Hand the whole task to a coding harness that already exists.
|
|
265
|
+
*
|
|
266
|
+
* Mirrored from lib/worker-harness.ts, which holds the same registry and the
|
|
267
|
+
* same output selection as pure functions with tests. This file is
|
|
268
|
+
* dependency-free and standalone by design, so it cannot import them — if you
|
|
269
|
+
* change one, change both. tests/worker-harness.test.ts pins the flags in
|
|
270
|
+
* BOTH files, so a drifting mirror fails the build rather than shipping a
|
|
271
|
+
* wrong command line to someone else's machine.
|
|
272
|
+
*
|
|
273
|
+
* Two things worth knowing before editing an adapter:
|
|
274
|
+
*
|
|
275
|
+
* Long flags only. These tools agree on nothing, including which letter -c
|
|
276
|
+
* is: --continue to OpenCode, --cwd to Cline. A short form on the wrong
|
|
277
|
+
* tool fails in a way that reads like the model being bad at its job.
|
|
278
|
+
*
|
|
279
|
+
* The brief is always the LAST argv entry (or the value of a flag that
|
|
280
|
+
* names it). A client writes the brief, and a brief beginning with a dash
|
|
281
|
+
* that lands where a flag is expected is a stranger configuring the
|
|
282
|
+
* harness that runs on your machine.
|
|
283
|
+
*/
|
|
284
|
+
const DELIVERABLE_DIR = '.handsel'
|
|
285
|
+
const HARNESSES = [
|
|
286
|
+
{
|
|
287
|
+
id: 'claude',
|
|
288
|
+
bin: 'claude',
|
|
289
|
+
label: 'Claude Code',
|
|
290
|
+
install: 'npm i -g @anthropic-ai/claude-code',
|
|
291
|
+
// No --add-dir: it is variadic and swallows the brief. The workdir is
|
|
292
|
+
// already this child's cwd. See lib/worker-harness.ts.
|
|
293
|
+
argv: (i) => [
|
|
294
|
+
'--print',
|
|
295
|
+
...(i.model ? ['--model', i.model] : []),
|
|
296
|
+
'--permission-mode',
|
|
297
|
+
'bypassPermissions',
|
|
298
|
+
i.brief,
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
id: 'codex',
|
|
303
|
+
bin: 'codex',
|
|
304
|
+
label: 'OpenAI Codex CLI',
|
|
305
|
+
install: 'npm i -g @openai/codex',
|
|
306
|
+
argv: (i) => [
|
|
307
|
+
'exec',
|
|
308
|
+
...(i.model ? ['--model', i.model] : []),
|
|
309
|
+
'--cd',
|
|
310
|
+
i.workdir,
|
|
311
|
+
'--full-auto',
|
|
312
|
+
'--skip-git-repo-check',
|
|
313
|
+
i.brief,
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: 'opencode',
|
|
318
|
+
bin: 'opencode',
|
|
319
|
+
label: 'OpenCode',
|
|
320
|
+
install: 'npm i -g opencode-ai',
|
|
321
|
+
argv: (i) => ['run', ...(i.model ? ['--model', i.model] : []), '--dir', i.workdir, '--auto', i.brief],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
id: 'cline',
|
|
325
|
+
bin: 'cline',
|
|
326
|
+
label: 'Cline CLI',
|
|
327
|
+
install: 'npm i -g cline',
|
|
328
|
+
argv: (i) => ['--yolo', ...(i.model ? ['--model', i.model] : []), '--cwd', i.workdir, i.brief],
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: 'gemini',
|
|
332
|
+
bin: 'gemini',
|
|
333
|
+
label: 'Gemini CLI',
|
|
334
|
+
install: 'npm i -g @google/gemini-cli',
|
|
335
|
+
argv: (i) => [...(i.model ? ['--model', i.model] : []), '--yolo', '--prompt', i.brief],
|
|
336
|
+
},
|
|
337
|
+
]
|
|
338
|
+
const AUTODETECT_ORDER = ['claude', 'codex', 'opencode', 'cline', 'gemini']
|
|
339
|
+
|
|
340
|
+
/** Per task, never one shared filename: --concurrency runs several tasks in
|
|
341
|
+
* this same directory, and a file left over from a previous task would be
|
|
342
|
+
* submitted to the next client as their deliverable. */
|
|
343
|
+
function deliverablePathFor(taskId) {
|
|
344
|
+
const safe = String(taskId).replace(/[^A-Za-z0-9_-]/g, '') || 'task'
|
|
345
|
+
// A user-defined harness names its own output file, but the per-task
|
|
346
|
+
// suffix stays: --concurrency runs several tasks in one directory, and one
|
|
347
|
+
// shared filename means a leftover from the previous task is submitted to
|
|
348
|
+
// the next client as their deliverable.
|
|
349
|
+
if (HARNESS_DELIVERABLE) {
|
|
350
|
+
const dot = HARNESS_DELIVERABLE.lastIndexOf('.')
|
|
351
|
+
const stem = dot > 0 ? HARNESS_DELIVERABLE.slice(0, dot) : HARNESS_DELIVERABLE
|
|
352
|
+
const ext = dot > 0 ? HARNESS_DELIVERABLE.slice(dot) : ''
|
|
353
|
+
return `${stem}-${safe.slice(0, 64)}${ext}`
|
|
354
|
+
}
|
|
355
|
+
return `${DELIVERABLE_DIR}/deliverable-${safe.slice(0, 64)}.md`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function harnessBrief(brief, relPath) {
|
|
359
|
+
return [
|
|
360
|
+
brief,
|
|
361
|
+
'',
|
|
362
|
+
'---',
|
|
363
|
+
'',
|
|
364
|
+
'HOW THIS IS SUBMITTED:',
|
|
365
|
+
`When you are finished, write your complete deliverable to \`${relPath}\` (create the directory if needed).`,
|
|
366
|
+
'That file is what gets submitted to the client and graded — nothing else you print is read.',
|
|
367
|
+
'If the task was to change code, the file should describe what you changed and why; the changed files themselves stay where you wrote them.',
|
|
368
|
+
'Write it as the last thing you do, once the work is actually done.',
|
|
369
|
+
].join('\n')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Is `bin` runnable? Asked through the platform's own lookup tool rather
|
|
373
|
+
* than by starting the binary, because starting it to test it runs it. */
|
|
374
|
+
async function onPath(bin) {
|
|
375
|
+
try {
|
|
376
|
+
await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [bin])
|
|
377
|
+
return true
|
|
378
|
+
} catch {
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Split --harness-cmd into a binary and arguments. Not a template, and no
|
|
384
|
+
* shell: the brief goes to the child on stdin precisely so a client's text
|
|
385
|
+
* never reaches a command line. */
|
|
386
|
+
function parseHarnessCommand(raw) {
|
|
387
|
+
const parts = []
|
|
388
|
+
let cur = ''
|
|
389
|
+
let quote = null
|
|
390
|
+
let any = false
|
|
391
|
+
for (const ch of raw) {
|
|
392
|
+
if (quote) {
|
|
393
|
+
if (ch === quote) quote = null
|
|
394
|
+
else cur += ch
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
if (ch === '"' || ch === "'") {
|
|
398
|
+
quote = ch
|
|
399
|
+
any = true
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
if (/\s/.test(ch)) {
|
|
403
|
+
if (cur || any) parts.push(cur)
|
|
404
|
+
cur = ''
|
|
405
|
+
any = false
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
cur += ch
|
|
409
|
+
}
|
|
410
|
+
if (cur || any) parts.push(cur)
|
|
411
|
+
if (quote) return null
|
|
412
|
+
const [bin, ...argv] = parts
|
|
413
|
+
return bin ? { bin, argv } : null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Chosen once at startup, so a misconfiguration is a refusal to start
|
|
417
|
+
* rather than every task failing one at a time. */
|
|
418
|
+
let HARNESS = null
|
|
419
|
+
|
|
420
|
+
async function resolveHarnessAtStartup() {
|
|
421
|
+
if (NO_HARNESS) return
|
|
422
|
+
if (HARNESS_CMD) {
|
|
423
|
+
const parsed = parseHarnessCommand(HARNESS_CMD)
|
|
424
|
+
if (!parsed) {
|
|
425
|
+
console.error('Could not read --harness-cmd (unbalanced quote, or empty).')
|
|
426
|
+
process.exit(1)
|
|
427
|
+
}
|
|
428
|
+
if (!WORKDIR) {
|
|
429
|
+
console.error('--harness-cmd needs --workdir: a coding harness with no directory to work in has nothing to do.')
|
|
430
|
+
process.exit(1)
|
|
431
|
+
}
|
|
432
|
+
// Placeholders, substituted INSIDE each already-split argument.
|
|
433
|
+
//
|
|
434
|
+
// The order is the safety property: the template is split into arguments
|
|
435
|
+
// first and tokens are replaced second, so a brief containing `; rm -rf ~`
|
|
436
|
+
// stays one argument instead of becoming several. Doing it the other way
|
|
437
|
+
// round — substitute into the string, then split — is the bug, and it is
|
|
438
|
+
// the obvious way to write this. Mirrored from lib/custom-harness.ts,
|
|
439
|
+
// which holds the same substitution as tested pure functions.
|
|
440
|
+
const usesBrief = parsed.argv.some((a) => a.includes('{brief}'))
|
|
441
|
+
if (!usesBrief && !HARNESS_STDIN) {
|
|
442
|
+
console.error(
|
|
443
|
+
'Your --harness-cmd never receives the task. Put {brief} in it, or add --harness-stdin to pipe it in.',
|
|
444
|
+
)
|
|
445
|
+
process.exit(1)
|
|
446
|
+
}
|
|
447
|
+
if (usesBrief && HARNESS_STDIN) {
|
|
448
|
+
console.error('--harness-stdin and {brief} would send the task twice — use one or the other.')
|
|
449
|
+
process.exit(1)
|
|
450
|
+
}
|
|
451
|
+
const model = flag('harness-model') ?? null
|
|
452
|
+
HARNESS = {
|
|
453
|
+
id: 'custom',
|
|
454
|
+
label: parsed.bin,
|
|
455
|
+
bin: parsed.bin,
|
|
456
|
+
briefOnStdin: HARNESS_STDIN,
|
|
457
|
+
argv: (i) =>
|
|
458
|
+
parsed.argv.map((a) =>
|
|
459
|
+
a.replace(/\{([a-z]+)\}/g, (whole, name) => {
|
|
460
|
+
if (name === 'brief') return i.brief
|
|
461
|
+
if (name === 'workdir') return i.workdir
|
|
462
|
+
if (name === 'deliverable') return HARNESS_DELIVERABLE ?? deliverablePathFor('task')
|
|
463
|
+
if (name === 'model') {
|
|
464
|
+
if (!model) {
|
|
465
|
+
// An empty string here silently runs the wrong model.
|
|
466
|
+
throw new Error('Your --harness-cmd uses {model} — start the worker with --harness-model too.')
|
|
467
|
+
}
|
|
468
|
+
return model
|
|
469
|
+
}
|
|
470
|
+
return whole
|
|
471
|
+
}),
|
|
472
|
+
),
|
|
473
|
+
}
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
if (HARNESS_ID) {
|
|
477
|
+
const spec = HARNESSES.find((h) => h.id === HARNESS_ID)
|
|
478
|
+
if (!spec) {
|
|
479
|
+
console.error(
|
|
480
|
+
`Unknown --harness "${HARNESS_ID}". Known: ${HARNESSES.map((h) => h.id).join(', ')}. ` +
|
|
481
|
+
'Any other tool can be attached with --harness-cmd "<its headless command>" — the brief arrives on stdin.',
|
|
482
|
+
)
|
|
483
|
+
process.exit(1)
|
|
484
|
+
}
|
|
485
|
+
if (!WORKDIR) {
|
|
486
|
+
console.error(`--harness ${spec.id} needs --workdir: a coding harness with no directory to work in has nothing to do.`)
|
|
487
|
+
process.exit(1)
|
|
488
|
+
}
|
|
489
|
+
if (!(await onPath(spec.bin))) {
|
|
490
|
+
console.error(`--harness ${spec.id} needs \`${spec.bin}\` on PATH. Install it with: ${spec.install}`)
|
|
491
|
+
process.exit(1)
|
|
492
|
+
}
|
|
493
|
+
HARNESS = spec
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
// Nothing asked for. Autodetect only makes sense with a workdir, and only
|
|
497
|
+
// ever UPGRADES a run that was already going to use the built-in loop.
|
|
498
|
+
if (!WORKDIR) return
|
|
499
|
+
for (const id of AUTODETECT_ORDER) {
|
|
500
|
+
const spec = HARNESSES.find((h) => h.id === id)
|
|
501
|
+
if (spec && (await onPath(spec.bin))) {
|
|
502
|
+
HARNESS = spec
|
|
503
|
+
console.log(`[worker] found ${spec.label} on PATH — using it for tasks (--no-harness to use the built-in loop)`)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/* ── Repo jobs: the diff IS the deliverable ───────────────────────────────
|
|
510
|
+
* Mirrored from lib/worker-deliverable.ts (tests/worker-deliverable.test.ts).
|
|
511
|
+
*
|
|
512
|
+
* The platform's repo-job brief has always said "submit ONE unified diff in a
|
|
513
|
+
* ```diff fenced block", and the platform side of that is complete: it
|
|
514
|
+
* extracts the diff, validates every path, opens a pull request, lets the
|
|
515
|
+
* repository's own CI grade it, and releases the escrow on merge. Harness
|
|
516
|
+
* mode broke exactly that by appending "write your deliverable to
|
|
517
|
+
* .handsel/deliverable-<task>.md — nothing else you print is read" to EVERY
|
|
518
|
+
* brief, which on a repo job overrides the only instruction that mattered.
|
|
519
|
+
*
|
|
520
|
+
* So a repo job takes a different path: clone into a per-task scratch
|
|
521
|
+
* checkout, run the harness with that as its working directory, and take the
|
|
522
|
+
* diff with git. Nothing in the loop is prose. */
|
|
523
|
+
const REPO_ROOT = '.handsel/repos'
|
|
524
|
+
|
|
525
|
+
function clonePathFor(taskId) {
|
|
526
|
+
const safe = String(taskId).replace(/[^A-Za-z0-9_-]/g, '') || 'task'
|
|
527
|
+
return `${REPO_ROOT}/${safe.slice(0, 64)}`
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** owner/repo, both segments starting alphanumeric, no `..`.
|
|
531
|
+
* This value reaches a git argv and a directory name, and git reads a
|
|
532
|
+
* leading dash as an OPTION — it has options that execute things, so no
|
|
533
|
+
* shell has to be involved for that to be code execution here. */
|
|
534
|
+
function validRepoName(s) {
|
|
535
|
+
if (typeof s !== 'string' || s.length > 140 || s.includes('..')) return false
|
|
536
|
+
return /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(s)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function validBranch(b) {
|
|
540
|
+
if (typeof b !== 'string' || !b || b.length > 200 || b.includes('..')) return false
|
|
541
|
+
return /^[A-Za-z0-9][A-Za-z0-9._\-/]*$/.test(b)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function repoOf(task) {
|
|
545
|
+
const r = task?.repo
|
|
546
|
+
if (!r || !validRepoName(r.full_name)) return null
|
|
547
|
+
// No branch means the repository's DEFAULT, which is not the same as
|
|
548
|
+
// 'main': octocat/Hello-World defaults to master and a guessed --branch
|
|
549
|
+
// fails the clone outright. --single-branch with no --branch takes the
|
|
550
|
+
// real default, so the right answer needs no lookup.
|
|
551
|
+
const branch = r.base_branch || null
|
|
552
|
+
if (branch && !validBranch(branch)) return null
|
|
553
|
+
return { fullName: r.full_name, baseBranch: branch }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function git(args, cwd) {
|
|
557
|
+
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024 })
|
|
558
|
+
return stdout
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Run a repo job end to end and return the submission.
|
|
563
|
+
*
|
|
564
|
+
* The harness runs with the CHECKOUT as its cwd, not the worker's --workdir,
|
|
565
|
+
* so `git diff` at the end is about this job and nothing else — with
|
|
566
|
+
* --concurrency two jobs share a workdir, and one clone between them would
|
|
567
|
+
* put each one's changes in the other's submission.
|
|
568
|
+
*/
|
|
569
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
570
|
+
* Run telemetry.
|
|
571
|
+
*
|
|
572
|
+
* This worker knew everything interesting about a run and threw all of it
|
|
573
|
+
* away: which phase it was in, which files the harness touched, what the
|
|
574
|
+
* harness printed, how hard this machine was working. The owner watching
|
|
575
|
+
* from the dashboard got "running", then four minutes of nothing, then
|
|
576
|
+
* "done" — and if the process was killed halfway, "running" forever.
|
|
577
|
+
*
|
|
578
|
+
* No new connection is needed for any of it. The poll loop already POSTs to
|
|
579
|
+
* the platform every few seconds with this agent's secret; it just had
|
|
580
|
+
* nothing to say. Everything below fills that message.
|
|
581
|
+
*
|
|
582
|
+
* Two rules, both mirrored on the server in lib/harness-run.ts:
|
|
583
|
+
* - A reading we could not take is NULL, never 0. "0% CPU" is a claim
|
|
584
|
+
* about an idle machine; "no reading" is the truth.
|
|
585
|
+
* - Nothing here may break a run. Telemetry rides along with paid work;
|
|
586
|
+
* if it throws, the work still has to finish.
|
|
587
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
588
|
+
|
|
589
|
+
/** taskId → what we have to say about that run on the next poll. */
|
|
590
|
+
const runs = new Map()
|
|
591
|
+
|
|
592
|
+
function beginRun(taskId) {
|
|
593
|
+
runs.set(taskId, { phase: 'plan', events: [], finished: false, ok: null })
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Record one thing that happened. Never throws — see the rule above. */
|
|
597
|
+
function note(taskId, text, opts = {}) {
|
|
598
|
+
try {
|
|
599
|
+
const run = runs.get(taskId)
|
|
600
|
+
if (!run || !text) return
|
|
601
|
+
if (opts.phase) run.phase = opts.phase
|
|
602
|
+
// Bounded here as well as on the server: a harness that prints a
|
|
603
|
+
// megabyte a second must not grow this process's memory between polls.
|
|
604
|
+
if (run.events.length > 200) run.events.splice(0, run.events.length - 200)
|
|
605
|
+
run.events.push({
|
|
606
|
+
at: Date.now(),
|
|
607
|
+
phase: opts.phase ?? run.phase,
|
|
608
|
+
text: String(text).slice(0, 300),
|
|
609
|
+
path: opts.path ?? null,
|
|
610
|
+
level: opts.level ?? 'info',
|
|
611
|
+
})
|
|
612
|
+
} catch {
|
|
613
|
+
/* telemetry must never take down a run */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function endRun(taskId, ok) {
|
|
618
|
+
const run = runs.get(taskId)
|
|
619
|
+
if (run) {
|
|
620
|
+
run.finished = true
|
|
621
|
+
run.ok = ok
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* CPU load since the previous call, from os.cpus() cumulative tick counters.
|
|
627
|
+
*
|
|
628
|
+
* Returns null rather than 0 when there is no interval to measure across —
|
|
629
|
+
* the first call after startup has nothing to diff against, and reporting
|
|
630
|
+
* that as an idle machine would be inventing a measurement.
|
|
631
|
+
*/
|
|
632
|
+
let lastCpuTimes = os.cpus().map((c) => c.times)
|
|
633
|
+
function cpuPercent() {
|
|
634
|
+
try {
|
|
635
|
+
const now = os.cpus().map((c) => c.times)
|
|
636
|
+
let idle = 0
|
|
637
|
+
let total = 0
|
|
638
|
+
for (let i = 0; i < now.length; i += 1) {
|
|
639
|
+
const a = lastCpuTimes[i]
|
|
640
|
+
const b = now[i]
|
|
641
|
+
if (!a) continue
|
|
642
|
+
idle += b.idle - a.idle
|
|
643
|
+
for (const k of Object.keys(b)) total += b[k] - a[k]
|
|
644
|
+
}
|
|
645
|
+
lastCpuTimes = now
|
|
646
|
+
if (total <= 0) return null
|
|
647
|
+
return Math.max(0, Math.min(100, Math.round((1 - idle / total) * 100)))
|
|
648
|
+
} catch {
|
|
649
|
+
return null
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function resourceSample() {
|
|
654
|
+
try {
|
|
655
|
+
const totalMb = Math.round(os.totalmem() / 1048576)
|
|
656
|
+
return {
|
|
657
|
+
cpuPct: cpuPercent(),
|
|
658
|
+
memUsedMb: Math.round((os.totalmem() - os.freemem()) / 1048576),
|
|
659
|
+
memTotalMb: totalMb,
|
|
660
|
+
}
|
|
661
|
+
} catch {
|
|
662
|
+
return { cpuPct: null, memUsedMb: null, memTotalMb: null }
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Everything worth saying since the last poll, and reset.
|
|
668
|
+
*
|
|
669
|
+
* Events are cleared once handed over so a slow poll cannot re-send them,
|
|
670
|
+
* and a finished run is dropped after its final report — the platform keeps
|
|
671
|
+
* the history, this process does not need to.
|
|
672
|
+
*/
|
|
673
|
+
function drainRuns() {
|
|
674
|
+
const out = []
|
|
675
|
+
const sample = resourceSample()
|
|
676
|
+
for (const [taskId, run] of runs) {
|
|
677
|
+
out.push({
|
|
678
|
+
taskId,
|
|
679
|
+
harnessId: HARNESS ? HARNESS.id : null,
|
|
680
|
+
model: flag('harness-model') ?? MODEL ?? null,
|
|
681
|
+
phase: run.phase,
|
|
682
|
+
events: run.events.splice(0, 40),
|
|
683
|
+
sample,
|
|
684
|
+
finished: run.finished,
|
|
685
|
+
ok: run.ok,
|
|
686
|
+
})
|
|
687
|
+
if (run.finished && run.events.length === 0) runs.delete(taskId)
|
|
688
|
+
}
|
|
689
|
+
return out
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Which files the harness has actually changed, straight from git.
|
|
694
|
+
*
|
|
695
|
+
* Reading the checkout beats scanning the harness's own chatter for
|
|
696
|
+
* filenames: `git status` is the ground truth about what is on disk, it
|
|
697
|
+
* needs no per-harness output format, and it cannot be fooled by a model
|
|
698
|
+
* that says it wrote a file it never wrote.
|
|
699
|
+
*/
|
|
700
|
+
function watchRepoFiles(taskId, cwd) {
|
|
701
|
+
const seen = new Set()
|
|
702
|
+
const tick = async () => {
|
|
703
|
+
try {
|
|
704
|
+
const out = await git(['status', '--porcelain'], cwd)
|
|
705
|
+
for (const line of out.split('\n')) {
|
|
706
|
+
const file = line.slice(3).trim()
|
|
707
|
+
if (!file || seen.has(file)) continue
|
|
708
|
+
seen.add(file)
|
|
709
|
+
note(taskId, `Wrote ${file}`, { phase: 'code', path: file })
|
|
710
|
+
}
|
|
711
|
+
} catch {
|
|
712
|
+
/* the checkout may be mid-write; try again on the next tick */
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const timer = setInterval(tick, 5000)
|
|
716
|
+
return () => {
|
|
717
|
+
clearInterval(timer)
|
|
718
|
+
return tick()
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
723
|
+
* Media jobs.
|
|
724
|
+
*
|
|
725
|
+
* The worker's contribution here is a machine with ffmpeg on it, and
|
|
726
|
+
* deliberately nothing else. It does not read the job description, does not
|
|
727
|
+
* ask a model what to do, and does not build a command: the platform
|
|
728
|
+
* compiled the argv from a validated recipe (lib/media-recipe.ts) and sent
|
|
729
|
+
* it, and this substitutes two path placeholders and runs the binary.
|
|
730
|
+
*
|
|
731
|
+
* One implementation of "what does this job mean" instead of two that drift
|
|
732
|
+
* until the same job renders differently depending on who claimed it. And no
|
|
733
|
+
* shell anywhere — `execFile`, an argv array, a binary named ffmpeg.
|
|
734
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
735
|
+
|
|
736
|
+
/** 512 MB. A source larger than this is a job for a rendering service, not
|
|
737
|
+
* for somebody's laptop, and streaming it to disk before finding that out
|
|
738
|
+
* is how a worker fills a home partition. */
|
|
739
|
+
const MEDIA_MAX_SOURCE_BYTES = 512 * 1024 * 1024
|
|
740
|
+
/** The callback carries artifacts inline as base64. Past this the render has
|
|
741
|
+
* to go to blob storage, and saying so beats a 413 from a POST. */
|
|
742
|
+
const MEDIA_MAX_INLINE_BYTES = 2 * 1024 * 1024
|
|
743
|
+
|
|
744
|
+
/** Is ffmpeg actually on this machine? Reported so a media job is matched to
|
|
745
|
+
* a worker that can do it rather than to one that merely claims 'video'. */
|
|
746
|
+
async function detectFfmpeg() {
|
|
747
|
+
try {
|
|
748
|
+
const { stdout } = await execFileAsync('ffmpeg', ['-version'], { timeout: 10_000 })
|
|
749
|
+
const line = String(stdout).split('\n')[0].trim()
|
|
750
|
+
return { present: true, version: line.slice(0, 120) }
|
|
751
|
+
} catch {
|
|
752
|
+
return { present: false, version: null }
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Stream the source to disk, refusing anything oversized or non-https. */
|
|
757
|
+
async function fetchSource(url, dest, taskId) {
|
|
758
|
+
const parsed = new URL(url)
|
|
759
|
+
if (parsed.protocol !== 'https:') throw new Error(`source must be https, got ${parsed.protocol}`)
|
|
760
|
+
note(taskId, `Downloading ${parsed.hostname}${parsed.pathname}`, { phase: 'plan' })
|
|
761
|
+
const res = await fetch(url, { redirect: 'follow' })
|
|
762
|
+
if (!res.ok) throw new Error(`source fetch failed: HTTP ${res.status}`)
|
|
763
|
+
const declared = Number(res.headers.get('content-length') ?? '0')
|
|
764
|
+
if (declared > MEDIA_MAX_SOURCE_BYTES) {
|
|
765
|
+
throw new Error(`source is ${(declared / 1048576).toFixed(0)}MB, over the ${MEDIA_MAX_SOURCE_BYTES / 1048576}MB limit`)
|
|
766
|
+
}
|
|
767
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
768
|
+
// Checked again after the fact: content-length is a claim, not a promise,
|
|
769
|
+
// and a chunked response does not send one at all.
|
|
770
|
+
if (buf.length > MEDIA_MAX_SOURCE_BYTES) {
|
|
771
|
+
throw new Error(`source turned out to be ${(buf.length / 1048576).toFixed(0)}MB, over the limit`)
|
|
772
|
+
}
|
|
773
|
+
await fs.writeFile(dest, buf)
|
|
774
|
+
note(taskId, `Downloaded ${(buf.length / 1048576).toFixed(1)}MB`, { phase: 'plan', level: 'good' })
|
|
775
|
+
return buf.length
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function runMediaTask(task, media) {
|
|
779
|
+
const dir = path.join(os.tmpdir(), `handsel-media-${task.task_id}`)
|
|
780
|
+
await fs.mkdir(dir, { recursive: true })
|
|
781
|
+
const inPath = path.join(dir, 'source')
|
|
782
|
+
const outPath = path.join(dir, 'render.mp4')
|
|
783
|
+
try {
|
|
784
|
+
await fetchSource(media.source_url, inPath, task.task_id)
|
|
785
|
+
|
|
786
|
+
const args = media.args.map((a) =>
|
|
787
|
+
a === media.input_token ? inPath : a === media.output_token ? outPath : a,
|
|
788
|
+
)
|
|
789
|
+
// Belt and braces on a value that arrived over the network and is going
|
|
790
|
+
// to a process: the platform built it, but "the other side checks it" is
|
|
791
|
+
// not a property this side gets to assume.
|
|
792
|
+
for (const a of args) {
|
|
793
|
+
if (/[;&|`$\n><]/.test(a)) throw new Error(`refusing an ffmpeg argument containing shell metacharacters: ${a.slice(0, 40)}`)
|
|
794
|
+
}
|
|
795
|
+
note(task.task_id, `ffmpeg ${args.filter((a) => a !== inPath && a !== outPath).join(' ')}`, { phase: 'code' })
|
|
796
|
+
|
|
797
|
+
const started = Date.now()
|
|
798
|
+
await execFileAsync('ffmpeg', args, { timeout: HARNESS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 })
|
|
799
|
+
const bytes = await fs.readFile(outPath)
|
|
800
|
+
note(
|
|
801
|
+
task.task_id,
|
|
802
|
+
`Rendered ${(bytes.length / 1048576).toFixed(2)}MB in ${((Date.now() - started) / 1000).toFixed(1)}s`,
|
|
803
|
+
{ phase: 'review', level: 'good', path: 'render.mp4' },
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
if (bytes.length > MEDIA_MAX_INLINE_BYTES) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
`render is ${(bytes.length / 1048576).toFixed(1)}MB, over the ${MEDIA_MAX_INLINE_BYTES / 1048576}MB inline limit — ` +
|
|
809
|
+
'ask a smaller output size, a shorter trim, or enable blob storage on the deployment',
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
output: `Rendered with ffmpeg from the job's media recipe. ${bytes.length} bytes.`,
|
|
814
|
+
artifacts: [{ name: 'render.mp4', mime: 'video/mp4', data_base64: bytes.toString('base64') }],
|
|
815
|
+
}
|
|
816
|
+
} finally {
|
|
817
|
+
// The source can be hundreds of megabytes. Leaving it behind fills a
|
|
818
|
+
// disk one job at a time, and the failure shows up on an unrelated run.
|
|
819
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function runRepoTask(task, repo) {
|
|
824
|
+
const rel = clonePathFor(task.task_id)
|
|
825
|
+
const dest = path.resolve(WORKDIR, rel)
|
|
826
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => {})
|
|
827
|
+
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
828
|
+
|
|
829
|
+
console.log(`\n[worker] cloning ${repo.fullName}${repo.baseBranch ? `@${repo.baseBranch}` : ' (default branch)'} → ${rel}`)
|
|
830
|
+
note(task.task_id, `Cloning ${repo.fullName}`, { phase: 'plan' })
|
|
831
|
+
await git(
|
|
832
|
+
[
|
|
833
|
+
'clone',
|
|
834
|
+
'--depth',
|
|
835
|
+
'1',
|
|
836
|
+
'--single-branch',
|
|
837
|
+
...(repo.baseBranch ? ['--branch', repo.baseBranch] : []),
|
|
838
|
+
'--',
|
|
839
|
+
`https://github.com/${repo.fullName}.git`,
|
|
840
|
+
dest,
|
|
841
|
+
],
|
|
842
|
+
WORKDIR,
|
|
843
|
+
)
|
|
844
|
+
const baseSha = (await git(['rev-parse', 'HEAD'], dest)).trim()
|
|
845
|
+
const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], dest)).trim()
|
|
846
|
+
|
|
847
|
+
const brief = [
|
|
848
|
+
task.task.trim(),
|
|
849
|
+
'',
|
|
850
|
+
'---',
|
|
851
|
+
'',
|
|
852
|
+
'HOW THIS RUN IS SET UP:',
|
|
853
|
+
`${repo.fullName} is already cloned for you at \`${rel}\` on branch \`${branch}\`, and that is your working directory.`,
|
|
854
|
+
'Make the change there, in the files. Do not print a diff and do not write a summary file —',
|
|
855
|
+
'the diff is taken from the checkout with git once you are done, so what is on disk IS the deliverable.',
|
|
856
|
+
].join('\n')
|
|
857
|
+
|
|
858
|
+
note(task.task_id, `Checked out ${branch} at ${baseSha.slice(0, 7)}`, { phase: 'plan', level: 'good' })
|
|
859
|
+
|
|
860
|
+
const stopWatching = watchRepoFiles(task.task_id, dest)
|
|
861
|
+
let stdout
|
|
862
|
+
try {
|
|
863
|
+
;({ stdout } = await spawnHarness(brief, dest, task.task_id))
|
|
864
|
+
} finally {
|
|
865
|
+
// Always drain the watcher, including on a throw: the last tick is the
|
|
866
|
+
// one that sees the files written just before the harness died, which is
|
|
867
|
+
// exactly what someone reading a failed run needs.
|
|
868
|
+
await stopWatching()
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Stage first: a diff that silently omits CREATED files is the most common
|
|
872
|
+
// way a repo-job submission fails review, and it reads as the worker having
|
|
873
|
+
// forgotten to write them.
|
|
874
|
+
await git(['add', '-A'], dest)
|
|
875
|
+
// Against the recorded base rather than HEAD, so this works whether or not
|
|
876
|
+
// the harness committed its own work — several of them do.
|
|
877
|
+
const diff = await git(['diff', '--cached', '--no-color', '--no-ext-diff', baseSha], dest)
|
|
878
|
+
|
|
879
|
+
const hasPatch = diff
|
|
880
|
+
.trim()
|
|
881
|
+
.split('\n')
|
|
882
|
+
.some((l) => l.startsWith('diff --git ') || l.startsWith('--- '))
|
|
883
|
+
if (!hasPatch) {
|
|
884
|
+
throw new Error(
|
|
885
|
+
`${HARNESS.label} changed nothing in ${repo.fullName} — no diff to submit. ` +
|
|
886
|
+
'Submitting a description of work that did not happen is worse than failing the job.',
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const summary = extractHarnessText(stdout).trim().slice(0, 1500)
|
|
891
|
+
console.log(`\n[worker] diff: ${diff.split('\n').length} lines from ${rel}`)
|
|
892
|
+
note(task.task_id, `Diff ready — ${diff.split('\n').length} lines`, { phase: 'review', level: 'good' })
|
|
893
|
+
return [summary, summary ? '' : null, '```diff', diff.trimEnd(), '```'].filter((l) => l !== null).join('\n')
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Run one task through the harness.
|
|
898
|
+
*
|
|
899
|
+
* stdout and stderr are streamed to the console rather than buffered
|
|
900
|
+
* silently: this is somebody's own machine, the run takes minutes, and a
|
|
901
|
+
* progress-free wait is indistinguishable from a hang.
|
|
902
|
+
*/
|
|
903
|
+
/**
|
|
904
|
+
* Run the harness once and hand back what it said.
|
|
905
|
+
*
|
|
906
|
+
* Split out of runHarnessTask so a repo job can point it at a scratch
|
|
907
|
+
* checkout instead of the worker's own --workdir: with --concurrency two jobs
|
|
908
|
+
* share a workdir, and one clone between them would put each job's changes in
|
|
909
|
+
* the other's submission.
|
|
910
|
+
*/
|
|
911
|
+
async function spawnHarness(brief, cwd, taskId = null) {
|
|
912
|
+
const argv = HARNESS.argv({ brief, workdir: cwd, model: flag('harness-model') ?? null })
|
|
913
|
+
note(taskId, `${HARNESS.label} started`, { phase: 'code' })
|
|
914
|
+
const { out, code, errTail } = await new Promise((resolve, reject) => {
|
|
915
|
+
const child = spawn(HARNESS.bin, argv, {
|
|
916
|
+
// The CALLER's directory, not WORKDIR: a repo job runs the harness
|
|
917
|
+
// inside its own scratch checkout, and using WORKDIR here silently put
|
|
918
|
+
// every edit one level up, where `git diff` in the checkout could not
|
|
919
|
+
// see it. Found by running it, not by a test.
|
|
920
|
+
cwd,
|
|
921
|
+
stdio: [HARNESS.briefOnStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
922
|
+
env: process.env,
|
|
923
|
+
})
|
|
924
|
+
let out = ''
|
|
925
|
+
// Kept so a failure explains itself in the TASK RECORD, not only on a
|
|
926
|
+
// console nobody is watching. "produced neither a file nor any output"
|
|
927
|
+
// is a symptom; the harness's own last words are the cause.
|
|
928
|
+
let errTail = ''
|
|
929
|
+
const timer = setTimeout(() => {
|
|
930
|
+
child.kill()
|
|
931
|
+
reject(new Error(`${HARNESS.label} exceeded --harness-timeout (${Math.round(HARNESS_TIMEOUT_MS / 1000)}s)`))
|
|
932
|
+
}, HARNESS_TIMEOUT_MS)
|
|
933
|
+
if (HARNESS.briefOnStdin) child.stdin.end(brief)
|
|
934
|
+
// The same bytes go to two places now: the owner's console, as before,
|
|
935
|
+
// and the run log, so somebody watching from the dashboard sees the same
|
|
936
|
+
// progress the person sitting at the machine does. Line-buffered, since
|
|
937
|
+
// a chunk boundary is not a log entry.
|
|
938
|
+
let pending = ''
|
|
939
|
+
child.stdout.on('data', (d) => {
|
|
940
|
+
out += d
|
|
941
|
+
process.stdout.write(d)
|
|
942
|
+
pending += d
|
|
943
|
+
const lines = pending.split('\n')
|
|
944
|
+
pending = lines.pop() ?? ''
|
|
945
|
+
for (const line of lines) if (line.trim()) note(taskId, line, { phase: 'code' })
|
|
946
|
+
})
|
|
947
|
+
child.stderr.on('data', (d) => {
|
|
948
|
+
errTail = (errTail + d).slice(-2000)
|
|
949
|
+
process.stderr.write(d)
|
|
950
|
+
})
|
|
951
|
+
child.on('error', (e) => {
|
|
952
|
+
clearTimeout(timer)
|
|
953
|
+
reject(new Error(`could not run ${HARNESS.bin}: ${e.message}`))
|
|
954
|
+
})
|
|
955
|
+
child.on('close', (code) => {
|
|
956
|
+
clearTimeout(timer)
|
|
957
|
+
// A non-zero exit is not automatically a failed task: several of these
|
|
958
|
+
// exit non-zero on a turn limit having already written a usable
|
|
959
|
+
// deliverable. The file decides; the code only colours the log.
|
|
960
|
+
if (code !== 0) console.log(`\n[worker] ${HARNESS.label} exited ${code}`)
|
|
961
|
+
note(taskId, `${HARNESS.label} exited ${code}`, { phase: 'code', level: code === 0 ? 'good' : 'bad' })
|
|
962
|
+
resolve({ out, code, errTail })
|
|
963
|
+
})
|
|
964
|
+
})
|
|
965
|
+
return { stdout: out, code, errTail }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function runHarnessTask(task) {
|
|
969
|
+
const rel = deliverablePathFor(task.task_id)
|
|
970
|
+
const abs = path.resolve(WORKDIR, rel)
|
|
971
|
+
await fs.mkdir(path.dirname(abs), { recursive: true })
|
|
972
|
+
// Never inherit a previous run's file: an interrupted task that left one
|
|
973
|
+
// behind would otherwise be submitted as this task's work.
|
|
974
|
+
await fs.unlink(abs).catch(() => {})
|
|
975
|
+
|
|
976
|
+
const brief = harnessBrief(`Working directory: ${WORKDIR}\n\nTask:\n${task.task}`, rel)
|
|
977
|
+
const { stdout, code, errTail } = await spawnHarness(brief, WORKDIR, task.task_id)
|
|
978
|
+
|
|
979
|
+
let file = null
|
|
980
|
+
try {
|
|
981
|
+
file = await fs.readFile(abs, 'utf8')
|
|
982
|
+
} catch {
|
|
983
|
+
/* the harness wrote nothing — fall back to what it said */
|
|
984
|
+
}
|
|
985
|
+
if (file && file.trim()) {
|
|
986
|
+
console.log(`\n[worker] deliverable from ${rel} (${file.trim().length} chars)`)
|
|
987
|
+
return file.trim()
|
|
988
|
+
}
|
|
989
|
+
const salvaged = extractHarnessText(stdout).trim()
|
|
990
|
+
if (!salvaged) {
|
|
991
|
+
throw new Error(
|
|
992
|
+
`${HARNESS.label} exited ${code} and produced neither ${rel} nor any output` +
|
|
993
|
+
(errTail.trim() ? `: ${errTail.trim().slice(-600)}` : ''),
|
|
994
|
+
)
|
|
995
|
+
}
|
|
996
|
+
console.log(`\n[worker] ${HARNESS.label} wrote no ${rel} — submitting its output instead`)
|
|
997
|
+
return salvaged
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Fallback only. These event streams are unversioned, so this is tolerant
|
|
1001
|
+
* by design: approximately right beats empty, because an empty submission
|
|
1002
|
+
* fails grading with no clue why. */
|
|
1003
|
+
function extractHarnessText(stdout) {
|
|
1004
|
+
const out = []
|
|
1005
|
+
const TEXT_KEYS = new Set(['text', 'result', 'content', 'message', 'response', 'output'])
|
|
1006
|
+
const walk = (node, depth) => {
|
|
1007
|
+
if (depth > 6 || node === null || node === undefined) return
|
|
1008
|
+
if (typeof node === 'string') {
|
|
1009
|
+
const t = node.trim()
|
|
1010
|
+
if (t) out.push(t)
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
if (Array.isArray(node)) {
|
|
1014
|
+
for (const item of node) walk(item, depth + 1)
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
if (typeof node !== 'object') return
|
|
1018
|
+
const type = typeof node.type === 'string' ? node.type : ''
|
|
1019
|
+
if (type && /tool|error|usage|thinking|reasoning/i.test(type)) return
|
|
1020
|
+
for (const key of Object.keys(node)) if (TEXT_KEYS.has(key)) walk(node[key], depth + 1)
|
|
1021
|
+
}
|
|
1022
|
+
for (const line of stdout.split('\n')) {
|
|
1023
|
+
const t = line.trim()
|
|
1024
|
+
if (!t.startsWith('{')) continue
|
|
1025
|
+
try {
|
|
1026
|
+
walk(JSON.parse(t), 0)
|
|
1027
|
+
} catch {
|
|
1028
|
+
/* truncated or not an event — skip the line, keep the run */
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const joined = out.join('\n').trim()
|
|
1032
|
+
return joined || stdout.trim()
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/* ── Agent mode ───────────────────────────────────────────────────────────
|
|
1036
|
+
* With --workdir the worker stops being a single prompt and becomes a loop:
|
|
1037
|
+
* the model emits action tags, we execute them against the directory, feed
|
|
1038
|
+
* the results back, and repeat until it says <done>. That is the difference
|
|
1039
|
+
* between an agent that describes a fix and one that makes it.
|
|
1040
|
+
*
|
|
1041
|
+
* The grammar is a text protocol rather than OpenAI function-calling
|
|
1042
|
+
* because this worker targets ANY OpenAI-compatible endpoint — Ollama, LM
|
|
1043
|
+
* Studio, llama.cpp, vLLM, Groq — and tool-calling support across those is
|
|
1044
|
+
* inconsistent and differently shaped. Tags work everywhere, including on
|
|
1045
|
+
* models with no tool support at all, which is the population this worker
|
|
1046
|
+
* exists to sell the labor of.
|
|
1047
|
+
*
|
|
1048
|
+
* Mirrored from lib/worker-agent-protocol.ts, which holds the same rules as
|
|
1049
|
+
* pure functions with tests (tests/worker-agent-protocol.test.ts). This file
|
|
1050
|
+
* is dependency-free and standalone by design, so it cannot import them —
|
|
1051
|
+
* if you change one, change both. */
|
|
1052
|
+
const MAX_AGENT_STEPS = 24
|
|
1053
|
+
const MAX_TOOL_OUTPUT = 8000
|
|
1054
|
+
|
|
1055
|
+
/** Resolve `candidate` inside WORKDIR, or null if it escapes. THE sandbox:
|
|
1056
|
+
* tasks can arrive from strangers, so this decides what a paying outsider's
|
|
1057
|
+
* model may touch on the owner's machine. Absolute paths are refused rather
|
|
1058
|
+
* than rebased — rebasing turns a request for /etc/passwd into a read of
|
|
1059
|
+
* <workdir>/etc/passwd, which succeeds quietly and hides the attempt. */
|
|
1060
|
+
function confinePath(candidate) {
|
|
1061
|
+
if (!candidate || candidate.includes('\0')) return null
|
|
1062
|
+
if (candidate.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(candidate)) return null
|
|
1063
|
+
const resolved = path.resolve(WORKDIR, candidate)
|
|
1064
|
+
const root = WORKDIR.endsWith(path.sep) ? WORKDIR : WORKDIR + path.sep
|
|
1065
|
+
if (resolved !== WORKDIR && !resolved.startsWith(root)) return null
|
|
1066
|
+
return resolved
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const ACTION_TAG = /<(read|write|list|bash|done)((?:\s+[a-z]+="[^"]*")*)\s*(?:\/>|>([\s\S]*?)<\/\1>)/g
|
|
1070
|
+
const attrOf = (raw, name) => (raw.match(new RegExp(`${name}="([^"]*)"`)) ?? [, ''])[1]
|
|
1071
|
+
|
|
1072
|
+
function parseActions(reply) {
|
|
1073
|
+
const out = []
|
|
1074
|
+
ACTION_TAG.lastIndex = 0
|
|
1075
|
+
for (const m of reply.matchAll(ACTION_TAG)) {
|
|
1076
|
+
const [, kind, rawAttrs, body = ''] = m
|
|
1077
|
+
if (kind === 'read') out.push({ kind, path: attrOf(rawAttrs, 'path') })
|
|
1078
|
+
else if (kind === 'list') out.push({ kind, path: attrOf(rawAttrs, 'path') || '.' })
|
|
1079
|
+
else if (kind === 'write') out.push({ kind, path: attrOf(rawAttrs, 'path'), content: body })
|
|
1080
|
+
else if (kind === 'bash') out.push({ kind, command: body.trim() })
|
|
1081
|
+
else if (kind === 'done') out.push({ kind, summary: body.trim() })
|
|
1082
|
+
}
|
|
1083
|
+
return out.filter((a) => (a.path === undefined ? true : a.path !== ''))
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const clamp = (t) => (t.length <= MAX_TOOL_OUTPUT ? t : `${t.slice(0, MAX_TOOL_OUTPUT)}\n…[truncated ${t.length - MAX_TOOL_OUTPUT} more characters]`)
|
|
1087
|
+
|
|
1088
|
+
/** Run one action and return what the model should see next. Every failure
|
|
1089
|
+
* becomes TEXT, never a throw: a refused path or a failing command is
|
|
1090
|
+
* information the agent should react to, not a reason to fail the task. */
|
|
1091
|
+
async function runAction(a) {
|
|
1092
|
+
if (a.kind === 'done') return null
|
|
1093
|
+
if (a.kind === 'bash' && !ALLOW_BASH) return 'ERROR: running commands is disabled (worker started without --allow-bash).'
|
|
1094
|
+
if (a.kind === 'bash') {
|
|
1095
|
+
try {
|
|
1096
|
+
const { stdout, stderr } = await execFileAsync('/bin/sh', ['-c', a.command], {
|
|
1097
|
+
cwd: WORKDIR,
|
|
1098
|
+
timeout: 120_000,
|
|
1099
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1100
|
+
})
|
|
1101
|
+
return clamp(`$ ${a.command}\n${stdout}${stderr ? `\n[stderr]\n${stderr}` : ''}` || '(no output)')
|
|
1102
|
+
} catch (e) {
|
|
1103
|
+
// A non-zero exit is a normal result for a test run — hand back the
|
|
1104
|
+
// output so the agent can fix what failed.
|
|
1105
|
+
return clamp(`$ ${a.command}\n[exit ${e.code ?? '?'}]\n${e.stdout ?? ''}${e.stderr ?? ''}` || String(e))
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const target = confinePath(a.path)
|
|
1110
|
+
if (!target) return `ERROR: "${a.path}" is outside the working directory. All paths are relative to it.`
|
|
1111
|
+
try {
|
|
1112
|
+
if (a.kind === 'list') {
|
|
1113
|
+
const entries = await fs.readdir(target, { withFileTypes: true })
|
|
1114
|
+
return clamp(entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).join('\n') || '(empty)')
|
|
1115
|
+
}
|
|
1116
|
+
if (a.kind === 'read') return clamp(await fs.readFile(target, 'utf8'))
|
|
1117
|
+
if (a.kind === 'write') {
|
|
1118
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
1119
|
+
await fs.writeFile(target, a.content, 'utf8')
|
|
1120
|
+
return `wrote ${a.path} (${a.content.length} chars)`
|
|
1121
|
+
}
|
|
1122
|
+
} catch (e) {
|
|
1123
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`
|
|
1124
|
+
}
|
|
1125
|
+
return null
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function agentSystemPrompt() {
|
|
1129
|
+
return [
|
|
1130
|
+
'You are an autonomous worker agent on the Handsel labor market, working on real source code.',
|
|
1131
|
+
'You have a working directory. All paths are relative to it. You cannot read or write outside it.',
|
|
1132
|
+
'',
|
|
1133
|
+
'Act by emitting these tags. You may emit several per reply; results come back before your next turn.',
|
|
1134
|
+
' <list path="src"/> — list a directory',
|
|
1135
|
+
' <read path="src/a.ts"/> — read a file',
|
|
1136
|
+
' <write path="src/a.ts">FULL NEW CONTENTS</write>',
|
|
1137
|
+
...(ALLOW_BASH ? [' <bash>npm test</bash> — run a command in the working directory'] : []),
|
|
1138
|
+
' <done>what you changed and why</done>',
|
|
1139
|
+
'',
|
|
1140
|
+
'Rules:',
|
|
1141
|
+
'- Read before you write. Never write a file you have not read, unless you are creating it.',
|
|
1142
|
+
'- <write> replaces the ENTIRE file. Emit the complete new contents, not a diff or a fragment.',
|
|
1143
|
+
...(ALLOW_BASH ? [] : ['- Running commands is disabled for this task. Do not emit <bash>.']),
|
|
1144
|
+
'- When the work is finished, emit <done> with a short summary. That summary is your submission.',
|
|
1145
|
+
`- You have at most ${MAX_AGENT_STEPS} turns. Spend them on the task, not on exploring.`,
|
|
1146
|
+
].join('\n')
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const SYSTEM_PROMPT =
|
|
1150
|
+
'You are an autonomous worker agent on the Handsel labor market. ' +
|
|
1151
|
+
'Complete the task exactly as specified. If the task requires code in a ' +
|
|
1152
|
+
'fenced code block, provide the complete, runnable code. Be factual and concise.'
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Both model paths STREAM the response. This matters for slow/reasoning
|
|
1156
|
+
* models (deepseek-r1 etc.): with stream:false the server sends nothing
|
|
1157
|
+
* until generation finishes, and Node's fetch kills a connection whose
|
|
1158
|
+
* headers take >5 minutes — the run dies as "fetch failed" right before
|
|
1159
|
+
* the model would have answered. Streaming delivers bytes continuously,
|
|
1160
|
+
* so no timeout trips no matter how long the model thinks.
|
|
1161
|
+
*/
|
|
1162
|
+
async function readStreamLines(res, onLine) {
|
|
1163
|
+
const reader = res.body.getReader()
|
|
1164
|
+
const decoder = new TextDecoder()
|
|
1165
|
+
let buf = ''
|
|
1166
|
+
for (;;) {
|
|
1167
|
+
const { done, value } = await reader.read()
|
|
1168
|
+
if (done) break
|
|
1169
|
+
buf += decoder.decode(value, { stream: true })
|
|
1170
|
+
let idx
|
|
1171
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
1172
|
+
const line = buf.slice(0, idx).trim()
|
|
1173
|
+
buf = buf.slice(idx + 1)
|
|
1174
|
+
if (line) onLine(line)
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (buf.trim()) onLine(buf.trim())
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function progressTicker() {
|
|
1181
|
+
let chunks = 0
|
|
1182
|
+
return () => {
|
|
1183
|
+
chunks += 1
|
|
1184
|
+
if (chunks % 50 === 0) process.stdout.write('▪') // heartbeat: the model is generating
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** Final cleanup for reasoning models: drop closed <think> blocks (older
|
|
1189
|
+
* Ollama embeds them in content); if content is empty but the model
|
|
1190
|
+
* streamed a separate thinking channel, fall back to it — a messy answer
|
|
1191
|
+
* beats an empty submission. */
|
|
1192
|
+
function finishOutput(content, thinking) {
|
|
1193
|
+
const cleaned = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
|
|
1194
|
+
if (cleaned) return cleaned
|
|
1195
|
+
if (content.trim()) return content.trim()
|
|
1196
|
+
return thinking.trim()
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** One model turn. `messages` is the full conversation, so the agent loop
|
|
1200
|
+
* can carry tool results forward; the single-shot path passes the same two
|
|
1201
|
+
* messages it always did. */
|
|
1202
|
+
async function askModel(messages) {
|
|
1203
|
+
const tick = progressTicker()
|
|
1204
|
+
let content = ''
|
|
1205
|
+
let thinking = ''
|
|
1206
|
+
|
|
1207
|
+
if (OPENAI_BASE) {
|
|
1208
|
+
const res = await fetch(`${OPENAI_BASE.replace(/\/+$/, '')}/chat/completions`, {
|
|
1209
|
+
method: 'POST',
|
|
1210
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` },
|
|
1211
|
+
body: JSON.stringify({
|
|
1212
|
+
model: MODEL,
|
|
1213
|
+
stream: true,
|
|
1214
|
+
messages,
|
|
1215
|
+
}),
|
|
1216
|
+
})
|
|
1217
|
+
if (!res.ok) throw new Error(`local model responded ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
|
1218
|
+
await readStreamLines(res, (line) => {
|
|
1219
|
+
if (!line.startsWith('data:')) return
|
|
1220
|
+
const data = line.slice(5).trim()
|
|
1221
|
+
if (data === '[DONE]') return
|
|
1222
|
+
try {
|
|
1223
|
+
const delta = JSON.parse(data).choices?.[0]?.delta
|
|
1224
|
+
if (delta?.content) content += delta.content
|
|
1225
|
+
if (delta?.reasoning_content) thinking += delta.reasoning_content
|
|
1226
|
+
if (delta?.content || delta?.reasoning_content) tick()
|
|
1227
|
+
} catch {
|
|
1228
|
+
/* partial/keepalive line */
|
|
1229
|
+
}
|
|
1230
|
+
})
|
|
1231
|
+
return finishOutput(content, thinking)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
|
|
1235
|
+
method: 'POST',
|
|
1236
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1237
|
+
body: JSON.stringify({
|
|
1238
|
+
model: MODEL,
|
|
1239
|
+
stream: true,
|
|
1240
|
+
messages,
|
|
1241
|
+
}),
|
|
1242
|
+
})
|
|
1243
|
+
if (!res.ok) throw new Error(`Ollama responded ${res.status}: ${(await res.text()).slice(0, 300)} — is Ollama running? (ollama serve / ollama pull ${MODEL})`)
|
|
1244
|
+
await readStreamLines(res, (line) => {
|
|
1245
|
+
try {
|
|
1246
|
+
const chunk = JSON.parse(line)
|
|
1247
|
+
// Reasoning models stream a separate "thinking" channel. Collect both:
|
|
1248
|
+
// the answer comes from content, but if a model pours everything into
|
|
1249
|
+
// thinking and leaves content empty, finishOutput falls back to it.
|
|
1250
|
+
if (chunk.message?.content) content += chunk.message.content
|
|
1251
|
+
if (chunk.message?.thinking) thinking += chunk.message.thinking
|
|
1252
|
+
if (chunk.message?.content || chunk.message?.thinking) tick()
|
|
1253
|
+
} catch {
|
|
1254
|
+
/* partial line */
|
|
1255
|
+
}
|
|
1256
|
+
})
|
|
1257
|
+
return finishOutput(content, thinking)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async function platformPost(path, payload) {
|
|
1261
|
+
const res = await fetch(`${PLATFORM}${path}`, {
|
|
1262
|
+
method: 'POST',
|
|
1263
|
+
headers: { 'Content-Type': 'application/json', 'X-Runtime-Secret': SECRET },
|
|
1264
|
+
body: JSON.stringify(payload),
|
|
1265
|
+
})
|
|
1266
|
+
if (!res.ok) throw new Error(`${path} responded ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
|
1267
|
+
return res.json()
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function event(taskId, type, success, detail = {}) {
|
|
1271
|
+
return {
|
|
1272
|
+
agent_id: AGENT_ID,
|
|
1273
|
+
task_id: taskId,
|
|
1274
|
+
event_type: type,
|
|
1275
|
+
success,
|
|
1276
|
+
execution_time: 0,
|
|
1277
|
+
token_cost: 0,
|
|
1278
|
+
quality_score: null,
|
|
1279
|
+
detail,
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Single-shot: the behaviour every install had before --workdir. */
|
|
1284
|
+
const askLocalModel = (task) =>
|
|
1285
|
+
askModel([
|
|
1286
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
1287
|
+
{ role: 'user', content: task },
|
|
1288
|
+
])
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Agent mode. Loop the model against real files until it says <done>, or
|
|
1292
|
+
* until the step budget runs out.
|
|
1293
|
+
*
|
|
1294
|
+
* What gets submitted is the <done> summary — the work itself is the files
|
|
1295
|
+
* the agent changed on disk, which is the point: with --allow-bash the
|
|
1296
|
+
* natural last step is `git diff`, and the summary describes a change a
|
|
1297
|
+
* human can actually inspect. If the budget runs out first we submit the
|
|
1298
|
+
* last thing the model said rather than nothing, because a partial answer
|
|
1299
|
+
* is gradeable and an empty submission is a forfeited bounty.
|
|
1300
|
+
*/
|
|
1301
|
+
async function runAgentTask(task) {
|
|
1302
|
+
const messages = [
|
|
1303
|
+
{ role: 'system', content: agentSystemPrompt() },
|
|
1304
|
+
{ role: 'user', content: `Working directory: ${WORKDIR}\n\nTask:\n${task}` },
|
|
1305
|
+
]
|
|
1306
|
+
let last = ''
|
|
1307
|
+
|
|
1308
|
+
for (let step = 0; step < MAX_AGENT_STEPS; step += 1) {
|
|
1309
|
+
const reply = await askModel(messages)
|
|
1310
|
+
last = reply
|
|
1311
|
+
messages.push({ role: 'assistant', content: reply })
|
|
1312
|
+
|
|
1313
|
+
const actions = parseActions(reply)
|
|
1314
|
+
const done = actions.find((a) => a.kind === 'done')
|
|
1315
|
+
if (done) {
|
|
1316
|
+
console.log(`\n[worker] done in ${step + 1} step(s)`)
|
|
1317
|
+
return done.summary || reply
|
|
1318
|
+
}
|
|
1319
|
+
if (actions.length === 0) {
|
|
1320
|
+
// No tags at all. Nudge once rather than looping on prose — a model
|
|
1321
|
+
// that cannot speak the protocol should fail fast and submit what it
|
|
1322
|
+
// said, not burn 24 turns saying it again.
|
|
1323
|
+
messages.push({
|
|
1324
|
+
role: 'user',
|
|
1325
|
+
content: 'You emitted no action tags. Emit <list>, <read>, <write>' + (ALLOW_BASH ? ', <bash>' : '') + ' or <done>.',
|
|
1326
|
+
})
|
|
1327
|
+
continue
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const results = []
|
|
1331
|
+
for (const a of actions) {
|
|
1332
|
+
const out = await runAction(a)
|
|
1333
|
+
if (out !== null) {
|
|
1334
|
+
const label = a.kind === 'bash' ? a.command : a.path
|
|
1335
|
+
results.push(`<result for="${a.kind}" path="${label}">\n${out}\n</result>`)
|
|
1336
|
+
process.stdout.write(a.kind === 'write' ? 'W' : a.kind === 'bash' ? '$' : 'r')
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
messages.push({ role: 'user', content: results.join('\n\n') })
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
console.log(`\n[worker] step budget (${MAX_AGENT_STEPS}) exhausted — submitting the last reply`)
|
|
1343
|
+
return last
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async function runOne(task) {
|
|
1347
|
+
const startedAt = Date.now()
|
|
1348
|
+
console.log(`\n[worker] task ${task.task_id}:`)
|
|
1349
|
+
console.log(` ${task.task.split('\n')[0].slice(0, 100)}…`)
|
|
1350
|
+
beginRun(task.task_id)
|
|
1351
|
+
note(task.task_id, `Claimed: ${task.task.split('\n')[0].slice(0, 120)}`, { phase: 'plan' })
|
|
1352
|
+
|
|
1353
|
+
let output = ''
|
|
1354
|
+
let artifacts = []
|
|
1355
|
+
let success = true
|
|
1356
|
+
let error
|
|
1357
|
+
try {
|
|
1358
|
+
// A media job is decided before anything else looks at the brief: the
|
|
1359
|
+
// platform already compiled the recipe, so there is nothing for a model
|
|
1360
|
+
// to interpret and handing it one would only invite it to improvise.
|
|
1361
|
+
if (task.media) {
|
|
1362
|
+
if (!FFMPEG.present) throw new Error('this job needs ffmpeg and it is not on this machine')
|
|
1363
|
+
const rendered = await runMediaTask(task, task.media)
|
|
1364
|
+
output = rendered.output
|
|
1365
|
+
artifacts = rendered.artifacts
|
|
1366
|
+
} else {
|
|
1367
|
+
// A repo job's deliverable is a diff, not prose — and only a harness with
|
|
1368
|
+
// a real checkout can produce one. The built-in loop keeps its own path:
|
|
1369
|
+
// it has no git and its brief already tells the model to paste a diff.
|
|
1370
|
+
const repo = HARNESS && WORKDIR ? repoOf(task) : null
|
|
1371
|
+
output = repo
|
|
1372
|
+
? await runRepoTask(task, repo)
|
|
1373
|
+
: HARNESS
|
|
1374
|
+
? await runHarnessTask(task)
|
|
1375
|
+
: WORKDIR
|
|
1376
|
+
? await runAgentTask(task.task)
|
|
1377
|
+
: await askLocalModel(task.task)
|
|
1378
|
+
}
|
|
1379
|
+
if (!output.trim()) {
|
|
1380
|
+
success = false
|
|
1381
|
+
error = 'local model returned empty output'
|
|
1382
|
+
}
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
success = false
|
|
1385
|
+
error = e instanceof Error ? e.message : String(e)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
process.stdout.write('\n')
|
|
1389
|
+
const executionTime = Math.round((Date.now() - startedAt) / 1000)
|
|
1390
|
+
note(
|
|
1391
|
+
task.task_id,
|
|
1392
|
+
success ? `Submitted after ${executionTime}s` : `Failed: ${String(error).slice(0, 200)}`,
|
|
1393
|
+
{ phase: 'review', level: success ? 'good' : 'bad' },
|
|
1394
|
+
)
|
|
1395
|
+
// Marked finished BEFORE the callback, so the very next poll carries the
|
|
1396
|
+
// final report even if the callback itself is what fails. A run that ends
|
|
1397
|
+
// without one sits on the console as "Running" until it goes stale, which
|
|
1398
|
+
// is a worse answer than "failed".
|
|
1399
|
+
endRun(task.task_id, success)
|
|
1400
|
+
const events = [
|
|
1401
|
+
event(task.task_id, 'TASK_STARTED', true, { task: task.task.slice(0, 200) }),
|
|
1402
|
+
{
|
|
1403
|
+
...event(task.task_id, success ? 'TASK_COMPLETED' : 'TASK_FAILED', success, {
|
|
1404
|
+
runtime: 'local-worker',
|
|
1405
|
+
model: MODEL,
|
|
1406
|
+
...(error ? { error: error.slice(0, 300) } : {}),
|
|
1407
|
+
}),
|
|
1408
|
+
execution_time: executionTime,
|
|
1409
|
+
},
|
|
1410
|
+
]
|
|
1411
|
+
|
|
1412
|
+
await platformPost('/api/runtime/callback', {
|
|
1413
|
+
task_id: task.task_id,
|
|
1414
|
+
agent_id: AGENT_ID,
|
|
1415
|
+
success,
|
|
1416
|
+
output: success ? output : `Local worker error: ${error}`,
|
|
1417
|
+
plan: '',
|
|
1418
|
+
quality_score: null, // self-scoring is worthless here; independent graders decide
|
|
1419
|
+
// The rendered file itself. Grading reads THESE BYTES (lib/mp4-probe.ts)
|
|
1420
|
+
// rather than any claim made about them, which is the only version of a
|
|
1421
|
+
// media job where "it rendered correctly" is somebody else's finding.
|
|
1422
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
1423
|
+
execution_time: executionTime,
|
|
1424
|
+
token_cost: 0,
|
|
1425
|
+
events,
|
|
1426
|
+
})
|
|
1427
|
+
console.log(success ? `[worker] done in ${executionTime}s — result submitted` : `[worker] FAILED: ${error}`)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* A cold Ollama/LM Studio process can take a while to load a model into
|
|
1432
|
+
* memory on its first request — sometimes minutes for a large model on a
|
|
1433
|
+
* slow disk, or a few seconds just for the local server to finish starting
|
|
1434
|
+
* up after install. Polling before the model is actually ready means the
|
|
1435
|
+
* platform can hand this worker a real task while it's still loading,
|
|
1436
|
+
* which fails immediately with a confusing runtime error. So: block here,
|
|
1437
|
+
* retrying a trivial prompt with backoff, and only start polling once the
|
|
1438
|
+
* model genuinely answers. Runs before a single task can ever be claimed.
|
|
1439
|
+
*/
|
|
1440
|
+
const WARMUP_MAX_ATTEMPTS = 8
|
|
1441
|
+
async function warmupModel() {
|
|
1442
|
+
const label = OPENAI_BASE ? `OpenAI-compatible endpoint ${OPENAI_BASE}` : `Ollama ${OLLAMA_BASE}`
|
|
1443
|
+
console.log(`[worker] warming up ${MODEL} via ${label} (first load can take a minute)…`)
|
|
1444
|
+
for (let attempt = 1; attempt <= WARMUP_MAX_ATTEMPTS; attempt++) {
|
|
1445
|
+
try {
|
|
1446
|
+
await askLocalModel('Reply with one word: ready')
|
|
1447
|
+
console.log('[worker] model is warm\n')
|
|
1448
|
+
return
|
|
1449
|
+
} catch (e) {
|
|
1450
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
1451
|
+
if (attempt === WARMUP_MAX_ATTEMPTS) {
|
|
1452
|
+
console.error(`[worker] model never became ready after ${WARMUP_MAX_ATTEMPTS} attempts: ${msg}`)
|
|
1453
|
+
console.error(OPENAI_BASE
|
|
1454
|
+
? '[worker] check --openai URL, --api-key, and --model are correct.'
|
|
1455
|
+
: `[worker] is Ollama running? Try: ollama serve / ollama pull ${MODEL}`)
|
|
1456
|
+
process.exit(1)
|
|
1457
|
+
}
|
|
1458
|
+
console.error(`[worker] still warming up (attempt ${attempt}/${WARMUP_MAX_ATTEMPTS}): ${msg}`)
|
|
1459
|
+
await new Promise((r) => setTimeout(r, Math.min(3000 * attempt, 20000)))
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
console.log(`[worker] Handsel local worker`)
|
|
1465
|
+
console.log(`[worker] agent ${AGENT_ID}`)
|
|
1466
|
+
console.log(`[worker] platform ${PLATFORM}`)
|
|
1467
|
+
await resolveHarnessAtStartup()
|
|
1468
|
+
// Only print the model line when that model is what actually runs the work.
|
|
1469
|
+
// A harness carries its own model and auth, and announcing an Ollama the
|
|
1470
|
+
// harness never calls is how someone spends an afternoon debugging Ollama.
|
|
1471
|
+
if (!HARNESS) {
|
|
1472
|
+
console.log(`[worker] model ${MODEL} via ${OPENAI_BASE ? `OpenAI-compatible ${OPENAI_BASE}` : `Ollama ${OLLAMA_BASE}`}`)
|
|
1473
|
+
}
|
|
1474
|
+
if (HARNESS) {
|
|
1475
|
+
console.log(`[worker] harness ${HARNESS.label} in ${WORKDIR}`)
|
|
1476
|
+
console.log(
|
|
1477
|
+
`[worker] NOTE: the harness runs with its approvals off — it can edit and run anything in that directory,\n` +
|
|
1478
|
+
`[worker] and tasks can come from strangers. Point it at a checkout you can throw away.`,
|
|
1479
|
+
)
|
|
1480
|
+
} else if (WORKDIR) {
|
|
1481
|
+
console.log(`[worker] workdir ${WORKDIR}${ALLOW_BASH ? ' (commands allowed)' : ''} — built-in agent loop`)
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// The built-in loop is what a warm model is for; a harness brings its own.
|
|
1485
|
+
if (!HARNESS) await warmupModel()
|
|
1486
|
+
|
|
1487
|
+
// Probed once at startup, not assumed from a flag: a worker that DECLARES
|
|
1488
|
+
// video and cannot render is matched to media jobs it will fail, and a
|
|
1489
|
+
// failed job costs the agent its own credit score.
|
|
1490
|
+
const FFMPEG = await detectFfmpeg()
|
|
1491
|
+
console.log(FFMPEG.present ? `[worker] ffmpeg ${FFMPEG.version}` : '[worker] ffmpeg not found — media jobs will not be offered')
|
|
1492
|
+
|
|
1493
|
+
console.log(
|
|
1494
|
+
`[worker] polling every ${POLL_MS / 1000}s` +
|
|
1495
|
+
(CONCURRENCY > 1 ? `, up to ${CONCURRENCY} jobs at once` : '') +
|
|
1496
|
+
` — Ctrl+C to stop\n`,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1500
|
+
|
|
1501
|
+
// Single poll driver, K executor slots. The driver serialises polling (so the
|
|
1502
|
+
// platform's in-poll auto-mine — which does on-chain accepts sharing this
|
|
1503
|
+
// agent's account nonce — never runs concurrently with itself), then hands
|
|
1504
|
+
// each returned task to a free slot that runs it in the background. With
|
|
1505
|
+
// CONCURRENCY === 1 this behaves exactly like the old serial loop.
|
|
1506
|
+
let active = 0
|
|
1507
|
+
let consecutiveErrors = 0
|
|
1508
|
+
for (;;) {
|
|
1509
|
+
if (active >= CONCURRENCY) {
|
|
1510
|
+
await sleep(POLL_MS)
|
|
1511
|
+
continue
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
let task
|
|
1515
|
+
try {
|
|
1516
|
+
// The harness is reported on every poll, not once at startup: a worker
|
|
1517
|
+
// gets restarted with a different --harness all the time, and a value
|
|
1518
|
+
// stored once would go on describing the tool that used to be here.
|
|
1519
|
+
;({ task } = await platformPost('/api/worker/poll', {
|
|
1520
|
+
agent_id: AGENT_ID,
|
|
1521
|
+
harness: HARNESS ? HARNESS.id : null,
|
|
1522
|
+
// Declared from a probe, so the match is on a machine that has the
|
|
1523
|
+
// tool rather than on a promise that it does.
|
|
1524
|
+
capabilities: FFMPEG.present ? ['text', 'video'] : ['text'],
|
|
1525
|
+
ffmpeg: FFMPEG.version,
|
|
1526
|
+
// Whatever the running jobs have to say since the last poll. Drained
|
|
1527
|
+
// here rather than pushed on a timer of its own: the poll is already
|
|
1528
|
+
// an authenticated round trip on a few-second cadence, and a second
|
|
1529
|
+
// channel would be a second thing to get wrong.
|
|
1530
|
+
runs: drainRuns(),
|
|
1531
|
+
}))
|
|
1532
|
+
consecutiveErrors = 0
|
|
1533
|
+
} catch (e) {
|
|
1534
|
+
consecutiveErrors += 1
|
|
1535
|
+
console.error(`\n[worker] poll failed (${consecutiveErrors}): ${e instanceof Error ? e.message : e}`)
|
|
1536
|
+
if (consecutiveErrors >= 5) {
|
|
1537
|
+
console.error('[worker] 5 consecutive failures — check your token and network, then restart.')
|
|
1538
|
+
process.exit(1)
|
|
1539
|
+
}
|
|
1540
|
+
await sleep(POLL_MS)
|
|
1541
|
+
continue
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (task) {
|
|
1545
|
+
active += 1
|
|
1546
|
+
// Run in the background; free the slot when done. Never let one task's
|
|
1547
|
+
// failure take down the loop — runOne already reports failures upstream.
|
|
1548
|
+
runOne(task)
|
|
1549
|
+
.catch((e) => console.error(`\n[worker] task ${task.task_id} crashed: ${e instanceof Error ? e.message : e}`))
|
|
1550
|
+
.finally(() => {
|
|
1551
|
+
active -= 1
|
|
1552
|
+
})
|
|
1553
|
+
// Slots free → poll again immediately to fill the next one; the poll's own
|
|
1554
|
+
// network latency paces this, so it's not a busy-spin.
|
|
1555
|
+
if (active < CONCURRENCY) continue
|
|
1556
|
+
await sleep(POLL_MS)
|
|
1557
|
+
} else {
|
|
1558
|
+
process.stdout.write('.')
|
|
1559
|
+
await sleep(POLL_MS)
|
|
1560
|
+
}
|
|
1561
|
+
}
|