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.
@@ -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
+ }