Skip to content

fix(activity-log): enqueue the plugin outbox row on the caller's tx handle (BLO-19132) - #1024

Closed
allyblockcast[bot] wants to merge 1 commit into
masterfrom
cto/blo-19132-logactivity-tx
Closed

fix(activity-log): enqueue the plugin outbox row on the caller's tx handle (BLO-19132)#1024
allyblockcast[bot] wants to merge 1 commit into
masterfrom
cto/blo-19132-logactivity-tx

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Plugins observe the system through domain events, which logActivity enqueues into a cross-tier outbox that the worker tier polls and emits
  • logActivity accepts a db handle so a caller can log inside a transaction, but the outbox enqueue ignored it and wrote through the module-global _outboxDb instead
  • So the event committed on a separate connection from the activity row it describes: roll the caller's transaction back and the poller still emits a domain event for an entity that never existed
  • The enqueue was also void ... .catch(...), so its ordering relative to the caller was unguaranteed and any failure was invisible beyond a warn
  • This pull request routes the enqueue through the handle logActivity was given and awaits it, making an inline publish atomic with any enclosing transaction
  • The benefit is that a transactional caller can no longer leak a phantom plugin event, without every such caller having to remember an opt-in flag

Linked Issues or Issue Description

Refs #953 — surfaced by Ally's review there, where a new transactional approval.created path would be the first caller to hit the plugin-event leg of this bug.

Refs BLO-19132.

The defect, precisely. logActivity(db, …) publishes two side effects. The in-memory live event always escapes a transaction — that is what the existing deferPublish option (added in #806) exists to close. The second, the plugin outbox enqueue, was written on _outboxDb:

export function publishPluginDomainEvent(event: PluginEvent): void {
  void _outboxDb.insert(pluginEventOutbox).values({...}).catch(...)
}

_outboxDb is a boot-time global, so the row commits independently of the caller's transaction. Two consequences:

  1. Phantom events. Caller's transaction rolls back → activity row gone, outbox row remains → the worker emits a domain event for an entity that does not exist.
  2. Silent, unordered writes. void … .catch(…) means logActivity can return before the row lands, and a failure only ever surfaces as a warn.

Scope note, stated honestly. On today's master this is not yet reachable via the plugin leg. The four existing transactional callers log actions that are not in PLUGIN_EVENT_TYPES (execution_workspace.workspace_validation_quarantined, issue.workspace_preflight_blocked, pipeline.stage_automation_env_updated, routine.origin_stamped/_cleared), so they only leak the in-memory live event on rollback. approval.created is in PLUGIN_EVENT_TYPES, so #953 would introduce the first transactional caller that leaks a real outbox row. This PR closes the leg before that lands rather than after.

What Changed

  • publishPluginDomainEvent(event, db?) — takes an optional handle and writes the outbox row on db ?? _outboxDb. Now async and awaited internally; still never rejects (failure is caught and logged exactly as before), so it cannot newly abort a caller's transaction or lose an activity write.
  • logActivity — the publish closure is parameterized by which handle the enqueue uses. Inline publish passes the caller's db, so the enqueue joins any enclosing transaction. The deferPublish path passes null (the global) because it runs after commit, when the transaction handle is already released — using db there would be a use-after-release.
  • heartbeat.ts — the one direct publishPluginDomainEvent caller is explicitly void-ed, since it is a sync non-transactional helper and the function now returns a promise.
  • New test server/src/__tests__/activity-log-transactional-publish.test.ts — 4 cases against embedded Postgres.

No migration. No API-surface change for logActivity callers: deferPublish semantics and the ActivityPublish return type are unchanged.

Verification

New regression test — this is the case Ally asked for (force the transaction to roll back after activity logging, prove no live/plugin event escapes):

npx vitest run server/src/__tests__/activity-log-transactional-publish.test.ts
  ✓ enqueues no plugin event when the enclosing transaction rolls back
  ✓ enqueues the plugin event when the enclosing transaction commits
  ✓ deferPublish withholds both the live event and the outbox row until after commit
  ✓ still publishes inline for a caller outside any transaction
  Test Files  1 passed (1)   Tests  4 passed (4)

Mutation-checked, so the green is load-bearing. Reverting just the fix (publishPluginDomainEvent(event, outboxDb)(event, null), i.e. pre-fix behaviour) fails the rollback case with the exact orphan row, while the other three stay green as controls:

× enqueues no plugin event when the enclosing transaction rolls back
  AssertionError: expected [ { …(12) } ] to have a length of +0 but got 1
  Tests  1 failed | 3 passed (4)

Existing suites over the same surface, unchanged:

npx vitest run activity-log-transactional-publish plugin-event-outbox \
                activity-service activity-log-responsible-user
  Test Files  4 passed (4)   Tests  22 passed (22)

npx vitest run plugin-orchestration-apis heartbeat-process-recovery \
                issue-comment-reopen-routes
  Test Files  3 passed (3)   Tests  289 passed (289)

npx tsc --noEmit -p server/tsconfig.json → exit 0.

CI gate to watch: the grouped general test suites job, asserting activity-log-transactional-publish.test.ts passes.

Risks

Low, with two behavioural shifts worth naming.

  • The inline enqueue is now awaited. logActivity previously returned before the outbox row landed; it now waits one extra round-trip on the caller's connection for actions that map to a plugin event. Correctness gain (ordering + visible failures), small latency cost. The 289 existing tests that poll for outbox rows are unaffected.
  • Enqueue failures could abort a transaction — deliberately prevented. Threading the insert into the caller's transaction means a failing insert could have poisoned it. publishPluginDomainEvent therefore catches and logs rather than rethrowing, preserving today's semantics: an outbox failure never costs you the activity write. (Postgres will still mark a transaction aborted if the insert itself errors — but that is an already-exceptional path, not a new one this PR creates.)
  • Not addressed here: the four existing transactional callers still publish inline, so they continue to leak the in-memory live event on rollback. Closing that means hoisting a deferred publish out of two large db.transaction blocks in heartbeat.ts; I left it out deliberately to keep this diff reviewable, and the leaked live event is a UI refresh hint rather than a plugin-visible domain event. Happy to follow up if a reviewer wants it in scope.

Model Used

Claude Opus 4.5 (claude-opus-5[1m], 1M context), extended thinking, with tool use and code execution — running as the Paperclip CTO agent.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — the doc comments on both functions, which is where this contract lives
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending
  • I will address all Greptile and reviewer comments before requesting merge

…andle (BLO-19132)

`logActivity` wrote its plugin domain event through the module-global
`_outboxDb` handle rather than the `db` it was handed. For a caller inside a
transaction that meant the outbox row committed on a separate connection,
independently of the activity row it describes: roll the caller's transaction
back and the worker-tier poller still emits a domain event for an entity that
never existed. The insert was also `void ... .catch(...)`, so ordering was
unguaranteed and failures were invisible.

Route the enqueue through the handle passed to `logActivity` and await it, so
an inline publish is atomic with any enclosing transaction. The deferred path
(`deferPublish`, added in #806) still uses the global, which is required there:
it runs after commit, when the transaction handle is already released.

`publishPluginDomainEvent` never rejects — an enqueue failure is logged and
swallowed, exactly as before — so this cannot newly abort a caller's
transaction or lose an activity write.

Refs #953
@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19132

@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

@ally please review at head e604d3c — this is the upstream fix for the Important finding you raised on #953 (transactional logActivity / plugin-outbox gap).

Specific review focus, in priority order:

  1. Is the handle selection correct in both directions? Inline publish passes the caller db so the enqueue joins an enclosing transaction; the deferPublish path passes null (the boot global) because it runs after commit and the tx handle is released by then. I believe passing db on the deferred path would be a use-after-release — please confirm I have that the right way round.
  2. Did I introduce a new transaction-abort path? I thread the insert into the caller tx, which means a failing insert could poison it. I catch-and-log inside publishPluginDomainEvent so it never rejects, preserving prior semantics. Is that the right trade, or should an outbox failure be loud?
  3. Awaiting the inline enqueue adds a round-trip per plugin-mapped activity. Any caller where that latency is unacceptable?
  4. Deliberate scope cut: the four existing transactional callers still leak the in-memory live event on rollback (their actions are not in PLUGIN_EVENT_TYPES, so no outbox row). Closing that needs a deferred publish hoisted out of two large heartbeat.ts tx blocks. Tell me if you want it in scope.

The regression test is mutation-checked — reverting the fix fails the rollback case with the orphan row, the other three stay green as controls. Details in the PR body.

@allyblockcast

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: e604d3c

Important Issues (1)

  • [gstack/review] server/src/services/activity-log.ts:86 — Catching the enlisted outbox insert does not preserve the caller transaction. PostgreSQL marks a transaction failed when a statement errors; swallowing the rejected promise at lines 93-95 only hides the original error. A later statement or commit still fails (and some drivers surface a rollback at commit), after logActivity appeared to succeed and after the live event was emitted. This directly contradicts the new Never rejects guarantee and commit message claim that the change cannot newly abort or lose the activity write.
    • For the transactional path, let the enqueue error propagate so the atomic activity/outbox unit fails explicitly. If best-effort activity persistence is truly required, isolate the enqueue in a savepoint and define the accepted event-loss behavior. Add an embedded-Postgres regression test that forces the outbox insert to fail and asserts the intended outer-transaction result.

Strengths

  • Inline publication selects the caller handle, while deferred post-commit publication correctly selects the boot-time global rather than retaining a released transaction handle.
  • The real-Postgres tests cover commit, rollback, deferred visibility, and ordinary non-transactional publication with an actual plugin-mapped action.
  • Awaiting the inline enqueue provides deterministic ordering; the added round-trip is confined to plugin-mapped activities and is appropriate for durable outbox correctness.

Recommended Action

  1. Fix the Important issue before merge.
  2. Keep the existing live-event rollback leak as explicitly tracked follow-up scope; it is pre-existing and does not invalidate this outbox-specific fix.

This PR is authored by app/allyblockcast, so the Ally App cannot approve it. After the blocker is fixed, the exact head must be reopened under an independent author before an App approval is possible.

@kkroo

kkroo commented Aug 4, 2026

Copy link
Copy Markdown

Closing as superseded by independent human-authored carrier #1031. The carrier preserves this PR's intended activity-log/outbox atomicity fix and addresses Ally's finding by propagating enlisted outbox insert errors so the outer transaction fails explicitly and rolls back the activity row.

@kkroo kkroo closed this Aug 4, 2026
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