Skip to content

Gateway: abstract task registry storage#56927

Merged
mbelinky merged 2 commits intomainfrom
mariano/task-lifecycle-store-hooks
Mar 29, 2026
Merged

Gateway: abstract task registry storage#56927
mbelinky merged 2 commits intomainfrom
mariano/task-lifecycle-store-hooks

Conversation

@mbelinky
Copy link
Copy Markdown
Contributor

@mbelinky mbelinky commented Mar 29, 2026

Summary

  • Extract the task registry persistence layer behind a TaskRegistryStore interface.
  • Keep the current JSON-backed implementation as the default backend in a dedicated task-registry.store.json.ts module.
  • Add a small incremental hook surface (restored, upserted, deleted) so follow-up work can attach indexing or compaction logic without reopening the lifecycle code.
  • Keep task behavior unchanged; this PR is an infrastructure seam stacked on top of Gateway: track background task lifecycle #52518.

Why

The lifecycle PR proves the background-task contract and channel behavior. This follow-up separates snapshot persistence from registry observation so we can harden storage later without rewriting the task logic.

Scope

  • No user-visible behavior change.
  • No delivery/routing semantics change.
  • No retry/UI work.
  • No new storage backend yet; JSON remains the default.

Design Notes

  • TaskRegistryStore owns snapshot load/save.
  • Hooks are incremental and observational, not a second persistence API.
  • The hook path is lazy, so the default runtime does not clone full task snapshots on every write.

Validation

Ran on mb-server:

  • pnpm exec vitest run src/tasks/task-registry.store.test.ts src/tasks/task-registry.test.ts
  • pnpm build

@openclaw-barnacle openclaw-barnacle bot added size: M maintainer Maintainer-authored PR labels Mar 29, 2026
Base automatically changed from mariano/task-lifecycle-review to main March 29, 2026 10:48
@openclaw-barnacle openclaw-barnacle bot added gateway Gateway runtime cli CLI command changes commands Command implementations agents Agent runtime and tooling size: XL and removed size: M labels Mar 29, 2026
@mbelinky mbelinky force-pushed the mariano/task-lifecycle-store-hooks branch from dd9e26b to 8db9b86 Compare March 29, 2026 10:49
@openclaw-barnacle openclaw-barnacle bot added size: M and removed gateway Gateway runtime cli CLI command changes commands Command implementations agents Agent runtime and tooling size: XL labels Mar 29, 2026
@mbelinky mbelinky marked this pull request as ready for review March 29, 2026 10:57
@mbelinky mbelinky merged commit 92d0b3a into main Mar 29, 2026
38 of 40 checks passed
@mbelinky mbelinky deleted the mariano/task-lifecycle-store-hooks branch March 29, 2026 10:58
@mbelinky
Copy link
Copy Markdown
Contributor Author

Merged via squash.

Thanks @mbelinky!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 29, 2026

Greptile Summary

This PR cleanly extracts the task registry's persistence layer behind a TaskRegistryStore interface and adds an incremental TaskRegistryHooks surface for observability. The JSON-backed implementation is preserved in a dedicated module with no behavior change to the runtime. The lazy closure pattern for hook event construction is a nice touch — the default path stays allocation-free when no hooks are registered.

Key points from the review:

  • Abstraction is well-designed: The TaskRegistryStore interface is minimal and correct; the "hooks" in params check in configureTaskRegistryRuntime correctly handles null to distinguish clearing hooks from omitting them.
  • P2 — Live map reference in saveSnapshot: persistTaskRegistry() passes the live, mutable tasks map to saveSnapshot. The current JSON store iterates and serializes synchronously, so this is safe today, but any future backend that defers reading (e.g., async batch flush) would observe a later state. Given the PR positions this as an infrastructure seam for future backends, documenting the synchronous-read contract or passing a shallow copy would improve safety.
  • P2 — restored hook silently skipped for empty snapshots: The hook event is not emitted when loadSnapshot() returns an empty map. Consumers that rely on restored to know initialization is complete will not receive it on a fresh store. The intent should be documented or the event emitted unconditionally.
  • Tests are thorough and cover the three hook event kinds; cleanup via afterEach correctly resets both registry state and runtime configuration.

Confidence Score: 5/5

Safe to merge — no user-visible behavior change and no current correctness issues; all findings are P2 improvement suggestions for future-proofing.

All remaining findings are P2: the live map reference concern is only relevant for future async store implementations, and the empty-snapshot hook silencing is a design choice with no current downstream consumers. The abstraction is correctly implemented and well-tested.

No files require special attention; src/tasks/task-registry.ts has two P2 notes worth considering before the next storage backend is introduced.

Important Files Changed

Filename Overview
src/tasks/task-registry.store.ts New abstraction layer introducing TaskRegistryStore and TaskRegistryHooks interfaces, with module-level singletons and configure/reset helpers. Well-structured; the "hooks" in params check correctly handles null to distinguish clearing hooks from omitting them.
src/tasks/task-registry.store.json.ts JSON persistence logic extracted verbatim from the old store file. Validation, versioning, and test-environment path isolation all carried over correctly.
src/tasks/task-registry.ts Registry updated to delegate persistence through the new store interface and emit incremental hook events on restore, upsert, and delete. Lazy closure pattern for hook events is correctly guarded, keeping the hot path allocation-free when no hooks are registered.
src/tasks/task-registry.store.test.ts New tests cover store injection and all three hook event kinds (restored, upserted, deleted). afterEach cleanup correctly resets both the registry and the runtime via resetTaskRegistryForTests.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/tasks/task-registry.ts
Line: 72-73

Comment:
**Live map reference passed to `saveSnapshot`**

`persistTaskRegistry()` passes the module-level `tasks` map directly to `saveSnapshot`. The parameter is typed as `ReadonlyMap`, which prevents mutations through the reference, but the underlying map continues to be mutated by the registry between calls. The current JSON store is safe because it iterates and serializes immediately, but any future store implementation that defers reading (e.g., async batching/flush) would observe a later, modified state rather than the snapshot captured at write time.

Since this PR explicitly positions `TaskRegistryStore` as an infrastructure seam for future backends, it's worth documenting the contract — or passing a snapshot at call time:

```ts
function persistTaskRegistry() {
  // Pass a shallow copy to give store implementations a stable snapshot.
  // The live map is mutable; any async store must copy immediately on save.
  getTaskRegistryStore().saveSnapshot(new Map(tasks));
}
```

Alternatively, document in the `TaskRegistryStore` type that `saveSnapshot` must read the map synchronously before returning.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/tasks/task-registry.ts
Line: 283-297

Comment:
**`restored` hook event is silently skipped for empty snapshots**

When `loadSnapshot()` returns an empty map, the function returns early on line 284 without emitting any hook event. A consumer that relies on the `restored` event to know the registry has finished initializing (e.g., to start indexing or drain a queue) will never receive it on a fresh or empty store.

If the intention is "no event on empty restore", that's a reasonable choice, but it should be documented in the `TaskRegistryHooks` or `TaskRegistryHookEvent` types. Alternatively, emit the event unconditionally (with an empty `tasks` array) so the hook contract is predictable:

```ts
// always emit restored so consumers know initialization is done
emitTaskRegistryHookEvent(() => ({
  kind: "restored",
  tasks: snapshotTaskRecords(tasks),
}));
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Gateway: tighten task store hooks" | Re-trigger Greptile

Comment on lines +72 to 73
getTaskRegistryStore().saveSnapshot(tasks);
}
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.

P2 Live map reference passed to saveSnapshot

persistTaskRegistry() passes the module-level tasks map directly to saveSnapshot. The parameter is typed as ReadonlyMap, which prevents mutations through the reference, but the underlying map continues to be mutated by the registry between calls. The current JSON store is safe because it iterates and serializes immediately, but any future store implementation that defers reading (e.g., async batching/flush) would observe a later, modified state rather than the snapshot captured at write time.

Since this PR explicitly positions TaskRegistryStore as an infrastructure seam for future backends, it's worth documenting the contract — or passing a snapshot at call time:

function persistTaskRegistry() {
  // Pass a shallow copy to give store implementations a stable snapshot.
  // The live map is mutable; any async store must copy immediately on save.
  getTaskRegistryStore().saveSnapshot(new Map(tasks));
}

Alternatively, document in the TaskRegistryStore type that saveSnapshot must read the map synchronously before returning.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tasks/task-registry.ts
Line: 72-73

Comment:
**Live map reference passed to `saveSnapshot`**

`persistTaskRegistry()` passes the module-level `tasks` map directly to `saveSnapshot`. The parameter is typed as `ReadonlyMap`, which prevents mutations through the reference, but the underlying map continues to be mutated by the registry between calls. The current JSON store is safe because it iterates and serializes immediately, but any future store implementation that defers reading (e.g., async batching/flush) would observe a later, modified state rather than the snapshot captured at write time.

Since this PR explicitly positions `TaskRegistryStore` as an infrastructure seam for future backends, it's worth documenting the contract — or passing a snapshot at call time:

```ts
function persistTaskRegistry() {
  // Pass a shallow copy to give store implementations a stable snapshot.
  // The live map is mutable; any async store must copy immediately on save.
  getTaskRegistryStore().saveSnapshot(new Map(tasks));
}
```

Alternatively, document in the `TaskRegistryStore` type that `saveSnapshot` must read the map synchronously before returning.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +283 to 297
const restored = getTaskRegistryStore().loadSnapshot();
if (restored.size === 0) {
return;
}
for (const [taskId, task] of restored.entries()) {
tasks.set(taskId, task);
}
rebuildRunIdIndex();
emitTaskRegistryHookEvent(() => ({
kind: "restored",
tasks: snapshotTaskRecords(tasks),
}));
} catch (error) {
log.warn("Failed to restore task registry", { 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.

P2 restored hook event is silently skipped for empty snapshots

When loadSnapshot() returns an empty map, the function returns early on line 284 without emitting any hook event. A consumer that relies on the restored event to know the registry has finished initializing (e.g., to start indexing or drain a queue) will never receive it on a fresh or empty store.

If the intention is "no event on empty restore", that's a reasonable choice, but it should be documented in the TaskRegistryHooks or TaskRegistryHookEvent types. Alternatively, emit the event unconditionally (with an empty tasks array) so the hook contract is predictable:

// always emit restored so consumers know initialization is done
emitTaskRegistryHookEvent(() => ({
  kind: "restored",
  tasks: snapshotTaskRecords(tasks),
}));
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tasks/task-registry.ts
Line: 283-297

Comment:
**`restored` hook event is silently skipped for empty snapshots**

When `loadSnapshot()` returns an empty map, the function returns early on line 284 without emitting any hook event. A consumer that relies on the `restored` event to know the registry has finished initializing (e.g., to start indexing or drain a queue) will never receive it on a fresh or empty store.

If the intention is "no event on empty restore", that's a reasonable choice, but it should be documented in the `TaskRegistryHooks` or `TaskRegistryHookEvent` types. Alternatively, emit the event unconditionally (with an empty `tasks` array) so the hook contract is predictable:

```ts
// always emit restored so consumers know initialization is done
emitTaskRegistryHookEvent(() => ({
  kind: "restored",
  tasks: snapshotTaskRecords(tasks),
}));
```

How can I resolve this? If you propose a fix, please make it concise.

alexjiang1 pushed a commit to alexjiang1/openclaw that referenced this pull request Mar 31, 2026
Merged via squash.

Prepared head SHA: 8db9b86
Co-authored-by: mbelinky <[email protected]>
Co-authored-by: mbelinky <[email protected]>
Reviewed-by: @mbelinky
livingghost pushed a commit to livingghost/openclaw that referenced this pull request Mar 31, 2026
Merged via squash.

Prepared head SHA: 8db9b86
Co-authored-by: mbelinky <[email protected]>
Co-authored-by: mbelinky <[email protected]>
Reviewed-by: @mbelinky
pgondhi987 pushed a commit to pgondhi987/openclaw that referenced this pull request Mar 31, 2026
Merged via squash.

Prepared head SHA: 8db9b86
Co-authored-by: mbelinky <[email protected]>
Co-authored-by: mbelinky <[email protected]>
Reviewed-by: @mbelinky
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant