Skip to content

Fix concurrency and event emission across storage and task systems - #686

Merged
sroussey merged 10 commits into
mainfrom
claude/libs-packages-refactor-ymv0ps
Aug 6, 2026
Merged

Fix concurrency and event emission across storage and task systems#686
sroussey merged 10 commits into
mainfrom
claude/libs-packages-refactor-ymv0ps

Conversation

@sroussey

@sroussey sroussey commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR addresses several concurrency, event emission, and error handling issues across the storage, task-graph, and worker systems. Key fixes include proper cross-instance connection guarding in DuckDB storage, vector similarity search event emission, task graph dependency validation, and worker error rehydration.

Key Changes

Storage & Concurrency

  • DuckDbTabularStorage: Added connectionHandle() and guardedWrite() methods to properly serialize writes across multiple storage instances sharing the same database connection. This prevents interleaving of BEGIN/COMMIT blocks when sibling instances call putBulk() concurrently.
  • DuckDbTabularStorage: Refactored withTransaction() to use runInTransactionOnConnection() for proper cross-instance transaction coordination.
  • BaseTabularStorage: Replaced the two literal NUL bytes inside the index-dedup join separators with the unicode escape sequence (backslash-u0000). Runtime behavior is byte-identical — the change makes the file valid UTF-8 text again so grep/ripgrep stop classifying it as binary and silently skipping it in symbol searches.

Vector Storage Events

  • SqliteAiVectorStorage, SqliteVectorStorage, PostgresVectorStorage, SupabaseVectorStorage: Added emitSimilaritySearch() helper method to emit the similaritySearch event on all vector search results (previously only the InMemory and IndexedDb backends emitted the event declared on IVectorStorage). The inherited events emitter is widened to carry vector-specific events.

Task Graph & Dependencies

  • TaskGraph: run() now spreads the full run config instead of hand-copying a field whitelist — enforceEntitlements and matchAllEmptyInputs were silently dropped before.
  • TaskJSON: Moved dependency validation and Dataflow creation from JsonTask into createGraphFromDependencyJSON() so that all graph construction (including recursed subtasks) properly wires dataflows and validates that dependency IDs exist in the graph.
  • JsonTask: Removed duplicate dependency wiring logic now handled by the factory function.

Task Execution & Validation

  • IteratorTask: Changed executeStream() to throw immediately if called (instead of yielding its input as the finish payload), since subclass output schemas exclude x-stream and runs should never route here. Failing loudly prevents silent wrong results.
  • ReduceTask: Strip x-stream annotation from child task output schemas when building the aggregate schema, ensuring the reduce output is never treated as a live stream (previously a reduce whose body ended in a streaming task returned its input without running an iteration).
  • AiChatWithKbTask: Delegated getJobInput() to base class to ensure timeoutMs, outputSchema, and future base fields are always populated. Added explicit gateOrThrow() call in executeStream() since the override doesn't call super.executeStream().
  • Vector tasks (Distance, Divide, DotProduct, Multiply, Subtract, Sum): Changed generic Error throws to TaskInvalidInputError for proper error classification.
  • DateFormatTask: Invalid-date throw now uses TaskInvalidInputError for consistent error classification.

Worker Error Handling

  • WorkerManager: Extracted error rehydration logic into new rehydrateWorkerError() function in scrubStack.ts. All three error message handlers now use this function for consistent stack scrubbing and error reconstruction — previously the stream and run-fn paths skipped the stack scrubbing the call path applied, leaking absolute container paths.
  • scrubStack: Added rehydrateWorkerError() function that rebuilds an Error from worker message payloads with defense-in-depth stack scrubbing.

Event Emission Safety

  • IndexedDbTabularStorage: Replaced direct this.events.emit() calls with safeEmit() — a throwing subscriber inside an IDB onsuccess callback previously unwound before resolve(), leaving put()'s promise permanently unsettled.

Job Queue

  • Queue storages (InMemory, IndexedDb, Postgres, Sqlite, Supabase): add() no longer clobbers a caller-set future visible_at, so send(..., { delaySeconds }) actually delays delivery instead of being a silent no-op.
  • JobQueueClient: the job_error client event now forwards the errorCode the server already carries.
  • genericJobQueueTests: Added assertion that deferred visibility is preserved in stored job rows (not clobbered to "now" on add).

Package Configuration

  • @workglow/javascript: Updated sideEffects to ["./dist/task.js"] (was false) to preserve task registration side effects, and added registerJavaScriptTasks() so serialized graphs containing a JavaScriptTask node can be rehydrated.
  • @workglow/mcp: McpListTask now declares the optional CREDENTIAL entitlement on the HTTP branch, matching its three siblings.
  • @workglow/util: the ./worker export's default condition now pairs worker-node.d.ts with worker-node.js (types previously pointed at worker-entry.d.ts, hiding the Worker.node exports from TypeScript), and the tsconfig now emits the worker-node/browser/bun.d.ts declarations the exports map names — the browser/bun types conditions previously pointed at files that were never built.
  • All packages: Added "license": "Apache-2.0" field to package.json files for compliance.

Documentation

  • EXECUTION_MODEL.md: Clarified that ABORTING state transitions to FAILED when the abort surfaces as an error.
  • TaskJSON JSDoc: Updated return type documentation to clarify that dataflows are wired for all dependencies including in recursed subtasks.

https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.33% 28173 / 45199
🔵 Statements 62.2% 29212 / 46958
🔵 Functions 62.55% 5371 / 8586
🔵 Branches 51.39% 13924 / 27094
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/storage/src/tabular/BaseTabularStorage.ts 18.55% 7.35% 10.14% 20.37% 80-119, 204, 206, 224, 227-229, 233, 245-252, 260, 262-272, 286, 289, 299-300, 306-309, 313-319, 328-329, 332, 337-914, 935-940, 960-969, 974-975, 978-979, 1002-1048, 1056-1144
Generated in workflow #2866 for commit 69eed2b by the Vitest Coverage Report Action

sroussey commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Code review (xhigh, --fix) — 10 finder angles + 5 adversarial verifiers. Fixes pushed in fc3c29a.

Bugs confirmed and fixed:

  • TaskGraph.run forwarded outputCache: config?.outputCache || this.outputCache — an explicit outputCache: false (the documented "disable all caching" signal TaskGraphRunner branches on) was swallowed and the graph's repo substituted. Now ??, with a regression test proving a second run re-executes under false.
  • DuckDbTabularStorage.guardedWrite's !this.inTransaction short-circuit was a hole: a write issued during the instance's own open transaction queued only on the instance mutex, woke after COMMIT, and could run on the shared connection with no chain slot — free to interleave inside a sibling instance's next BEGIN/COMMIT. The chain is now always consulted (runOnConnection inlines genuine tx descendants and queues unrelated calls; verified deadlock-free since the tx callback gets the proxy). Added a deterministic scripted-stub test asserting statement-level ordering.
  • IndexedDbQueueStorage.add() unconditionally recomputed fingerprint, silently replacing a caller-supplied one (the other four backends use ?? makeFingerprint) — custom-fingerprint dedup could never match on IDB. Pre-existing, adjacent to this PR's visible_at hunk. Fixed + pinned in the generic suite.
  • The new delayed-send assertion compared visibleAt - createdAt >= 150, coupling two timestamps taken at different points with 50 ms of headroom — flaky under CI load. Rewritten as a [t0 + 200, t1 + 200] bracket on the client clock.
  • WorkerManager logged the raw worker payload (including the unscrubbed data.stack the scrubbing commit exists to remove) at the run-path debug site and the preview-path warn; both now log the rehydrated error.
  • AiChatWithKbTask.getJobInput's spread embedded sessionId inside the serialized taskInput; it's now set on the job input after delegating to the base, staying a top-level field only.

Quality: the six identical emitSimilaritySearch blocks (4 added here + InMemory/IndexedDb inline copies) collapsed into one shared helper in @workglow/storage typed against the exported VectorEventListeners; examples/web's now-redundant dependency re-wiring deleted (verified harmless — addEdge dedupes on Dataflow.id — but dead); util tsconfig formatting churn reverted. Added TaskJSON tests covering the new nested-subtask wiring (top-level, nested, out-of-scope id throw, toDependencyJSON round-trip — the round-trip silently lost every subgraph edge before this PR).

Investigated and cleared: WhileTask does not have ReduceTask's x-stream leak (it overrides executeStream with the full condition/maxIterations loop); the nested-dependency validation throw is intentional strictness (anything that now throws was already running mis-wired — worth a line in the PR description); the run() spread newly activating enforceEntitlements/matchAllEmptyInputs is the fix working as described.

Verified: build:packages clean, tsc -b clean, storage/queue/graph/task/util/ai vitest sections green (section re-run in flight at push time; will follow up if CI disagrees).


Generated by Claude Code

sroussey added 10 commits August 6, 2026 19:26
…uard

- Replace two raw NUL bytes in BaseTabularStorage.ts join separators with
  the backslash-u0000 escape so grep/ripgrep stop classifying the file as
  binary.
- Route IndexedDbTabularStorage's eight remaining raw event emits through
  safeEmit: a throwing subscriber inside an IDB onsuccess callback unwound
  before resolve(), leaving the put/get/delete promise unsettled forever.
- Emit the similaritySearch event declared on IVectorStorage from the
  Sqlite, SqliteAi, Postgres, and Supabase vector backends (previously only
  InMemory and IndexedDb fired it).
- Give DuckDbTabularStorage the cross-instance connection guard SQLite and
  Postgres already have: connectionHandle() for a shared DuckDbDatabase and
  runOnConnection/runInTransactionOnConnection around writes and
  withTransaction, so a sibling instance's write can no longer interleave
  inside another instance's BEGIN/COMMIT.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
…vents

Every core queue storage's add() unconditionally stamped visible_at = now,
clobbering the future timestamp the client computed for delaySeconds — a
delayed send ran immediately on all five backends. Keep a caller-provided
visible_at and assert the deferred visibility in the generic suite.

Also forward errorCode on the client's job_error event; the server carries
it and handleJobError received it, but the re-emit dropped it.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
… wire dependency-JSON edges

- TaskGraph.run() hand-copied nine config fields into runGraph, silently
  dropping enforceEntitlements and matchAllEmptyInputs; spread the config
  so every field forwards (runPreview already did).
- A ReduceTask whose body ended in a streaming task copied the x-stream
  annotation onto its aggregate output schema, routed the run through
  IteratorTask.executeStream, and returned the input untouched without
  running an iteration. Strip x-stream from copied ports and make the
  iterator's executeStream throw a TaskConfigurationError instead of
  silently yielding its input.
- createGraphFromDependencyJSON never read item.dependencies; JsonTask
  re-wired them at the top level, but graphs recursed through subtasks lost
  their edges silently (and toDependencyJSON did not round-trip). Wire the
  dataflows inside the factory and drop JsonTask's duplicate loop.
- Document ABORTING as the real post-abort status in EXECUTION_MODEL.md
  (the ABORTED status it described does not exist).

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
AiChatWithKbTask duplicated AiChatTask's multi-turn loop but its copy never
called gateOrThrow (violating the contract that both execute paths gate)
and hand-rolled getJobInput without timeoutMs, so its provider calls always
fell back to the 60-minute default. Gate in executeStream and delegate
getJobInput to the base so timeoutMs, outputSchema, and future base fields
stay populated.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
The one-shot worker call path scrubbed absolute filesystem paths out of
rehydrated error stacks, but the stream and run-fn paths rebuilt errors
without scrubbing — the exact leak the scrubbing was added to stop.
Extract rehydrateWorkerError() next to scrubStack and use it on all three
paths.

Also pair the ./worker export's default types with worker-node.d.ts; it
pointed at worker-entry.d.ts, hiding the Worker.node exports that the
worker-node.js runtime actually ships.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
…ntitlement parity

- Vector task and DateFormatTask input validation threw bare Error; use
  TaskInvalidInputError so the runner classifies the failures as
  non-retryable input errors like the scalar tasks do.
- JavaScriptTask was never registered in TaskRegistry, so a serialized
  graph containing one could not rehydrate; export
  registerJavaScriptTasks(). The package also declared sideEffects: false
  while relying on a module-level Workflow.prototype patch — switch to an
  allowlist so bundlers keep the registration side effect.
- McpListTask's HTTP entitlements omitted the optional CREDENTIAL
  entitlement its three siblings declare for the same auth path.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
Every source file carries the Apache-2.0 SPDX header, but none of the 39
package.json files declared a license field, so npm listed the published
packages as unlicensed.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
client.getJob returns a Job instance with camelCase Date fields, not the
raw storage row — the deferred-visibility assertion parsed snake_case
string fields and compared NaN.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
The ./worker export's browser, bun, and (as of the previous commit)
default types conditions point at worker-browser.d.ts, worker-bun.d.ts,
and worker-node.d.ts — but the tsconfig files list only type-built
worker-entry.ts, so none of those declaration files were ever emitted
and consumers of @workglow/util/worker failed to resolve its types.
Add the three platform entries to the files list so the declarations
the exports map names actually exist.

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
…d ai

- TaskGraph.run: preserve an explicit outputCache:false (?? instead of ||)
  so per-run cache disabling reaches the runner; add coverage
- DuckDbTabularStorage.guardedWrite: consult the connection chain even while
  inTransaction — a write deferred on the instance mutex could wake after
  COMMIT and interleave into a sibling instance's next transaction; add a
  deterministic scripted-stub regression test
- IndexedDbQueueStorage.add: keep a caller-supplied fingerprint instead of
  always recomputing (parity with the other four backends); pin with a
  generic-suite test
- genericJobQueueTests: assert delayed-send visibility against the client
  clock bracket [t0+200, t1+200] instead of created_at minus 50ms slack
- WorkerManager: log the rehydrated (stack-scrubbed) worker error at both
  raw-payload log sites
- AiChatWithKbTask.getJobInput: set sessionId after delegating to the base
  so it stays a top-level job field, out of the serialized taskInput
- vector storages: extract the six identical emitSimilaritySearch blocks
  into one shared helper in @workglow/storage typed against
  VectorEventListeners
- examples/web: drop the redundant dependency re-wiring now that
  createGraphFromDependencyJSON wires dataflows itself
- TaskJSON: add tests for dependency wiring (top-level, nested subtasks,
  out-of-scope id error, toDependencyJSON round-trip)
- util tsconfig: revert include/exclude formatting churn

Claude-Session: https://claude.ai/code/session_01TQFDxK7AKh4w4DCjRJUpQm
@sroussey
sroussey force-pushed the claude/libs-packages-refactor-ymv0ps branch from fc3c29a to bb3a081 Compare August 6, 2026 19:39
@sroussey
sroussey merged commit 2076a37 into main Aug 6, 2026
11 of 14 checks passed
sroussey pushed a commit that referenced this pull request Aug 6, 2026
Rebase onto current main hit conflicts in files also touched by #684
(queue-adapter deletions), #685/#686 (Usage seam, TaskInvalidInputError),
and #641 (AiSessionContext). Per this PR's own conflict-resolution
guidance: took main's side on every conflict, then re-ran `bun run
format` to reapply the type-import conversion the autofix commit
originally made to those files.
sroussey added a commit that referenced this pull request Aug 6, 2026
…e autofix (#683)

* chore(eslint): enforce consistent-type-imports

Adds @typescript-eslint/consistent-type-imports so type-only imports are
written as `import type`. `disallowTypeAnnotations` is left off because
inline `import()` type annotations are the established way optional peer
dependencies are typed here without a static import.

Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn

* chore: apply consistent-type-imports autofix

Mechanical output of `bun run format` (eslint --fix + prettier) after
enabling @typescript-eslint/consistent-type-imports. No hand edits.

Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn

* chore: convert the remaining inline type specifiers to top-level import type

Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn

* chore: reapply consistent-type-imports autofix after rebase conflicts

Rebase onto current main hit conflicts in files also touched by #684
(queue-adapter deletions), #685/#686 (Usage seam, TaskInvalidInputError),
and #641 (AiSessionContext). Per this PR's own conflict-resolution
guidance: took main's side on every conflict, then re-ran `bun run
format` to reapply the type-import conversion the autofix commit
originally made to those files.

---------

Co-authored-by: Claude <noreply@anthropic.com>
@sroussey
sroussey deleted the claude/libs-packages-refactor-ymv0ps branch August 13, 2026 05:02
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.

1 participant