Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 838cff4a97f9c3a279827b20004344f2f6cefc5f
parent b7f7a1195135bf9ec0f122f820985c4bbb582aa0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:08:50 -0400

ops: --wait survives a poll that fails, and can be bounded

The editor is one process, so a busy in-process build-index starves the event
loop until `fetch` rejects outright — and followJob treated that rejection as
the job's problem, printing a stack trace about work that was running fine and
went on to finish. Anyone who had run `pnpm ops build-site --wait` had seen it;
the note in plans/curated-tags.md telling the operator to go poll
/api/jobs/active by hand is the workaround written down.

Now every poll is caught, the wait backs off 1→2→…→30 s rather than hammering a
server that is already struggling, and `from` survives the failure so no log
text is lost. After three consecutive failures the question changes from "what
does the log say" to "is the job still there": /api/jobs/active is cheap and is
what the editor's own head polls. Listed ⇒ keep waiting. Absent ⇒ it went
terminal unobserved, so one last log poll reads the status it ended with — and
if that fails too the follow errors rather than claiming an outcome it never
read.

--wait-timeout <seconds> is for a caller that cannot hang. Default none: the
honest answer for a queue that may hold a job behind hours of other work is to
wait. followJob takes an injected {fetch, sleep, now} so the four paths are
unit-testable without a server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mscripts/archilyzer-ops.mjs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mscripts/archilyzer-ops.test.mjs | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 221 insertions(+), 15 deletions(-)

diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -8,7 +8,8 @@ // // USAGE // -// pnpm ops <action> [--json '<body>' | --file <path>] [--wait] [--quiet] +// pnpm ops <action> [--json '<body>' | --file <path>] [--wait] +// [--wait-timeout <seconds>] [--quiet] // pnpm ops get channel <slug> [--counts] // pnpm ops get tags [<tagId>] // pnpm ops list @@ -42,7 +43,9 @@ // --wait follows /api/jobs/<jobId>/log to the end for a job-starting action and // exits 0 only if the job finished `done`. Without it the command returns as // soon as the job is QUEUED, which is the honest answer: the queue may hold it -// behind other work for hours. +// behind other work for hours. A poll that fails does NOT end the follow — see +// followJob — and --wait-timeout <seconds> is there for a caller that cannot +// wait indefinitely. // // The response JSON is printed verbatim on stdout (log lines from --wait go to // stderr), so `pnpm ops … | jq` works. @@ -104,10 +107,29 @@ export function parseArgs(argv) { let wait = false; let quiet = false; let counts = false; + let waitTimeout = null; + const readTimeout = (raw) => { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + return { error: "--wait-timeout needs a positive number of seconds" }; + } + waitTimeout = n; + return null; + }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--wait") { wait = true; + } else if (arg === "--wait-timeout") { + const raw = argv[++i]; + if (raw === undefined) { + return { error: "--wait-timeout needs a number of seconds" }; + } + const err = readTimeout(raw); + if (err) return err; + } else if (arg.startsWith("--wait-timeout=")) { + const err = readTimeout(arg.slice("--wait-timeout=".length)); + if (err) return err; } else if (arg === "--quiet") { quiet = true; } else if (arg === "--counts") { @@ -167,6 +189,7 @@ export function parseArgs(argv) { path: GETTERS[noun](positional[2], counts), wait: false, quiet, + waitTimeout, }; } const action = positional[0]; @@ -194,18 +217,26 @@ export function parseArgs(argv) { ...(action === "tag-videos" ? { defaultSource: agentSource() } : {}), wait, quiet, + waitTimeout, }; } export function usage() { return [ "Usage: pnpm ops <action> [--json '<body>' | --file <path>] [--wait]", + " [--wait-timeout <seconds>] [--quiet]", " pnpm ops get channel <slug> [--counts]", " pnpm ops get tags [<tagId>]", " pnpm ops list", "", `Actions: ${ACTIONS.join(", ")}`, "", + "--wait follows the job's log and survives a poll that fails (a busy", + " in-process build starves the server): it backs off and, after three", + " failures, asks /api/jobs/active whether the job is still there.", + "--wait-timeout <seconds> gives up and exits 1 instead of waiting forever.", + " Default: no timeout — the queue may legitimately hold a job for hours.", + "", "Env: ARCHILYZER_EDITOR_URL (default http://localhost:3001), WORKER_TOKEN,", " ARCHILYZER_AGENT (provenance of a tag write; default \"cli\")", ].join("\n"); @@ -229,19 +260,98 @@ function authHeaders() { // the browser polls it same-origin with no token. Sending one here implied a // gate that does not exist, which is worse than sending nothing: the next // person to read this would conclude the endpoint was protected. -async function followJob(jobId, quiet) { +// +// A POLL FAILURE IS NOT A JOB FAILURE, and this used to treat them as the same +// thing. The editor is single-process: a busy in-process build-index starves +// the event loop for long enough that `fetch` rejects outright, and one +// rejection ended the follow with a stack trace about a job that was running +// fine and went on to finish. So every poll is caught, the wait backs off +// 1→2→4…→30 s instead of hammering a server that is already struggling, and +// `from` is kept across the failure so not one line of log text is lost. +// +// After three consecutive failures the endpoint is no longer trusted to answer +// at all, and the question becomes a different one — IS THE JOB STILL THERE? +// `/api/jobs/active` is cheap and is what the editor's own head polls. Listed +// ⇒ keep waiting, however long that takes. Absent ⇒ it ended while we could not +// see it, so one last log poll reads the terminal status; if even that fails, +// the follow gives up rather than claiming an outcome it never read. +// +// `--wait-timeout` bounds the whole thing for a caller that cannot hang (CI, +// an agent). Default none, because the honest default for a queue that may hold +// a job behind hours of other work is to wait. +export async function followJob(jobId, quiet, opts = {}) { + const doFetch = opts.fetch ?? fetch; + const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + const now = opts.now ?? (() => Date.now()); + const deadline = + opts.timeoutSeconds > 0 ? now() + opts.timeoutSeconds * 1000 : null; + const base = opts.baseUrl ?? baseUrl(); + const id = encodeURIComponent(jobId); + let from = 0; + let failures = 0; + let backoff = 1000; + + // One log poll. Returns the status, or null when the poll itself failed — + // never throws, so a transient fetch rejection cannot end the follow. + const pollLog = async () => { + try { + const res = await doFetch(`${base}/api/jobs/${id}/log?from=${from}`); + if (!res.ok) return null; + const payload = await res.json(); + if (payload.content && !quiet) process.stderr.write(payload.content); + // Only advance once the chunk is in hand: a poll that failed halfway + // must re-ask for the same offset. + from = payload.nextOffset ?? from; + return payload.status ?? null; + } catch { + return null; + } + }; + + const stillListed = async () => { + try { + const res = await doFetch(`${base}/api/jobs/active`); + if (!res.ok) return null; + const payload = await res.json(); + const jobs = Array.isArray(payload.jobs) ? payload.jobs : []; + return jobs.some((j) => j && j.id === jobId); + } catch { + return null; + } + }; + for (;;) { - const res = await fetch( - `${baseUrl()}/api/jobs/${encodeURIComponent(jobId)}/log?from=${from}`, - ); - if (!res.ok) throw new Error(`log poll failed: HTTP ${res.status}`); - const payload = await res.json(); - if (payload.content && !quiet) process.stderr.write(payload.content); - from = payload.nextOffset ?? from; - const status = payload.status; - if (status !== "queued" && status !== "running") return status; - await new Promise((r) => setTimeout(r, 1000)); + const status = await pollLog(); + if (status !== null) { + failures = 0; + backoff = 1000; + if (status !== "queued" && status !== "running") return status; + } else { + failures++; + if (failures >= 3) { + const listed = await stillListed(); + if (listed === false) { + // Gone from the active list: it went terminal while the log endpoint + // was unreachable. One more try at the status it ended with. + const final = await pollLog(); + if (final !== null) return final; + throw new Error( + `lost contact with job ${jobId}: it is no longer active and its log could not be read`, + ); + } + // Listed (or the probe failed too) — keep waiting. A job we can still + // see is a job to wait for. + if (listed === true) failures = 0; + } + backoff = Math.min(backoff * 2, 30_000); + } + if (deadline !== null && now() >= deadline) { + throw new Error( + `--wait-timeout: gave up after ${opts.timeoutSeconds}s waiting for job ${jobId}; it is still running and can be followed on /jobs`, + ); + } + await sleep(status !== null ? 1000 : backoff); } } @@ -325,7 +435,9 @@ async function main() { } let worst = 0; for (const jobId of jobIds) { - const status = await followJob(jobId, parsed.quiet); + const status = await followJob(jobId, parsed.quiet, { + timeoutSeconds: parsed.waitTimeout ?? 0, + }); console.error(`[${jobId}] ${status}`); if (status !== "done") worst = 1; } diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -5,7 +5,7 @@ // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; -import { parseArgs, usage } from "./archilyzer-ops.mjs"; +import { followJob, parseArgs, usage } from "./archilyzer-ops.mjs"; test("no arguments prints usage", () => { assert.equal(parseArgs([]).help, true); @@ -118,3 +118,97 @@ test("get tags reads the whole vocabulary, or one tag's assignments", () => { // The optional argument is per-noun: `get channel` still demands a slug. assert.match(parseArgs(["get", "channel"]).error, /needs an argument/); }); + +// ─── --wait, and the poll that fails ─── + +test("--wait-timeout is parsed, and refuses a non-number", () => { + assert.equal(parseArgs(["build-index", "--wait", "--wait-timeout", "90"]).waitTimeout, 90); + assert.equal(parseArgs(["build-index", "--wait-timeout=90"]).waitTimeout, 90); + // Default is NONE: the queue may legitimately hold a job for hours. + assert.equal(parseArgs(["build-index", "--wait"]).waitTimeout, null); + assert.match(parseArgs(["build-index", "--wait-timeout"]).error, /needs a number/); + assert.match(parseArgs(["build-index", "--wait-timeout", "soon"]).error, /positive number/); + assert.match(parseArgs(["build-index", "--wait-timeout", "0"]).error, /positive number/); + assert.match(usage(), /--wait-timeout/); +}); + +// A fake editor. `log` is the sequence of log-poll outcomes — an Error is +// thrown at the caller the way a starved server makes `fetch` reject. +function fakeEditor({ log = [], active = [] } = {}) { + const calls = { log: 0, active: 0 }; + const reply = (body) => ({ ok: true, json: async () => body }); + const doFetch = async (url) => { + if (url.includes("/api/jobs/active")) { + const next = active[Math.min(calls.active, active.length - 1)]; + calls.active++; + if (next instanceof Error) throw next; + return reply({ jobs: next }); + } + const next = log[Math.min(calls.log, log.length - 1)]; + calls.log++; + if (next instanceof Error) throw next; + return reply(next); + }; + return { doFetch, calls }; +} + +const follow = (jobId, editor, opts = {}) => + followJob(jobId, true, { + fetch: editor.doFetch, + sleep: async () => {}, + baseUrl: "http://editor", + ...opts, + }); + +test("a poll that rejects does not end the follow", async () => { + // THE BUG THIS FIXES: one `fetch failed` while an in-process build-index + // starved the server used to abort --wait and report a job that was fine. + const editor = fakeEditor({ + log: [ + { content: "a", nextOffset: 1, status: "running" }, + new Error("fetch failed"), + new Error("fetch failed"), + { content: "b", nextOffset: 2, status: "done" }, + ], + }); + assert.equal(await follow("j1", editor), "done"); + // Two failures is below the probe threshold, so it never asked. + assert.equal(editor.calls.active, 0); +}); + +test("after three failures it asks whether the job is still there", async () => { + const boom = new Error("fetch failed"); + // Still listed ⇒ keep waiting, and the follow ends on the status it finally + // reads rather than on a guess. + const waiting = fakeEditor({ + log: [boom, boom, boom, { content: "", nextOffset: 0, status: "done" }], + active: [[{ id: "j1" }]], + }); + assert.equal(await follow("j1", waiting), "done"); + assert.equal(waiting.calls.active, 1); + + // Absent from the active list ⇒ it ended while the log was unreachable, so + // one final poll reads the terminal status. `failed` is reported, not hidden. + const gone = fakeEditor({ + log: [boom, boom, boom, { content: "", nextOffset: 0, status: "failed" }], + active: [[]], + }); + assert.equal(await follow("j1", gone), "failed"); +}); + +test("a job that is gone AND unreadable refuses to claim an outcome", async () => { + const boom = new Error("fetch failed"); + const editor = fakeEditor({ log: [boom], active: [[]] }); + await assert.rejects(follow("j1", editor), /lost contact with job j1/); +}); + +test("--wait-timeout gives up on a job that never ends", async () => { + const editor = fakeEditor({ + log: [{ content: "", nextOffset: 0, status: "running" }], + }); + let clock = 0; + await assert.rejects( + follow("j1", editor, { timeoutSeconds: 5, now: () => (clock += 3000) }), + /--wait-timeout: gave up after 5s/, + ); +});