Repository navigation
Settle a job whose worker thread dies while Harper keeps running - #2763
Merged
Merged
Conversation
cb1kenobi
marked this pull request as ready for review
September 23, 2026 05:18
github-actions
Bot
requested review from
Ethan-Arrowood,
heskew and
kriszyp
September 23, 2026 05:18
cb1kenobi
force-pushed
the
fix/job-boot-reconciliation
branch
from
September 24, 2026 02:27
44f7a91 to
30f0640
Compare
The boot sweep added for #2635 settles rows whose owning process is gone. A job worker that dies while the process keeps running leaves a row the sweep cannot reach: it still carries this process's owner id, and the sweep only runs at boot. The row reads as IN_PROGRESS until the next restart. `startJobWorker` now settles that row from the worker's own exit, but only for an exit `manageThreads.exitedUnexpectedly()` reports -- a deliberate stop is always either replaced or part of a teardown the boot sweep owns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-model review (codex) found that `startCopy()` restarts a worker by calling `startWorker(path, options)` directly, so a settle listener attached to the returned worker never reached a replacement -- and a replacement can be stranded the same way. The hook is now an `onUnexpectedExit` option, which `startCopy` carries for free, and `exitedUnexpectedly` goes back to being internal. Adds a real-worker test that kills a thread and asserts the callback fires, does not fire for a deliberate stop, and does fire for a `startCopy` replacement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gemini flagged that the hook runs inside an 'exit' listener, where a synchronous throw from any startWorker caller is an uncaught exception that takes the process down. It is now wrapped and logged. Also trims the comments both legs flagged across two rounds -- including the nit PR #2645 deferred -- now that server/DESIGN.md carries the contract durably. Gemini's end_datetime question needs no change: updateJob stamps it for COMPLETE and ERROR (server/jobs/jobs.ts:314-316), so the test assertion is on real behavior. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Node resets `worker.threadId` to -1 once the thread has exited, so every log line an exit listener emits with it reads `-1`. Gemini caught this on the new onUnexpectedExit error log; the symmetry check found the same defect already present in `whenWorkerStarted`'s exit listener, so both now capture the id in the closure first. The existing note at the ADDED_PORT handler attributes this to Bun. It is plain Node behavior too -- verified: a worker reports its real id before exit and -1 inside its own exit listener. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gemini: the try/catch only caught a synchronous throw, so a handler that returns a rejecting promise still reached the process as an unhandled rejection -- the exact crash the containment existed to prevent. Both shapes now route through one `runExitHandler`, covered by a test that fails without it. Also drops the comment implying whenWorkerStarted's timeout log was at risk of -1; its cleanup clears the timeout on exit, so that log only ever runs while the thread is alive. The shared capture stays, so the function has one spelling of the id. Verified THREAD_TYPES.JOB === 'job' (utility/hdbTerms.ts:978), which Gemini could not check without a checkout: the constant swap is a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cb1kenobi
force-pushed
the
fix/job-thread-death-settlement
branch
from
September 24, 2026 02:38
f46b4fb to
d4746b7
Compare
Contributor
|
Reviewed; no blockers found. |
kriszyp
approved these changes
Sep 24, 2026
kriszyp
left a comment
Member
There was a problem hiding this comment.
Great, good approach!
🤖 Reviewed with Codex
kriszyp
approved these changes
Oct 1, 2026
| ) { | ||
| const unexpected = exitedUnexpectedly(worker); | ||
| // An option rather than a listener on the returned worker, so that `startCopy`'s replacement | ||
| // inherits it — see server/DESIGN.md. |
Member
There was a problem hiding this comment.
These APIs accept a worker type for a permanent scoped stop: for example, shutdownWorkersNow('job') marks the job worker as shut down, does not start a replacement, and leaves processShuttingDown false. This condition then skips settlement, while the boot sweep cannot run until a later process start, so the row remains CREATED or IN_PROGRESS in the live process. Please settle the associated job on permanent targeted stops, or prevent these helpers from stopping job workers without a replacement or process teardown.
This was referenced Oct 4, 2026
Merged
This was referenced Oct 5, 2026
This was referenced Oct 6, 2026
- manageThreads.js became manageThreads.ts on main; the exit-handler options (onUnexpectedExit/onPermanentStop) are ported to the .ts file, reusing main's own captured exitedThreadId. - jobOwnership.ts: keep main's reconcileInterruptedJobsOnce, its no-incarnation guard and its "outcome is unknown" wording; settle() now carries that wording for both settlement paths. - jobRunner.ts: startJobWorker uses main's RUNTIME_SRC_ROOT job path. - The job-settlement note moves to a new server/jobs/DESIGN.md and shares an index line, so main's over-budget DESIGN.md and server/DESIGN.md gain no lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lint Source Code is red on main: DESIGN.md, resources/DESIGN.md and server/DESIGN.md are each over budget. No wording changes: - DESIGN.md: the request-queue shed and request-time notes share a line. - server/DESIGN.md: reflow the hard-wrapped dedicated worker pools note. - resources/DESIGN.md: the same reflow as #3802, so either merge order applies cleanly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… test the job wiring - startWorker re-reads exitedUnexpectedly() after onUnexpectedExit runs, so a handler that begins process shutdown cannot reach the restart branch. - jobOwnership.test.js: startJobWorker's onUnexpectedExit and onPermanentStop each settle a real row (fails with either option removed from jobRunner). - server/jobs/DESIGN.md: onUnexpectedExit fires for every self-initiated exit; a startCopy() replacement re-runs the job (pre-existing); a failed settle waits for the boot sweep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrating test comments AGENTS.md bars new rewire/sinon uses, and each rewire re-ran jobRunner's top level, leaving another START_JOB listener registered. jobRunner now builds the worker options in jobWorkerOptions(), which startJobWorker passes to startWorker, and the test runs each hook from it against a real row. server/jobs/DESIGN.md also names the pre-existing COMPLETE-to-ERROR overwrite by a startCopy() replacement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cb1kenobi
added a commit
that referenced
this pull request
Nov 2, 2026
Resolve server/jobs conflicts with #2763's worker-exit settlement: job workers start through startJobWorker with the transient env, and settleAbandonedJob leaves a deferred (on_restart) job's row to the receipt that settles it instead of marking it ERROR when its worker exits by design. Condense the restore design notes so dataLayer/DESIGN.md fits the 40 KiB budget and the root index stays under its ceiling, per #3857's byte budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
A job runs in its own worker thread, and that worker writes the job's final status. If the thread dies first while Harper keeps running,
get_jobreports the jobIN_PROGRESSuntil the next restart. The boot sweep from Settle jobs left unfinished by a Harper process that is gone (#2645) skips rows the running process owns. @kriszyp raised this gap there.❓ Your call: is settling in-process worth it?
I think yes: without it, a dead job worker reads as running until a restart. The alternative is to accept that and rely on the boot sweep alone, which means reverting this PR.
💡 Solution
CREATEDorIN_PROGRESS, the row becomesERRORwith "Job was interrupted: its worker thread exited before it finished".COMPLETEwithERROR. The design note records it.⚖️ Alternatives
worker.on('exit')in the job runner — a replacement started bystartCopy()would not inherit it. The hook is astartWorkeroption instead.ERRORthat a replacement is about to re-run.❓ Your call: keep
onPermanentStop?Nothing stops a job worker for good while Harper keeps running today, so the option has no production trigger. I kept it because
restartWorkerswithout replacements can reach it. Dropping it is a two-line change.🔧 Changes
server/threads/manageThreads.ts—startWorkertakesonUnexpectedExitandonPermanentStopoptions.exitedUnexpectedly()routes each exit, and the restart decision re-reads it after the hook.runExitHandlercatches a handler that throws or rejects. A permanent stop is marked instopWorkerand inrestartWorkerswithout replacements.whenWorkerStartedcaptures the thread id before it reads-1.server/jobs/jobRunner.ts— both launch paths (main thread,START_JOB) usestartJobWorker(). Its options come from the exportedjobWorkerOptions(), so tests can call the hooks withoutrewireorsinon.server/jobs/jobOwnership.ts—settleAbandonedJob()re-reads the row and settles it only if it is unfinished. The boot sweep sharessettle()andjobTable(). Its ownership filter is unchanged.❓ Your call: retry a failed settle?
A failed settle is logged, and the boot sweep claims the row later. One delayed retry would cover a transient 503. I left it out to keep the exit path simple.
server/jobs/DESIGN.md(new), indexed inDESIGN.md— which mechanism settles a row depends on what survived: the process or only the thread.DESIGN.md,server/DESIGN.md,resources/DESIGN.md— reflowed, no wording changes, to meet the line budgetsmaincurrently fails. Theresources/change is identical to Bring resources/DESIGN.md back under its line budget by reflowing one hard-wrapped note #3802. Easy to drop if you'd rather land it separately.✅ Verification
unexpectedExit.test.jskills a real worker thread (fixture). The hook fires on a crash, on astartCopy()replacement, and not on a deliberate stop. A throwing or rejecting handler is contained.jobOwnership.test.jssettles real job rows, and runs both job-worker hooks. Deleting the hooks fromdistfails those two tests.npm run build: 45 passing in these files andjobRunner; 72 in the otherserver/jobsandmanageThreadstests; 137 passing, 7 pending in the restart-path thread suites. Lint, format,check:design-docsandtscon touched files are clean.test:unit:mainsince the merge withmain, and an end-to-end test of a real job worker dying, which no operation can trigger.🤖 Generated with Claude Code
Release target: main — milestoned v5.4 (the main line); a v5.3 backport of #2645 follow-ups is a customer-need call, not requested
Related PRs: #3802 overlaps (identical
resources/DESIGN.mdreflow; either merge order applies cleanly), #2752 overlaps (DESIGN.md/resources/DESIGN.mdindex and note lines only), #3763 independent, 19 others independentComplexity: medium
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-composer(auth); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=3; full=1 @ 9147a6f
Review-Attention: study ~9m (hot: manageThreads.ts; decisions: do-less-alternative, settle-failure-deferred-to-boot, test-seam-export, rolling-restart-reruns-job, design-budget-reflow-in-pr) @ 9147a6f