Skip to content

[WIP] Node Manager: desired-state, reconciliation & multi-step tasks - #3948

Draft
Apollon77 wants to merge 89 commits into
mainfrom
node-manager
Draft

[WIP] Node Manager: desired-state, reconciliation & multi-step tasks#3948
Apollon77 wants to merge 89 commits into
mainfrom
node-manager

Conversation

@Apollon77

Copy link
Copy Markdown
Collaborator

Umbrella / integration branch for the Node Manager feature. Long-lived WIP PR — each phase merges into node-manager via its own sub-PR; this PR is the CI backstop for the integrated branch against main. Do not merge until all phases land and it's de-WIP'd.

Design doc: docs/superpowers/specs/2026-06-14-node-manager-desired-state-design.md

What this builds

A controller-side layer holding the intended state of fabric nodes (certs/keys/IPK, group keys, bindings, ACLs, group membership), with offline-tolerant reconciliation and multi-step orchestration (e.g. group-key rotation). Modeled on the JointFabric Datastore cluster (0x0752) and generalized.

Phase checklist

  • Phase 1 — Tier-1 desired-state model (@matter/node): ManagedItem/StatusEntry, ItemKind registry, capacity admission + typed errors, persistent DesiredStateBehavior on ClientNode (Node Manager Phase 1: Tier-1 desired-state model #3946)
  • Phase 1b — live drift detection via subscriptions (watchPaths)
  • Phase 2 — Reconciler + Task layer (new @matter/node-manager pkg): triggers, settle delay, verify-barrier, priority ordering, capacity reads, concrete ItemKinds; RotateGroupKey/MoveNodeToGroup; changeset rollback; 2-node rotation harness
  • Phase 3 — JFDS 0x0752 facade
  • Phase 4 — policy/optimizer (ACL merge, CAT grants, world-reconcile)
  • Phase 5 — developer API (Bindings/Groups/Scenes)

Carry-forwards tracked for Phase 2

  • Capacity cache → make ephemeral (refresh on connect / subscription re-establish), not persisted; cache limit only, derive used fresh at admission.
  • itemMapKey separator unescaped — escape/document before non-identifier keys.
  • Public-API surface: confirm public vs internal for the Tier-1 barrel exports when the Reconciler package forces the boundary.

🤖 Generated with Claude Code

Apollon77 and others added 17 commits June 19, 2026 13:52
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…edStateBehavior

Drop the second `, unknown` type param from both Observable declarations in
DesiredStateBehavior.Events to match the codebase convention. Convert the two
test handlers that return number (Array.push) to block bodies so they type-check
under the stricter void return. Remove the inline WHAT comment above static schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add export * from "./desired-state/index.js" to system behavior barrel
- Add DesiredStateBehavior to ClientNode.RootEndpoint.with(...)
- New DesiredStatePersistenceTest: verifies registration on ClientNode and
  intent persistence across a node restart via shared Environment storage
- Update PEER1_STATE in ClientNodeTest to include desiredState initial state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add test coverage for GroupCapacityExceededError mapping and remove
the unused unknownKindMapped fixture entry from the cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-emit a peer's BasicInformation softwareVersion change as a node-level
lifecycle signal, wired into Peers BasicInformation instrumentation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure status x mode branch-table function, internal to the package
(consumed by the reconciler via #-import, not part of the public API).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opt-in ServerNode-root behavior driving reachable peers toward intended
state: pure planActions decision + executeActions executor, six triggers
(settle, sweep, peers add/del, subscription-active, intent-change,
software-version-change), per-peer in-flight guard, capacity refresh, and
asyncDispose cleanup. Reachability gated on an active sustained subscription.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion

- Wire per-peer trigger handlers directly on the peer ObserverGroup instead
  of via this.callback, so they are torn down on peer removal (no reactor
  leak on peer churn).
- #reachable mirrors NetworkClient.subscriptionActive: a sustained
  subscription counts as reachable only once active, not merely created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconciler engine: @matter/node-manager package, ReconcilerBehavior
(opt-in ServerNode-root, planActions decision + executeActions executor,
six triggers, per-peer in-flight guard, capacity refresh, asyncDispose),
plus @matter/node ephemeral capacity cache and softwareVersionChanged signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apollon77 and others added 12 commits June 22, 2026 13:43
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rity bands

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng, dispose race

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ard test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… tradeoff

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… unread

Pre-flight admission stays meaningful instead of failing open; the device
write remains the authoritative gate for over-capacity (RESOURCE_EXHAUSTED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apollon77 and others added 12 commits July 6, 2026 16:02
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lFailed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… redundant comment

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g on stale state

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vert

A slow forward apply() could write "committed" after a concurrent revert had
flipped the intent to "deletePending", resurrecting the item the revert removed
and silently undoing auto-rollback. Re-read live state after apply()/remove()
and skip the status write when the intent transitioned, so a concurrent revert
(or re-add) wins. Backstopped by the itemChanged follow-up reconcile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Commissioned-peer scenarios: auto-rollback on hard failure, cancel revert
handle, create-if-absent key-set provisioning, parked-revert resume across
controller restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	packages/node/src/node/client/Peers.ts
#	packages/node/test/behaviors/group-key-management/GroupKeyManagementServerTest.ts
#	packages/node/test/node/ClientNodeTest.ts
#	packages/node/test/node/EventReadResponseTest.ts
#	packages/node/test/node/NodePhysicalPropertiesTest.ts
Apollon77 and others added 12 commits July 9, 2026 10:11
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o invariant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prettier import ordering regressed when relative harness imports were
repointed to @matter/node/testing during the main->node-manager merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he, not fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssion

GroupMembershipItemKind.capacity counts distinct groups (groupTable length)
against maxGroupsPerFabric, but admission counts per-endpoint membership keys
(groupId:endpoint). Joining an already-mapped group on a new endpoint at the
group-count limit would false-reject a valid operation. The group slot is
already gated by GroupKeyMapItemKind; add ItemKind.excludeFromAdmission so a
kind whose capacity counts a coarser resource opts out of admission while still
serving the reconciler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	packages/node/test/node/ServerSubscriptionTest.ts
* feat(node-manager): groupKey write-if-set-differs (enables key rotation)

* feat(node-manager): TaskContext.peersWithIntent for member derivation

* feat(node-manager): RotateGroupKey task (gap-free 3-phase group-key rotation)

* test(node-manager): 2-node group-key rotation harness (happy/park/restart/drift)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(node-manager): set revertOf at task creation, not post-run

Thread revertOf into the persisted seed passed to TaskRegistry.create so the
revert task's first persisted record already has it, instead of patching it
onto the live task object after run() has already tracked/driven it.

* test(node-manager): correct revertOf test comment to not overclaim

The comment implied these assertions prove the pre-fix post-hoc assignment
was racy; they don't discriminate old vs new code in the current call graph
(revertOf is set synchronously before the revert's own first persist either
way). Reword to describe the invariant actually being checked.

* refactor(node-manager): narrow RunningTaskContext to ReconcilerSurface

RunningTaskContext only ever calls itemKind() and reconcile() on the
reconciler it's given, not the rest of ReconcilerBehavior's surface. Type
the ctor param against a new 2-method ReconcilerSurface interface instead,
so fakes in tests can implement it directly instead of casting through
`as unknown as ReconcilerBehavior`. ReconcilerBehavior satisfies the new
interface unchanged.

* fix(node-manager): rotation is forward-only once cleanup begins (decline late cancel)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(node-manager): per-rotation id + revert only during distribute (PR review)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(node-manager): serialize rotations per key set (reject concurrent rotation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(node-manager): rotation requires single-key steady state (validate, refuse loudly)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(node-manager): close cancel race at the distribute→activate boundary

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/node/test/behavior/state/managed/values/StructManagerTest.ts
#	packages/node/test/behaviors/ota/OtaTest.ts
#	packages/node/test/node/ClientTuningTest.ts
#	packages/node/test/node/NodePhysicalPropertiesTest.ts
#	packages/node/test/node/ServerSubscriptionTest.ts

Copilot AI 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.

Pull request overview

Adds the WIP Node Manager foundation: persistent desired state, reconciliation, capacity handling, group orchestration, rollback, and shared node test utilities.

Changes:

  • Adds desired-state behavior and lifecycle triggers to @matter/node.
  • Introduces @matter/node-manager with reconcilers and persistent multi-step tasks.
  • Moves reusable node mocks into @matter/node/testing and updates tests.

Verification: Not run during this review.

Reviewed changes

Copilot reviewed 173 out of 178 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
package.json Registers workspace package.
package-lock.json Locks workspace package.
tsconfig.json Adds project reference.
packages/nodejs-shell/src/MatterNode.ts Reorders import.
packages/node/package.json Exports testing utilities.
packages/node/src/tsconfig.json Enables testing globals.
packages/node/src/testing/index.ts Exports shared test helpers.
packages/node/src/testing/mock-exchange.ts Adds exchange mock.
packages/node/src/testing/mock-server-node.ts Adds server-node mock.
packages/node/src/testing/mock-site.ts Adds network-site mock.
packages/node/src/testing/node-helpers.ts Moves shared node helpers.
packages/node/src/node/NodeLifecycle.ts Adds software-version event.
packages/node/src/node/ClientNode.ts Mounts desired state.
packages/node/src/node/client/Peers.ts Wires version changes.
packages/node/src/behavior/system/index.ts Exports desired state.
packages/node/src/behavior/system/desired-state/types.ts Defines desired-state records.
packages/node/src/behavior/system/desired-state/errors.ts Defines typed errors.
packages/node/src/behavior/system/desired-state/capacity.ts Adds capacity admission.
packages/node/src/behavior/system/desired-state/ItemKind.ts Adds item-kind registry.
packages/node/src/behavior/system/desired-state/DesiredStateBehavior.ts Implements intent storage.
packages/node/src/behavior/system/desired-state/index.ts Exports desired-state API.
packages/node/test/behavior/system/desired-state/TypesTest.ts Tests status/key helpers.
packages/node/test/behavior/system/desired-state/ItemMapKeyTest.ts Tests key composition.
packages/node/test/behavior/system/desired-state/ItemKindRegistryTest.ts Tests registry behavior.
packages/node/test/behavior/system/desired-state/ErrorsTest.ts Tests error hierarchy.
packages/node/test/behavior/system/desired-state/CapacityTest.ts Tests capacity checks.
packages/node/test/behavior/system/desired-state/DesiredStateBehaviorTest.ts Tests intent operations.
packages/node/test/behavior/system/desired-state/DesiredStatePersistenceTest.ts Tests persistence behavior.
packages/node/test/node/ClientNodeTest.ts Updates expected desired state.
packages/node/test/node/mock-exchange.ts Moves mock to source testing.
packages/node/test/node/mock-server-node.ts Moves mock to source testing.
packages/node/test/node/mock-site.ts Moves mock to source testing.
packages/node/test/node/node-helpers.ts Moves helpers to source testing.
packages/node/test/node/SplitCommissioningTest.ts Uses shared testing export.
packages/node/test/node/ShutdownActivityTest.ts Uses shared testing export.
packages/node/test/node/SetRegulatoryConfigSideEffectTest.ts Uses shared testing export.
packages/node/test/node/ServerSubscriptionTest.ts Uses shared testing export.
packages/node/test/node/ServerNodeTest.ts Uses shared testing export.
packages/node/test/node/RegulatoryConfigCommissioningTest.ts Uses shared testing export.
packages/node/test/node/read-helpers.ts Uses shared testing export.
packages/node/test/node/PluginsTest.ts Uses shared testing export.
packages/node/test/node/NodePhysicalPropertiesTest.ts Uses shared testing export.
packages/node/test/node/NodeLifecycleSeededTest.ts Uses shared testing export.
packages/node/test/node/FailsafeCommissioningTest.ts Uses shared testing export.
packages/node/test/node/EventReadResponseTest.ts Uses shared testing export.
packages/node/test/node/DecommissionTest.ts Uses shared testing export.
packages/node/test/node/CommandInvokeResponseTest.ts Uses shared testing export.
packages/node/test/node/ClientTuningTest.ts Uses shared testing export.
packages/node/test/node/ClientSubscribeTest.ts Uses shared testing export.
packages/node/test/node/ClientStructureOffloadTest.ts Uses shared testing export.
packages/node/test/node/ClientStartUpTest.ts Uses shared testing export.
packages/node/test/node/ClientNodeTcpTest.ts Uses shared testing export.
packages/node/test/node/ClientNodePhysicalPropertiesTest.ts Uses shared testing export.
packages/node/test/node/ClientNodeConfigurationVersionTest.ts Uses shared testing export.
packages/node/test/node/ClientNestedStructStateTest.ts Uses shared testing export.
packages/node/test/node/ClientInvokeTest.ts Uses shared testing export.
packages/node/test/node/ClientEventNotificationTest.ts Uses shared testing export.
packages/node/test/node/ClientConnectivityTest.ts Uses shared testing export.
packages/node/test/node/ClientChunkedListTest.ts Uses shared testing export.
packages/node/test/node/ClientCacheBufferTest.ts Uses shared testing export.
packages/node/test/node/ClientAttestationTest.ts Uses shared testing export.
packages/node/test/node/ClientAddressProbeTest.ts Uses shared testing export.
packages/node/test/node/BitmapWriteValidationTest.ts Uses shared testing export.
packages/node/test/node/AttributeWriteResponseTest.ts Uses shared testing export.
packages/node/test/node/AttributeSubscriptionResponseTest.ts Uses shared testing export.
packages/node/test/node/AttributeReadResponseTest.ts Uses shared testing export.
packages/node/test/node/client/PeersInstrumentBridgedConfigurationVersionTest.ts Uses shared testing export.
packages/node/test/node/client/PeersInstrumentBasicInformationTest.ts Uses shared testing export.
packages/node/test/node/client/PeersCommissionedTest.ts Uses shared testing export.
packages/node/test/node/client/PeersAutoStartTest.ts Uses shared testing export.
packages/node/test/node/client/ClientNodePhysicalPropertiesIcdTest.ts Uses shared testing export.
packages/node/test/node/client/ClientNodeInteractionIcdTest.ts Uses shared testing export.
packages/node/test/endpoints/LargeBridgeTest.ts Uses shared testing export.
packages/node/test/endpoints/ClusterClientIntegrationTest.ts Uses shared testing export.
packages/node/test/endpoints/bridge-helpers.ts Uses shared testing export.
packages/node/test/endpoint/server/ProtocolServiceTest.ts Uses shared testing export.
packages/node/test/endpoint/server/InteractionTestUtils.ts Uses shared testing export.
packages/node/test/endpoint/server/InteractionProtocolTest.ts Uses shared testing export.
packages/node/test/endpoint/mock-endpoint.ts Uses shared testing export.
packages/node/test/endpoint/EndpointVariableServiceTest.ts Uses shared testing export.
packages/node/test/endpoint/EndpointTest.ts Uses shared testing export.
packages/node/test/endpoint/EndpointGetServerTest.ts Uses shared testing export.
packages/node/test/endpoint/EndpointGetClientTest.ts Uses shared testing export.
packages/node/test/endpoint/EndpointClientFeaturesTest.ts Uses shared testing export.
packages/node/test/behavior/ReactorTransactionDeadlockTest.ts Uses shared testing export.
packages/node/test/behavior/state/managed/values/StructManagerTest.ts Uses shared testing export.
packages/node/test/behavior/state/managed/values/ListManagerTest.ts Uses shared testing export.
packages/node/test/behavior/system/sessions/SessionsBehaviorTest.ts Uses shared testing export.
packages/node/test/behavior/system/network/ConnectionStateTest.ts Uses shared testing export.
packages/node/test/behavior/system/icd/litSupportedTest.ts Uses shared testing export.
packages/node/test/behavior/system/icd/IcdClientTest.ts Uses shared testing export.
packages/node/test/behavior/system/commissioning/CommissioningWindowTest.ts Uses shared testing export.
packages/node/test/behaviors/access-control/AccessControlCustomExtensionTest.ts Uses shared testing export.
packages/node/test/behaviors/basic-information/CapabilityMinimaTest.ts Uses shared testing export.
packages/node/test/behaviors/basic-information/ConfigurationVersionTest.ts Uses shared testing export.
packages/node/test/behaviors/binding/BindingIntegrationTest.ts Uses shared testing export.
packages/node/test/behaviors/binding/BindingManagerTest.ts Uses shared testing export.
packages/node/test/behaviors/binding/BindingServerTest.ts Uses shared testing export.
packages/node/test/behaviors/boolean-state/BooleanStateServerTest.ts Uses shared testing export.
packages/node/test/behaviors/color-control/ColorControlServerTest.ts Uses shared testing export.
packages/node/test/behaviors/concentration-measurement/ConcentrationMeasurementServerTest.ts Uses shared testing export.
packages/node/test/behaviors/descriptor/DescriptorServerTest.ts Uses shared testing export.
packages/node/test/behaviors/energy-evse-mode/EnergyEvsModeServerTest.ts Uses shared testing export.
packages/node/test/behaviors/fan-control/FanControlTest.ts Uses shared testing export.
packages/node/test/behaviors/general-diagnostics/GeneralDiagnosticsFeaturesTest.ts Uses shared testing export.
packages/node/test/behaviors/group-key-management/GroupKeyManagementServerTest.ts Uses shared testing export.
packages/node/test/behaviors/groupcast/GroupcastProvisionalTest.ts Uses shared testing export.
packages/node/test/behaviors/groups/GroupsServerSchemaTest.ts Uses shared testing export.
packages/node/test/behaviors/hepa-filter-monitoring/HepaFilterMonitoringServerTest.ts Uses shared testing export.
packages/node/test/behaviors/icd-management/IcdManagementServerTest.ts Uses shared testing export.
packages/node/test/behaviors/identify/IdentifyServerTest.ts Uses shared testing export.
packages/node/test/behaviors/level-control/LevelControlServerTest.ts Uses shared testing export.
packages/node/test/behaviors/occupancy-sensing/OccupancySensingServerTest.ts Uses shared testing export.
packages/node/test/behaviors/on-off/OnOffServerTest.ts Uses shared testing export.
packages/node/test/behaviors/ota/OtaTest.ts Uses shared testing export.
packages/node/test/behaviors/ota/ota-utils.ts Uses shared testing export.
packages/node/test/behaviors/power-source/PowerSourceServerTest.ts Uses shared testing export.
packages/node/test/behaviors/rvc-operational-state/RvcOperationalStateServerTest.ts Uses shared testing export.
packages/node/test/behaviors/scenes-management/ScenesManagementServerTest.ts Uses shared testing export.
packages/node/test/behaviors/service-area/ServiceAreaServerTest.ts Uses shared testing export.
packages/node/test/behaviors/thermostat/AtomicWriteHandlerTest.ts Uses shared testing export.
packages/node/test/behaviors/thermostat/ThermostatBehaviorTest.ts Uses shared testing export.
packages/node/test/behaviors/thermostat-user-interface-configuration/ThermostatUserInterfaceConfgurationServerTest.ts Uses shared testing export.
packages/node/test/behaviors/thread-network-diagnostics/ThreadNetworkDiagnosticsProvisionalTest.ts Uses shared testing export.
packages/node/test/behaviors/time-format-localization/TimeFormatLocalizationServerTest.ts Uses shared testing export.
packages/node/test/behaviors/unit-localization/UnitLocalizationServerTest.ts Uses shared testing export.
packages/node-manager/package.json Defines public package.
packages/node-manager/tsconfig.json Defines composite project.
packages/node-manager/src/tsconfig.json Configures library build.
packages/node-manager/test/tsconfig.json Configures test build.
packages/node-manager/.mocharc.cjs Configures test runner.
packages/node-manager/src/index.ts Defines public exports.
packages/node-manager/src/ReconcilerBehavior.ts Implements reconciliation triggers.
packages/node-manager/src/reconcile/ReconcilerSurface.ts Defines task-facing interface.
packages/node-manager/src/reconcile/priority.ts Defines action priorities.
packages/node-manager/src/reconcile/planActions.ts Plans reconciliation actions.
packages/node-manager/src/reconcile/executeActions.ts Executes reconciliation actions.
packages/node-manager/src/reconcile/acl-coverage.ts Implements ACL coverage.
packages/node-manager/src/reconcile/AclItemKind.ts Reconciles ACL entries.
packages/node-manager/src/reconcile/BindingItemKind.ts Reconciles bindings.
packages/node-manager/src/reconcile/GroupKeyItemKind.ts Reconciles group keys.
packages/node-manager/src/reconcile/GroupKeyMapItemKind.ts Reconciles group mappings.
packages/node-manager/src/reconcile/GroupMembershipItemKind.ts Reconciles memberships.
packages/node-manager/src/task/types.ts Defines task contracts.
packages/node-manager/src/task/errors.ts Defines task errors.
packages/node-manager/src/task/Task.ts Defines task base class.
packages/node-manager/src/task/TaskRegistry.ts Registers task types.
packages/node-manager/src/task/RunningTaskContext.ts Implements task gates.
packages/node-manager/src/task/TaskManagerBehavior.ts Drives persistent tasks.
packages/node-manager/src/task/Revert.ts Implements rollback.
packages/node-manager/src/task/groups/keys.ts Defines membership keys.
packages/node-manager/src/task/groups/AddNodeToGroup.ts Implements group addition.
packages/node-manager/src/task/groups/RemoveNodeFromGroup.ts Implements group removal.
packages/node-manager/src/task/groups/RotateGroupKey.ts Implements key rotation.
packages/node-manager/test/ReconcilerBehaviorTest.ts Tests reconciliation executor.
packages/node-manager/test/ReconcilerIntegrationTest.ts Tests ACL reconciliation.
packages/node-manager/test/BindingIntegrationTest.ts Tests binding reconciliation.
packages/node-manager/test/GroupKeyIntegrationTest.ts Tests key reconciliation.
packages/node-manager/test/GroupMembershipIntegrationTest.ts Tests membership reconciliation.
packages/node-manager/test/reconcile/PlanActionsTest.ts Tests action planning.
packages/node-manager/test/reconcile/AclCoverageTest.ts Tests ACL coverage.
packages/node-manager/test/reconcile/AclItemKindTest.ts Tests ACL item kind.
packages/node-manager/test/reconcile/BindingItemKindTest.ts Tests binding item kind.
packages/node-manager/test/reconcile/GroupKeyItemKindTest.ts Tests key item kind.
packages/node-manager/test/reconcile/GroupKeyMapItemKindTest.ts Tests mapping item kind.
packages/node-manager/test/reconcile/GroupMembershipItemKindTest.ts Tests membership item kind.
packages/node-manager/test/task/helpers.ts Adds task test doubles.
packages/node-manager/test/task/AdmissionTest.ts Tests capacity admission.
packages/node-manager/test/task/ChangesetTest.ts Tests prior-state capture.
packages/node-manager/test/task/PeersWithIntentTest.ts Tests member discovery.
packages/node-manager/test/task/RemoveIntentTest.ts Tests referenced removal.
packages/node-manager/test/task/RevertTaskTest.ts Tests rollback behavior.
packages/node-manager/test/task/RollbackIntegrationTest.ts Tests automatic rollback.
packages/node-manager/test/task/TaskContextGateTest.ts Tests task gates.
packages/node-manager/test/task/TaskLifecycleTest.ts Tests resume and cancellation.
packages/node-manager/test/task/TaskManagerBehaviorTest.ts Tests task management.
packages/node-manager/test/task/groups/AddNodeToGroupIntegrationTest.ts Tests group addition.
packages/node-manager/test/task/groups/RemoveNodeFromGroupTest.ts Tests group removal logic.
packages/node-manager/test/task/groups/RemoveNodeFromGroupIntegrationTest.ts Tests group removal.
packages/node-manager/test/task/groups/RotateGroupKeyTest.ts Tests key rotation.
packages/node-manager/test/task/groups/RotateGroupKeyIntegrationTest.ts Tests multi-node rotation.
packages/node-manager/test/task/groups/RollbackIntegrationTest.ts Tests group rollback.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +36 to +38
export function itemMapKey(kind: string, key: string): string {
return `${kind}${ITEM_KEY_SEPARATOR}${key}`;
}
removes.sort((a, b) => priority(b.item, registry) - priority(a.item, registry));

for (const { item, action } of [...others, ...removes]) {
const kind = registry.get(item.kind);
Comment on lines +86 to +88
async awaitCommitted(items: Array<{ peer: ClientNode; kind: string; key: string }>): Promise<void> {
const peers = [...new Set(items.map(i => i.peer))];
await this.awaitGate(peers, () => items.every(i => this.#itemState(i.peer, i.kind, i.key) === "committed"));
Comment on lines +53 to +57
async setIntent(peer: ClientNode, kind: string, key: string, intent: unknown, mode: ItemMode = "converge") {
this.#record(peer, kind, key);
await peer.act(agent => {
agent.get(DesiredStateBehavior).setIntent(kind, key, intent, mode);
});
Comment on lines +148 to +151
const rk = task.resourceKey();
if (rk !== undefined) {
for (const t of this.internal.live.values()) {
if (t.id !== task.id && !TERMINAL_STATES.has(t.progress.state) && this.#occupies(t, rk)) {
Comment on lines +2 to +4
"name": "@matter/node-manager",
"version": "0.0.0-git",
"description": "Controller-side node management (desired-state reconciliation) for matter.js",
Comment on lines +9 to +10
export * from "./task/groups/AddNodeToGroup.js";
export * from "./task/Task.js";
Comment on lines +5 to +17
"references": [
{
"path": "../../general/src"
},
{
"path": "../../node/src"
},
{
"path": "../../testing/src"
},
{
"path": "../../types/src"
}
Comment on lines +28 to +32
"dependencies": {
"@matter/general": "*",
"@matter/node": "*",
"@matter/types": "*"
},
Comment on lines +10 to +24
"references": [
{
"path": "../../general/src"
},
{
"path": "../../node/src"
},
{
"path": "../../testing/src"
},
{
"path": "../../types/src"
},
{
"path": "../src"
* fix(node-manager): task cancel liveness and lifecycle hardening

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>

* fix(node-manager): refuse to activate a rotation member that joined after distribute

The member set is re-derived per phase, which is what lets a peer whose intent was
meanwhile removed drop out of the barrier. The cost is the opposite case: a peer
that gains an intent for the key set between distribute and activate joins the
barrier without ever having received the new key, so once members that already
committed activate flip their transmit key it cannot decrypt their traffic --
precisely the gap the three-phase barrier exists to close.

activate now refuses unless every current member already carries this rotation's
new key in slot 1, which also covers the park/resume re-drive where a member has
already committed activate. The distributed keys stay dormant, so a rotation with
a fresh id recovers. A member joining at cleanup needs no guard: it converges onto
the surviving key with no gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(node-manager): keep a task handle answering for its task

TaskHandle.status was evaluated once when the handle was built, so a caller that
held the handle and polled it observed the state frozen at creation time -- a task
that had since completed or failed still read as running. The handle now reads
through to the task it names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(node-manager): decide task admission by id, not by comparing parameters

Deciding whether two requests are "the same request" by structurally comparing
their parameters cannot be made correct. The comparator had to stay bit-exact with
the storage codec, with a deep-equality helper that cannot compare a Map at all,
and with a storage backend that keeps raw references in memory but JSON on disk --
three moving targets synchronized by hand. It produced a defect in four
consecutive review rounds: any Map comparing equal to any other, arrays with
undefined elements mismatching, and finally a Map comparing equal to an equivalent
plain array so that two different requests deduplicated onto one task.

Admission is now decided by the task id alone. Only one live task may hold an id:
the caller that passes an externalId re-issues its own request idempotently, and
any other request for a held id is refused with TaskConflictError rather than
silently resolving onto work it did not ask for. This deliberately narrows the
earlier promise that an anonymous identical re-issue is idempotent -- a refusal
naming the live task is more honest than a guess about equivalence.

Terminal-task retention goes with it. Retention bounded a history that the keying
cannot keep: records are stored per task id and last-writer-wins, so re-running a
task overwrites its finished record, and cancelling that re-run overwrites the
previous rollback's record too. Bounding storage is worth doing once history has
a representation that survives a repeat of the same work.

Per-task sets of external ids go back to the single optional id they were: a
second caller's id was accepted, resolved to the first caller's task, and left the
second caller unable to observe or cancel what it asked for.

What a terminal id no longer does is block a re-run -- that fix stays, because
without it re-running after a cancel returned the cancelled handle and the
requested change was never applied until a process restart.

Identity, lookup and retention are being redesigned together around per-run ids, a
slot key that carries exclusion, and history separate from the live table. This
removes what that redesign replaces so the rest of the branch -- cancellation
liveness, shutdown handling and gate wakeups -- lands on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(node-manager): record a rollback before it drives, and refuse a cancel the write cannot record

A rollback began mutating peers before the write that names it was durable, and
the lifecycle check that guards a cancel ran before its write was enqueued rather
than when the write ran. Shutdown starting in that gap left the task cancelled in
memory and linked to a rollback already driving, while storage still held the old
non-terminal record and the caller saw a lifecycle error instead of
TaskManagerClosingError.

Creating a rollback is now separate from driving it. A cancel prepares the
rollback, writes both records in one transaction, and only then starts it; a write
that cannot land undoes the state change, forgets the prepared rollback and leaves
nothing behind for the next start to resume. The refusal moved inside the
serialized write, so a shutdown that begins while the write queues cannot slip
past it. Auto-rollback in the driver follows the same order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(node-manager): refuse to complete a rotation a member joined during activate

The activate phase checked its member set only on entry. Writing the intents and
waiting on the barrier both yield, and provisioning a group takes no lock on its
key set, so a peer could gain an intent afterwards: absent from the member set it
never received the new key, and cleanup was the first phase to touch it. Between
activate completing and that peer committing cleanup, the members that already
flipped transmit with the new key and it cannot decrypt them.

The condition is now re-established after the barrier, so the rotation fails
instead of proceeding to cleanup and reporting success. It refuses rather than
heals: the old key is still present on the members that flipped, and rotating
again with the same new key covers every current member because distribute takes
the whole set. Preventing the window would mean serializing group provisioning
against a live rotation, which is too coarse -- legitimate concurrent joins to
different groups sharing a key set would reject each other -- and belongs with the
broader identity and exclusion redesign.

Cleanup needs no equivalent guard: a peer joining there receives the sole surviving
key back-dated to the original operational start, so it converges with no gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(node-manager): keep a task driveable when its cancel is refused

Cancelling stops the driver and drops its gate before the rollback is prepared, so
a rollback the manager refuses -- one whose id a live rollback already holds --
left the task reporting running or parked with nothing to advance it. It kept its
state, as intended, but lost its driver, and only a restart brought it back.

A declined cancel now re-drives the task before rethrowing, sharing the path the
resume pass uses. The test that claimed the task was left untouched asserted only
its state; it now also asserts the task still owns a driver, which is what makes
"untouched" true.

The rotationId documentation promised that re-issuing the same id is idempotent.
Since admission became a matter of the task id alone that holds only for the caller
whose externalId the live rotation carries; a terminal id may be run again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(node-manager): assert a task declined a cancel still converges

The test asserted the task kept a driver but stopped there, so it did not show the
driver still leads anywhere. It now lets the device have the item and waits for the
task to complete, which is only reachable if declining the cancel restored both the
driver and its gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <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