fix(node-manager): task cancel liveness and lifecycle hardening - #4219
Open
Apollon77 wants to merge 1 commit into
Open
fix(node-manager): task cancel liveness and lifecycle hardening#4219Apollon77 wants to merge 1 commit into
Apollon77 wants to merge 1 commit into
Conversation
The task layer's abort intent lived only on a per-task gate object that the driver created late, so a cancel arriving before the first phase built its gate was discarded: the task went on to write its intents to the peer after the cancel was accepted, parked on a peer that would never commit, and cancel() never settled. The gate is now created before driving starts, and the driver checks for a recorded abort after admission and again after the phase context is built -- a check at the loop top still let a cancel arriving during the act pass through to a device write. A cancel that overlapped shutdown was lost entirely: the drive unwound without persisting, and cancel()'s continuation then marked the task cancelled in memory and spawned a rollback the dispose drain had already passed, persisting into a mutex about to close. Storage kept the task non-terminal with no rollback, so the next start re-applied exactly what was cancelled. cancel() now refuses with TaskManagerClosingError once the endpoint leaves the active state, and writes the cancelled record and the rollback record in one transaction. A task that fails during teardown is left resumable rather than rolled back outside the drain, and a task type registered during teardown no longer resumes into it. internal.live had no terminal lifecycle, so re-running a task after a cancel returned the cancelled handle: no phase ran, nothing persisted, no error was raised, and the change was never applied until the process restarted. Terminal ids are now reusable, dedup applies only to a live non-terminal task with the same request, a differing request is rejected rather than silently ignored, and terminal tasks are retained as bounded observable history in both memory and storage. Parameter sameness compares storage-shaped values, because the persistence codec drops undefined-valued properties at every depth and leaves an undefined array element as a hole. A commit gate only ever observed success, so an intent the reconciler dropped after an unrecoverable device rejection parked its task forever and blocked the automatic rollback. Such a gate now fails with TaskFailedError. Underneath that, a dropped item announces itself on itemRemoved while the gate watched only itemChanged, so a parked gate had no wake source at all. A gate also evaluated its predicate once before registering the observers it parks on, so a change, removal or reachability flip arriving in between was announced before anything listened and the gate parked with the event it needed already gone. Registration now precedes the first evaluation, which reaches the same coalescing path as any later wakeup, so the window closes without adding a verify-reconcile to every park. A synchronous failure of that first evaluation closes the observers instead of leaking them, and an evaluation completing after the gate settled no longer starts a follow-up reconcile that would race the rollback a cancel spawns next. An externalId supplied by a caller that deduped onto an existing task was discarded, leaving that caller with an id resolving to nothing and a cancel indistinguishable from "nothing to revert". Tasks now carry the set of external ids that claim them, a conflicting claim is refused, and cancel() distinguishes an unknown id from a task with nothing to revert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
|
Tick the box to add this pull request to the merge queue (same as
|
Contributor
There was a problem hiding this comment.
Pull request overview
Hardens Task Manager cancellation, lifecycle, reconciliation gates, deduplication, and retention.
Changes:
- Makes cancellation and rollback persistence lifecycle-aware.
- Adds terminal retention and external-ID deduplication.
- Fixes gate wakeups and rejected-intent handling with expanded tests.
Reported build, formatting, lint, and relevant package tests pass.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
packages/node-manager/src/task/TaskManagerBehavior.ts |
Implements lifecycle, deduplication, rollback, and retention changes. |
packages/node-manager/src/task/RunningTaskContext.ts |
Hardens gate evaluation and item-removal handling. |
packages/node-manager/src/task/Task.ts |
Persists multiple external IDs. |
packages/node-manager/src/task/types.ts |
Updates task status typing. |
packages/node-manager/src/task/errors.ts |
Adds closing and not-found errors. |
packages/node-manager/test/task/CancelRobustnessTest.ts |
Tests cancellation and shutdown races. |
packages/node-manager/test/task/TaskContextGateTest.ts |
Tests gate wakeups, aborts, and failures. |
packages/node-manager/test/task/TaskLifecycleTest.ts |
Tests restart, deduplication, and rollback lifecycle. |
packages/node-manager/test/task/TaskManagerBehaviorTest.ts |
Tests retention and external-ID behavior. |
packages/node-manager/test/task/helpers.ts |
Expands fake reconciliation behavior. |
packages/node-manager/test/task/groups/RotateGroupKeyIntegrationTest.ts |
Makes phase-specific parking assertions robust. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+51
to
+52
| if (value instanceof Map) { | ||
| return [...value.entries()].map(asStored); |
Comment on lines
229
to
+232
| const id = this.internal.registry.idFor(type, params); | ||
| // Driving started now would outlive the dispose drain and write to peers after close, with no way to | ||
| // record what it did. | ||
| this.#refuseIfClosing(`Task ${id} cannot start`); |
| const handle = this.#spawnRevert(task); | ||
| await this.#persist(task); | ||
| return handle; | ||
| await this.#persist(task, revert); |
| if (!TERMINAL_STATES.has(t.progress.state)) { | ||
| return t; | ||
| } | ||
| retained = t; |
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.
Hardening pass over the Task layer's cancel, shutdown and task-lifecycle paths. All defects here are pre-existing (they predate Increment 3) and none were covered by a test. Base is
node-manager, so CI runs via umbrella #3948 — the full local gate below is the only gate this PR itself gets.Defects fixed
Abort intent had nowhere to live but the gate, and the gate was created late.
#abortGatesilently no-ops when no gate entry exists, and the driver only created one inside#drive, after#admitand the first#persist. A cancel arriving in that window was discarded: the task then wrote its intents to the peer after the cancel was accepted, parked on a peer that would never commit, andcancel()never settled — holding the caller's enclosingacttransaction open with it. The gate is now created in#trackbefore driving starts, and the driver re-checks for a recorded abort after admission and again after the phase context is built. That second checkpoint matters: a check at the loop top still let a cancel arriving duringendpoint.actthrough to a device write.A cancel overlapping shutdown was lost.
cancel()had no shutdown guard, and everything after itsawaitran outside whatasyncDisposewaits for: dispose overwrote the gate's abort reason, the drive unwound without persisting, andcancel()'s continuation then marked the task cancelled in memory and spawned a rollback whose driving dispose had already passed over — persisting into a mutex about to close (Mutex.runthrows synchronously once closed). Storage kept the forward task non-terminal with no rollback, so the next start re-applied exactly what was cancelled, with no record the cancel happened.cancel()now refuses withTaskManagerClosingErroronce the endpoint leavesLifecycle.Status.Active, and the cancelled record and rollback record are written in one transaction. A task that fails during teardown is left resumable instead of rolled back outside the drain, and a task type registered during teardown no longer resumes into it.internal.livehad no terminal lifecycle. Ids are deterministic and nothing ever removed entries, so re-running a task after a cancel returned the cancelled handle: no phase ran, nothing persisted, no error was raised, and the requested change was never applied for the rest of the process lifetime — a restart "fixed" it, which is the worst possible signature. The map was also unbounded. Terminal ids are now reusable; dedup applies only to a live non-terminal task representing the same request; a differing request is rejected rather than silently ignored; and terminal tasks are kept as bounded observable history (terminalRetention, default 50) in both memory and storage, with inherited records trimmed at startup.A commit gate only ever observed success. Reconciliation turns an apply error into
commitFailed, and an unrecoverable follow-up drops the item — but the wait predicate looked only forcommitted, so a real device rejection parked its task forever and blocked the automatic rollback. Such a gate now fails withTaskFailedError, which the driver routes into the normal failure path. Underneath that:dropItemannounces onitemRemovedwhile the gate watched onlyitemChanged, so a parked gate had no wake source at all.A gate could park on an event already gone. The predicate was evaluated once before the parking observers were registered, so a change, removal or reachability flip arriving in that window was announced before anything listened. Registration now precedes the first evaluation, which goes through the same coalescing path as any later wakeup — closing the window without adding a verify-reconcile to every park.
A caller's
externalIdwas discarded on dedup. The second caller received a handle carrying the first caller's id, andget()/cancel()on its own id both returnedundefined— indistinguishable from "no such task" and from "nothing to revert". Tasks now carry the set of external ids that claim them, a conflicting claim is refused, andcancel()raisesTaskNotFoundErrorfor an unknown id soundefinedmeans only "nothing to revert".Contracts chosen
(id, parameters)against live non-terminal work only. Parameters compare as storage returns them, because the persistence codec drops undefined-valued properties at every depth and leaves an undefined array element as a hole — comparing raw values rejects an idempotent re-issue after a restart. AMapcompares by entries, since a structural comparison sees no properties on one and would accept any map for any other.Construction.closeapplyingDestroyingbefore the destructor reachesbehaviors.close().liveas history, visible toget()/tasks, still cancellable through their recorded changeSet, never driven again.Refuted while investigating
close()begins everyendpoint.actthrowsDestroyedDependencyError, so such a task fails on its next act rather than reaching a phase and parking. The real (smaller) defect was shutdown turning a resumable task into an in-memoryfailed; that is fixed.Set.addof a present member leavessizeunchanged;#findreads onlyliveand#resumeTypeskips terminal records). Two other reachable ones were found and fixed:terminalRetentionof0made every write evict the id it was storing, and a terminal write could queue an id a live re-run already owned.Verification
Every fix was written test-first and mutation-proofed per hunk (revert the hunk, confirm red, restore). Notable reds:
Test timeout: Promise did not resolve within one (virtual) hourat thecancel()await; a device write present after an accepted cancel; and a test-suite crash from an unhandled rejection when#spawnRevertthrew out of#drive's catch.Three review rounds: the implementer's own, an independent adversarial pass over the cumulative diff with an assumption-free prompt, and a further adversarial pass. The independent pass caught that three tests claiming to cover the new
itemRemovedsubscription did not — the fake peer dropped the item during the initial evaluation, so the gate returned before observers registered and deleting the production line left all three green. Those now reach a parked gate.FakePeeralso now models the reconciler's real two-pass recoverable/unrecoverable behaviour.Gates (repository root):
No CHANGELOG entry: the package is unreleased on this branch and the entry lands at umbrella merge. No type casts added in production code.
Known-weak, stated plainly
progress.state, because nothing observable is persisted in that scenario. It also no longer uniquely pins the post-admission abort check — the closing guard produces the same outcome, and the only remaining discriminator is a log message.Carried forward, not in this PR
#resumeTypebypasses the parameter, cancelling, pending-revert and resourceKey guards that#spawnenforces (only the closing guard was added here). Reachable when the paired persist in#drive's catch fails while the rollback's own persist succeeds: a forward task and its own rollback then both resume non-terminal and write opposing intents. Tracked for the next hardening PR together with the abandoned in-flight evaluation on abort.Reconcile-layer defects found in passing, untouched here:
planActionsmaps a failed remove toretry, which callsapplyand re-adds the item a rollback was deleting;ReconcilerBehavior.#wirePeerignoresitemRemoved, so a drop triggers no reconcile pass;defaultRecoverable(0)treats any non-Matter-status apply exception as unrecoverable.🤖 Generated with Claude Code