Skip to content

feat(ts): add BackgroundTaskEngine - #3838

Draft
gautamsirdeshmukh wants to merge 1 commit into
strands-agents:mainfrom
gautamsirdeshmukh:gsird-task/background-task-engine
Draft

feat(ts): add BackgroundTaskEngine#3838
gautamsirdeshmukh wants to merge 1 commit into
strands-agents:mainfrom
gautamsirdeshmukh:gsird-task/background-task-engine

Conversation

@gautamsirdeshmukh

@gautamsirdeshmukh gautamsirdeshmukh commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Description

This PR extracts the first isolated Background Tasks implementation slice planned in #2496: the internal BackgroundTaskEngine from #3632. Reviewing it separately keeps the bounded-execution and lifecycle contract independent from the later TaskManager, plugin, Agent, hook, and tool-execution integration.

The engine preserves #3632 behavior for idempotent admission, bounded execution, execution-scoped cancellation and timeouts, pause/resume, persisted-state recovery, observation, and drain/cancel shutdown. This slice changes no dependencies, package exports, public barrels, Agent wiring, or reachable runtime behavior.

Only the generic StoredEngineTask durable record is introduced here. The later TaskManager can specialize and adapt that record into the public BackgroundTask view; this split deliberately introduces neither BackgroundTask nor a second stored-task record type.

Relative to #3632, the only behavior changes are narrow integrity corrections found while isolating and adversarially testing the engine: caller-, executor-, and observer-owned values are snapshotted; persisted lifecycle invariants are validated; timestamps must use a valid ISO-8601 shape and calendar date; timer delays are capped at the platform maximum; invalid task data fails that task without poisoning unrelated work; actual persistence-hook failures stop the engine while still allowing shutdown cleanup; observation and shutdown waiters are cleaned up; shutdown options are always validated asynchronously; and initialization after shutdown is rejected. Concurrent shutdown calls retain #3632's first-valid-call-wins mode and deadline rather than adding escalation behavior, and the extraction does not add duplicate restored-record identity policy.

This slice intentionally retains #3632's synchronous, pre-commit onTaskUpdated hook because its consumer writes to agent.appState synchronously. The async pluggable Storage described in #3531 is not implemented by #3632 and requires a separate persistence contract in a later manager/storage slice.

Related Issues

Part of #2496

Parent Background Tasks implementation: #3632

Merged execution-scoped cancellation prerequisite: #3807

Parent feature design (proposed): #3531

Documentation PR

No documentation changes are needed because this split is internal and adds no public API.

Type of Change

New feature

Testing

  • npx vitest run src/background-tasks/engine/**tests**/engine.test.ts (22 tests in Node and 22 in Chromium)
  • npm run build -w strands-ts
  • npm run lint -w strands-ts
  • npm run format:check -w strands-ts
  • npm run type-check -w strands-ts
  • npm run complexity
  • npm run complexity -- --base upstream/main (complexity/low, max 10)
  • git diff --check upstream/main

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@github-actions github-actions Bot added area-async Related to asynchronous flows or multi-threading area-language Related to new language development typescript Pull requests that update typescript code enhancement New feature or request complexity/medium Touched functions have moderate cognitive complexity (11-25) size/l strands-running labels Aug 15, 2026
Comment thread strands-ts/src/background-tasks/engine/types.ts
Comment thread strands-ts/src/background-tasks/engine/engine.ts
Comment thread strands-ts/src/background-tasks/errors.ts Outdated
Comment thread strands-ts/src/background-tasks/engine/engine.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Assessment: Comment

Cleanly-scoped, internal-only slice — no barrel exports, dependencies, or Agent wiring change, so the "no public API" claim holds and no API bar-raising label is required. The engine's lifecycle model (idempotent admission, bounded concurrency, snapshot isolation, execution-scoped cancel/timeout, pause/resume, drain/cancel shutdown) is well-structured and the test suite covers the main paths thoroughly. The feedback below is about hardening one contract and tightening tests/docs, not the design.

Review themes
  • Testing: Assertions lean on expect.objectContaining throughout; docs/TESTING.md calls for whole-object toEqual so unexpected/regressed fields are caught. StoredEngineTask is flat enough to assert fully with expect.any(String) for the UUID/timestamps.
  • Contract robustness: The persistence-failure path (a throwing onTaskUpdated) doesn't clearly match the description's "stop the engine coherently," and isn't tested. Worth pinning down the intended behavior and covering it.
  • Documentation: types.ts interface properties need the required single-line descriptions — especially the onTaskUpdated vs onEvent contract difference and the timeout: Infinity sentinel.
  • Consistency: BackgroundTaskNotFoundError is missing the @internal tag that every other symbol in the slice carries.

Nice work isolating this from #3632 — the adversarial snapshot/recovery integrity tests are a strong addition.

@gautamsirdeshmukh

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent review please

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified at 43f0ef7 (branch pr-3838): ✅ npx vitest run --project unit-node src/background-tasks22/22 · ✅ eslint / prettier --check / tsc --noEmit --project src/tsconfig.json clean · ✅ 4 repros and 3 test mutations re-run independently rather than relayed · ✅ two suggested fixes applied and re-tested (one lands 22/22 with a 2-line test update; one doesn't work — flagged inline) · ❌ unit-browser not run (no Playwright here) — this file has no env suffix, so CI runs it in Chromium too, and the cloneable set differs there.

🔴 Changes requested. Good slice — the state machine itself held: I couldn't violate either new ! assertion, hang a waiter, leak a listener, or produce an unhandled rejection, and the suite is genuinely strong (whole-object toEqual throughout). The new failure latch is the part that needs another pass: it can't distinguish "the store is broken" from "this one task's data is bad", so one task's result takes down unrelated tasks and leaves shutdown() rejecting forever — including the un-caught shutdown inside #3632's _reloadFromAppState, i.e. the recovery path itself.

  • 🔴 engine.ts:495-503 — a per-task validation/clone fault latches the whole engine: a healthy sibling's waiter is rejected, its record stuck at working, cancel/shutdown throw. #3531 specifies per-task rejection.
  • 🔴 engine.ts:388 — a completed outcome with an undefined result latches the engine; type-checks clean, and the obvious delete record.result fix does not fix it (verified — record.ts:89 wants it defined).
  • 🟡 ×4 — callback sees uncommitted state + re-entrant writes clobbered; shutdown() sync throw and dropped escalation; requireDate canonical-only restore; the integrity behaviors' test gap (3 surviving mutations).

Reachability, plainly: nothing imports the engine at merge, so zero user impact today — no 🔴 here can break the shipping SDK. I'm still tiering them 🔴 for this PR rather than "fix in slice 4", because this PR is the review of that design, the consumer already exists (#3632 in-process-task-manager.ts:63-72 instantiates Result from arbitrary tool output; :208 awaits shutdown un-caught), and today's policy is asserted in tests (engine.test.ts:273-299) so it reads as settled once merged. Both fixes are ~10 lines in code this PR introduces. Landing as-is is defensible given the zero reach — but then these two want tracking issues, not silence.

(blocking) onTaskUpdated is synchronous (types.ts:14), while #3531 advertises a pluggable Storage (S3Storage example) that "persists the task before returning the dispatch acknowledgement". How does an async store meet that through a sync hook — does the engine contract change in a later slice, or does durability stay behind appState? Reviewing the engine in isolation is the moment to pin it.

Per-pass breakdown, 3 non-blocking questions, and the non-blocking appendix (18)

Passes. Triage routed 4 of 7: correctness/safety, issue-alignment, adversarial, test-quality. api-bar-raiser / llm-context / docs-accuracy were routed out (internal-only slice, no public API, no model-facing text, no docs). The adversarial pass ran on the default model tier after an advanced-tier infrastructure failure, and the test-quality pass is a time-boxed re-run after a first-attempt timeout — both completed against 43f0ef7, but treat their coverage as slightly shallower than usual.

  • correctness/safety — changes requested, 1 blocker (latch scope) + 2 🟡 (requireDate, shutdown sync throw). 13 items verified OK, including: both new ! assertions are statically sound and the two guards removed in the fix commit were unreachable defense-in-depth; _failEngine never hangs a waiter (waitForIdle/shutdown reject rather than pend); timer bounds exact at 2³¹−1; initialize rollback leaves no dirty state; pause/resume identity semantics match the docs; stale timeout timers can't misfire.
  • issue-alignment — aligned, with under-disclosure. Exactly epic #2496 item 3, nothing outside background-tasks/ (git diff --stat = 5 files, +1855/−0; no dep, barrel, exports, or Agent change), and #3632 already performs the record adaptation this PR promises. But the PR's "the only behavior changes are narrow integrity corrections" doesn't hold: the failure latch is new design that #3531 contradicts, and 5 dropped behaviors + 2 strictness changes go unmentioned.
  • adversarial — broke-it: 4 landed attacks (1 engine-killing, 3 correctness/contract), 9 held. Held: cancel racing completion, completion after timeout, resume after cancel, executor self-re-entrancy, onEvent('admitted')cancel, forcing both ! assertions, AbortSignal listener leaks (55 waits, 0 leaked), unhandled rejections (4 scenarios), and a seeded 40-task lifecycle fuzz.
  • test-quality — gaps-found: 6 of the 13 new behaviors are mutation-proof, 1 has no test at all, and the headline behavior (the latch) is half-proven. The earlier review's objectContaining point is fixed — zero objectContaining/toMatchObject/toBeTruthy in the file. Isolation is clean (module-level engines Set + afterEach, UUID ids, no fake-timer leakage), and the three real-timer budgets are deterministic, not flaky.

Questions (non-blocking)

  1. Can the body list the 5 behaviors dropped vs #3632 and the 2 strictness changes? (dropped _execute re-check guard, resume's not-found throw, initialize's up-front clear(), trailing _pump(), submit emitting the live stored object at :117; plus assertTimerDelay on the user-supplied config.timeout and requireDate's canonical requirement.) The delta currently reads as purely additive.
  2. Is slice 4 (TaskManager) expected in the same release, so nothing ships unreachable to users? A "unreachable until slice 4" line makes it a knowing merge — I found no prior strands-ts PR whose entire content was unreachable at merge (closest: sandbox/stream-process.ts, #1090 → consumers in #1110).
  3. Is wait() resolving on paused the intended contract (isSettled, :572-574)? A waiter returns before the task is done and .result is undefined; if deliberate, an @returns note covers it, otherwise waitForSettled reads truer. Related: should Descriptor/Result/State be bounded (extends JSONValue) instead of leaving structured-cloneability unexpressed? That's the shared root cause of both 🔴s.

Appendix — non-blocking (18)

Engine behavior: structuredClone strips prototypes/accessors (:109, :569) and Interrupt/InterruptState are classes (src/interrupt.ts:28, :169) — #3632 uses InterruptStateData as State, so an instance type-checks and reaches the resumed executor de-prototyped · a storage failure with no waiter is swallowed and the engine logs nothing (logger.error in _failEngine per strands-ts/AGENTS.md:73-80 would close it) · waitForIdle ignores an already-aborted signal when idle (:536) where wait() throws (:187) · re-entrant submit from onTaskUpdated defeats idempotency (:100-103) · validateStoredEngineTask is unsound for untrusted objects (prototype-chain reads at record.ts:16), safe only because callers clone first · stale snapshots handed out (submit after an observer cancel; executionFinished for a pending-removal task).

Scope / conventions: submit emits the live stored object (:117) where #3632 snapshotted — safe only because _emit clones, and it's the one emit path with no isolation test · failure.type is an open string while the public union is closed, and engine.ts:7 hardcodes 'executionError' · none of the 8 throwing methods carry TSDoc against the @throws rule (strands-ts/AGENTS.md:150); the earlier fix covered interface properties only · MAX_TIMER_DELAY is a new convention, unique in the codebase (swarm.ts:334, graph.ts:325 don't bound delays; graph defaults maxConcurrency to Infinity).

Verification gap: unit-browser unrun — a Buffer in a descriptor/result/state clones in Node but not Chromium, so the same latch would be green in one project and red in the other.

Test nits: updatedAt never proven to advance (:92, mutation survives) · global setTimeout spy fragile and load-bearing, mockRestore outside finally (:624,639) · zero nested describes + two mega-tests (:203, :478) vs docs/TESTING.md:104-136 · toContain('executionFinished') (:474) allows dropped/duplicated events · constructor lower bounds untested (:642 covers only the upper) · waitForStatus microtask-only spin with retry: 2 masking flakes · private-field peeks (:356,932).

No pre-existing bugs to file — every finding is in code this PR introduces.

Comment on lines +495 to +503
private _persistTask(task: StoredEngineTask<Descriptor, Result, State>): void {
try {
validateStoredEngineTask(task)
this._config.onTaskUpdated?.(snapshot(task))
} catch (error) {
this._failEngine(error)
throw error
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 _persistTask can't tell "the store is broken" from "this one task's data is bad", so one task's result destroys every other task in the engine.

Verified at 43f0ef7 (unit-node) — task B returns {status:'completed', result:{fn:() => 1}} while unrelated healthy task A is mid-flight:

A's waiter          => REJECTED DataCloneError: () => 1 could not be cloned.
A in memory/store   => working            (forever)
cancel(A)           => THROWS DataCloneError
shutdown({cancel})  => THROWS DataCloneError   (both modes, permanently — _throwIfFailed at :419)

The engine already gets this right one function up: the same bad value in a descriptor is cloned at :109, outside _persistTask, so submit throws cleanly and the engine keeps working (verified). Only the update path escalates a per-task fault to engine death.

Two consequences I'd rather flag than assert:

  • docs: add Background Tasks design #3531 specifies the opposite policy — "If persistence fails, the task is not accepted and the model receives an error instead of a task ID" — per-task rejection, not engine-wide death.
  • the next slice can't recover from it: in-process-task-manager.ts:208 (feat(ts): add Background Tasks mechanism #3632) awaits this._engine.shutdown({mode:'cancel', …}) un-caught inside _reloadFromAppState, the only place that rebuilds a fresh engine — so a latched engine makes the escape hatch throw too.

Validation is per-task and runs before anything is committed (_tasks.set at :463), so it can throw to the caller exactly like submit does, leaving the latch for the genuinely engine-wide case (onTaskUpdated failing):

Suggested change
private _persistTask(task: StoredEngineTask<Descriptor, Result, State>): void {
try {
validateStoredEngineTask(task)
this._config.onTaskUpdated?.(snapshot(task))
} catch (error) {
this._failEngine(error)
throw error
}
}
private _persistTask(task: StoredEngineTask<Descriptor, Result, State>): void {
validateStoredEngineTask(task)
try {
this._config.onTaskUpdated?.(snapshot(task))
} catch (error) {
this._failEngine(error)
throw error
}
}

Pair that with dropping _failEngine(error) from the clone catch at :456-461 (bare rethrow), and with exempting _shutdownEngine's _throwIfFailed() (:419) so a latched engine can still be closed.

Blast radius, since I ran it: this policy is deliberately asserted today — engine.test.ts:273-299 asserts both the stuck-at-working record and the permanently-rejecting shutdown(). With the change above, exactly that one test fails (21/22), so the test moves with the policy. Which is the real question: is "one bad result → whole engine unrecoverable" the contract you want, or is per-task failure (mirroring the execute-throw handling at :331-340) closer to intent?

Comment on lines +386 to +388
record.status = 'completed'
delete record.attemptId
record.result = outcome.result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 A task that completes with no result kills the engine — and the obvious fix doesn't work.

:388 writes record.result = outcome.result unguarded. If Result admits undefined (BackgroundTaskEngine<D, void, S> type-checks — the generics are unconstrained), validateCompleted (record.ts:89) throws task.result is required while completed inside _persistTask_failEngine. Verified at 43f0ef7, vitest type-check clean on the repro:

wait:         Error: task.result is required while completed
later submit: Error: task.result is required while completed
shutdown:     Error: task.result is required while completed
get():        status "working"      <- a task that finished normally, stuck forever

Same shape via resume (:237record.ts:69): resume(id, () => ({ state: undefined, ready: true }))task.state is required while queued for resumption, engine latched.

Don't mirror the failed branch (delete record.result, :379-383): I applied exactly that patch and re-ran — the failure is unchanged, because validateCompleted requires result to be defined and deleting the property leaves it undefined too. The underlying disagreement is inside the PR: types.ts:76-77 documents result?: Result as "Optional execution result retained for completed or failed tasks", while record.ts:89 makes it mandatory.

Pick one:

  • resultless completion is legaldelete record.result here and drop the record.ts:89 requirement;
  • completed always carries a result → bound the generic so Result cannot admit undefined (e.g. Result extends JSONValue, matching StoredEngineTask's default at types.ts:59), making this a compile error rather than a runtime engine death.

Either way it should stop being engine-fatal — see the _persistTask comment.

(No caller today, and #3632's Result = ToolResultBlockData doesn't admit undefined — this is a type-legal trap for the next instantiation, e.g. a fire-and-forget task with no payload.)

Comment on lines +462 to +463
this._persistTask(stored)
this._tasks.set(taskId, stored)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 The task map is updated after the persistence hook, so a callback observes a task the engine denies exists — and any write it makes is discarded.

_persistTask (→ onTaskUpdated) runs at :462, this._tasks.set(taskId, stored) at :463; submit has the same order (:115-116). Verified at 43f0ef7: during admission the hook's own record isn't in the map — engine.get(task.taskId)undefined, engine.cancel(task.taskId)BackgroundTaskNotFoundError for the task it was just handed. On a terminal record the natural reaction — engine.remove(taskId), which #3632's manager pairs at in-process-task-manager.ts:163-164 from a later call site — throws cannot be removed before reaching a terminal status, because the map still says working. Un-caught inside the hook, that throw becomes engine death.

Second half of the same root cause: _updateTask snapshots at :452 and unconditionally writes that snapshot at :463, so anything the callback chain wrote in between is silently rolled back. Three verified cases: cancel() from onTaskUpdated on the queued→working write returns a cancelled record and the task completes anyway; cancel() from resume's own update callback makes the task execute a second time (runs: 2); shutdown({mode:'cancel'}) from onTaskUpdated waits on a task it believes it cancelled → shutdown timed out, record left working.

The two fixes have to be designed together: simply swapping :462/:463 would defeat the "nothing is committed when validation fails" property the _persistTask comment relies on. The shape that satisfies both is validate → commit → notify: validateStoredEngineTask(stored), then this._tasks.set(taskId, stored), then the onTaskUpdated call (still latching if it throws).

If re-entrancy is out of contract instead, that's a fine answer — it just needs writing down: one line on onTaskUpdated / onEvent (types.ts:13,15) saying a callback must not call back into the engine. Nothing hints at it today and the failure is silent.

Comment on lines +244 to +252
shutdown(options: { readonly mode: 'drain' | 'cancel'; readonly timeout: number }): Promise<void> {
if (this._shutdown) return this._shutdown
assertTimerDelay('shutdown timeout', options.timeout)
this._shutdown = this._shutdownEngine(options).catch((error: unknown) => {
this._shutdown = undefined
throw error
})
return this._shutdown
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 shutdown() validates before a promise exists and memoizes before it looks at options, so cleanup code gets a synchronous throw and an escalation is silently dropped.

Verified at 43f0ef7:

shutdown({mode:'drain', timeout: 0})       -> SYNC THROW TypeError: shutdown timeout must be a positive finite integer, got 0
drain = shutdown({drain, 300}); force = shutdown({cancel, 10})
  same promise identity: true
  force settled after ~300ms -> Error: Background Task Engine shutdown timed out after 300ms
  task after "forceful" shutdown: working      <- never cancelled
shutdown(NaN) as a second call: no error       <- validation skipped by the memo

The sync throw escapes shutdown(opts).catch(handle) and Promise.allSettled(engines.map((e) => e.shutdown(opts))) — the shape this suite's own afterEach uses (engine.test.ts:31-34). Validating before the memo check and making the method async fixes both halves:

Suggested change
shutdown(options: { readonly mode: 'drain' | 'cancel'; readonly timeout: number }): Promise<void> {
if (this._shutdown) return this._shutdown
assertTimerDelay('shutdown timeout', options.timeout)
this._shutdown = this._shutdownEngine(options).catch((error: unknown) => {
this._shutdown = undefined
throw error
})
return this._shutdown
}
async shutdown(options: { readonly mode: 'drain' | 'cancel'; readonly timeout: number }): Promise<void> {
assertTimerDelay('shutdown timeout', options.timeout)
if (this._shutdown) return this._shutdown
this._shutdown = this._shutdownEngine(options).catch((error: unknown) => {
this._shutdown = undefined
throw error
})
return this._shutdown
}

Verified: with this change plus a two-line test update (make it('rejects timer delays above the platform limit') async and await expect(...).rejects.toThrow(...) at engine.test.ts:656), the suite is 22/22 and tsc --noEmit is clean.

Left open deliberately: a second call's mode is still ignored, so drain on SIGTERM → cancel on a deadline is a no-op (sequential escalation after a rejection does work — the memo is cleared at :248). Worth either honouring the escalation or documenting that the first call wins.

requireString(value, path)
const date = new Date(value)
if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
throw new Error(`${path} must be a canonical ISO-8601 timestamp`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 requireDate accepts only Date.prototype.toISOString() output, which makes restore all-or-nothing against any store that normalizes timestamps.

:142 compares strings, not instants. Verified acceptance set: 2024-01-01T00:00:00.000Z ✅; 2024-01-01T00:00:00Z, 2024-01-01T00:00:00.000+00:00, 2024-01-01T00:00:00.123456Z (Python datetime.isoformat()), 2024-01-01 all ❌. initialize() then throws and rolls back (engine.ts:83-88), so no task is recoverable — every persisted task is lost on restart. Clean error rather than corruption, and #3632 accepted anything Date.parse handled, so this reads as a compatibility decision to state in the PR body rather than a defect.

One correction to the obvious fix, since I tried it: don't relax to Date.parseDate.parse('08/15/2026') succeeds, Date.parse('2026-02-30T00:00:00.000Z') succeeds by rolling to Mar 2, and …123456Z silently truncates to 123 ms; you'd lose two real rejections that engine.test.ts:853-854 cover. So either keep the strict check and make the contract legible where a store author reads it (types.ts:83,85 say only "Canonical ISO-8601", which doesn't answer "is +00:00 ok?"):

Suggested change
throw new Error(`${path} must be a canonical ISO-8601 timestamp`)
throw new Error(`${path} must be a canonical ISO-8601 UTC timestamp in 'YYYY-MM-DDTHH:mm:ss.sssZ' form`)

(verified: 22/22 still green, prettier clean) — or accept ISO-8601 shape variants (offset, extra precision) while still rejecting junk and impossible dates. Cross-SDK parity pushes toward the latter: a Python-written store will emit microseconds (root AGENTS.md:49-54).

)
})

it('stops execution when task storage fails', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 The slice's integrity behaviors are its least-tested part — three single-line mutations survive the suite.

Re-verified two myself at 43f0ef7:

  • deleting validateStoredEngineTask(task) from _persistTask (engine.ts:497) → 22/22 still pass. That's "validate on every persist", it runs on every write, and nothing exercises it: engine.test.ts:610,785 call the validator directly, which keeps passing.
  • deleting if (record.result === undefined) throw … from validateCompleted (record.ts:89) → 22/22 still pass. Same for the failed / cancelled "required" rules; the table at :788-855 covers contradictions but not omissions — one array row each is the cheapest patch in this review.
  • from the test pass (not re-run here): removing _throwIfFailed() from initialize / submit / cancel / wait / resume (engine.ts:43,96,150,185,223) → 22/22. This test proves the latch trips, but never calls a mutating API after it trips, and never asserts the side effects (siblings aborted with the error, queue cleared).

Two drop-in extensions:

// after `await expect(terminalEngine.waitForIdle()).rejects.toBe(terminalError)`
expect(() => terminalEngine.submit({ descriptor: { value: 'again' } })).toThrow(terminalError)
expect(() => terminalEngine.cancel(terminal.taskId, { reason: 'x' })).toThrow(terminalError)
await expect(terminalEngine.wait(terminal.taskId)).rejects.toBe(terminalError)
// persist-time validation, currently unproven
const engine = initialize(createEngine(async () => ({ status: 'paused', state: { phase: 'p' } })))
const task = engine.submit({ descriptor: { value: 'work' } })
await engine.wait(task.taskId)
expect(() => engine.resume(task.taskId, () => ({ state: undefined as unknown as TestState, ready: false })))
  .toThrow('task.state is required while paused')

Two of the review's findings live inside exactly this code, which is why it's worth closing before the next slice depends on it.

@gautamsirdeshmukh
gautamsirdeshmukh force-pushed the gsird-task/background-task-engine branch from 43f0ef7 to 6f06b79 Compare August 15, 2026 20:18
@github-actions github-actions Bot added complexity/low Touched functions have low cognitive complexity (<=10) and removed complexity/medium Touched functions have moderate cognitive complexity (11-25) labels Aug 15, 2026
@gautamsirdeshmukh

Copy link
Copy Markdown
Contributor Author

@strandly-the-agent have another look, thanks

@github-actions

Copy link
Copy Markdown
Contributor

Re-review at 43f0ef7 — thanks for the quick turnaround.

Of my four original threads, three are resolved by this commit (property docs incl. the timeout: Infinity sentinel and the onTaskUpdated/onEvent contract; @internal on BackgroundTaskNotFoundError; the persistence-failure coherent-stop + its tests). The clone-count nit remains and stays non-blocking. My earlier objectContaining testing point is also fully resolved — the suite now asserts whole objects.

Assessment: Comment (deferring to the standing Changes requested). I concur with @strandly-the-agent's blocker: the new failure latch is the right instinct but wrong scope — it can't separate "store is broken" from "this task's data is bad," so one task's fault stops unrelated tasks and leaves shutdown() rejecting (including the un-caught shutdown on #3632's recovery path). Pinning the sync-onTaskUpdated vs async-Storage (#3531) contract now, while the engine is reviewed in isolation, is the right call. No new findings to add from my side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-async Related to asynchronous flows or multi-threading area-language Related to new language development complexity/low Touched functions have low cognitive complexity (<=10) enhancement New feature or request size/l typescript Pull requests that update typescript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants