Skip to content

Settle a job whose worker thread dies while Harper keeps running - #2763

Merged
cb1kenobi merged 13 commits into
mainfrom
fix/job-thread-death-settlement
Nov 1, 2026
Merged

cb1kenobi merged 13 commits into
mainfrom
fix/job-thread-death-settlement

Conversation

@cb1kenobi

@cb1kenobi cb1kenobi commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

⊙ 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_job reports the job IN_PROGRESS until 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

  • When a job worker exits and its row is still CREATED or IN_PROGRESS, the row becomes ERROR with "Job was interrupted: its worker thread exited before it finished".
  • A rolling restart does not settle the row, because its replacement worker re-runs the job. Process shutdown leaves the row to the boot sweep.
  • Not fixed here, and older than this PR: a dev-mode reload re-runs a running job, and can overwrite COMPLETE with ERROR. The design note records it.

⚖️ Alternatives

  • worker.on('exit') in the job runner — a replacement started by startCopy() would not inherit it. The hook is a startWorker option instead.
  • Settle on every exit, including deliberate stops — briefly marks a job ERROR that 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 restartWorkers without replacements can reach it. Dropping it is a two-line change.

🔧 Changes

❓ 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.

✅ Verification

  • unexpectedExit.test.js kills a real worker thread (fixture). The hook fires on a crash, on a startCopy() replacement, and not on a deliberate stop. A throwing or rejecting handler is contained.
  • jobOwnership.test.js settles real job rows, and runs both job-worker hooks. Deleting the hooks from dist fails those two tests.
  • After npm run build: 45 passing in these files and jobRunner; 72 in the other server/jobs and manageThreads tests; 137 passing, 7 pending in the restart-path thread suites. Lint, format, check:design-docs and tsc on touched files are clean.
  • Not run: full test:unit:main since the merge with main, 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.md reflow; either merge order applies cleanly), #2752 overlaps (DESIGN.md / resources/DESIGN.md index and note lines only), #3763 independent, 19 others independent
Complexity: 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

@cb1kenobi cb1kenobi added this to the v5.3 milestone Sep 23, 2026
gemini-code-assist[bot]

This comment was marked as resolved.

cb1kenobi and others added 6 commits September 23, 2026 22:36
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
cb1kenobi force-pushed the fix/job-thread-death-settlement branch from f46b4fb to d4746b7 Compare September 24, 2026 02:38
@claude

claude Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, good approach!

🤖 Reviewed with Codex

Comment thread server/threads/manageThreads.js Resolved
Base automatically changed from fix/job-boot-reconciliation to main September 28, 2026 18:29
Comment thread server/threads/manageThreads.js Outdated
) {
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kriszyp kriszyp modified the milestones: v5.3, v5.4 Oct 11, 2026
cb1kenobi and others added 5 commits October 30, 2026 14:58
- 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
cb1kenobi merged commit cfb122d into main Nov 1, 2026
73 checks passed
@cb1kenobi
cb1kenobi deleted the fix/job-thread-death-settlement branch November 1, 2026 16:53
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants