refactor: single-result Job plus HandoffJob for one-shot handoff - #22
Merged
Merged
Conversation
Remove the Published type parameter, JobPublisher, the publisher body argument, Job.value(), and publication state. A Job answers one question: the final result once its body and descendants settle. One-shot handoff belongs to the consumer that needs it, composed with an ordinary Job. Closes #21
A HandoffJob's body offers one value mid-execution and suspends until the consumer resumes it. The rendezvous is state released by the Job's own cancellation and closure: no listener, settlement observer, gate Job, or queue. A body that returns without offering fails; a Job that closes without offering rejects its receiver with its failure. Supervisor.run(job) now preserves the submitted Job subtype.
Expose the offered flag directly, name the rendezvous sides by role, and settle a receiver only with a Job failure: a successful close without an offer is already impossible because the body fails first. Replace a tautological failure-identity test with the actual contract (a resumed answer is the body's to interpret), assert receiver and Job share one failure, and cover the unobserved-receiver path.
This was referenced Sep 12, 2026
Merged
miinhho
added a commit
that referenced
this pull request
Sep 12, 2026
Breaking since 0.2.1: Job is a single-result lifetime (#22) — the Published type parameter, the publisher body argument, JobPublisher, and Job.value() are gone; HandoffJob is the one-shot handoff. #24 and #26 change no public behavior. The README is rewritten for a first-time reader: the model in one paragraph, Jobs grouped by ownership / lifecycle / results / failure, cancellation before context, Supervisor and TaskGroup with their policies, HandoffJob last, and an API summary.
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.
Summary
Closes #21 on the runner side.
Jobis a single-result lifetime again.Publishedtype parameter fromJob,Supervisor,GroupMember, andGroupResultsJobPublisher, the publisher body argument,Job.value(), and publication state/completion logicnew Job<Result>(() => Result | PromiseLike<Result>);result(),join(), andawait jobremain the only observationsHandoffJob<Result, Offered, Resumed>models the one-shot, two-way handoff.Handoffandawait handoff.offer(value)suspends it until the consumer answers; the consumer usesjob.receive()andjob.resume(answer)withResolvers, three booleans). It owns no Job, signal, queue, or listener;Job.cancel()andJob.complete()release it with a field check, so no per-Job abort listener or settlement observer is reintroducedSupervisor.run(job)now preserves the submitted Job subtypeThis departs from the issue's "composition first" ordering deliberately: the four required semantics below all bind to Job-internal events (cancellation, closure, body return). Composed outside the runner each consumer would re-add a
result().thenplus an abort listener per Job — the cost #18 removed — and re-derive the same rules.Required semantics (tests/handoff-job.test.ts)
offer()andresume()are each accepted once; a second call throwsTypeErrorreceive()with the composed Job failure (child and body failures aggregated)TypeError; the Job does not succeedoffer()with the original reason; an offer made after cancellation still reaches the consumer and rejects immediately for the body; aresume()after release is discardedreceive()rejects self/ancestor observation synchronouslyA generator-based body was measured and rejected: one
yield/next()round trip costs ~1.2 µs versus ~0.33 µs for a promise rendezvous, async generators queue concurrentnext()calls, and 0..N yields drop the exactly-once guarantee.Measured
Per-Job microbenchmark (Node 24, 400k iterations,
/tmp/job-bench.mjs), vendored 0.2.0 publish build vs this branch:Jobstart → completeThe plain-Job difference is a publication artifact: every Job that never published allocated a
LifecycleStateError(with stack capture) on completion. The handoff round trip is unchanged within noise.Server migration
@tiberjs/servercurrently usesJob<void, ExchangeResponse>andexchange.job.value(). Against this runner:One Exchange Job per request is retained and the transport never awaits settlement. The server working tree is mid-refactor and does not typecheck at the moment, so that migration, its tests, the packed-runner compatibility check, and the request-path benchmark are not included here and remain open before #21 closes.
Verification
pnpm format,pnpm check,pnpm buildpnpm test— 11 files, 124 testspnpm pack—dist/index.d.tsno longer mentionsPublish