Skip to content

[3549 by Claude Opus 5.5] Plugin event subscriptions: dispatcher, retries, health and the Activity list - #3578

Merged
propr-dev[bot] merged 6 commits into
3542-epic-plugin-system-xu8from
3549/claude-opus-5-5-plugin-event-subscription-20261115-1434-1ne
Nov 15, 2026
Merged

propr-dev[bot] merged 6 commits into
3542-epic-plugin-system-xu8from
3549/claude-opus-5-5-plugin-event-subscription-20261115-1434-1ne

Conversation

@propr-dev

@propr-dev propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

AI Implementation Summary

Closes #3549

Branch: 3549/claude-opus-5-5-plugin-event-subscription-20261115-1434-1ne
Commits: ✅ Changes committed (ab019fe)


AI Processing Completed

Execution Details:

Summary:
Plugins now receive the repository events they subscribe to. A daemon-side dispatcher reads the event journal, retries failed handlers, tracks plugin health, and records every delivery in an Activity list on the Plugins page. All the acceptance criteria are covered by new tests, and everything I ran passes: 265 server-side tests, 53 UI component tests and 4 Playwright end-to-end tests. Typecheck and the zero-warning lint checks are clean too. I didn't run the full repository test suite or a docs site build, so docs links are unchecked.

Subscriptions. The manifest format is now { on: '<domain>.<type>', handler, filter? }. on must be a real journal event, and filter matches event fields or payload.<key> values. This replaces the old placeholder { event } shape, so I updated the 12 plugin-check test manifests that still used it. Each handler call now carries a stable deliveryId (evt-<seq>-<handler>) and the attempt number.

Delivery.

  • Cursors and order: each plugin keeps a cursor per repository. Events for one repository reach a plugin in order, one at a time; a delivery waiting on a retry holds back only its own repository.
  • Retries: a failed or timed-out (30 s) handler is retried after 1 min, 5 min, 30 min and 2 h. After the fifth attempt it is marked failed and the next event follows.
  • Lease: only one worker delivers to a plugin at a time. If a worker stalls past its lease, the event is redelivered with the same id. That is why delivery is at least once.
  • Gaps: events that left the journal before delivery are skipped, and the gap is recorded once.
  • Health: after 3 failures in a row the plugin shows as Degraded, and administrators get one Inbox notification. After 10 (set by PROPR_PLUGIN_PAUSE_AFTER_FAILURES), deliveries pause until someone presses Resume.

The dispatcher starts from src/daemon.ts, and the hourly cleanup job that prunes the journal also deletes delivery records after 14 days.

Behaviour you might not expect:

  • Enabling a plugin again starts its subscriptions fresh, the same as installing it. Events that happened while it was disabled are never delivered.
  • A manual Retry gets exactly one attempt, outside the repository's normal order. While deliveries are paused, the retry waits for Resume.
  • Retry and Resume are recorded in the plugin's audit trail, using two new audit actions.

API and UI. Three new admin-only routes live in packages/api/routes/pluginActivityRoutes.ts: list activity, retry a delivery, and resume. I regenerated the OpenAPI spec and client types. The plugin detail page now has a working Activity list with Retry and a Refresh button, plus Degraded or Paused badges and a Resume button. To keep the end-to-end spec under the 400-line limit, I moved its shared mock API into propr-ui/e2e/plugins.fixture.ts. Preview screenshots of the Degraded and Paused states, desktop and phone, are attached separately.

Acceptance criteria

  • AC1: met — packages/core/src/plugins/pluginDispatcher.ts; packages/core/test/pluginDispatcher.test.ts covers: runs once per matching event, in order per repository, with filters and the repository limit; the retry schedule then failed; the cursor surviving a restart, and a crashed attempt redelivered with the same id; history not replayed.
  • AC2: met — packages/core/test/pluginDispatcher.test.ts ("a handler on pull_request.merged that throws, hangs or exhausts its retries never affects the merge step") drives the real recordPullRequestOutcome merge step.
  • AC3: met — packages/core/test/pluginDispatcher.test.ts ("two dispatchers running at once deliver each event once (lease)"). Breaking the lease makes this test and the restart test fail.
  • AC4: met — packages/core/src/plugins/pluginDeliveries.ts, pluginDispatcher.ts; packages/core/test/pluginDispatcher.test.ts ("repeated failures make the plugin Degraded with one notification, then pause it until Resume").
  • AC5: met — packages/api/routes/pluginActivityRoutes.ts, packages/api/test/pluginActivityRoutes.test.ts, propr-ui/src/pages/PluginsPage/PluginActivity.tsx, PluginActivity.test.tsx, propr-ui/e2e/plugin-activity.pw.ts.
  • AC6: met — docs/docs/features/plugins.md (Event subscriptions: events and payloads, delivery guarantees and limits, health, Activity) and docs/docs/architecture/plugin-runtime.md (Event dispatcher); the pause setting is also listed in docs/docs/operations/configuration-reference.md.

Review digest

Acceptance criteria (6 of 6 met, as stated by the agent)

  • ✅ AC1 (met): With a test plugin: a subscribed handler runs once per matching event, in order per repository; filters are honoured; a failing handler is retried on the schedule and then marked failed; the cursor survives a worker restart without loss; duplicate delivery ids are stable. — `packages/core/src/plugins/pluginDispatcher.ts`; `packages/core/test/pluginDispatcher.test.ts` covers: runs once per matching event, in order per repository, with filters and the repository limit; the retry schedule then failed; the cursor surviving a restart, and a crashed attempt redelivered with…
  • ✅ AC2 (met): A handler that throws, hangs past the timeout or exhausts retries does not affect the originating flow (test with a handler on `pull_request.merged`). — `packages/core/test/pluginDispatcher.test.ts` ("a handler on pull_request.merged that throws, hangs or exhausts its retries never affects the merge step") drives the real `recordPullRequestOutcome` merge step.
  • ✅ AC3 (met): Two dispatchers running at once deliver each event once (lease test). — `packages/core/test/pluginDispatcher.test.ts` ("two dispatchers running at once deliver each event once (lease)"). Breaking the lease makes this test and the restart test fail.
  • ✅ AC4 (met): Degraded state, pause after repeated failures, Resume and the single notification work. — `packages/core/src/plugins/pluginDeliveries.ts`, `pluginDispatcher.ts`; `packages/core/test/pluginDispatcher.test.ts` ("repeated failures make the plugin Degraded with one notification, then pause it until Resume").
  • ✅ AC5 (met): Activity entries are recorded and visible through the API; the Plugins page shows them with Retry. — `packages/api/routes/pluginActivityRoutes.ts`, `packages/api/test/pluginActivityRoutes.test.ts`, `propr-ui/src/pages/PluginsPage/PluginActivity.tsx`, `PluginActivity.test.tsx`, `propr-ui/e2e/plugin-activity.pw.ts`.
  • ✅ AC6 (met): Docs: events list with payloads, delivery guarantees and limits in `docs/docs/features/plugins.md` and `docs/docs/architecture/plugin-runtime.md`. — `docs/docs/features/plugins.md` (Event subscriptions: events and payloads, delivery guarantees and limits, health, Activity) and `docs/docs/architecture/plugin-runtime.md` (Event dispatcher); the pause setting is also listed in `docs/docs/operations/configuration-reference.md`.

Risk-tiered paths

  • High risk (1 file)
    • packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js (added, +74/−0): database migration
  • Normal risk (23 files)
    • packages/api/openapi/registeredRoutes.ts (+1/−1)
    • packages/api/openapi/routeDocsPlugins.ts (+11/−0)
    • packages/api/openapi/schemasPlugins.ts (+29/−1)
    • packages/api/pluginRouteRegistry.ts (+6/−2)
    • packages/api/routes/pluginActivityRoutes.ts (added, +31/−0)
    • packages/api/routes/pluginRoutes.ts (+19/−9)
    • packages/api/server.ts (+2/−1)
    • packages/client/src/generated/apiTypes.ts (+64/−1)
    • packages/core/src/index.ts (+3/−0)
    • packages/core/src/plugins/pluginDeliveries.ts (added, +219/−0)
    • packages/core/src/plugins/pluginDispatcher.ts (added, +371/−0)
    • packages/core/src/plugins/pluginStore.ts (+6/−0)
    • packages/shared/src/index.ts (+1/−0)
    • packages/shared/src/pluginDeliveries.ts (added, +77/−0)
    • packages/shared/src/pluginInstallations.ts (+1/−1)
    • packages/shared/src/pluginSdk.ts (+7/−1)
    • packages/shared/src/plugins.ts (+39/−4)
    • propr-ui/src/api/pluginsApi.ts (+11/−0)
    • propr-ui/src/pages/PluginsPage/PluginActivity.tsx (+107/−8)
    • propr-ui/src/pages/PluginsPage/PluginDetail.tsx (+6/−3)
    • propr-ui/src/pages/PluginsPage/usePluginActivity.ts (added, +45/−0)
    • src/daemon.ts (+7/−2)
    • src/daemon/pluginEventDispatch.ts (added, +26/−0)
  • Low risk (34 files)
    • docs/docs/architecture/plugin-runtime.md (+105/−5)
    • docs/docs/features/plugins.md (+139/−8)
    • docs/docs/operations/configuration-reference.md (+1/−0)
    • docs/static/openapi/propr-api.yaml (+326/−2)
    • packages/api/test/pluginActivityRoutes.test.ts (added, +138/−0)
    • packages/api/test/pluginRoutes.test.ts (+5/−2)
    • packages/core/test/fixtures/plugins/fixture-events/handlers.ts (added, +39/−0)
    • packages/core/test/fixtures/plugins/fixture-events/manifest.json (added, +19/−0)
    • packages/core/test/fixtures/plugins/registry.json (+2/−1)
    • packages/core/test/helpers/pluginHandlerFixture.ts (+14/−8)
    • packages/core/test/pluginContributions.test.ts (+3/−1)
    • packages/core/test/pluginDispatcher.test.ts (added, +316/−0)
    • packages/core/test/pluginHandlerRunner.test.ts (+1/−1)
    • packages/core/test/pluginStore.test.ts (+3/−1)
    • packages/plugins/test/testing.test.ts (+1/−0)
    • packages/shared/test/plugins.test.ts (+14/−2)
    • propr-ui/e2e/plugin-activity.pw.ts (added, +69/−0)
    • propr-ui/e2e/plugins.fixture.ts (added, +235/−0)
    • propr-ui/e2e/plugins.pw.ts (+2/−194)
    • propr-ui/src/pages/PluginsPage/PluginActivity.test.tsx (added, +112/−0)
    • propr-ui/src/pages/PluginsPage/PluginDetail.test.tsx (+1/−0)
    • propr-ui/src/pages/PluginsPage/PluginSettingsAndApproval.test.tsx (+3/−1)
    • test/fixtures/plugins-check/manifests/bad-id.json (+2/−1)
    • test/fixtures/plugins-check/manifests/duplicate-server-name.json (+2/−1)
    • test/fixtures/plugins-check/manifests/host-not-hostname.json (+2/−1)
    • test/fixtures/plugins-check/manifests/missing-connection.json (+2/−1)
    • …and 8 more

Validation evidence

  • No repository validation commands ran.

Diff size: 58 files changed, +2,734/−272 lines

Detailed Logs:

  • Conversation: 44 messages
  • Session: 92c5b2b6-c348-4327-bca2-66ec3de55110

Log files stored at:

  • conversation: /usr/src/app/logs/executions/issue-3549-2026-11-15T15-12-17-353Z-conversation.json
  • output: /usr/src/app/logs/executions/issue-3549-2026-11-15T15-12-17-353Z-output.txt
Latest Conversation Messages
ASSISTANT: [content unavailable]

ASSISTANT: Plugins now receive the repository events they subscribe to. A daemon-side dispatcher reads the event journal, retries failed handlers, tracks plugin health, and records every delivery in an Activity ...


This PR was created automatically by ProPR after processing issue #3549.


💡 Need changes?

Comment on this PR to request refinements — the AI agent monitors comments and will update the implementation based on your feedback. Keep iterating until you're satisfied!


Visual preview

Plugin Activity: Degraded

Plugin Activity: Degraded

The plugin detail page's Activity list at desktop width with Degraded health and the last error, failed deliveries with Retry, a timed-out delivery waiting to retry, successes with expandable log lines and a retention gap.

Full-size original

Plugin Activity: Paused with Resume

Plugin Activity: Paused with Resume

Deliveries paused after 10 consecutive failures, with the Resume deliveries action; the delivery retried earlier is pending again.

Full-size original

Plugin Activity on a phone

Plugin Activity on a phone

The same Paused state and Activity list at 390 px width.

Full-size original

…-side dispatcher reads the event journal, retries failed handlers, tracks plugin health, and records every delivery in an Activity list on the Plugins page. All the acceptance criteria are covered by new tests, and everything I ran passes: 265 server-side tests, 53 UI component tests and 4 Playwright end-to-end tests. Typecheck and the zero-warning lint checks are clean too. I didn't run the full repository test suite or a docs site build, so docs links are unchecked.

Subscriptions. The manifest format is now `{ on: '<domain>.<type>', handler, filter? }`. `on` must be a real journal event, and `filter` matches event fields or `payload.<key>` values. This replaces the old placeholder `{ event }` shape, so I updated the 12 plugin-check test manifests that still used it. Each handler call now carries a stable `deliveryId` (`evt-<seq>-<handler>`) and the `attempt` number.

Delivery.
- Cursors and order: each plugin keeps a cursor per repository. Events for one repository reach a plugin in order, one at a time; a delivery waiting on a retry holds back only its own repository.
- Retries: a failed or timed-out (30 s) handler is retried after 1 min, 5 min, 30 min and 2 h. After the fifth attempt it is marked failed and the next event follows.
- Lease: only one worker delivers to a plugin at a time. If a worker stalls past its lease, the event is redelivered with the same id. That is why delivery is at least once.
- Gaps: events that left the journal before delivery are skipped, and the gap is recorded once.
- Health: after 3 failures in a row the plugin shows as Degraded, and administrators get one Inbox notification. After 10 (set by `PROPR_PLUGIN_PAUSE_AFTER_FAILURES`), deliveries pause until someone presses Resume.

The dispatcher starts from `src/daemon.ts`, and the hourly cleanup job that prunes the journal also deletes delivery records after 14 days.

Behaviour you might not expect:
- Enabling a plugin again starts its subscriptions fresh, the same as installing it. Events that happened while it was disabled are never delivered.
- A manual Retry gets exactly one attempt, outside the repository's normal order. While deliveries are paused, the retry waits for Resume.
- Retry and Resume are recorded in the plugin's audit trail, using two new audit actions.

API and UI. Three new admin-only routes live in `packages/api/routes/pluginActivityRoutes.ts`: list activity, retry a delivery, and resume. I regenerated the OpenAPI spec and client types. The plugin detail page now has a working Activity list with Retry and a Refresh button, plus Degraded or Paused badges and a Resume button. To keep the end-to-end spec under the 400-line limit, I moved its shared mock API into `propr-ui/e2e/plugins.fixture.ts`. Preview screenshots of the Degraded and Paused states, desktop and phone, are attached separately.

Acceptance criteria
- AC1: met — `packages/core/src/plugins/pluginDispatcher.ts`; `packages/core/test/pluginDispatcher.test.ts` covers: runs once per matching event, in order per repository, with filters and the repository limit; the retry schedule then failed; the cursor surviving a restart, and a crashed attempt redelivered with the same id; history not replayed.
- AC2: met — `packages/core/test/pluginDispatcher.test.ts` ("a handler on pull_request.merged that throws, hangs or exhausts its retries never affects the merge step") drives the real `recordPullRequestOutcome` merge step.
- AC3: met — `packages/core/test/pluginDispatcher.test.ts` ("two dispatchers running at once deliver each event once (lease)"). Breaking the lease makes this test and the restart test fail.
- AC4: met — `packages/core/src/plugins/pluginDeliveries.ts`, `pluginDispatcher.ts`; `packages/core/test/pluginDispatcher.test.ts` ("repeated failures make the plugin Degraded with one notification, then pause it until Resume").
- AC5: met — `packages/api/routes/pluginActivityRoutes.ts`, `packages/api/test/pluginActivityRoutes.test.ts`, `propr-ui/src/pages/PluginsPage/PluginActivity.tsx`, `PluginActivity.test.tsx`, `propr-ui/e2e/plugin-activity.pw.ts`.
- AC6: met — `docs/docs/features/plugins.md` (Event subscriptions: events and payloads, delivery guarantees and limits, health, Activity) and `docs/docs/architecture/plugin-runtime.md` (Event dispatcher); the pause setting is also listed in `docs/docs/operations/configuration-reference.md`.
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

/ultrafix goal=8 max=10
Triggered automatically by the requested execution settings.

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔄 Ultrafix loop started (goal: 8/10, max cycles: 10)

First action: /review

💡 Tip: Remove the ultrafix label from this PR to stop further ultrafix cycles.

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR implements journal-driven plugin subscriptions, durable delivery records, retries, health tracking, and an administrative Activity UI. It needs significant corrections before merge: pause enforcement, recovery, and delivery authorization have reachable gaps.

✅ Producer isolation — Delivery runs separately from journal appends, and the added tests exercise the real merge-outcome path with failing and timed-out handlers.

✅ API integration — Activity, Retry, and Resume reuse the administrator/preflight wrapper and include API schemas, client types, and UI coverage.

✅ Stable delivery identity — Delivery IDs consistently derive from event sequence and handler name across ordinary retries and worker recovery.

This review is a static analysis of the supplied code; no commands were run. The supplied current-head status reports no failed checks and four pending checks.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F1: 🔴 Pausing does not stop the current pass

  • Category: correctness

  • Required behavior: Once the failure threshold is reached, deliveries must stop until an administrator resumes them.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, deliver, deliverRepository, and manualRetries.

    1. A plugin has events in repositories A and B, and its failure count is one below the pause threshold.
    2. An attempt in A fails. recordHealth persists paused_at, and deliverRepository throws Blocked.
    3. deliver catches Blocked and immediately executes continue, bypassing its subsequent pause check.
    4. Repository B starts another handler because attempt renews the lease without checking paused_at.

    A manual retry that reaches the threshold has the same problem: manualRetries breaks its loop, but deliver proceeds into ordinary repository delivery. Consequently, handlers can produce additional effects after the plugin is paused. Installation filtering checks pause only before the pass; neither lease renewal nor the runner enforces delivery health.

    static trace: Both paths follow directly from the added control flow. The existing pause test uses one repository and does not exercise either continuation.

  • Minimum fix: Stop the installation’s pass whenever it becomes paused, including after manual retries and on the Blocked path. Check pause before starting each subsequent attempt.

F2: 🔴 Crashed manual retries are never recovered

  • Category: correctness

  • Required behavior: An interrupted delivery must remain recoverable after worker restart with its stable delivery ID.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, manualRetries, attempt, and deliverRepository.

    1. An event exhausts its automatic retries, becomes failed, and its repository cursor advances past it.
    2. An administrator requests Retry. The record becomes pending with an existing attempt count.
    3. manualRetries selects it, and attempt persists running. The worker terminates while awaiting the handler.
    4. After lease expiry, a replacement dispatcher excludes the record from manualRetries, which selects only pending. Ordinary delivery also excludes its event because the repository cursor has already advanced.

    The delivery remains running indefinitely, receives no recovery attempt, and cannot be retried through the API because that route accepts only failed. Lease takeover alone cannot recover it. The existing crash test covers an event still ahead of its cursor, not an out-of-order manual retry.

    static trace: The selection predicates and cursor progression establish this failure without assumptions about handler behavior beyond interruption during execution.

  • Minimum fix: Recover abandoned manual attempts under the acquired lease, retaining their stable ID and one-attempt manual retry semantics. Persist a manual-retry marker if necessary to distinguish their recovery from automatic retries.

F3: 🔴 Delivery bypasses current installation scope

  • Category: security

  • Required behavior: Event delivery must respect the plugin’s current enabled state and repository limit; restricting SDK calls does not authorize disclosure of the input event.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, manualRetries, deliver, and attempt.

    1. A delivery from repository A fails. An administrator then restricts the plugin to repository B and requests Retry for the failed A delivery.
    2. manualRetries selects the record without applying the installation’s repository limit and loads the A event.
    3. attempt passes that event directly to the handler. The repository filter in deliver runs only afterward, for ordinary stream delivery.

    The handler therefore receives an event from a repository outside its current scope. The API permits this retry, and the runner’s scoped ctx.propr checks protect subsequent SDK operations, not the event already handed to plugin code.

    The ordinary path shares the missing authorization check: while attempt awaits one handler, an administrator can disable the plugin or narrow its repository limit. Subsequent attempts reuse the pass’s previously loaded installation.target; renew checks only the dispatch lease. Disabling does not invalidate that lease. The supplied runner checks the loaded record’s enabled flag, so these subsequent invocations can still receive events.

    static trace: Verified against the changed dispatcher and store paths and the supplied runner/context excerpts. Handler input is delivered independently of any ctx.propr call.

  • Minimum fix: Before each invocation, validate the current installation identity, enabled state, effective events.read grant, and event repository scope. Apply the same guard to manual and ordinary deliveries, and stop stale passes after a dispatch restart.

F4: 🔴 Fully pruned repositories lose gap reporting

  • Category: correctness

  • Required behavior: Events removed before delivery must be skipped with a recorded retention gap, and unfinished delivery records must reach a terminal state.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, deliver and cursor; packages/core/src/plugins/pluginDeliveries.ts, prunePluginDeliveries.

    1. A plugin has undelivered events in repository A, potentially including a retrying delivery, and remains paused or offline beyond journal retention.
    2. Journal cleanup removes every retained event in A and records its repository floor.
    3. After Resume or restart, deliver discovers repositories exclusively from remaining repository_events rows after start_seq.
    4. A is absent, so cursor never reads its floor and never records the gap.

    Activity silently omits the missed events until another event happens to arrive in A. If a delivery record already existed, it remains retrying or running; even a later gap does not finalize that missing event’s record. Delivery cleanup excludes these statuses, leaving misleading activity and permanently retained unfinished records.

    static trace: Repository discovery cannot see a fully pruned repository, and neither gap handling nor pruning transitions its unfinished records. The added retention test always leaves a newer event in the same repository, avoiding this case.

  • Minimum fix: Include relevant journal floors, existing cursors, and unfinished deliveries when discovering repositories. Record gaps even when no events remain, and terminalize unfinished deliveries whose exact journal events no longer exist, while preserving retained events below the floor.

Suggestions

These are optional follow-ups and are not sent to /fix.

S1: 🟢 Make the example deduplication sustainable

The new example in docs/docs/features/plugins.md stores one posted:${deliveryId} key forever, while the documented state limit is 256 keys. Consider a bounded deduplication example or an upstream idempotency key, and check the HTTP response before marking delivery complete. This is optional documentation guidance rather than a dispatcher merge requirement.

Score

The implementation covers the principal surfaces and has substantial tests, but the verified lifecycle and authorization defects prevent merge readiness. Passing checks do not cover the demonstrated cross-repository, manual-recovery, and retention cases.

Score: 5/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 25s
  • Tokens: 105,161 (103,077 in / 2,084 out)
  • Cost: $1.03

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F1 F2 F3 F4 S1

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 583db81

Addressed 1 AI review comment (IDs: 6896880860)

Addressed findings F1, F2, F3, F4

Summary of Changes

I fixed all four findings (F1–F4) in the dispatcher. To check the new tests, I also ran them against the PR's previous source: 8 of 10 fail there and all 10 pass now. The old and new dispatcher suites pass together (18 tests). The plugin route, store, contributions and runner suites pass too (37 tests), and core typecheck and the zero-warning lint are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

F1 – pausing now stops the pass.

  • Before every attempt, the dispatcher renews its lease and re-reads the installation and its dispatch state; if the plugin is paused, the pass for that plugin ends.
  • An attempt that itself causes the pause also ends the pass at once. This covers the retry-wait path that used to skip on to the next repository, and a manual retry followed by ordinary delivery.

F2 – an interrupted manual retry is picked up again.

  • Retry now marks the delivery as manual (a new manual column in this PR's migration) until its one attempt is final.
  • The next lease holder also picks up manual deliveries left running, with the same delivery id. The attempt is still final, success or failure, so no automatic retries follow.

F3 – deliveries respect the plugin's current settings. The same pre-attempt check stops the pass if:

  • the plugin was disabled, enabled again since the pass began, or reinstalled.
  • it no longer has the events.read permission.
  • the event's repository is outside the current repository limit. For a manual retry it marks the delivery failed instead.

The handler is now given the installation as it is now, not as it was at the start of the pass. Marking a delivery running only succeeds if its stored state hasn't changed since it was read. One window can't be closed: a change that lands between the check and the handler starting. The next attempt sees it.

F4 – emptied repositories still get their gap recorded.

  • Besides repositories with recent events, the dispatcher now also visits:
    • repositories with unfinished deliveries.
    • repositories whose retention floor (the lowest event still guaranteed in the journal) moved past the plugin's start and past what their cursor last recorded.
  • So a gap is recorded even when no events are left in the repository.
  • When the floor moves, unfinished deliveries whose event is gone become skipped, so the 14-day cleanup removes them. Older events still in the journal below the floor are still delivered.

Tests and docs.

  • The new regressions are in packages/core/test/pluginDispatcherGuards.test.ts. The shared setup moved to packages/core/test/helpers/pluginDispatcherFixture.ts.
  • One of the new tests passes on the old code as well: "disable and enable again while a handler runs". The old lease check already caught that case, so the test only records the behaviour.
  • docs/docs/architecture/plugin-runtime.md and docs/docs/features/plugins.md now describe the pre-attempt check, how a crashed manual retry is recovered, and the repositories the dispatcher visits.

Found separately, not changed. When a plugin is enabled again, its unfinished deliveries are marked failed ("Not delivered: the plugin was disabled"), and Retry still accepts them. Retrying one would send an event from before the restart, possibly one from while the plugin was disabled. Changing that wasn't part of these four findings.

Review digest

Risk-tiered paths

  • High risk (1 file)
    • packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js (+4/−1): database migration
  • Normal risk (2 files)
    • packages/core/src/plugins/pluginDeliveries.ts (+4/−4)
    • packages/core/src/plugins/pluginDispatcher.ts (+111/−60)
  • Low risk (5 files)
    • docs/docs/architecture/plugin-runtime.md (+17/−6)
    • docs/docs/features/plugins.md (+4/−2)
    • packages/core/test/helpers/pluginDispatcherFixture.ts (added, +59/−0)
    • packages/core/test/pluginDispatcher.test.ts (+3/−41)
    • packages/core/test/pluginDispatcherGuards.test.ts (added, +160/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 8 files changed, +362/−114 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 11m 46s
  • Tokens: 3,455,449 (3,418,368 in / 37,081 out)
  • Cost: $2.01

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…ck the new tests, I also ran them against the PR's previous source: 8 of 10 fail there and all 10 pass now. The old and new dispatcher suites pass together (18 tests). The plugin route, store, contributions and runner suites pass too (37 tests), and core typecheck and the zero-warning lint are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

I fixed all four findings (F1–F4) in the dispatcher. To check the new tests, I also ran them against the PR's previous source: 8 of 10 fail there and all 10 pass now. The old and new dispatcher suites pass together (18 tests). The plugin route, store, contributions and runner suites pass too (37 tests), and core typecheck and the zero-warning lint are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

**F1 – pausing now stops the pass.**
- Before every attempt, the dispatcher renews its lease and re-reads the installation and its dispatch state; if the plugin is paused, the pass for that plugin ends.
- An attempt that itself causes the pause also ends the pass at once. This covers the retry-wait path that used to skip on to the next repository, and a manual retry followed by ordinary delivery.

**F2 – an interrupted manual retry is picked up again.**
- Retry now marks the delivery as manual (a new `manual` column in this PR's migration) until its one attempt is final.
- The next lease holder also picks up manual deliveries left `running`, with the same delivery id. The attempt is still final, success or failure, so no automatic retries follow.

**F3 – deliveries respect the plugin's current settings.** The same pre-attempt check stops the pass if:
- the plugin was disabled, enabled again since the pass began, or reinstalled.
- it no longer has the `events.read` permission.
- the event's repository is outside the current repository limit. For a manual retry it marks the delivery failed instead.

The handler is now given the installation as it is now, not as it was at the start of the pass. Marking a delivery `running` only succeeds if its stored state hasn't changed since it was read. One window can't be closed: a change that lands between the check and the handler starting. The next attempt sees it.

**F4 – emptied repositories still get their gap recorded.**
- Besides repositories with recent events, the dispatcher now also visits:
  - repositories with unfinished deliveries.
  - repositories whose retention floor (the lowest event still guaranteed in the journal) moved past the plugin's start and past what their cursor last recorded.
- So a gap is recorded even when no events are left in the repository.
- When the floor moves, unfinished deliveries whose event is gone become `skipped`, so the 14-day cleanup removes them. Older events still in the journal below the floor are still delivered.

**Tests and docs.**
- The new regressions are in `packages/core/test/pluginDispatcherGuards.test.ts`. The shared setup moved to `packages/core/test/helpers/pluginDispatcherFixture.ts`.
- One of the new tests passes on the old code as well: "disable and enable again while a handler runs". The old lease check already caught that case, so the test only records the behaviour.
- `docs/docs/architecture/plugin-runtime.md` and `docs/docs/features/plugins.md` now describe the pre-attempt check, how a crashed manual retry is recovered, and the repositories the dispatcher visits.

**Found separately, not changed.** When a plugin is enabled again, its unfinished deliveries are marked failed ("Not delivered: the plugin was disabled"), and Retry still accepts them. Retrying one would send an event from before the restart, possibly one from while the plugin was disabled. Changing that wasn't part of these four findings.

PR: #3578
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

This PR adds durable plugin event delivery, retries, health tracking, and an administrator Activity interface. It needs changes before merge: retention can erase unfinished delivery obligations, continuous appends can starve dispatch, and repository casing can break ordering.

✅ Scoped delivery checks — Each attempt refreshes installation permissions and repository limits, and pause transitions stop subsequent attempts.

✅ Integrated administration — Activity, Retry, and Resume share the existing administrator request wrapper and include API schemas, audit records, and UI coverage.

✅ Recovery coverage — Added tests exercise retry schedules, worker takeover, manual retry recovery, and retention gaps.

This review is a static analysis of the supplied code; no commands were run. Current checks show 29 passed, five pending, and no failures.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F5: 🔴 Retention removes records still needed by cursors

  • Category: correctness

  • Required behavior: Durable cursors and retry state must preserve completed handler outcomes until the containing event has been fully processed.

  • Evidence: packages/core/src/plugins/pluginDeliveries.ts, prunePluginDeliveries; packages/core/src/plugins/pluginDispatcher.ts, deliverRepository and attempt.

    1. An event matches two handlers. The first succeeds; the second repeatedly fails and pauses the installation. The repository cursor remains before this event.
    2. The installation stays paused for 15 days. The hourly cleanup deletes the first handler’s successful record because it is final and its created_at exceeds 14 days. The journal still retains the event for 30 days.
    3. Resume processes the event again. delivery() recreates the deleted record as pending, and the already successful first handler runs again with attempt 1.

    A related case occurs when the failure that triggers pause is terminal: attempt() throws Stopped before the cursor advances. Cleanup can delete that failed record, so Resume restarts its exhausted automatic retry budget.

    The unique delivery key prevents duplicates only while the record exists; cleanup does not inspect cursor progress. static trace: both sequences follow the supplied cleanup predicates and cursor-update order. These are maintenance-induced replays and retry resets without a worker crash.

  • Minimum fix: Retain terminal delivery records while their event remains ahead of the repository cursor, or retain equivalent durable completion markers. Cover both partially completed multi-handler events and terminal failures that trigger pause.

F6: 🔴 Continuous appends can prevent dispatch indefinitely

  • Category: correctness

  • Required behavior: Subscribed events must continue to be delivered while events are being appended.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, start, wake, and loop.

    1. A dispatcher finishes a pass and schedules its next poll.
    2. Journal appends in the same process arrive continuously less than 200 milliseconds apart.
    3. Each append calls wake(200), which clears the existing timer and schedules a new timer 200 milliseconds later.
    4. No timer fires while that traffic continues, so no further pass starts and subscribed events accumulate undelivered.

    This is a trailing debounce with no maximum wait. The five-second poll is also cleared by append notifications, and again protects only an already running pass. static trace: the append subscription and unconditional clearTimeout establish this sequence directly; the supplied journal context confirms that every committed local append invokes listeners.

  • Minimum fix: Preserve an already scheduled earlier wake, or otherwise bound the maximum delay under continuous appends. Add a regression showing dispatch progresses without a quiet interval.

F7: 🔴 Repository casing splits the ordered stream

  • Category: correctness

  • Required behavior: One plugin must receive events for the same repository in journal order, including repository names differing only in letter case.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, repositories, cursor, and deliverRepository; packages/core/test/pluginDispatcher.test.ts, first ordering test.

    1. Append an event for owner/allowed, followed by an event for Owner/Allowed. Both spellings are accepted by the added fixture and pass the case-insensitive repository limit.
    2. repositories() retains both strings as distinct entries and sorts them. Owner/Allowed sorts first.
    3. Separate cursors and exact repository queries deliver the newer uppercase-spelled event before the earlier lowercase-spelled event.

    A retry waiting on one spelling also fails to block later events under the other spelling. The added test includes both spellings but checks ordering separately using case-sensitive equality, so it does not detect this violation. static trace: repository enumeration, sorting, cursor keys, and the supplied exact-match journal query demonstrate the reversed order.

  • Minimum fix: Use one canonical repository identity for dispatch enumeration, cursors, and retry barriers, reading all existing casing variants together in sequence order. Test mixed-case events as one ordered stream.

Suggestions

These are optional follow-ups and are not sent to /fix.

S2: 🟢 Bound the deduplication example

The new example in docs/docs/features/plugins.md stores one posted:<deliveryId> key forever, while the documented state limit is 256 keys. Explain bounded retention or downstream idempotency, including the crash window between posting and recording success. This is optional documentation guidance rather than a dispatcher merge requirement.

Score

The implementation has substantial integration and recovery coverage, but three verified delivery defects remain. Localized corrections should preserve the overall approach.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 30s
  • Tokens: 109,432 (107,443 in / 1,989 out)
  • Cost: $1.07

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F5 F6 F7 S2

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 1f801d2

Addressed 1 AI review comment (IDs: 6897253683)

Addressed findings F5, F6, F7

Summary of Changes

I fixed F5, F6 and F7. Each one has a new regression test, and all four new tests fail when run against the PR's previous source.

F5 (cleanup deleted records a cursor still needed). prunePluginDeliveries in pluginDeliveries.ts now keeps a finished record, even past 14 days, when two things hold:

  • its event is still in the journal, and
  • the event is still ahead of the repository's cursor (or of the installation's start, if there is no cursor yet).

Once the cursor moves past the event, or retention removes the event from the journal, the record is pruned as before. So nothing is kept forever. Both cases from the finding are tested in the new file packages/core/test/pluginDispatcherStreams.test.ts:

  • Two handlers, one paused: the first handler succeeded and the second is retrying while deliveries are paused. Cleanup at 15 days keeps both records, and after Resume only the retrying handler runs.
  • Final failure that paused deliveries: cleanup keeps the failed record, so Resume doesn't start its retries over, and the next event is then delivered.

F6 (constant appends blocked dispatch). wake() now remembers when the scheduled pass is due. An append only brings a pass forward and never pushes one back. The test appends every 40 ms, well inside the 200 ms wake delay, with the 5-second poll set to a minute. It checks that delivery happens while the appends are still arriving.

F7 (letter case split one repository into two streams). The dispatcher now treats every spelling of a repository name as one repository, keyed by its lower-case form. That covers which repositories it visits, the cursor, the gap floor (the highest floor of any spelling) and the cleanup of unfinished deliveries. Events are read across all spellings in journal order in a single query, through a new anyCase option on listRepositoryEvents. It uses the case-insensitive journal index that already exists. A single query also avoids a gap where an event under a newly seen spelling could be skipped. The new test checks that:

  • owner/allowed, Owner/Allowed and OWNER/ALLOWED events arrive in journal order with one cursor.
  • a retry waiting on one spelling holds back a later event under another.

I also changed the existing ordering test to group repositories case-insensitively, which was the check F7 said it missed.

Supporting edits:

  • The fixture-events test manifest has two new pull_request.closed subscriptions (record and flaky), so one event can match two handlers. No existing test uses that event type.
  • The docs (plugin-runtime.md, plugins.md) now describe case-insensitive repository streams, the wake behaviour, and the longer retention while an event still waits.

Checks: the three dispatcher suites pass (22 tests), and so do the plugin contributions, lifecycle, runner, store and journal suites (64 tests). Core typecheck and the zero-warning lint on the changed source files are clean. I didn't run the full repository suite.

Separate observation, not changed: the lower-case key comes from JavaScript, while the database's case-insensitive matching only folds A–Z. They agree for repository names, which GitHub limits to plain ASCII letters.

Nothing on screen changes, so there are no previews.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (3 files)
    • packages/core/src/plugins/pluginDeliveries.ts (+21/−2)
    • packages/core/src/plugins/pluginDispatcher.ts (+27/−15)
    • packages/core/src/services/repositoryEventJournal.ts (+4/−1)
  • Low risk (5 files)
    • docs/docs/architecture/plugin-runtime.md (+12/−7)
    • docs/docs/features/plugins.md (+2/−1)
    • packages/core/test/fixtures/plugins/fixture-events/manifest.json (+3/−1)
    • packages/core/test/pluginDispatcher.test.ts (+2/−1)
    • packages/core/test/pluginDispatcherStreams.test.ts (added, +87/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 8 files changed, +158/−28 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 8m 40s
  • Tokens: 3,271,275 (3,248,258 in / 23,017 out)
  • Cost: $1.60

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…and all four new tests fail when run against the PR's previous source.

I fixed F5, F6 and F7. Each one has a new regression test, and all four new tests fail when run against the PR's previous source.

**F5 (cleanup deleted records a cursor still needed).** `prunePluginDeliveries` in `pluginDeliveries.ts` now keeps a finished record, even past 14 days, when two things hold:
- its event is still in the journal, and
- the event is still ahead of the repository's cursor (or of the installation's start, if there is no cursor yet).

Once the cursor moves past the event, or retention removes the event from the journal, the record is pruned as before. So nothing is kept forever. Both cases from the finding are tested in the new file `packages/core/test/pluginDispatcherStreams.test.ts`:
- **Two handlers, one paused:** the first handler succeeded and the second is retrying while deliveries are paused. Cleanup at 15 days keeps both records, and after Resume only the retrying handler runs.
- **Final failure that paused deliveries:** cleanup keeps the failed record, so Resume doesn't start its retries over, and the next event is then delivered.

**F6 (constant appends blocked dispatch).** `wake()` now remembers when the scheduled pass is due. An append only brings a pass forward and never pushes one back. The test appends every 40 ms, well inside the 200 ms wake delay, with the 5-second poll set to a minute. It checks that delivery happens while the appends are still arriving.

**F7 (letter case split one repository into two streams).** The dispatcher now treats every spelling of a repository name as one repository, keyed by its lower-case form. That covers which repositories it visits, the cursor, the gap floor (the highest floor of any spelling) and the cleanup of unfinished deliveries. Events are read across all spellings in journal order in a single query, through a new `anyCase` option on `listRepositoryEvents`. It uses the case-insensitive journal index that already exists. A single query also avoids a gap where an event under a newly seen spelling could be skipped. The new test checks that:
- `owner/allowed`, `Owner/Allowed` and `OWNER/ALLOWED` events arrive in journal order with one cursor.
- a retry waiting on one spelling holds back a later event under another.

I also changed the existing ordering test to group repositories case-insensitively, which was the check F7 said it missed.

**Supporting edits:**
- The `fixture-events` test manifest has two new `pull_request.closed` subscriptions (`record` and `flaky`), so one event can match two handlers. No existing test uses that event type.
- The docs (`plugin-runtime.md`, `plugins.md`) now describe case-insensitive repository streams, the wake behaviour, and the longer retention while an event still waits.

**Checks:** the three dispatcher suites pass (22 tests), and so do the plugin contributions, lifecycle, runner, store and journal suites (64 tests). Core typecheck and the zero-warning lint on the changed source files are clean. I didn't run the full repository suite.

**Separate observation, not changed:** the lower-case key comes from JavaScript, while the database's case-insensitive matching only folds A–Z. They agree for repository names, which GitHub limits to plain ASCII letters.

Nothing on screen changes, so there are no previews.

PR: #3578
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR implements durable event delivery, retries, health reporting, and an administrative Activity UI. It needs changes before merge: a re-enable race can permanently stall delivery, and failed degradation notifications are never retried.

✅ Stable delivery identity — Automatic retries and crash recovery preserve evt-<seq>-<handler> identifiers.

✅ Administrative access controls — Activity, Retry, and Resume reuse the existing permission and request-contract checks.

✅ Focused recovery coverage — Added tests cover repository ordering, retention, paused deliveries, and worker takeover.

This review used static analysis only; no commands were run. The supplied current-head status reports 30 passed, four pending, and no failed checks.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F8: 🔴 Stale passes can permanently stall delivery

  • Category: correctness

  • Required behavior: Re-enabling must start subscriptions at the current journal end, and subsequent eligible events must remain deliverable.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, installations, acquire, cursor, and admit; packages/core/src/plugins/pluginDeliveries.ts, restartPluginDispatch.

    1. Dispatcher A reads an installation whose start_seq is 0. The journal contains a matching event at sequence 1.
    2. Between A's awaited state read and lease acquisition, an administrator disables and re-enables the plugin. restartPluginDispatch sets start_seq to 1 and deletes its cursors.
    3. A acquires the newly reset lease. Its installation snapshot still has start_seq = 0, so repositories finds the old event and cursor inserts a cursor at 0.
    4. admit detects that the dispatch state changed and stops A, but leaves that newly inserted cursor behind.
    5. The next pass reads the correct start sequence of 1 but reuses cursor 0. It encounters event 1 again, and admit stops it because event.seq <= state.start_seq. Every subsequent pass repeats this, blocking newer events.

    The administrator's transaction does not protect later cursor creation by an already-started pass. The generation check occurs after that durable mutation. static trace: followed the awaited state-read/acquisition boundary, cursor insertion, and both admission checks in the supplied diff.

  • Minimum fix: Fence cursor creation and advancement against the current dispatch generation and lease in the same transaction as the cursor mutation. Add a regression that re-enables between installation discovery and cursor creation, then verifies a newer event is delivered.

F9: 🔴 Failed degradation notifications are abandoned

  • Category: correctness

  • Required behavior: Becoming Degraded must produce the administrator Inbox notification required by AC4.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, attempt and recordHealth; packages/core/src/plugins/pluginDeliveries.ts, notifySafely.

    1. The third consecutive failure commits degraded_at and produces a notification request.
    2. The subsequent awaited notifier fails before creating the Inbox notification. notifySafely catches and logs that failure.
    3. Later failed attempts see an existing degraded_at, so becomesDegraded remains false and no notification is attempted again.
    4. The plugin can reach Paused without administrators receiving any Inbox notification.

    The notification deduplication key prevents duplicates but does not schedule another attempt. Committing health before notification also leaves the same lost-notification window if the worker exits between those operations. static trace: the caught notifier failure has no persisted pending state or retry path.

  • Minimum fix: Persist a pending notification alongside the degradation transition and retry it until creation succeeds, using the existing deduplication key. Clear the pending obligation only after success.

Suggestions

These are optional follow-ups and are not sent to /fix.

S3: 🟢 Bound the deduplication example

The example in docs/docs/features/plugins.md stores one posted:<deliveryId> key per event indefinitely, while the documented state limit is 256 keys. Consider showing bounded retention or destination-side idempotency and explaining the interruption window between posting and saving state. This improves author guidance without changing the dispatcher’s at-least-once contract.

Score

The implementation has substantial focused coverage and coherent API integration, but the delivery-stall race and lost notification obligation require correction. Pending checks remain additional validation, not evidence against these static findings.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 26s
  • Tokens: 111,654 (109,624 in / 2,030 out)
  • Cost: $1.09

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F8 F9 S3

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 746a092

Addressed 1 AI review comment (IDs: 6897636736)

Addressed findings F8, F9

Summary of Changes

I fixed F8 and F9. Each fix has new regression tests, and all four new tests fail when run against the PR's previous source. The three dispatcher suites, the plugin store, contributions and runner suites, and the two API plugin route suites all pass (63 tests). Core typecheck and the zero-warning lint are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

F8: a pass that started before a re-enable could stall delivery for good

  • A pass now writes a cursor (creating it, moving it on, or recording a gap) only inside a transaction that first checks the dispatch row. The row must still show this dispatcher as lease holder and have the same installed_at and start_seq the pass read. If it doesn't, the pass stops and writes nothing. This is the new fenced helper in packages/core/src/plugins/pluginDispatcher.ts.
  • Moving a cursor on also requires the new position to be ahead of the stored one, so a cursor can never go backwards.
  • The same stale-start mistake also affected creating delivery records. A stale pass could leave a record of a pre-restart event stuck as "pending" in Activity forever. Creating those records now goes through the same check.
  • Regressions (in pluginDispatcherGuards.test.ts): the plugin is disabled and enabled again either between reading the installation and taking the lease, or between creating the cursor and the delivery record. In both cases nothing from the earlier start is left behind, and a newer event is delivered.

F9: a failed Degraded notification was never sent again

  • The migration adds a degraded_notice column. If a database already ran the earlier copy of the migration, it adds the column there too.
  • The transaction that sets degraded_at now also stores the notification that is owed. That covers both a failed send and the worker exiting before it sends.
  • sendPendingDegradedNotices in pluginDeliveries.ts sends what is owed right after the attempt and again at the start of each pass, paused plugins included. After a failure it waits a minute before trying again. It clears the stored notice only once sending succeeds, and only if it is still the same notice. The existing deduplication key keeps a repeat, or two dispatchers sending at once, to one Inbox entry.
  • Enabling the plugin again keeps a notice that is still owed.
  • Regressions: the Inbox fails first and works later, so the notification arrives exactly once after the plugin has paused. The worker hangs before sending, and the next dispatcher sends it.

I updated docs/docs/architecture/plugin-runtime.md to describe both changes.

Two limits of the F9 fix you might not expect:

  • If the plugin recovers and becomes Degraded again while an earlier notice is still unsent, the newer notice replaces the older one. Administrators get one notification, about the latest time it became Degraded.
  • Uninstalling deletes the dispatch row, and any notice still owed goes with it.

One race no check can close: an administrator can re-enable the plugin between the last check and the handler starting. That gap was already there and is unchanged; the next write sees the change and stops.

Review digest

Risk-tiered paths

  • High risk (1 file)
    • packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js (+7/−0): database migration
  • Normal risk (2 files)
    • packages/core/src/plugins/pluginDeliveries.ts (+34/−3)
    • packages/core/src/plugins/pluginDispatcher.ts (+61/−20)
  • Low risk (2 files)
    • docs/docs/architecture/plugin-runtime.md (+8/−2)
    • packages/core/test/pluginDispatcherGuards.test.ts (+86/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 5 files changed, +196/−25 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @propr-ultrafix
  • Time: 8m 47s
  • Tokens: 2,282,111 (2,257,268 in / 24,843 out)
  • Cost: $1.42

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…ll four new tests fail when run against the PR's previous source. The three dispatcher suites, the plugin store, contributions and runner suites, and the two API plugin route suites all pass (63 tests). Core typecheck and the zero-warning lint are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

I fixed F8 and F9. Each fix has new regression tests, and all four new tests fail when run against the PR's previous source. The three dispatcher suites, the plugin store, contributions and runner suites, and the two API plugin route suites all pass (63 tests). Core typecheck and the zero-warning lint are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

**F8: a pass that started before a re-enable could stall delivery for good**
- A pass now writes a cursor (creating it, moving it on, or recording a gap) only inside a transaction that first checks the dispatch row. The row must still show this dispatcher as lease holder and have the same `installed_at` and `start_seq` the pass read. If it doesn't, the pass stops and writes nothing. This is the new `fenced` helper in `packages/core/src/plugins/pluginDispatcher.ts`.
- Moving a cursor on also requires the new position to be ahead of the stored one, so a cursor can never go backwards.
- The same stale-start mistake also affected creating delivery records. A stale pass could leave a record of a pre-restart event stuck as "pending" in Activity forever. Creating those records now goes through the same check.
- **Regressions** (in `pluginDispatcherGuards.test.ts`): the plugin is disabled and enabled again either between reading the installation and taking the lease, or between creating the cursor and the delivery record. In both cases nothing from the earlier start is left behind, and a newer event is delivered.

**F9: a failed Degraded notification was never sent again**
- The migration adds a `degraded_notice` column. If a database already ran the earlier copy of the migration, it adds the column there too.
- The transaction that sets `degraded_at` now also stores the notification that is owed. That covers both a failed send and the worker exiting before it sends.
- `sendPendingDegradedNotices` in `pluginDeliveries.ts` sends what is owed right after the attempt and again at the start of each pass, paused plugins included. After a failure it waits a minute before trying again. It clears the stored notice only once sending succeeds, and only if it is still the same notice. The existing deduplication key keeps a repeat, or two dispatchers sending at once, to one Inbox entry.
- Enabling the plugin again keeps a notice that is still owed.
- **Regressions:** the Inbox fails first and works later, so the notification arrives exactly once after the plugin has paused. The worker hangs before sending, and the next dispatcher sends it.

I updated `docs/docs/architecture/plugin-runtime.md` to describe both changes.

**Two limits of the F9 fix you might not expect:**
- If the plugin recovers and becomes Degraded again while an earlier notice is still unsent, the newer notice replaces the older one. Administrators get one notification, about the latest time it became Degraded.
- Uninstalling deletes the dispatch row, and any notice still owed goes with it.

**One race no check can close:** an administrator can re-enable the plugin between the last check and the handler starting. That gap was already there and is unchanged; the next write sees the change and stops.

PR: #3578
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR implements durable plugin subscriptions, retries, health reporting, and Activity controls with substantial focused coverage. It needs a localized correctness fix before merge: stale dispatcher initialization can reset a replacement installation and silently skip its events.

✅ Stable delivery identity — Delivery records and handler inputs preserve evt-<seq>-<handler> across automatic retries and crash recovery.

✅ Retention-aware recovery — Cleanup preserves completed handler records while their event remains ahead of the cursor, preventing replay when another handler is still waiting.

✅ Protected activity controls — The new routes reuse administrator authorization and mutation preflight checks, and audit Retry and Resume.

This review is a static analysis of the supplied code; no commands were run. The authoritative checks show 31 passed, three pending, and no failures.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F10: 🔴 Stale initialization resets replacement installations

  • Category: correctness

  • Required behavior: Events after an installation is enabled must survive worker processing without loss; a stale dispatcher must not overwrite a replacement installation’s dispatch state.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, installations() and state(); packages/core/src/plugins/pluginDeliveries.ts, restartPluginDispatch().

    1. installations() reads the enabled installation rows, capturing plugin P’s original installed_at.
    2. Before state() finishes reading P’s dispatch row, an administrator uninstalls P, reinstalls it with a new timestamp, and enables it. Those transactions create the replacement installation’s valid dispatch state.
    3. A matching event E is appended after the replacement is enabled.
    4. The dispatcher’s awaited dispatch-row read returns the replacement row. Because its timestamp differs from the stale installation snapshot, state() calls restartPluginDispatch() with the old installation timestamp.
    5. That helper deletes the replacement’s cursors and overwrites its dispatch row with start_seq at the current journal head, including E. On the next pass, initialization detects the reverse mismatch and resets the row again. E is now behind the start position and is never delivered.

    static trace: The installation read and dispatch-state reconciliation are separate awaited operations. Initialization holds no installation lock and performs no current-installation identity check inside its reset transaction. The transaction makes the incorrect reset atomic; it does not validate its authority. Later admission and fenced() checks cannot restore events excluded by the overwritten start position. The absent-row initialization branch likewise inserts state using the stale installation snapshot without revalidating it.

  • Minimum fix: Re-read and validate the current installation inside the transaction that creates or replaces dispatch state. If its identity differs from the snapshot, stop that installation’s pass without writing. Apply this guard to both initialization branches and add a regression covering reinstall between the installation read and state reconciliation.

Suggestions

These are optional follow-ups and are not sent to /fix.

S4: 🟢 Bound the deduplication example

The new example in docs/docs/features/plugins.md stores one posted:<deliveryId> key indefinitely, while the documented state limit is 256 keys. Consider showing bounded bookkeeping or destination-side idempotency, and explaining the crash window between posting and recording success. This is optional documentation guidance; it does not require changing the dispatcher’s at-least-once contract.

Score

The implementation has strong recovery coverage and coherent API integration, but initialization can silently lose events across a concurrent reinstall. Correcting that ownership check is necessary before merge.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 34s
  • Tokens: 112,397 (110,630 in / 1,767 out)
  • Cost: $1.09

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F10 S4

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 892bb71

Addressed 1 AI review comment (IDs: 6897988252)

Addressed finding F10

Summary of Changes

I fixed F10. The dispatcher can no longer use an out-of-date view of an installation to create or reset dispatch state, so a replacement installation's start position isn't overwritten and events after it was enabled are delivered.

The fix (state() in packages/core/src/plugins/pluginDispatcher.ts):

  • If the stored dispatch row already matches the installation the pass read, it's used as before.
  • Otherwise, the write happens in one transaction that first re-reads the current installation. If it's gone, has a different installed_at, or is disabled, the transaction writes nothing and that plugin is skipped for this pass. The next pass starts from the installation as it is then.
  • Inside that transaction the dispatch row is read again before deciding anything. A missing row is inserted at the journal end. A row from an earlier installation is reset with restartPluginDispatch. A row that already belongs to the current installation is left alone.
  • This covers both branches the finding named: the missing-row insert and the earlier-installation reset.

Regression tests (packages/core/test/pluginDispatcherGuards.test.ts):

  • Reinstall race: uninstall, reinstall and enable, then append event E, all between the installation read and the dispatch-row read. The stale pass makes no attempt and leaves the new row unchanged, and E is delivered on the next pass.
  • Uninstall race: with the dispatch row missing, uninstall happens between the two reads. No orphan dispatch row is created, and after installing again the next event is delivered.
  • Missing row (normal case): an installation with no dispatch row still gets one at the journal end, with no history replayed.

The first two fail against the previous source and pass now. The third passes on both, as a check that the normal path still works.

Other code with the same risk: I checked the other places that read an installation and then write. None of them write dispatch state from an out-of-date view, so I changed nothing else:

  • The pre-attempt check and the cursor and delivery-record writes already confirm the installation and its start before acting.
  • The attempt's outcome is recorded only while the lease is held, and a restart or reinstall clears the lease.
  • Install, enable, Retry and Resume run in transactions that read the current installation.

Docs: I added a sentence on this guard to the Event dispatcher section of docs/docs/architecture/plugin-runtime.md.

Verification: the guards suite passes (17 tests), and the other dispatcher suites plus the plugin store, handler runner and contributions suites pass (47 tests). Core typecheck and lint with zero warnings are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

Review digest

Risk-tiered paths

  • High risk: none
  • Normal risk (1 file)
    • packages/core/src/plugins/pluginDispatcher.ts (+27/−16)
  • Low risk (2 files)
    • docs/docs/architecture/plugin-runtime.md (+4/−1)
    • packages/core/test/pluginDispatcherGuards.test.ts (+54/−1)

Validation evidence

  • No repository validation commands ran.

Diff size: 3 files changed, +85/−18 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (high)
  • Requested By: @propr-ultrafix
  • Time: 6m 20s
  • Tokens: 1,570,608 (1,558,798 in / 11,810 out)
  • Cost: $0.96

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…e view of an installation to create or reset dispatch state, so a replacement installation's start position isn't overwritten and events after it was enabled are delivered.

I fixed F10. The dispatcher can no longer use an out-of-date view of an installation to create or reset dispatch state, so a replacement installation's start position isn't overwritten and events after it was enabled are delivered.

**The fix** (`state()` in `packages/core/src/plugins/pluginDispatcher.ts`):
- If the stored dispatch row already matches the installation the pass read, it's used as before.
- Otherwise, the write happens in one transaction that first re-reads the current installation. If it's gone, has a different `installed_at`, or is disabled, the transaction writes nothing and that plugin is skipped for this pass. The next pass starts from the installation as it is then.
- Inside that transaction the dispatch row is read again before deciding anything. A missing row is inserted at the journal end. A row from an earlier installation is reset with `restartPluginDispatch`. A row that already belongs to the current installation is left alone.
- This covers both branches the finding named: the missing-row insert and the earlier-installation reset.

**Regression tests** (`packages/core/test/pluginDispatcherGuards.test.ts`):
- **Reinstall race:** uninstall, reinstall and enable, then append event E, all between the installation read and the dispatch-row read. The stale pass makes no attempt and leaves the new row unchanged, and E is delivered on the next pass.
- **Uninstall race:** with the dispatch row missing, uninstall happens between the two reads. No orphan dispatch row is created, and after installing again the next event is delivered.
- **Missing row (normal case):** an installation with no dispatch row still gets one at the journal end, with no history replayed.

The first two fail against the previous source and pass now. The third passes on both, as a check that the normal path still works.

**Other code with the same risk:** I checked the other places that read an installation and then write. None of them write dispatch state from an out-of-date view, so I changed nothing else:
- The pre-attempt check and the cursor and delivery-record writes already confirm the installation and its start before acting.
- The attempt's outcome is recorded only while the lease is held, and a restart or reinstall clears the lease.
- Install, enable, Retry and Resume run in transactions that read the current installation.

**Docs:** I added a sentence on this guard to the Event dispatcher section of `docs/docs/architecture/plugin-runtime.md`.

**Verification:** the guards suite passes (17 tests), and the other dispatcher suites plus the plugin store, handler runner and contributions suites pass (47 tests). Core typecheck and lint with zero warnings are clean. I didn't run the full repository suite. Nothing on screen changes, so there are no previews.

PR: #3578
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR implements durable plugin event delivery, retries, health reporting, and an administrator Activity interface. It needs changes before merge: attempt admission can outlive lease ownership, and an unsent health notification can be overwritten.

✅ Durable recovery — Delivery records preserve IDs and attempt counts across worker restarts, with explicit recovery tests.

✅ Retention protection — Cleanup preserves final records needed by events still ahead of their repository cursor, preventing already-completed handlers from running again.

✅ Administrative controls — Activity, Retry, and Resume use the existing administrator and preflight protections.

This review is based on static tracing of the supplied code; no commands were run. Current checks show 29 passed, five pending, and no failures.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F11: 🔴 Fence the attempt claim with ownership

  • Category: correctness

  • Required behavior: Only the current lease holder may start a delivery attempt; a worker that has lost ownership must stop.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, attempt() between await this.admit(...) and the update marking the delivery running.

    1. Worker A completes admit(), renewing its lease and reading a pending delivery at attempt zero.
    2. A is suspended before executing the subsequent record update, for longer than the two-minute lease.
    3. Worker B acquires the expired lease. Its pass has not yet read this delivery.
    4. A resumes. Its update checks only record ID, status, and attempt; these are unchanged, so A marks the record running and invokes the handler despite B owning the lease.
    5. B reads that running record and follows the crash-recovery path, starting another attempt while A's newly started handler is active.

    The observable consequence is concurrent handler execution and potentially duplicate external effects from a worker already known to have lost ownership. The record comparison prevents some competing updates, but it does not bind the claim to lease ownership. The outcome transaction rejects A's eventual result, which is too late to prevent its handler execution.

    static trace: The interruption point is outside a transaction, acquire() permits takeover after expiry, and running records are immediately recoverable by the new holder. The supplied takeover test suspends an already-started handler; it does not cover suspension between admission and claiming the attempt.

  • Minimum fix: Perform the conditional transition to running in a transaction that also verifies the current lease owner and installation/start identity. Refuse the claim when ownership has changed, and add a regression covering this admission-to-claim interruption.

F12: 🔴 Preserve previously owed degradation notices

  • Category: correctness

  • Required behavior: A degradation notification recorded as owed must survive notification failures until it is successfully created.

  • Evidence: packages/core/src/plugins/pluginDispatcher.ts, recordHealth(); packages/core/src/plugins/pluginDeliveries.ts, sendPendingDegradedNotices().

    1. Three consecutive failures create notice N1 in degraded_notice.
    2. Notification creation throws. N1 remains stored for retry, as intended.
    3. A subsequent handler succeeds. recordHealth() clears degraded_at but leaves N1 owed.
    4. Another three consecutive failures occur before notification delivery succeeds.
    5. Because degraded_at is null, the new transition unconditionally writes notice N2 into degraded_notice, replacing N1.
    6. Once notification creation recovers, only N2 can be sent; N1 has permanently disappeared without being delivered.

    Transactions make each overwrite atomic but do not preserve the earlier obligation. Notification deduplication cannot recover a notice that was never submitted successfully. Existing notification tests cover persistent degradation and worker restart, not recovery followed by another degradation while a notice remains owed.

    static trace: Both the success reset and unconditional replacement are explicit in recordHealth(). Failed notification delivery retaining the original notice is an explicitly supported path.

  • Minimum fix: Preserve each outstanding degradation notice until successful delivery, using a small durable queue or equivalent representation that cannot overwrite an unsent transition. Add a regression with failed notification delivery, recovery, and a second degradation.

Suggestions

These are optional follow-ups and are not sent to /fix.

S5: 🟢 Bound notification delivery waits

passOnce() awaits notification delivery before processing any installations, and stop() waits for that pass. Consider bounding notification attempts so a notifier that never settles cannot stop all plugin delivery and shutdown indefinitely. This is optional hardening: the supplied context does not establish whether the production notification service already bounds every dependency.

Score

The implementation has substantial recovery coverage and coherent API integration, but two durable-state correctness defects remain. Pending checks are not themselves blockers.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 49s
  • Tokens: 115,216 (112,845 in / 2,371 out)
  • Cost: $1.14

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F11 F12 S5

🤖 Review by ProPR

@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 1f77503

Addressed 1 AI review comment (IDs: 6898307337)

Addressed findings F11, F12

Summary of Changes

I fixed both findings, F11 and F12, and added a regression test for each. All four new tests fail against the previous source and pass now. The three dispatcher suites, the plugin store, contributions and runner suites, and the two API plugin route suites pass (70 tests). Core typecheck and the zero-warning lint are clean. I didn't run the full repository suite.

F11: starting an attempt is now tied to holding the lease (pluginDispatcher.ts, attempt())

  • Marking a delivery running now happens in one transaction. It only goes through if this worker still holds the lease for the same installation and start position, the installation is still the same one and enabled, and the record hasn't changed. Otherwise the pass stops and the handler doesn't run.
  • The same transaction renews the lease. Without that, a slow admission could leave the lease already expired when the handler starts, so another worker could take over while the handler runs. A worker that gets past the claim now has a full lease ahead of it.
  • Same defect elsewhere: two other places changed a delivery without checking the lease. One marks a manual retry failed when its event has left the journal; the other marks it failed when its repository is outside the plugin's limit. Both now go through the same lease check and only apply if the record is unchanged.

F12: owed Degraded notices are kept until sent (recordHealth(), sendPendingDegradedNotices())

  • degraded_notice now holds a JSON list instead of a single notice. Each new Degraded transition is added after any notices not yet sent, so an unsent one is never replaced. A value written as a single notice by an earlier copy of this branch is read as a one-item list.
  • Notices go out oldest first. Each one is removed only after it was sent. The removal re-reads the list inside a transaction, so a notice added while others were being sent stays on the list. The existing deduplication key still keeps a re-sent notice to one Inbox entry.
  • The column and table are unchanged, so the migration needed only a comment update. docs/docs/architecture/plugin-runtime.md now describes the list and the lease-checked start.

New tests (packages/core/test/pluginDispatcherOwnership.test.ts)

  • A worker paused between admitting an attempt and claiming it, whose lease is taken over meanwhile, doesn't run the handler. The new lease holder then delivers it once.
  • Claiming renews the lease, so no other worker can take over between the claim and the handler.
  • A worker that loses the lease before finishing a manual retry whose event is gone changes nothing; the lease holder then marks it failed.
  • Your scenario: a failed notification send, then recovery, then a second degradation. Both notices are sent, oldest first, once each, and nothing is left on the list.

The two extra lease checks I added for manual retries (the "outside the limit" case and the missing-event case) are partly untested. The missing-event case has a test. The outside-the-limit case doesn't, because there's no clean point in a test to pause between its reads and its write.

Nothing on screen changes, so there are no previews.

Review digest

Risk-tiered paths

  • High risk (1 file)
    • packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js (+3/−2): database migration
  • Normal risk (2 files)
    • packages/core/src/plugins/pluginDeliveries.ts (+37/−15)
    • packages/core/src/plugins/pluginDispatcher.ts (+21/−11)
  • Low risk (2 files)
    • docs/docs/architecture/plugin-runtime.md (+8/−4)
    • packages/core/test/pluginDispatcherOwnership.test.ts (added, +130/−0)

Validation evidence

  • No repository validation commands ran.

Diff size: 5 files changed, +199/−32 lines


🤖 Implementation Details

  • Model: Claude Opus 5.5 (high)
  • Requested By: @propr-ultrafix
  • Time: 6m 59s
  • Tokens: 1,688,844 (1,670,232 in / 18,612 out)
  • Cost: $1.14

View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR

…test for each. All four new tests fail against the previous source and pass now. The three dispatcher suites, the plugin store, contributions and runner suites, and the two API plugin route suites pass (70 tests). Core typecheck and the zero-warning lint are clean. I didn't run the full repository suite.

I fixed both findings, F11 and F12, and added a regression test for each. All four new tests fail against the previous source and pass now. The three dispatcher suites, the plugin store, contributions and runner suites, and the two API plugin route suites pass (70 tests). Core typecheck and the zero-warning lint are clean. I didn't run the full repository suite.

**F11: starting an attempt is now tied to holding the lease** (`pluginDispatcher.ts`, `attempt()`)
- Marking a delivery `running` now happens in one transaction. It only goes through if this worker still holds the lease for the same installation and start position, the installation is still the same one and enabled, and the record hasn't changed. Otherwise the pass stops and the handler doesn't run.
- The same transaction renews the lease. Without that, a slow admission could leave the lease already expired when the handler starts, so another worker could take over while the handler runs. A worker that gets past the claim now has a full lease ahead of it.
- **Same defect elsewhere:** two other places changed a delivery without checking the lease. One marks a manual retry failed when its event has left the journal; the other marks it failed when its repository is outside the plugin's limit. Both now go through the same lease check and only apply if the record is unchanged.

**F12: owed Degraded notices are kept until sent** (`recordHealth()`, `sendPendingDegradedNotices()`)
- `degraded_notice` now holds a JSON list instead of a single notice. Each new Degraded transition is added after any notices not yet sent, so an unsent one is never replaced. A value written as a single notice by an earlier copy of this branch is read as a one-item list.
- Notices go out oldest first. Each one is removed only after it was sent. The removal re-reads the list inside a transaction, so a notice added while others were being sent stays on the list. The existing deduplication key still keeps a re-sent notice to one Inbox entry.
- The column and table are unchanged, so the migration needed only a comment update. `docs/docs/architecture/plugin-runtime.md` now describes the list and the lease-checked start.

**New tests** (`packages/core/test/pluginDispatcherOwnership.test.ts`)
- A worker paused between admitting an attempt and claiming it, whose lease is taken over meanwhile, doesn't run the handler. The new lease holder then delivers it once.
- Claiming renews the lease, so no other worker can take over between the claim and the handler.
- A worker that loses the lease before finishing a manual retry whose event is gone changes nothing; the lease holder then marks it failed.
- Your scenario: a failed notification send, then recovery, then a second degradation. Both notices are sent, oldest first, once each, and nothing is left on the list.

The two extra lease checks I added for manual retries (the "outside the limit" case and the missing-event case) are partly untested. The missing-event case has a test. The outside-the-limit case doesn't, because there's no clean point in a test to pause between its reads and its write.

Nothing on screen changes, so there are no previews.

PR: #3578
Comment by: @propr-ultrafix (ID: 0)
Model: Claude Opus 5.5
@propr-dev

propr-dev Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @propr-ultrafix

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR implements durable plugin subscriptions across dispatch, retries, health, administrative controls, and Activity UI. Static review found no verified merge blockers. It is merge-ready within the reviewed scope, conditional on the remaining checks completing successfully.

✅ Durable delivery tracking — Stable delivery IDs, persisted attempts, and per-repository cursors support recovery; retention preserves final records still needed to prevent replay.

✅ Ownership protection — Delivery claims check lease ownership and installation identity transactionally. Added tests cover takeover, reinstall, and re-enable interleavings.

✅ Administrative controls — Activity, Retry, and Resume reuse the administrator/preflight wrapper, with audit records and matching API schemas.

This review used the supplied code only; no commands or tests were run. The authoritative check status reports 30 passed, four pending, and no failures.

Merge blockers

No merge blockers.

Suggestions

These are optional follow-ups and are not sent to /fix.

S6: 🟢 Bound the deduplication example

In docs/docs/features/plugins.md, the subscription example stores one posted:<deliveryId> key indefinitely, while the documented state limit is 256 keys. Consider showing bounded bookkeeping and explaining that a crash between the external POST and the state write can still duplicate the effect. An upstream idempotency key is preferable where supported. This improves author guidance without changing the dispatcher's stated at-least-once contract.

S7: 🟢 Check case-insensitive query performance

The new anyCase query in packages/core/src/services/repositoryEventJournal.ts uses COLLATE NOCASE, while the supplied journal migration creates the repository/sequence index with default collation. Consider checking query plans against a realistically sized journal and adding a matching index if warranted. This is optional performance work; the supplied code does not establish a material production slowdown.

Score

The implementation has substantial coverage of delivery and recovery behavior, coherent API integration, and no verified blockers in this static review. Four current-head checks remain pending.

Score: 8/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 56s
  • Tokens: 118,796 (116,379 in / 2,417 out)
  • Cost: $1.16

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix S6 S7

🤖 Review by ProPR

@propr-dev propr-dev Bot removed the ultrafix label Nov 15, 2026
@propr-dev

propr-dev Bot commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

ℹ️ GitHub auto-merge is turned off for this repository, so ProPR merges this PR itself once it is ready (checks passed or no CI configured, and the review goal met).

To let GitHub merge it the moment required checks pass, enable Settings → General → Pull Requests → Allow auto-merge.

@propr-dev
propr-dev Bot merged commit 82c887d into 3542-epic-plugin-system-xu8 Nov 15, 2026
50 checks passed
@propr-dev
propr-dev Bot deleted the 3549/claude-opus-5-5-plugin-event-subscription-20261115-1434-1ne branch November 15, 2026 17:54
@propr-dev propr-dev Bot changed the title 🔨 [3549 by Claude Opus 5.5] Plugin event subscriptions: dispatcher, retries, health and the Activity list [3549 by Claude Opus 5.5] Plugin event subscriptions: dispatcher, retries, health and the Activity list Nov 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants