mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(server): wake chat receiver discovery from configuration and lease deadlines (#17572)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat receivers own persistent provider connections and fenced database leases. > - The chat maintenance timer repeatedly queries all eligible receivers, including when none exist. > - Configuration changes and known lease deadlines already identify when discovery is needed. > - This pull request moves receiver discovery to the shared delivery coordinator. > - Empty or settled discovery stops querying, while ownership and recovery remain explicit. ## Linked Issues or Issue Description Refs #17557. This is the next stacked slice after Discord command registration. The base is `codex/sleep-19-discord-command-registration`. **What happened?** The recurring chat maintenance lane scans receiver inventory and reconciles Discord and Photon runtimes even when configuration has not changed. **Expected behavior** Reconstruct receiver work at startup. React to committed configuration changes and local listener retirement. Retry foreign ownership at the saved lease expiry. Stop discovery when there is no remaining obligation. **Steps to reproduce** Start an instance with no chat endpoints. Let startup finish. Observe the recurring receiver inventory query from chat maintenance. With a configured receiver, remove its ownership token while listener shutdown is delayed and signal a configuration refresh: the old implementation can replace the listener before shutdown completes. ## What Changed - Preserve healthy receiver progress after a slow failed batch while retaining per-receiver retry backoff. - Register a receiver discovery worker with scoped refreshes, paged startup inventory, fair batches, and per-receiver or scope failure backoff. - Keep owned receiver identities cached without a discovery deadline, so deletion still stops their listeners. - Use existing transaction signals for configuration, credentials, health, access revocation, company deletion, and provider lifecycle changes. Rename the generic command-only helper to describe its broader configuration contract. - Keep lease-key selection with the receiver owner. Shared scheduling contains no provider protocol. - Retain stopping ownership until listener shutdown and lease release complete. Concurrent acquisition joins the existing stop promise. - Remove receiver discovery from the periodic chat lane. Preserve voice and the other unmigrated duties. - Record producer coverage, failure evidence, limits, and remaining work in `doc/plans/2026-10-25-chat-receiver-discovery-sleep-audit.md`. ## Verification Published head: `ab39569250f4b699a0ab7a829e66dbe27c15020b`. Discord #17557 has landed at master `5bf0142d45`. A normal merge reconciles its squash ancestry by applying only the recorded 27-file receiver patch from parent `547fdf2139`. Every other path matches that master. Every server and package production/test file is byte-identical to the published `6e88ca0201` source that passed the 131-case run, typecheck and build. Unrelated upstream UI/eval/documentation changes are preserved. This PR targets master. Fresh exact-head reviews and CI are required. - **131 distinct targeted cases pass.** Five complete receiver, coordinator, batching, startup, and GitHub health error-projection suites pass 99 cases. Twelve real Discord automatic-registration/receiver and Slack held-transaction cases pass separately, with 1,581 other chat integration cases deselected. The complete Photon channel suite passes 20 cases. - The standalone receiver and Photon fixtures each reproduced missing-table failures on separate initially empty PostgreSQL 17 databases. Both now apply pending migrations before client use; their complete 20-case files pass on those same databases. The large chat producer suite uses a separate migrated clone. - The old review concerns already have source fixes: discovery stops suppress their own completion wake at `1604a9fcba`; slow failed batches supply `nextRunAtOnError` at `ab56393acc`. Guarded mutations prove the existing regressions: reenabling the stop wake yields two inventory reads instead of one, and removing the error deadline fails the 1,100 ms provider-delay next-wake assertion. Both original source files are restored byte for byte. Normal full worker and real producer suites pass with those fixes. - All 131 distinct targeted cases, production server typecheck and build pass on source `6e88ca0201`. Exact source comparison carries that evidence to this normal merge; the unchanged suites were not rerun just for unrelated upstream changes. Receiver and Photon fixtures self-migrate separate initially empty databases; the large producer fixture uses its own migrated clone. Source is unchanged after these checks. Recorded listener-count warnings and controlled provider failure logs remain; no assertion was weakened. An initial whole-file identity check rejected expected parent queue additions; precise patch comparison confirms those declarations are correctly preserved. - Old server shard 12 failed because GitHub reports that its self-hosted runner lost communication. The grouped-test step is cancelled, and the retained log has no failing assertion or completed summary. The underlying host/resource/network cause is unproven. That interrupted job is not a pass; the updated parent and fixture source require a fresh full CI run. - [Current acceptance and failure record](https://github.com/paperclipai/paperclip/blob/ab39569250f4b699a0ab7a829e66dbe27c15020b/doc/plans/2026-10-25-chat-receiver-discovery-sleep-audit.md#compatibility-after-discord-squash-merge-october-27). Prior 218-case and full-workspace results describe earlier revisions. No full local repository-suite, multi-writer deployment, Cloud wake, host suspension, or fleet cost claim is made. ## Risks - Process-local transaction hints cover the active database owner and its shared clients. Arbitrary direct SQL or independent configuration writers need explicit wake integration. Startup/resume reconstruction and known foreign-lease takeover remain supported; this does not add general multi-writer discovery. - Active receiver sockets and lease renewals continue. This change does not prove host suspension or extend Cloud alarm coverage. - Configuration signals can arrive during drain, initialization, or shutdown. The worker preserves dirty scopes and the owner joins listener retirement before replacement. - Voice, Teams, milestone projection, publication maintenance, GitHub recovery, and the remaining stack inventory are separate work. - The Discord parent has landed and the receiver child now targets master. Fresh exact-head reviews and all CI must pass before merge. ## Model Used OpenAI GPT-6 via Codex, with repository inspection, code execution, and test tools. The runtime does not expose a more specific deployment identifier or context-window limit. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket identifier - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7d8bab773d
commit
dce0042593
27 files changed
+1123
-73
No files matched your search
@@ -17,7 +17,7 @@ Do not mark an item done from a timer removal or a green check alone.
|
||||
| 03 | Run and task recovery | Orphan runs and active leases; shutdown workspace settlement; scheduled retries; queued runs; stranded assignments; resolved dependencies; task watchdogs; silent-run monitoring; stale locks | Pending |
|
||||
| 04 | Decision retention and notifications | Retention deadlines and notification delivery are separate reasons | Approval retention [#17497](https://github.com/paperclipai/paperclip/pull/17497) and archive delivery [#17475](https://github.com/paperclipai/paperclip/pull/17475) merged; other source retention pending |
|
||||
| 05 | Decision expiry and interrupted effects | Expiry, effect recovery, continuation recovery | Continuations [#17478](https://github.com/paperclipai/paperclip/pull/17478) merged; expiry [#17479](https://github.com/paperclipai/paperclip/pull/17479) merged; effects [#17482](https://github.com/paperclipai/paperclip/pull/17482) merged; targets [#17486](https://github.com/paperclipai/paperclip/pull/17486) merged |
|
||||
| 06 | Tool connection health | Configuration/use activation and defined proactive health deadlines | #17554 repaired at `8c1d293d1a`; fresh CI/review in progress |
|
||||
| 06 | Tool connection health | Configuration/use activation and defined proactive health deadlines | [#17554](https://github.com/paperclipai/paperclip/pull/17554) merged after all checks and both 5/5 reviews |
|
||||
| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Slices 02–07 merged: [secret proposals](https://github.com/paperclipai/paperclip/pull/17446), [credentials](https://github.com/paperclipai/paperclip/pull/17450), [review expiry](https://github.com/paperclipai/paperclip/pull/17453), [approved recovery](https://github.com/paperclipai/paperclip/pull/17454), [interrupted claims](https://github.com/paperclipai/paperclip/pull/17458), [projection](https://github.com/paperclipai/paperclip/pull/17468) |
|
||||
| 08 | GitHub connection events and continuity | Event discovery/replay and credential continuity separately | Pending |
|
||||
| 09 | Merged-PR confirmations | Candidate writes, webhook activation where supported, outstanding-candidate fallback | Pending |
|
||||
@@ -1483,6 +1483,42 @@ loops or changing domain backoff. All 152 selected command/coordinator/health,
|
||||
producer/Slack, delivery/standby/startup cases and server types/build pass. Publish
|
||||
for fresh CI/review. The full inventory, #17490 CI failure, and stack/master
|
||||
reconciliation remain open; no merge authority is implied.
|
||||
|
||||
|
||||
## Slice 20 receiver implementation
|
||||
|
||||
Receiver discovery now uses scoped committed configuration changes, startup
|
||||
reconstruction, foreign-lease deadlines, and listener-stop completion. Voice and
|
||||
all other maintenance reasons remain separate. See
|
||||
[the receiver audit](2026-10-25-chat-receiver-discovery-sleep-audit.md).
|
||||
A failed-scope deadline regression and a real concurrent listener-stop race were
|
||||
reproduced and repaired. All 218 selected receiver/startup, Discord, Photon,
|
||||
command/delivery and standby cases pass, with workspace typechecking passing.
|
||||
Workspace build also passes; publication and fresh remote gates remain open. This is not
|
||||
completion of the slice or the full stack. Master is refreshed at 0d73505098;
|
||||
final stack/master reconciliation remains required.
|
||||
|
||||
Parent Discord #17557 at d3187f76a4 and company cleanup #17561 at e8febb30eb now
|
||||
have no failed or pending checks (Paperclip Review skipped). No merge was made.
|
||||
The older #17490 E2E cancellation failure remains unresolved. The entire original
|
||||
inventory stays in scope, with voice the next separate reason after receivers.
|
||||
|
||||
|
||||
## Receiver review repair
|
||||
|
||||
The real company-removal producer regression reproduced two inventory reads for
|
||||
one observed removal. Discovery-initiated stops now suppress their own completion
|
||||
wake; independent token loss still wakes discovery after listener retirement.
|
||||
A full reconstruction also covers its already-pending scoped hints. Hints that
|
||||
arrive during the load remain dirty, and narrower writes cannot bypass a failed
|
||||
full reconstruction's retry deadline.
|
||||
|
||||
Both redundant-load regressions failed before repair. All 45 selected receiver,
|
||||
startup and real Discord producer tests pass after repair, including the added
|
||||
full-refresh failure/backoff case. Server typecheck and build pass. Prior broad
|
||||
consumer/workspace verification remains recorded above; these 45 overlap that
|
||||
coverage and are not an additional unique total. Publication requires fresh
|
||||
remote CI and review. The full migration inventory remains outstanding.
|
||||
## Acceptance follow-up after the plugin parent merged (October 27)
|
||||
|
||||
Eighteen preceding sleep PRs are merged through plugin scheduling #17518 at
|
||||
@@ -1498,6 +1534,16 @@ status and test counts above are historical. The original remaining inventory
|
||||
is still in scope beyond the 65 published numbered slices; native backups and
|
||||
the remaining aggregate duties have not been migrated by this follow-up.
|
||||
|
||||
## Receiver follow-up with current Discord parent, October 27
|
||||
|
||||
Nineteen sleep PRs are merged through health #17554. Receiver discovery #17572
|
||||
now includes current Discord parent `cedf2030c4`, retaining the original 27-file
|
||||
scope. Its existing slow-failure and stop-wake repairs have guarded mutation
|
||||
proofs, 131 targeted cases pass, and both newly supported external fixtures
|
||||
self-migrate. See [the owning audit](2026-10-25-chat-receiver-discovery-sleep-audit.md#current-parent-and-review-acceptance-october-27)
|
||||
for exact evidence and the older CI runner-loss failure. Fresh CI/reviews and
|
||||
parent landing are still required. The original remaining-duty inventory is unchanged.
|
||||
|
||||
|
||||
## Discord registration landing compatibility, October 27
|
||||
|
||||
@@ -1505,3 +1551,11 @@ Company deletion has landed. The Discord command slice retains only its original
|
||||
26-file delta against the landed parent and current master at `f8563d2507`. All
|
||||
141 targeted cases, server typecheck and build pass. The [owning audit](2026-10-25-discord-command-registration-sleep-audit.md#acceptance-after-the-company-deletion-parent-landed-october-27)
|
||||
records exact scope, fixture setup and remaining remote gates.
|
||||
|
||||
|
||||
## Receiver integration with landed foundations, October 27
|
||||
|
||||
Receiver discovery keeps its 27-file scope on Discord parent `547fdf2139` and
|
||||
preserves current master queue declarations. All 131 targeted cases, server
|
||||
typecheck and build pass. See the [receiver audit](2026-10-25-chat-receiver-discovery-sleep-audit.md#landed-foundation-integration-acceptance-october-27).
|
||||
Fresh exact-head review, CI and parent landing remain required.
|
||||
@@ -0,0 +1,351 @@
|
||||
# Chat receiver discovery sleep migration audit
|
||||
|
||||
Parent: Discord command registration #17557, head d3187f76a4.
|
||||
Master refreshed during this continuation: e6ec781a1a.
|
||||
Branch: codex/sleep-20-chat-runtime-discovery.
|
||||
Status: implementation and focused lifecycle verification; publication and final acceptance pending.
|
||||
|
||||
## One reason for this PR
|
||||
|
||||
Remove recurring inventory discovery for leased chat receivers from the aggregate
|
||||
chat maintenance timer. Preserve receipt projection, credential fencing, runtime
|
||||
initialization, takeover, and stop semantics. Discord command registration belongs
|
||||
to the parent; voice reconciliation, publication maintenance, milestones, Teams,
|
||||
and GitHub recovery remain separate migration reasons.
|
||||
|
||||
`server/src/app.ts` currently invokes `chatChannels.reconcileProviderRuntimes()`
|
||||
and `chatChannels.voice.reconcile()` in the same recurring maintenance lane.
|
||||
The receiver method in `services/chat-channels.ts` queries every eligible Discord
|
||||
or iMessage Photon endpoint, stops local ownership missing from that inventory,
|
||||
then calls `runtimeFor` and `reconcileDiscordRuntime` for each eligible endpoint.
|
||||
This query runs even when there are no receivers. Separating voice is necessary
|
||||
before removing this callback from the maintenance lane; removing both would lose
|
||||
an unmigrated obligation.
|
||||
|
||||
## Durable truth and owner boundaries
|
||||
|
||||
Existing `chat_endpoints`, `tool_connections`, and `chat_endpoint_leases` contain
|
||||
the necessary configuration and ownership. No additional durable queue table is
|
||||
needed. The receiver owner must retain the current eligibility policy: supported
|
||||
leased provider; verifying/active/attention endpoint; active, enabled connection;
|
||||
and exclusion of attention plus failed connection health.
|
||||
|
||||
The receiver provider policy currently lives in `leasedChatProvider` in
|
||||
`chat-channels.ts` and the lease-key selection inside `runtime/ownership.ts`.
|
||||
Centralize that existing policy with the chat provider/receiver owner before
|
||||
adding another independent Discord/Photon switch in scheduling. Shared delivery
|
||||
coordination should consume normalized receiver identities and deadlines, not
|
||||
provider protocols, credentials, or command-registration rules.
|
||||
|
||||
`runtime/ownership.ts` owns acquisition, renewal, token fencing, and stop order.
|
||||
Acquisition uses insert or expired-token CAS; background acquisition attempts once.
|
||||
Foreground configuration can wait up to twenty seconds, checking every 100 ms.
|
||||
That bounded active-operation wait is separate from idle inventory polling.
|
||||
The current default live lease lasts fifteen seconds and renews every five
|
||||
seconds while owned. These renewals are required active transport work and are
|
||||
not removed in this slice. Existing inbound admission reclaims the exact token
|
||||
after expiry before accepting buffered callbacks; preserve that safety boundary.
|
||||
|
||||
## Proposed wake contract to verify in code
|
||||
|
||||
- Reconstruct eligible receiver obligations at startup with bounded UUID pages.
|
||||
- Notify before supported endpoint/connection eligibility or credential changes
|
||||
in their existing transactions. Decode scoped hints only at the notification
|
||||
boundary; narrow normal refreshes to an endpoint or connection.
|
||||
- Keep locally owned healthy receivers in the inventory cache even when they have
|
||||
no discovery deadline. Otherwise later removal cannot identify the runtime that
|
||||
must stop. A cache of only future deadlines is insufficient for this duty.
|
||||
- A locally healthy unchanged receiver needs no periodic rediscovery query. Its
|
||||
active lease renewal and socket lifecycle remain owned by the receiver runtime.
|
||||
- On a concrete foreign lease, schedule the saved expiry as the next takeover
|
||||
attempt. Reload current authority at that time; a renewed foreign lease yields
|
||||
its new expiry. This is a configured receiver's takeover obligation, not proof
|
||||
that a standby with such an obligation can sleep indefinitely.
|
||||
- Initialization/credential/database failures retain scoped retry/backoff. One
|
||||
failed receiver must not starve independent companies or a healthy due backlog.
|
||||
- After ownership loss, finish listener fencing/stop and lease release, then wake
|
||||
discovery for that endpoint. Do not reinitialize before the old listener stops.
|
||||
Avoid a callback cycle on shutdown or a permanent retry after disable/removal.
|
||||
- Preserve a change arriving during refresh, drain, or stop. Use the existing
|
||||
coordinator's settled hints and memory-only pending predicate; no passive SQL
|
||||
during drain. Join admitted work before returning from shutdown.
|
||||
- Retain the explicit/manual reconciliation entry point used by current callers,
|
||||
or update those callers and their assertions deliberately. Do not leave a
|
||||
second automatic periodic entry point invoking the old full inventory query.
|
||||
|
||||
## Producer audit still required before implementation is accepted
|
||||
|
||||
Existing helpers are starting points, not proof of complete coverage:
|
||||
|
||||
1. `chat-work-notifications.ts:notifyChatEndpointWork` already covers shared
|
||||
connection-origin delivery/command wakes. Inspect its callers individually.
|
||||
2. `chat-channels/configuration.ts`: configure, reconnect, resume, pause, remove,
|
||||
activation and rollback, including foreground acquisition and ownership waits.
|
||||
3. `chat-channels/setup-probes.ts`, `provider-effects.ts`, resource reconciliation,
|
||||
publication failures, and inbound activation: eligibility/health transitions.
|
||||
4. Generic tool access, removal, health observations, credential/binding changes,
|
||||
membership removal, and company deletion: these can change a receiver without
|
||||
calling the endpoint configuration service.
|
||||
5. `runtime/ownership.ts`: renewal failure, token loss, failed initialization,
|
||||
runtime invalidation, lease release, and shutdown. A direct callback is suitable
|
||||
for process-local completion; do not invent a SQL row for that callback.
|
||||
6. Photon runtime callbacks and configuration: independently verify these paths;
|
||||
the Discord parent producer audit is not evidence of Photon coverage.
|
||||
7. Existing live replica/independent-writer assumptions: process-local transaction
|
||||
hints cannot discover another process's arbitrary configuration writes. Retain
|
||||
or explicitly integrate the supported cross-process wake contract rather than
|
||||
silently removing its only recovery path.
|
||||
|
||||
## Required evidence
|
||||
|
||||
Start with a failing empty-instance regression proving that the aggregate chat
|
||||
cadence repeatedly queries receiver inventory. Then verify:
|
||||
|
||||
- empty, disabled, and retained-history states have no discovery SQL after startup;
|
||||
- actual first configure/reconnect/disable/remove producers wake or cancel work;
|
||||
- healthy local ownership adds no inventory scan beyond explicit mutations;
|
||||
- foreign lease expiry, extension, and takeover preserve single ownership;
|
||||
- renewal failure triggers bounded recovery only after fencing the old runtime;
|
||||
- old buffered messages remain fenced after token or credential generation change;
|
||||
- Photon and Discord preserve their existing distinct transport behavior;
|
||||
- scoped changes, company deletion, nested transaction commit/rollback, uncertain
|
||||
commit, page boundaries, failure isolation, concurrent changes, restart,
|
||||
drain/resume, and shutdown retain obligations;
|
||||
- shared coordinator behavior without this worker remains unchanged;
|
||||
- the existing command-registration cases retain saved verification deadlines.
|
||||
|
||||
Use real PostgreSQL for lease CAS and transaction ownership, and controlled local
|
||||
runtime/provider fixtures for lifecycle effects. Existing chat integration tests
|
||||
exercise manual reconciliation, foreign ownership, credential rotations, and
|
||||
Photon restart. Inspect those cases before designing fixtures. SQL silence must
|
||||
be measured after both tracked work and scheduler settlement; a null next-wake
|
||||
value alone can occur during an admitted reload.
|
||||
|
||||
## Sleep limits and remaining stack gates
|
||||
|
||||
Active receiver sockets and lease renewals remain physical sleep blockers. Cloud
|
||||
alarm coverage and admission/sleep-safety proof do not change. This migration
|
||||
removes idle discovery activity, not the receiver's live transport obligation.
|
||||
The full original recurring-server inventory remains in scope in the stack ledger.
|
||||
|
||||
At audit creation, health #17554 has a passing security scan on 9ef6e4f4c8 but
|
||||
other current-head checks remain running. Company cleanup #17561 is e8febb30eb.
|
||||
Discord #17557 is d3187f76a4 with fresh checks required after its failure-path and
|
||||
CI fixture repairs. The older #17490 E2E cancellation failure, final reconciliation
|
||||
with master, and all fresh review/check gates remain outstanding. No stack PR was
|
||||
merged and no new merge authority is implied.
|
||||
|
||||
|
||||
Follow-up acceptance snapshot: health #17554 at 9ef6e4f4c8 now has no pending or
|
||||
failed checks, passing security, and fresh Greptile 5/5. Its three reviewed findings
|
||||
(fair batching, real producer coverage, inventory amplification) are implemented,
|
||||
verified, and their threads resolved. This is acceptance of that current head,
|
||||
not a claim that the full stack is complete or reconciled with moving master.
|
||||
Company cleanup and Discord still have live current-head checks.
|
||||
|
||||
|
||||
## Implementation and verification update
|
||||
|
||||
The aggregate chat lane now calls voice reconciliation only. Receiver discovery
|
||||
registers with the existing delivery coordinator. The receiver owner loads UUID
|
||||
pages of 100, keeps owned identities with no discovery deadline, and processes
|
||||
25 due receivers per fair company batch. Configuration hints refresh only their
|
||||
endpoint, connection, or company. A foreign owner schedules its saved lease
|
||||
expiry; a changed lease is reread before another attempt. Failures retain scoped
|
||||
30-second exponential backoff capped at five minutes. Dirty scopes suppress their
|
||||
cached deadlines until refresh succeeds; otherwise an old takeover deadline could
|
||||
cause repeated useless dispatch while the required refresh is in backoff.
|
||||
|
||||
The generic configuration notifier replaces the misleading command-only helper
|
||||
name. Existing producers now notify both relevant owners. Shared delivery code
|
||||
still receives only a queue topic and scope hint. Provider selection and lease
|
||||
keys remain in the chat receiver owner. No schema or new per-worker table exists.
|
||||
|
||||
Audited producer groups:
|
||||
|
||||
| Owner | Receiver-relevant changes and integration |
|
||||
| --- | --- |
|
||||
| Configuration and setup probes | Existing connection helper covers configure, rotation, resume, pause, removal and completed setup before transaction writes |
|
||||
| Credential persistence and removal | Configuration notifier before reference changes, including Photon |
|
||||
| Inbound activation and resource reconciliation | Endpoint/connection notifier before status and health transitions |
|
||||
| Publication, interaction and provider-effect failures | Endpoint configuration notifier before attention/failed-health writes |
|
||||
| Discord Gateway lifecycle | Receiver hints before recovery/failure transitions; metadata-only writes do not create discovery work |
|
||||
| Photon transport failure and runtime recovery | Failure callback now signals before attention/disable; existing assert-owned recovery uses the shared endpoint helper |
|
||||
| Generic tool update/removal, manual health and access revocation | Existing chat connection helper, broadened configuration dispatch, and SDK transport filter |
|
||||
| Company deletion | Company hint precedes endpoint removal in the owning deletion transaction |
|
||||
| Ownership loss / invalidation | Local completion callback after listener stop and lease release; suppressed during shutdown |
|
||||
|
||||
The additional direct-writer audit found publication-resolution, publication
|
||||
success, provider-effect success, inbound filtering, and Discord root-thread
|
||||
writes that only update timestamps. Endpoint display/reach preferences do not
|
||||
change receiver eligibility or the runtime generation. Photon configuration
|
||||
invalidates the runtime, writes allocation metadata, and proceeds through the
|
||||
credential/configuration owner; its allocation metadata is not an independent
|
||||
receiver identity. Slack/GitHub-only setup writers do not create leased receiver
|
||||
work. Capability projection in Discord reconciliation retains its existing owner.
|
||||
|
||||
A real token-loss regression found a concurrent configuration wake could acquire
|
||||
another lease while the old listener was still stopping. The ownership map now
|
||||
retains the stopping owner until listener shutdown and lease release complete.
|
||||
Acquisition joins that owner's existing stop promise. Current-authority checks
|
||||
still reject stopping owners. The test uses real timing and holds listener stop;
|
||||
an earlier fake-clock observation returned before async SQL completed and did not
|
||||
prove absence of overlap. The strengthened test failed before the ownership fix.
|
||||
|
||||
Current passing evidence: 32 receiver/startup cases, 90 selected Discord
|
||||
integration cases, all 20 Photon channel cases, and server typechecking. The
|
||||
Discord selection includes configuration, credential rotation, pause/resume,
|
||||
company deletion, token-loss stop ordering, and existing takeover/admission fences.
|
||||
The dedicated real-DB worker takeover test also passes, as do 75 command/delivery/
|
||||
standby consumer cases and full workspace typechecking. This is 218 unique selected
|
||||
passing cases. Workspace build passes. Remote review/check gates remain pending.
|
||||
The full local test matrix is not claimed: embedded PostgreSQL is unavailable
|
||||
on this host, so these database suites use validated disposable external URLs.
|
||||
Remote CI must run the full matrix before acceptance.
|
||||
|
||||
Recorded failures remain part of acceptance: initial receiver teardown omitted
|
||||
agent deletion; initial Discord pause used incomplete onboarding; failed-scope
|
||||
backoff initially selected the blocked cached takeover deadline; the real-timing
|
||||
stop race reproduced overlapping listeners. The first two were fixture defects;
|
||||
the latter two required implementation repairs. An initial backoff test expected
|
||||
zero idle holds during failed intent recovery; the correct contract retains the
|
||||
intent hold until reconciliation succeeds, so the test now checks the retry
|
||||
deadline and SQL silence rather than weakening that hold.
|
||||
|
||||
## Writer and sleep boundaries
|
||||
|
||||
Notifications cover the active database owner and its transaction-sharing clients.
|
||||
Warm standby does not run discovery; activation reconstructs its pending startup
|
||||
inventory. An already known foreign receiver remains recoverable from lease expiry,
|
||||
with real token CAS and callback fences. Arbitrary new configuration written by
|
||||
an independent process or direct SQL is not discovered by process-local hints;
|
||||
that deployment needs an explicit wake integration. This follows the existing
|
||||
single active writer contract and must not be described as multi-writer support.
|
||||
The manual reconciliation method remains available for explicit callers.
|
||||
|
||||
Active sockets and lease renewals remain required work, and no Cloud admission
|
||||
exemption is added. Voice, Teams, publication maintenance, milestones and other
|
||||
inventory reasons remain separate PRs. The full stack is not complete.
|
||||
|
||||
Master was fetched again at 0d73505098. Company prerequisite #17561 at e8febb30eb
|
||||
and Discord parent #17557 at d3187f76a4 now have no failed or pending checks;
|
||||
Paperclip Review is skipped. Fresh review-content reconciliation and final stack
|
||||
rebasing/verification remain distinct from these check results.
|
||||
|
||||
|
||||
## Receiver review repair
|
||||
|
||||
The real company-removal producer regression reproduced two inventory reads for
|
||||
one observed removal. Discovery-initiated stops now suppress their own completion
|
||||
wake; independent token loss still wakes discovery after listener retirement.
|
||||
A full reconstruction also covers its already-pending scoped hints. Hints that
|
||||
arrive during the load remain dirty, and narrower writes cannot bypass a failed
|
||||
full reconstruction's retry deadline.
|
||||
|
||||
Both redundant-load regressions failed before repair. All 45 selected receiver,
|
||||
startup and real Discord producer tests pass after repair, including the added
|
||||
full-refresh failure/backoff case. Server typecheck and build pass. Prior broad
|
||||
consumer/workspace verification remains recorded above; these 45 overlap that
|
||||
coverage and are not an additional unique total. Publication requires fresh
|
||||
remote CI and review. The full migration inventory remains outstanding.
|
||||
|
||||
## Healthy receiver progress after a slow failed batch
|
||||
|
||||
The receiver worker already stores a retry deadline for each failed receiver and
|
||||
scope. It now supplies that deadline calculation through the coordinator's
|
||||
`nextRunAtOnError` contract as well. A completed batch can take longer than the
|
||||
one-second batching interval. When that batch contained an error, the coordinator
|
||||
previously ignored an already-due healthy backlog and waited for its generic
|
||||
30-second retry. The changed registration lets healthy due entries proceed while
|
||||
the failed receiver retains its own exponential backoff.
|
||||
|
||||
The existing 105-receiver PostgreSQL regression now runs with both an immediate
|
||||
failure and a controlled 1,100 ms elapsed interval in the failed provider call.
|
||||
The latter failed before the repair: its next wake was 30 seconds away although
|
||||
healthy receivers were due. Afterward, all 105 are attempted before retrying the
|
||||
failed receiver, which is attempted once during the healthy drain. Recovery then
|
||||
settles and two former poll periods add no SQL. The assertion helper advances its
|
||||
own fake clock; the test no longer adds an extra second on every assertion retry.
|
||||
|
||||
All 20 receiver lifecycle cases and the server production typecheck pass on the
|
||||
owning branch. This changes one coordinator registration and adds no provider
|
||||
branch, queue, table, or independent timer. Current-head CI and combined-stack
|
||||
verification remain separate gates.
|
||||
|
||||
## Current-parent and review acceptance, October 27
|
||||
|
||||
The branch normally integrates Discord parent #17557 at
|
||||
`cedf2030c49c3650a41b76a4767176fd78170819` through `3e957d1204`. Only the
|
||||
original 27 receiver-owned paths differ from that parent. Health #17554 is now
|
||||
merged; company cleanup #17561 and Discord #17557 still precede this slice.
|
||||
Older stack status above describes its recorded source revision.
|
||||
|
||||
The latest old review summaries still mention the delay and redundant-stop
|
||||
findings, but the actual source already contains their repairs: stop-wake
|
||||
suppression at `1604a9fcba` and the error-deadline registration at `ab56393acc`.
|
||||
Both were revalidated on the integrated parent rather than duplicated. A guarded
|
||||
mutation removing `nextRunAtOnError` fails the 1,100 ms slow-provider next-deadline
|
||||
assertion. The expected error also remains visible in teardown after that early
|
||||
assertion. A separate mutation reenabling discovery-initiated stop notification
|
||||
fails the real company-removal producer assertion with two inventory reads
|
||||
instead of one. Both source files are restored byte for byte after the checks.
|
||||
Normal worker and real producer suites pass with the original fixes.
|
||||
|
||||
The two newly supported external-database fixtures initially skipped migrations.
|
||||
Actual worker reconstruction and Photon transport-loss cases failed on separate
|
||||
empty PostgreSQL 17 databases with missing-table errors. Both fixtures now apply
|
||||
pending migrations before client use. On those same initially empty databases,
|
||||
the complete receiver file passes all 20 cases and the complete Photon channel
|
||||
file passes all 20 cases. Existing embedded setup and safety validation remain.
|
||||
|
||||
Current acceptance: five complete receiver/coordinator/batching/startup/GitHub
|
||||
error-projection suites pass 99 cases. Twelve actual Discord automatic-command,
|
||||
receiver configure/rotation/pause/resume/removal, ownership-loss/takeover, and Slack
|
||||
held-transaction cases pass separately; 1,581 other chat integration cases are
|
||||
not counted as run. With the 20 Photon cases this is 131 distinct targeted cases.
|
||||
The large chat producer suite uses a separate migrated clone. Server production
|
||||
typecheck and build pass on the integrated source; the later fixture-only changes
|
||||
add migration setup and do not change production code. Startup listener-count
|
||||
warnings and expected controlled provider failures remain in the logs.
|
||||
|
||||
Required CI at the older `ab56393acc` head failed server shard 12. GitHub's
|
||||
check-run annotation says the self-hosted runner lost communication with the
|
||||
server; its grouped-test step is marked cancelled. The retained log ends without
|
||||
a failed assertion or final test summary. That annotation does not establish the
|
||||
underlying host, resource, or network cause, and the interrupted job is not a
|
||||
passing result. The new parent integration and fixture repairs require a fresh
|
||||
full CI run; local passes do not replace it. Fresh exact-head Greptile and
|
||||
Ponytail reviews remain mandatory. No deployed sleep or full local suite is claimed.
|
||||
|
||||
|
||||
## Landed-foundation integration acceptance, October 27
|
||||
|
||||
This slice normally integrates Discord parent `547fdf2139`, which contains
|
||||
master `f8563d2507` and landed company-deletion fixes. The original 27-file
|
||||
scope is unchanged. All owned source matches its precise original patch applied
|
||||
to that parent. The shared queue catalog also preserves master's task-drain and
|
||||
native-checkpoint declarations. An initial whole-file identity assertion rejected
|
||||
those expected parent additions; the corrected proof checks the actual owned
|
||||
patch against the integrated parent. No failed verification is counted as a pass.
|
||||
|
||||
The complete five worker/shared-consumer suites pass 99 cases. Real Discord and
|
||||
Slack producers pass 12 cases; the complete Photon suite passes 20. This is 131
|
||||
distinct targeted cases, with 1,581 chat cases deselected. Receiver and Photon
|
||||
self-migrate separate initially empty databases. The large chat fixture uses a
|
||||
separate migrated clone. Server typecheck and build pass. Only documentation
|
||||
changes follow the passing source. Expected controlled provider failures and
|
||||
startup signal-listener warnings remain recorded. Fresh exact-head review and
|
||||
all CI must pass before this child lands after Discord. No deployed sleep or
|
||||
full local suite result is inferred from these checks.
|
||||
|
||||
|
||||
## Compatibility after Discord squash merge, October 27
|
||||
|
||||
Discord #17557 landed at `5bf0142d45`. The normal merge of that master caused
|
||||
ancestry conflicts in overlapping producer paths. Resolution applies only the
|
||||
recorded 27-file receiver patch from Discord parent `547fdf2139` to actual landed
|
||||
master. Every server and package production/test file is byte-identical to the
|
||||
published `6e88ca0201` source that passes 131 cases, typecheck and build. No source
|
||||
verification is rerun merely for unrelated upstream UI/eval/documentation changes.
|
||||
The PR-owned delta passes its whitespace check. This integration records that
|
||||
proof and requires fresh exact-head review and CI before merging into master.
|
||||
@@ -43,3 +43,19 @@ followed by registration on resume without another producer. Production server
|
||||
typecheck passes in /tmp/sleep19-drain-typecheck.log. These tests use the migrated
|
||||
paperclip_sleep19_drain_20261025_test database. Remote CI/review must run on the
|
||||
new head; this is not evidence of provider suspension.
|
||||
|
||||
## Receiver discovery — slice 20 / #17572
|
||||
|
||||
The strengthened first-receiver drain case fails with active=1 after commit in
|
||||
/tmp/sleep20-drain-before.log. Apply the same owner-level pending predicate while
|
||||
retaining all dirty scopes and lease/retry deadlines. All 19 receiver lifecycle
|
||||
cases pass in /tmp/sleep20-drain-tests.log, including no SQL and an unchanged idle
|
||||
snapshot for a minute after settlement, then receiver acquisition on resume.
|
||||
Production server typecheck passes in /tmp/sleep20-drain-typecheck.log. The fresh
|
||||
migrated database is paperclip_sleep20_drain_20261025_test.
|
||||
|
||||
The three configuration owners now have measured drain activity as well as traffic
|
||||
checks. Their retained timers remain memory-only while paused; they do not provide
|
||||
external wake coverage. Fresh remote CI/review and downstream propagation remain
|
||||
required. Origin/master was refreshed to e335265568; the latest change removes
|
||||
idle task-run UI polling and does not replace these worker repairs.
|
||||
@@ -25,7 +25,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
processFailedGitHubWebhookDeliveries,
|
||||
processPendingDeliveries,
|
||||
flushPublications,
|
||||
reconcileProviderRuntimes: async () => undefined,
|
||||
reconcileVoiceSessions: async () => undefined,
|
||||
projectRunMilestones: async () => 0,
|
||||
processPendingSlackFileUploadReceipts: async () => undefined,
|
||||
processPendingSlackSessionSyncs: async () => undefined,
|
||||
@@ -74,6 +74,14 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps receiver inventory out of periodic chat maintenance while preserving voice", () => {
|
||||
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
|
||||
const maintenance = source.slice(source.indexOf("const chatReconciliation ="), source.indexOf("const unsubscribeChatPublicationSignals"));
|
||||
expect(maintenance).not.toContain("chatChannels.reconcileProviderRuntimes()");
|
||||
expect(maintenance).toContain("chatChannels.voice.reconcile()");
|
||||
expect(source).toContain("chatChannels.registerReceiverWork(deliveryWork,");
|
||||
});
|
||||
|
||||
it("wires GitHub recovery into its independent lane", () => {
|
||||
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
|
||||
expect(source).toContain(
|
||||
@@ -135,7 +143,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
const onError = vi.fn();
|
||||
const coordinator = createChatReconciliationCoordinator({
|
||||
projectRunMilestones,
|
||||
reconcileProviderRuntimes: async () => undefined,
|
||||
reconcileVoiceSessions: async () => undefined,
|
||||
processPendingDeliveries: async () => undefined,
|
||||
flushPublications,
|
||||
processPendingSlackFileUploadReceipts: async () => undefined,
|
||||
@@ -198,7 +206,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
const coordinator = createChatReconciliationCoordinator({
|
||||
projectRunMilestones,
|
||||
flushPublications,
|
||||
reconcileProviderRuntimes: async () => undefined,
|
||||
reconcileVoiceSessions: async () => undefined,
|
||||
processPendingDeliveries: async () => undefined,
|
||||
processPendingSlackFileUploadReceipts: async () => undefined,
|
||||
processPendingSlackSessionSyncs: async () => undefined,
|
||||
@@ -238,7 +246,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
const slackStatusReleased = new Promise<void>((resolve) => {
|
||||
releaseSlackStatus = resolve;
|
||||
});
|
||||
const reconcileProviderRuntimes = vi.fn(async () => undefined);
|
||||
const reconcileVoiceSessions = vi.fn(async () => undefined);
|
||||
const processPendingDeliveries = vi.fn(async () => deliveryReleased);
|
||||
const flushPublications = vi.fn(async () => undefined);
|
||||
const processPendingSlackFileUploadReceipts = vi.fn(async () => undefined);
|
||||
@@ -248,7 +256,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
const onError = vi.fn();
|
||||
const coordinator = createChatReconciliationCoordinator({
|
||||
projectRunMilestones: async () => 0,
|
||||
reconcileProviderRuntimes,
|
||||
reconcileVoiceSessions,
|
||||
processPendingDeliveries,
|
||||
flushPublications,
|
||||
processPendingSlackFileUploadReceipts,
|
||||
@@ -258,7 +266,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
|
||||
coordinator.reconcile();
|
||||
await vi.waitFor(() => {
|
||||
expect(reconcileProviderRuntimes).toHaveBeenCalledTimes(1);
|
||||
expect(reconcileVoiceSessions).toHaveBeenCalledTimes(1);
|
||||
expect(processPendingDeliveries).toHaveBeenCalledTimes(1);
|
||||
expect(flushPublications).toHaveBeenCalledTimes(1);
|
||||
expect(processPendingSlackFileUploadReceipts).toHaveBeenCalledTimes(1);
|
||||
@@ -266,7 +274,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
});
|
||||
coordinator.reconcile();
|
||||
await vi.waitFor(() => {
|
||||
expect(reconcileProviderRuntimes).toHaveBeenCalledTimes(2);
|
||||
expect(reconcileVoiceSessions).toHaveBeenCalledTimes(2);
|
||||
expect(flushPublications).toHaveBeenCalledTimes(2);
|
||||
expect(processPendingSlackFileUploadReceipts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -293,14 +301,14 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
await receiptReleased;
|
||||
if (rejects) throw lookupError;
|
||||
});
|
||||
const reconcileProviderRuntimes = vi.fn(async () => undefined);
|
||||
const reconcileVoiceSessions = vi.fn(async () => undefined);
|
||||
const processPendingDeliveries = vi.fn(async () => undefined);
|
||||
const flushPublications = vi.fn(async () => undefined);
|
||||
const processPendingSlackSessionSyncs = vi.fn(async () => undefined);
|
||||
const onError = vi.fn();
|
||||
const coordinator = createChatReconciliationCoordinator({
|
||||
projectRunMilestones: async () => 0,
|
||||
reconcileProviderRuntimes,
|
||||
reconcileVoiceSessions,
|
||||
processPendingDeliveries,
|
||||
flushPublications,
|
||||
processPendingSlackFileUploadReceipts,
|
||||
@@ -318,7 +326,7 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
});
|
||||
coordinator.reconcile();
|
||||
await vi.waitFor(() => {
|
||||
expect(reconcileProviderRuntimes).toHaveBeenCalledTimes(2);
|
||||
expect(reconcileVoiceSessions).toHaveBeenCalledTimes(2);
|
||||
expect(processPendingDeliveries).toHaveBeenCalledTimes(2);
|
||||
expect(flushPublications).toHaveBeenCalledTimes(2);
|
||||
expect(processPendingSlackSessionSyncs).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatEndpointConfiguration, notifyChatCompanyConfiguration } from "../services/chat-channels/configuration-work-notifications.js";
|
||||
import { deleteCompany } from "../services/company-deletion.js";
|
||||
import { adapterExecutionControls, createAdapterExecutionControl } from "../services/adapter-execution-control.js";
|
||||
import { chatRunSourceMessageId } from "../services/chat-channels/run-origin.js";
|
||||
@@ -7133,6 +7134,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
|
||||
async function registrationFixture(
|
||||
mode: "success" | "unavailable" | "unknown" = "success",
|
||||
receiverOptions: Partial<Pick<ChatChannelServiceOptions, "discordGatewayLeaseTtlMs" | "discordGatewayLeaseRenewalIntervalMs">> = {},
|
||||
) {
|
||||
const fixture = await seedCompany();
|
||||
const applicationId = uniqueDiscordApplicationId();
|
||||
@@ -7190,7 +7192,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
return Response.json(command);
|
||||
},
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
const context = createService(new FakeChatSdkRuntime(), providerFetch);
|
||||
const context = createService(new FakeChatSdkRuntime(), providerFetch, receiverOptions);
|
||||
const endpoint = await context.service.create(
|
||||
fixture.companyId,
|
||||
{
|
||||
@@ -7243,6 +7245,125 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("drives receiver discovery through real configure, rotation, pause, and resume producers", async () => {
|
||||
const f = await registrationFixture("success", { discordGatewayLeaseTtlMs: 600_000, discordGatewayLeaseRenewalIntervalMs: 300_000 });
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
const settle = async () => vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
}, { timeout: 5000 });
|
||||
const quiet = async () => {
|
||||
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
|
||||
} finally { reads.mockRestore(); writes.mockRestore(); }
|
||||
};
|
||||
try {
|
||||
await f.service.registerReceiverWork(coordinator, () => true).ready;
|
||||
await settle(); await quiet();
|
||||
await f.configure(); await settle();
|
||||
const first = f.runtime.get(f.endpoint.id); expect(first).not.toBeNull();
|
||||
await quiet();
|
||||
await f.service.configure(f.endpoint.id, { action: "reconnect", credentials: { ...f.credentials, botToken: "rotated-receiver-token" } }, "owner-user");
|
||||
await settle(); expect(f.runtime.get(f.endpoint.id)).not.toBe(first); expect(first!.shutdown).toHaveBeenCalledOnce();
|
||||
// Pause requires completed onboarding; configure/reconnect intentionally
|
||||
// leave this fixture waiting for its first test conversation.
|
||||
const configured = await f.service.get(f.endpoint.id);
|
||||
await db.update(chatEndpoints).set({ status: "active", setup: { ...configured.setup, step: "complete" } })
|
||||
.where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
await f.service.configure(f.endpoint.id, { action: "pause" }, "owner-user");
|
||||
await settle(); expect(f.runtime.get(f.endpoint.id)).toBeNull(); await quiet();
|
||||
await f.service.configure(f.endpoint.id, { action: "resume" }, "owner-user");
|
||||
await settle(); expect(f.runtime.get(f.endpoint.id)).not.toBeNull(); await quiet();
|
||||
await f.service.configure(f.endpoint.id, { action: "remove" }, "owner-user");
|
||||
await settle(); expect(f.runtime.get(f.endpoint.id)).toBeNull(); await quiet();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await coordinator.stop(); await f.service.shutdown(); vi.useRealTimers();
|
||||
await retireRegistrationFixture(f.service, f.endpoint.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not rediscover a receiver stopped by a company removal refresh", async () => {
|
||||
const f = await registrationFixture("success", { discordGatewayLeaseTtlMs: 600_000, discordGatewayLeaseRenewalIntervalMs: 300_000 });
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
|
||||
const onError = vi.fn(), coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
let reads: ReturnType<typeof vi.spyOn> | undefined;
|
||||
try {
|
||||
await f.configure(); await f.service.registerReceiverWork(coordinator, () => true).ready;
|
||||
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull(); });
|
||||
const owned = f.runtime.get(f.endpoint.id)!;
|
||||
reads = vi.spyOn(db, "select");
|
||||
await db.transaction(async tx => {
|
||||
await notifyChatCompanyConfiguration(tx, f.fixture.companyId);
|
||||
await tx.delete(chatEndpoints).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
});
|
||||
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull(); });
|
||||
expect(owned.shutdown).toHaveBeenCalledOnce();
|
||||
expect(reads).toHaveBeenCalledOnce(); expect(onError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
reads?.mockRestore(); await coordinator.stop(); vi.useRealTimers(); await f.service.shutdown();
|
||||
await db.update(chatEndpoints).set({ status: "archived" }).where(eq(chatEndpoints.id, f.endpoint.id));
|
||||
}
|
||||
});
|
||||
|
||||
it("takes over a persisted foreign receiver lease at its deadline through the worker", async () => {
|
||||
const f = await registrationFixture();
|
||||
await f.configure(); await f.service.shutdown();
|
||||
const token = randomUUID(), expiresAt = new Date(Date.now() + 500);
|
||||
await db.insert(chatEndpointLeases).values({ companyId: f.fixture.companyId, endpointId: f.endpoint.id,
|
||||
leaseKey: "discord_gateway_runtime", token, expiresAt });
|
||||
const standby = createService(new FakeChatSdkRuntime(), f.providerFetch);
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
try {
|
||||
await standby.service.registerReceiverWork(coordinator, () => true).ready;
|
||||
expect(standby.runtime.get(f.endpoint.id)).toBeNull();
|
||||
expect(coordinator.nextWakeAt()).toBeGreaterThanOrEqual(expiresAt.getTime());
|
||||
await vi.waitFor(() => expect(standby.runtime.get(f.endpoint.id)).not.toBeNull(), { timeout: 5000 });
|
||||
const [lease] = await db.select().from(chatEndpointLeases).where(eq(chatEndpointLeases.endpointId, f.endpoint.id));
|
||||
expect(lease.token).not.toBe(token);
|
||||
expect(lease.expiresAt.getTime()).toBeGreaterThan(expiresAt.getTime());
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await coordinator.stop(); await retireRegistrationFixture(standby.service, f.endpoint.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts a receiver after token loss only after the old listener has stopped", async () => {
|
||||
const f = await registrationFixture("success", { discordGatewayLeaseTtlMs: 60_000, discordGatewayLeaseRenewalIntervalMs: 50 });
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
let release!: () => void;
|
||||
const stopped = new Promise<void>(resolve => { release = resolve; });
|
||||
try {
|
||||
await f.configure();
|
||||
await f.service.registerReceiverWork(coordinator, () => true).ready;
|
||||
const old = f.runtime.get(f.endpoint.id)!;
|
||||
old.shutdown.mockImplementation(async () => { await stopped; });
|
||||
await db.delete(chatEndpointLeases).where(eq(chatEndpointLeases.endpointId, f.endpoint.id));
|
||||
await vi.waitFor(() => expect(old.shutdown).toHaveBeenCalledOnce());
|
||||
await db.transaction(tx => notifyChatEndpointConfiguration(tx, f.endpoint));
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
expect(f.runtime.get(f.endpoint.id)).toBeNull();
|
||||
release();
|
||||
await vi.waitFor(async () => {
|
||||
expect(f.runtime.get(f.endpoint.id)).not.toBeNull();
|
||||
expect(f.runtime.get(f.endpoint.id)).not.toBe(old);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
});
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
release(); await coordinator.stop(); vi.useRealTimers();
|
||||
await retireRegistrationFixture(f.service, f.endpoint.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("learns the first configured command and cancels its deadline when the endpoint is removed", async () => {
|
||||
const f = await registrationFixture();
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { applyPendingMigrations, agents, chatEndpoints, chatEndpointLeases, companies, createDb, toolApplications, toolConnections } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { notifyDeliveryWork, DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
import { chatReceiverSchedule } from "../services/chat-channels/runtime/receiver-schedule.js";
|
||||
import { registerChatReceiverWork } from "../services/chat-channels/runtime/receiver-work.js";
|
||||
import { notifyChatEndpointConfiguration, notifyChatConnectionConfiguration, notifyChatCompanyConfiguration } from "../services/chat-channels/configuration-work-notifications.js";
|
||||
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_RECEIVER_TEST_DATABASE_URL");
|
||||
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("chat receiver discovery", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: ReturnType<typeof createDb>, companyId: string, agentId: string, applicationId: string;
|
||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||
let enabled = true, primary = true;
|
||||
const errors: unknown[] = [];
|
||||
const reconcile = vi.fn(async (_id: string): Promise<"owned" | "foreign"> => "owned"), stop = vi.fn(async (_id: string) => {});
|
||||
beforeAll(async () => {
|
||||
if (!external) temporary = await startEmbeddedPostgresTestDatabase("chat-receivers-");
|
||||
if (external) await applyPendingMigrations(external);
|
||||
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
|
||||
}, 60_000);
|
||||
beforeEach(async () => {
|
||||
companyId = randomUUID(); agentId = randomUUID(); applicationId = randomUUID(); enabled = true; primary = true; errors.length = 0;
|
||||
reconcile.mockReset().mockResolvedValue("owned"); stop.mockReset().mockResolvedValue(undefined);
|
||||
await db.insert(companies).values({ id: companyId, name: "Receivers", issuePrefix: `R${companyId.slice(0, 7)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Receiver", adapterType: "codex_local" });
|
||||
await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Receiver", type: "chat" });
|
||||
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select 1`)));
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await coordinator?.stop(); coordinator = undefined;
|
||||
vi.useRealTimers(); vi.restoreAllMocks();
|
||||
await db.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId));
|
||||
await db.delete(agents).where(eq(agents.companyId, companyId));
|
||||
await db.delete(companies).where(eq(companies.id, companyId));
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
||||
async function endpoint(patch: Partial<typeof chatEndpoints.$inferInsert> = {}, writer = db) {
|
||||
const id = patch.id ?? randomUUID(), connectionId = randomUUID();
|
||||
return writer.transaction(async tx => {
|
||||
await notifyChatEndpointConfiguration(tx, { id, provider: patch.provider ?? "discord" });
|
||||
await tx.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: id, uid: id,
|
||||
transport: "chat_sdk", connectionPurpose: "channel", enabled: true, status: "active" });
|
||||
return (await tx.insert(chatEndpoints).values({ id, companyId, connectionId, assignedAgentId: agentId,
|
||||
publicId: id, provider: "discord", status: "active", ...patch }).returning())[0];
|
||||
});
|
||||
}
|
||||
async function start(service = chatReceiverSchedule(db, { reconcile, stop })) {
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => enabled && primary, canReconcile: () => primary, onError: error => errors.push(error) });
|
||||
const worker = registerChatReceiverWork(coordinator, service, () => enabled && primary);
|
||||
await worker.ready;
|
||||
return { worker, service };
|
||||
}
|
||||
async function settled(expected: number | null = null) {
|
||||
await vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
expect(coordinator!.nextWakeAt()).toBe(expected);
|
||||
}, { timeout: 5000 });
|
||||
}
|
||||
async function quiet(ms = 120_000) {
|
||||
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction"), execute = vi.spyOn(db, "execute");
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
|
||||
} finally { reads.mockRestore(); writes.mockRestore(); execute.mockRestore(); }
|
||||
}
|
||||
it("does no recurring SQL after empty reconstruction", async () => { await start(); await settled(); await quiet(); });
|
||||
it("leaves disabled, archived, and webhook-only endpoints quiet", async () => {
|
||||
await endpoint({ status: "paused" }); await endpoint({ status: "archived" }); await endpoint({ provider: "slack" });
|
||||
const disabled = await endpoint(); await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, disabled.connectionId));
|
||||
const failed = await endpoint({ status: "attention" }); await db.update(toolConnections).set({ healthStatus: "failed" }).where(eq(toolConnections.id, failed.connectionId));
|
||||
await start(); await settled(); await quiet(); expect(reconcile).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["discord", "imessage-photon"] as const)("wakes the first %s receiver and retains its identity until removal", async provider => {
|
||||
await start(); await settled(); await quiet();
|
||||
const row = await endpoint({ provider }); await settled();
|
||||
expect(reconcile).toHaveBeenCalledWith(row.id); await quiet();
|
||||
await db.transaction(async tx => { await notifyChatConnectionConfiguration(tx, row.connectionId); await tx.delete(chatEndpoints).where(eq(chatEndpoints.id, row.id)); });
|
||||
await settled(); expect(stop).toHaveBeenCalledWith(row.id); await quiet();
|
||||
});
|
||||
it("waits for outer commit and discards rolled-back configuration", async () => {
|
||||
await start(); await settled();
|
||||
await expect(db.transaction(async tx => { await endpoint({}, tx as unknown as typeof db); await vi.advanceTimersByTimeAsync(60_000); expect(reconcile).not.toHaveBeenCalled(); throw new Error("rollback"); })).rejects.toThrow("rollback");
|
||||
await settled(); await quiet();
|
||||
let id = "";
|
||||
await db.transaction(async tx => { id = (await endpoint({}, tx as unknown as typeof db)).id; await vi.advanceTimersByTimeAsync(60_000); expect(reconcile).not.toHaveBeenCalled(); });
|
||||
await settled(); expect(reconcile).toHaveBeenCalledWith(id);
|
||||
});
|
||||
it("preserves the first receiver written during drain without passive SQL", async () => {
|
||||
await start(); await settled(); enabled = false; const row = await endpoint();
|
||||
await vi.waitFor(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
});
|
||||
const drained = idleWorkSnapshot();
|
||||
await quiet(60_000); expect(idleWorkSnapshot()).toEqual(drained); expect(reconcile).not.toHaveBeenCalled();
|
||||
enabled = true; await settled(); expect(reconcile).toHaveBeenCalledWith(row.id);
|
||||
});
|
||||
it("reconstructs after standby becomes primary without restarting the worker", async () => {
|
||||
await endpoint(); primary = false; await start(); await quiet(60_000);
|
||||
primary = true; await settled(); expect(reconcile).toHaveBeenCalledOnce(); await quiet();
|
||||
});
|
||||
it("waits for a foreign lease, observes its extension, and takes over after release", async () => {
|
||||
const row = await endpoint(); let owned = false;
|
||||
reconcile.mockImplementation(async () => owned ? "owned" : "foreign");
|
||||
const expiry = Date.now() + 60_000;
|
||||
await db.insert(chatEndpointLeases).values({ companyId, endpointId: row.id, leaseKey: "discord_gateway_runtime", token: randomUUID(), expiresAt: new Date(expiry) });
|
||||
await start(); await settled(expiry); await quiet(10_000); expect(reconcile).toHaveBeenCalledOnce();
|
||||
const renewed = expiry + 60_000;
|
||||
await db.update(chatEndpointLeases).set({ expiresAt: new Date(renewed) }).where(eq(chatEndpointLeases.endpointId, row.id));
|
||||
await vi.advanceTimersByTimeAsync(expiry - Date.now()); await settled(renewed); expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
await db.delete(chatEndpointLeases).where(eq(chatEndpointLeases.endpointId, row.id)); owned = true;
|
||||
await vi.advanceTimersByTimeAsync(renewed - Date.now()); await settled(); expect(reconcile).toHaveBeenCalledTimes(3); await quiet();
|
||||
});
|
||||
it("wakes after a local receiver stops and then returns to quiet", async () => {
|
||||
const row = await endpoint(); const { worker } = await start(); await settled(); await quiet();
|
||||
worker.changed(row.id); await settled(); expect(reconcile).toHaveBeenCalledTimes(2); await quiet();
|
||||
});
|
||||
it("retains a producer arriving during its scoped refresh", async () => {
|
||||
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
||||
await start(service); await settled(); let inserted = false;
|
||||
service.load = async (...args) => { const rows = await original(...args); if (!inserted) { inserted = true; await endpoint(); } return rows; };
|
||||
await endpoint(); await settled(); expect(reconcile).toHaveBeenCalledTimes(2); await quiet();
|
||||
});
|
||||
it("cancels all cached owned receivers after a company deletion hint", async () => {
|
||||
await endpoint(); await endpoint(); await start(); await settled();
|
||||
await db.transaction(async tx => { await notifyChatCompanyConfiguration(tx, companyId); await tx.delete(chatEndpoints).where(eq(chatEndpoints.companyId, companyId)); });
|
||||
await settled(); expect(stop).toHaveBeenCalledTimes(2); await quiet();
|
||||
});
|
||||
it("retains transient load failures and does not repeatedly scan during their backoff", async () => {
|
||||
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
||||
await start(service); await settled(); let broken = true;
|
||||
service.load = async (...args) => { if (broken) throw new Error("load unavailable"); return original(...args); };
|
||||
await endpoint(); await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(errors.length).toBeGreaterThan(0); });
|
||||
await quiet(10_000); broken = false; await settled(); expect(reconcile).toHaveBeenCalledOnce(); errors.length = 0;
|
||||
});
|
||||
it("does not schedule blocked takeover attempts before a failed scope refresh can retry", async () => {
|
||||
const row = await endpoint();
|
||||
reconcile.mockResolvedValue("foreign");
|
||||
const expiry = Date.now() + 10_000;
|
||||
await db.insert(chatEndpointLeases).values({ companyId, endpointId: row.id, leaseKey: "discord_gateway_runtime", token: randomUUID(), expiresAt: new Date(expiry) });
|
||||
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
||||
await start(service); await settled(expiry);
|
||||
service.load = async () => { throw new Error("scoped inventory unavailable"); };
|
||||
await db.transaction(tx => notifyChatConnectionConfiguration(tx, row.connectionId));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.waitFor(() => expect(errors).toHaveLength(1));
|
||||
expect(coordinator!.nextWakeAt()).toBeGreaterThan(Date.now() + 20_000);
|
||||
await quiet(10_000);
|
||||
service.load = original; reconcile.mockResolvedValue("owned");
|
||||
await settled(); errors.length = 0; await quiet();
|
||||
});
|
||||
it("coalesces a full refresh with its already pending scoped hints", async () => {
|
||||
const service = chatReceiverSchedule(db, { reconcile, stop });
|
||||
await start(service); await settled();
|
||||
const load = vi.spyOn(service, "load");
|
||||
await db.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatReceivers, "*");
|
||||
await endpoint({}, tx as unknown as typeof db);
|
||||
await endpoint({}, tx as unknown as typeof db);
|
||||
});
|
||||
await settled(); expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
expect(load).toHaveBeenCalledOnce(); expect(load.mock.calls[0][0]).toBeUndefined();
|
||||
load.mockRestore(); await quiet();
|
||||
});
|
||||
it("keeps full-refresh backoff when a narrower producer arrives", async () => {
|
||||
const service = chatReceiverSchedule(db, { reconcile, stop }), original = service.load;
|
||||
await start(service); await settled();
|
||||
const load = vi.spyOn(service, "load").mockRejectedValue(new Error("inventory unavailable"));
|
||||
await db.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.chatReceivers, "*"));
|
||||
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(errors).toHaveLength(1); });
|
||||
const retryAt = coordinator!.nextWakeAt();
|
||||
const row = await endpoint();
|
||||
await quiet(10_000);
|
||||
expect(load).toHaveBeenCalledOnce(); expect(coordinator!.nextWakeAt()).toBe(retryAt);
|
||||
load.mockImplementation(original);
|
||||
await settled(); expect(reconcile).toHaveBeenCalledWith(row.id);
|
||||
expect(load).toHaveBeenCalledTimes(2); expect(load.mock.calls[1][0]).toBeUndefined();
|
||||
errors.length = 0; load.mockRestore(); await quiet();
|
||||
});
|
||||
it("recovers malformed scopes without losing a subsequent valid producer", async () => {
|
||||
await start(); await settled();
|
||||
await db.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.chatReceivers, "unknown:old"));
|
||||
await endpoint();
|
||||
await settled(); expect(reconcile).toHaveBeenCalledOnce(); expect(errors.length).toBeGreaterThan(0); errors.length = 0; await quiet();
|
||||
});
|
||||
it.each([0, 1100])("drains paged inventory despite a failed receiver after %i ms", async delay => {
|
||||
for (let i = 0; i < 105; i++) await endpoint();
|
||||
const service = chatReceiverSchedule(db, { reconcile, stop });
|
||||
const reads = vi.spyOn(db, "select"); const inventory = await service.load({ kind: "company", id: companyId });
|
||||
expect(inventory).toHaveLength(105); expect(reads).toHaveBeenCalledTimes(2); reads.mockRestore();
|
||||
const failedId = inventory[0].id;
|
||||
reconcile.mockImplementation(async id => {
|
||||
if (id === failedId) { vi.setSystemTime(Date.now() + delay); throw new Error("bad credential"); }
|
||||
return "owned";
|
||||
});
|
||||
await start(service);
|
||||
expect(coordinator!.nextWakeAt()).not.toBeNull();
|
||||
expect(coordinator!.nextWakeAt()!).toBeLessThanOrEqual(Date.now() + 1000);
|
||||
await vi.waitFor(() => expect(new Set(reconcile.mock.calls.map(([id]) => id)).size).toBe(105), { timeout: 5000 });
|
||||
expect(reconcile.mock.calls.filter(([id]) => id === failedId)).toHaveLength(1);
|
||||
reconcile.mockResolvedValue("owned"); await settled(); errors.length = 0; await quiet();
|
||||
});
|
||||
it("includes a second company in the first batch despite a larger earlier backlog", async () => {
|
||||
for (let i = 1; i <= 30; i++) await endpoint({ id: `00000000-0000-4000-8000-${String(i).padStart(12, "0")}` });
|
||||
const firstCompany = companyId, firstAgent = agentId, firstApplication = applicationId;
|
||||
const secondCompany = randomUUID(), secondAgent = randomUUID(), secondApplication = randomUUID();
|
||||
await db.insert(companies).values({ id: secondCompany, name: "Other receiver company", issuePrefix: `R${secondCompany.slice(0, 7)}` });
|
||||
await db.insert(agents).values({ id: secondAgent, companyId: secondCompany, name: "Receiver", adapterType: "codex_local" });
|
||||
await db.insert(toolApplications).values({ id: secondApplication, companyId: secondCompany, name: "Receiver", type: "chat" });
|
||||
try {
|
||||
companyId = secondCompany; agentId = secondAgent; applicationId = secondApplication;
|
||||
const other = await endpoint({ id: "ffffffff-ffff-4fff-8fff-ffffffffffff" });
|
||||
companyId = firstCompany; agentId = firstAgent; applicationId = firstApplication;
|
||||
await start();
|
||||
expect(reconcile.mock.calls.slice(0, 25).map(([id]) => id)).toContain(other.id);
|
||||
await settled(); expect(reconcile).toHaveBeenCalledTimes(31); await quiet();
|
||||
} finally {
|
||||
companyId = firstCompany; agentId = firstAgent; applicationId = firstApplication;
|
||||
await coordinator?.stop(); coordinator = undefined;
|
||||
await db.delete(chatEndpoints).where(eq(chatEndpoints.companyId, secondCompany));
|
||||
await db.delete(agents).where(eq(agents.companyId, secondCompany));
|
||||
await db.delete(companies).where(eq(companies.id, secondCompany));
|
||||
}
|
||||
});
|
||||
it("joins admitted runtime initialization during shutdown", async () => {
|
||||
await start(); await settled(); let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
reconcile.mockImplementation(async () => { await gate; return "owned"; });
|
||||
await endpoint(); await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(reconcile).toHaveBeenCalledOnce(); });
|
||||
let stopped = false; const stopping = coordinator!.stop().then(() => { stopped = true; });
|
||||
await vi.advanceTimersByTimeAsync(1000); expect(stopped).toBe(false);
|
||||
release(); await stopping; coordinator = undefined; errors.length = 0;
|
||||
await quiet(); expect(reconcile).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createDeliveryWorkCoordinator } from "../../services/delivery-work-coordinator.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "../helpers/external-test-database.js";
|
||||
import { Readable } from "node:stream";
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "@photon-ai/advanced-imessage";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
companies,
|
||||
agents,
|
||||
@@ -75,15 +78,18 @@ function idleStream<T>(): TypedEventStream<T> {
|
||||
);
|
||||
}
|
||||
describe("iMessage Photon channel control plane", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let secrets: string;
|
||||
const oldKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const services: ChatChannelService[] = [];
|
||||
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
|
||||
const companyIds: string[] = [];
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-photon-");
|
||||
db = createDb(database.connectionString);
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_PHOTON_TEST_DATABASE_URL");
|
||||
if (!external) database = await startEmbeddedPostgresTestDatabase("paperclip-photon-");
|
||||
if (external) await applyPendingMigrations(external);
|
||||
db = createDb(external ?? database!.connectionString);
|
||||
secrets = await mkdtemp(path.join(tmpdir(), "photon-secrets-"));
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(
|
||||
secrets,
|
||||
@@ -91,6 +97,7 @@ describe("iMessage Photon channel control plane", () => {
|
||||
);
|
||||
}, 30_000);
|
||||
afterEach(async () => {
|
||||
await Promise.all(coordinators.splice(0).map(coordinator => coordinator.stop()));
|
||||
await Promise.all(services.splice(0).map((service) => service.shutdown()));
|
||||
for (const id of companyIds.splice(0))
|
||||
await db
|
||||
@@ -100,6 +107,7 @@ describe("iMessage Photon channel control plane", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await database?.cleanup();
|
||||
if (secrets) await rm(secrets, { recursive: true, force: true });
|
||||
if (oldKey === undefined)
|
||||
@@ -364,8 +372,33 @@ describe("iMessage Photon channel control plane", () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
it("recovers a Photon transport loss through the receiver worker and cancels fatal recovery", async () => {
|
||||
const t = await setup();
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
coordinators.push(coordinator);
|
||||
await t.service.registerReceiverWork(coordinator, () => true).ready;
|
||||
const first = t.service.runtime.get(t.endpoint.id);
|
||||
expect(first).not.toBeNull();
|
||||
await t.callbacks().onPhotonFailure!(new Error("transport interrupted"));
|
||||
await vi.waitFor(() => {
|
||||
const current = t.service.runtime.get(t.endpoint.id);
|
||||
expect(current).not.toBeNull(); expect(current).not.toBe(first);
|
||||
}, { timeout: 5000 });
|
||||
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 5000 });
|
||||
await t.callbacks().onPhotonFailure!(new PhotonError("credentials", "Rejected credentials"));
|
||||
await vi.waitFor(() => expect(t.service.runtime.get(t.endpoint.id)).toBeNull(), { timeout: 5000 });
|
||||
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 5000 });
|
||||
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, t.endpoint.connectionId)))
|
||||
.toEqual([expect.objectContaining({ enabled: false })]);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps publication authentication failures stopped across receiver reconciliation and restart", async () => {
|
||||
const t = await setup();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: () => {} });
|
||||
coordinators.push(coordinator);
|
||||
await t.service.registerReceiverWork(coordinator, () => true).ready;
|
||||
const conversation = await t.start();
|
||||
await t.qualify();
|
||||
const callbacks = t.callbacks();
|
||||
@@ -380,8 +413,8 @@ describe("iMessage Photon channel control plane", () => {
|
||||
expect.objectContaining({ status: "active", enabled: true, healthStatus: "failed" }),
|
||||
]);
|
||||
await expect(callbacks.onPhotonAssertOwned!()).rejects.toThrow();
|
||||
await t.service.reconcileProviderRuntimes();
|
||||
expect(t.service.runtime.get(t.endpoint.id)).toBeFalsy();
|
||||
await vi.waitFor(() => expect(t.service.runtime.get(t.endpoint.id)).toBeFalsy(), { timeout: 5000 });
|
||||
await coordinator.stop();
|
||||
const restarted = await t.restart();
|
||||
expect(restarted.runtime.get(t.endpoint.id)).toBeFalsy();
|
||||
expect(await restarted.get(t.endpoint.id)).toMatchObject({ status: "attention" });
|
||||
|
||||
+5
-7
@@ -321,7 +321,7 @@ export function shouldEnablePrivateHostnameGuard(opts: {
|
||||
}
|
||||
|
||||
type ChatReconciliationLane =
|
||||
| "provider runtimes"
|
||||
| "voice sessions"
|
||||
| "deliveries"
|
||||
| "GitHub webhook recovery"
|
||||
| "run milestones"
|
||||
@@ -335,7 +335,7 @@ type ChatReconciliationLane =
|
||||
* next publication sweep for every endpoint.
|
||||
*/
|
||||
export function createChatReconciliationCoordinator(input: {
|
||||
reconcileProviderRuntimes: () => Promise<unknown>;
|
||||
reconcileVoiceSessions: () => Promise<unknown>;
|
||||
processPendingDeliveries: () => Promise<unknown>;
|
||||
processFailedGitHubWebhookDeliveries?: () => Promise<unknown>;
|
||||
projectRunMilestones: () => Promise<number>;
|
||||
@@ -377,7 +377,7 @@ export function createChatReconciliationCoordinator(input: {
|
||||
return {
|
||||
reconcile() {
|
||||
if (stopped) return;
|
||||
start("provider runtimes", input.reconcileProviderRuntimes);
|
||||
start("voice sessions", input.reconcileVoiceSessions);
|
||||
start("deliveries", input.processPendingDeliveries);
|
||||
if (input.processFailedGitHubWebhookDeliveries) {
|
||||
start(
|
||||
@@ -1298,16 +1298,14 @@ export async function createApp(
|
||||
}
|
||||
registerChatDeliveryWork(deliveryWork, chatChannels, () => !isIdleTaskDrainActive());
|
||||
registerChatActionWork(deliveryWork, chatChannels, () => !isIdleTaskDrainActive());
|
||||
chatChannels.registerReceiverWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
|
||||
chatChannels.registerCommandWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
|
||||
emailChannels.start();
|
||||
const reconcileChatPublicationMaintenance = async () => {
|
||||
await chatChannels.processPublicationMaintenance();
|
||||
};
|
||||
const chatReconciliation = createChatReconciliationCoordinator({
|
||||
reconcileProviderRuntimes: async () => {
|
||||
await chatChannels.reconcileProviderRuntimes();
|
||||
await chatChannels.voice.reconcile();
|
||||
},
|
||||
reconcileVoiceSessions: () => chatChannels.voice.reconcile(),
|
||||
// Chat delivery, action, and Telegram discovery workers now own these
|
||||
// duties. Keep the coordinator's other maintenance lanes independent.
|
||||
processPendingDeliveries: async () => undefined,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { notifyChatCommandRegistration } from "./chat-channels/command-work-notifications.js";
|
||||
import { leasedChatProvider } from "./chat-channels/runtime/receiver-policy.js";
|
||||
import { chatReceiverSchedule } from "./chat-channels/runtime/receiver-schedule.js";
|
||||
import { registerChatReceiverWork } from "./chat-channels/runtime/receiver-work.js";
|
||||
import { notifyChatEndpointConfiguration } from "./chat-channels/configuration-work-notifications.js";
|
||||
import { registerDiscordCommandWork } from "./chat-channels/discord/command-work.js";
|
||||
import { discordCommandSchedule } from "./chat-channels/discord/command-schedule.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
@@ -290,7 +293,6 @@ const ORPHAN_FOLLOW_UP_GRACE_MS = 5_000;
|
||||
const ORPHAN_FOLLOW_UP_MAX_ATTEMPTS = 12;
|
||||
const PUBLICATION_ENDPOINT_CONCURRENCY = 4;
|
||||
|
||||
function leasedChatProvider(provider: string): boolean { return provider === "discord" || provider === "imessage-photon"; }
|
||||
|
||||
const DISCORD_GATEWAY_LEASE_POLL_MS = 100;
|
||||
const PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS = 25_000;
|
||||
@@ -563,7 +565,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
const discordGatewayOwnerships = new Map<string, DiscordGatewayOwnership>();
|
||||
const discordGatewayStopTasks = new Set<Promise<void>>();
|
||||
let shuttingDown = false;
|
||||
const { discordGatewayNotOwnedError, isDiscordGatewayNotOwnedError, releaseDiscordGatewayLeaseRow, stopDiscordGatewayOwnership, renewDiscordGatewayOwnershipForMessageAdmission, ensureDiscordGatewayRuntimeIsCurrent, acquireDiscordGatewayOwnership, discordGatewayRuntimeIsCurrent } = createChatReceiverOwnership(db, { runtime, runtimeVersions, runtimeCallbackEndpoint, redactError, leasedChatProvider, discordGatewayOwnerships, discordGatewayStopTasks, isShuttingDown: () => shuttingDown, get discordGatewayLeaseTtlMs() { return options.discordGatewayLeaseTtlMs; }, get discordGatewayLeaseRenewalIntervalMs() { return options.discordGatewayLeaseRenewalIntervalMs; }, get discordGatewayLeaseWaitMs() { return options.discordGatewayLeaseWaitMs; }, get renewDiscordGatewayLease() { return options.renewDiscordGatewayLease; } });
|
||||
let receiverWork: ReturnType<typeof registerChatReceiverWork> | undefined;
|
||||
const { discordGatewayNotOwnedError, isDiscordGatewayNotOwnedError, releaseDiscordGatewayLeaseRow, stopDiscordGatewayOwnership, renewDiscordGatewayOwnershipForMessageAdmission, ensureDiscordGatewayRuntimeIsCurrent, acquireDiscordGatewayOwnership, discordGatewayRuntimeIsCurrent } = createChatReceiverOwnership(db, { runtime, runtimeVersions, runtimeCallbackEndpoint, redactError, leasedChatProvider, discordGatewayOwnerships, discordGatewayStopTasks, isShuttingDown: () => shuttingDown, onStopped: id => receiverWork?.changed(id), get discordGatewayLeaseTtlMs() { return options.discordGatewayLeaseTtlMs; }, get discordGatewayLeaseRenewalIntervalMs() { return options.discordGatewayLeaseRenewalIntervalMs; }, get discordGatewayLeaseWaitMs() { return options.discordGatewayLeaseWaitMs; }, get renewDiscordGatewayLease() { return options.renewDiscordGatewayLease; } });
|
||||
const { receiveSdkMessages } = createChatSdkMessageReceiver({
|
||||
endpointRecord, processMessage,
|
||||
ingressOnly: (provider) => provider === "imessage-photon"
|
||||
@@ -1117,7 +1120,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
runtimeVersions.delete(endpoint.id);
|
||||
}
|
||||
if (discordOwnership) {
|
||||
await stopDiscordGatewayOwnership(discordOwnership, false);
|
||||
await stopDiscordGatewayOwnership(discordOwnership, { removeRuntime: false });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -1134,6 +1137,30 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
const receiverSchedule = chatReceiverSchedule(db, {
|
||||
stop: async id => {
|
||||
const ownership = discordGatewayOwnerships.get(id);
|
||||
// Discovery already observed removal; only independent ownership loss needs another wake.
|
||||
if (ownership) await stopDiscordGatewayOwnership(ownership, { notifyStopped: false });
|
||||
},
|
||||
reconcile: async id => {
|
||||
const record = await endpointRecord(id);
|
||||
if (!record) return "owned";
|
||||
try {
|
||||
const instance = await runtimeFor(record.endpoint, { requireDiscordOwnership: true });
|
||||
await reconcileDiscordRuntime(id, instance);
|
||||
return "owned";
|
||||
} catch (error) {
|
||||
if (isDiscordGatewayNotOwnedError(error)) return "foreign";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
function registerReceiverWork(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
|
||||
receiverWork = registerChatReceiverWork(coordinator, receiverSchedule, () => !shuttingDown && canRun());
|
||||
return receiverWork;
|
||||
}
|
||||
|
||||
async function reconcileProviderRuntimes() {
|
||||
if (shuttingDown) {
|
||||
return { eligible: 0, local: 0, ownedElsewhere: 0, failed: 0 };
|
||||
@@ -2211,7 +2238,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
? "Started a conversation by mentioning the agent."
|
||||
: "Sent an empty message.");
|
||||
let comment!: Awaited<ReturnType<typeof issuesSvc.addComment>>;
|
||||
await notifyChatCommandRegistration(taskTx, taskEndpoint);
|
||||
await notifyChatEndpointConfiguration(taskTx, taskEndpoint);
|
||||
await taskTx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
@@ -3472,6 +3499,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
const { getPublicationBatchStatus, publishComment, publishBoardMessage } = createChatPublicationRequests(db, { endpointRecord, stageTeamsBoardFileIntent, processPendingSlackBoardMessages, processPendingPublications, prepareTeamsBoardSend });
|
||||
return {
|
||||
registerCommandWork,
|
||||
registerReceiverWork,
|
||||
voice,
|
||||
authorizeFastResponse,
|
||||
slackRegistration,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ChatProvider, ToolConnectionTransport } from "@paperclipai/shared";
|
||||
import { notifyDiscordCommandRegistration } from "./discord/command-work-notifications.js";
|
||||
|
||||
export async function notifyChatCommandRegistration(transaction: object, endpoint: { id: string; provider: ChatProvider }) {
|
||||
if (endpoint.provider === "discord") {
|
||||
await notifyDiscordCommandRegistration(transaction, { kind: "endpoint", id: endpoint.id });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyChatConnectionCommandRegistration(transaction: object, connectionId: string, transport: ToolConnectionTransport = "chat_sdk") {
|
||||
if (transport === "chat_sdk") {
|
||||
await notifyDiscordCommandRegistration(transaction, { kind: "connection", id: connectionId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyChatCompanyCommandRegistration(transaction: object, companyId: string) {
|
||||
await notifyDiscordCommandRegistration(transaction, { kind: "company", id: companyId });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { notifyChatReceiver, notifyChatConnectionReceiver, notifyChatCompanyReceivers } from "./runtime/receiver-work-notifications.js";
|
||||
import type { ChatProvider, ToolConnectionTransport } from "@paperclipai/shared";
|
||||
import { notifyDiscordCommandRegistration } from "./discord/command-work-notifications.js";
|
||||
|
||||
export async function notifyChatEndpointConfiguration(transaction: object, endpoint: { id: string; provider: ChatProvider }) {
|
||||
await notifyChatReceiver(transaction, endpoint);
|
||||
if (endpoint.provider === "discord") {
|
||||
await notifyDiscordCommandRegistration(transaction, { kind: "endpoint", id: endpoint.id });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyChatConnectionConfiguration(transaction: object, connectionId: string, transport: ToolConnectionTransport = "chat_sdk") {
|
||||
await notifyChatConnectionReceiver(transaction, connectionId, transport);
|
||||
if (transport === "chat_sdk") {
|
||||
await notifyDiscordCommandRegistration(transaction, { kind: "connection", id: connectionId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyChatCompanyConfiguration(transaction: object, companyId: string) {
|
||||
await notifyChatCompanyReceivers(transaction, companyId);
|
||||
await notifyDiscordCommandRegistration(transaction, { kind: "company", id: companyId });
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatCommandRegistration } from "./command-work-notifications.js";
|
||||
import { notifyChatEndpointConfiguration } from "./configuration-work-notifications.js";
|
||||
import { prepareSlackInventory } from "./slack/inventory.js";
|
||||
import { prepareGitHubInventory } from "./github/inventory.js";
|
||||
import { prepareDiscordInventory } from "./discord/inventory.js";
|
||||
@@ -352,7 +352,7 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
|
||||
code: "chat_endpoint_credentials_changed",
|
||||
});
|
||||
}
|
||||
await notifyChatCommandRegistration(tx, endpoint);
|
||||
await notifyChatEndpointConfiguration(tx, endpoint);
|
||||
await tx
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
@@ -464,7 +464,7 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!current) throw notFound("Chat connection not found");
|
||||
refs = current.refs;
|
||||
await notifyChatCommandRegistration(tx, endpoint);
|
||||
await notifyChatEndpointConfiguration(tx, endpoint);
|
||||
await tx
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatReceiver } from "../runtime/receiver-work-notifications.js";
|
||||
import { notifyDiscordCommandRegistration } from "./command-work-notifications.js";
|
||||
import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence, InternalSetupState } from "../runtime-types.js";
|
||||
|
||||
@@ -130,7 +131,10 @@ export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEvents
|
||||
}
|
||||
const now = new Date();
|
||||
if (event.type === "connecting") {
|
||||
if (current.status === "attention") await notifyDiscordCommandRegistration(tx, { kind: "endpoint", id: current.id });
|
||||
if (current.status === "attention") {
|
||||
await notifyChatReceiver(tx, current);
|
||||
await notifyDiscordCommandRegistration(tx, { kind: "endpoint", id: current.id });
|
||||
}
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
@@ -159,6 +163,7 @@ export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEvents
|
||||
if (current.status === "attention" && !gatewayOwnedAttention) return;
|
||||
const status =
|
||||
current.setup.step === "complete" ? "active" : "verifying";
|
||||
await notifyChatReceiver(tx, current);
|
||||
await notifyDiscordCommandRegistration(tx, { kind: "endpoint", id: current.id });
|
||||
if (status !== current.status) await notifyChatEndpointWork(tx, current.connectionId);
|
||||
await tx
|
||||
@@ -189,6 +194,7 @@ export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEvents
|
||||
return;
|
||||
}
|
||||
|
||||
await notifyChatReceiver(tx, current);
|
||||
await notifyDiscordCommandRegistration(tx, { kind: "endpoint", id: current.id });
|
||||
const detail = discordGatewayFailureDetail(event);
|
||||
const transientGuildOutage = event.type === "guild_unavailable";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatReceiver } from "../runtime/receiver-work-notifications.js";
|
||||
import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js";
|
||||
|
||||
import { notifyChatPublicationWork } from "../../chat-work-notifications.js";
|
||||
@@ -104,6 +105,7 @@ export function createPhotonEvents(db: Db, options: PhotonEventsOptions) {
|
||||
"attention",
|
||||
]);
|
||||
if (!endpoint) return;
|
||||
await notifyChatReceiver(tx, endpoint);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatCommandRegistration } from "../command-work-notifications.js";
|
||||
import { notifyChatEndpointConfiguration } from "../configuration-work-notifications.js";
|
||||
import { notifyChatConnectionHealth } from "../connection-health-notifications.js";
|
||||
import { slackQuestionFormValidationError } from "../slack/question-forms.js";
|
||||
import { chatInteractionForm, chatItemVerdicts } from "../../chat-interaction-forms.js";
|
||||
@@ -418,7 +418,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
["active"],
|
||||
);
|
||||
if (currentEndpoint) {
|
||||
await notifyChatCommandRegistration(tx, currentEndpoint);
|
||||
await notifyChatEndpointConfiguration(tx, currentEndpoint);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatCommandRegistration } from "./command-work-notifications.js";
|
||||
import { notifyChatEndpointConfiguration } from "./configuration-work-notifications.js";
|
||||
import { notifyChatConnectionHealth } from "./connection-health-notifications.js";
|
||||
import { createTelegramCallbackNoticeAuthority } from "./telegram/callback-notices.js";
|
||||
|
||||
@@ -318,7 +318,7 @@ export function createProviderEffects(db: Db, options: ProviderEffectsOptions) {
|
||||
);
|
||||
}
|
||||
if (input.disposition.kind === "endpoint_attention" && currentEndpoint) {
|
||||
await notifyChatCommandRegistration(tx, currentEndpoint);
|
||||
await notifyChatEndpointConfiguration(tx, currentEndpoint);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatCommandRegistration } from "../command-work-notifications.js";
|
||||
import { notifyChatEndpointConfiguration } from "../configuration-work-notifications.js";
|
||||
import { notifyChatConnectionHealth } from "../connection-health-notifications.js";
|
||||
import { isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError } from "../outbound-attachments.js";
|
||||
|
||||
@@ -190,7 +190,7 @@ export function createChatPublicationFailures(db: Db, options: PublicationFailur
|
||||
: null;
|
||||
|
||||
if (disposition.kind === "endpoint_attention" && currentEndpoint) {
|
||||
await notifyChatCommandRegistration(tx, currentEndpoint);
|
||||
await notifyChatEndpointConfiguration(tx, currentEndpoint);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatCommandRegistration } from "./command-work-notifications.js";
|
||||
import { notifyChatEndpointConfiguration } from "./configuration-work-notifications.js";
|
||||
import { notifyChatConnectionHealth } from "./connection-health-notifications.js";
|
||||
import type { ConnectionManagementContext } from "../connection-management.js";
|
||||
import { imessageResourceParticipants } from "./imessage-photon/resources.js";
|
||||
@@ -511,7 +511,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
|
||||
: currentEndpoint.status === "attention"
|
||||
? "verifying"
|
||||
: currentEndpoint.status;
|
||||
await notifyChatCommandRegistration(tx, currentEndpoint);
|
||||
await notifyChatEndpointConfiguration(tx, currentEndpoint);
|
||||
if (status !== currentEndpoint.status) await notifyChatEndpointWork(tx, currentEndpoint.connectionId);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
@@ -541,7 +541,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
}
|
||||
} else {
|
||||
await notifyChatCommandRegistration(tx, currentEndpoint);
|
||||
await notifyChatEndpointConfiguration(tx, currentEndpoint);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { receiverLeaseKey as leaseKeyForProvider } from "./receiver-policy.js";
|
||||
import type { EndpointRow, DbTransaction, RuntimeContext, InboundRuntimeContext } from "../runtime-types.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, lte } from "drizzle-orm";
|
||||
@@ -17,12 +18,12 @@ type OwnershipOptions = {
|
||||
discordGatewayOwnerships: Map<string, DiscordGatewayOwnership>;
|
||||
discordGatewayStopTasks: Set<Promise<void>>;
|
||||
isShuttingDown: () => boolean;
|
||||
onStopped?: (endpointId: string) => void;
|
||||
readonly discordGatewayLeaseTtlMs?: number;
|
||||
readonly discordGatewayLeaseRenewalIntervalMs?: number;
|
||||
readonly discordGatewayLeaseWaitMs?: number;
|
||||
readonly renewDiscordGatewayLease?: (input: { companyId: string; credentialFingerprint: string; endpointId: string; expiresAt: Date; generation: number; leaseKey: string; token: string }) => Promise<boolean>;
|
||||
};
|
||||
const DISCORD_GATEWAY_LEASE_KEY = "discord_gateway_runtime";
|
||||
const DISCORD_GATEWAY_LEASE_TTL_MS = 15_000;
|
||||
const DISCORD_GATEWAY_LEASE_WAIT_MS = 20_000;
|
||||
const DISCORD_GATEWAY_LEASE_POLL_MS = 100;
|
||||
@@ -73,7 +74,7 @@ export function createChatReceiverOwnership(db: Db, options: OwnershipOptions) {
|
||||
|
||||
async function stopDiscordGatewayOwnership(
|
||||
ownership: DiscordGatewayOwnership,
|
||||
removeRuntime = true,
|
||||
stopOptions: { removeRuntime?: boolean; notifyStopped?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (ownership.stopPromise) return ownership.stopPromise;
|
||||
ownership.stopping = true;
|
||||
@@ -81,12 +82,9 @@ export function createChatReceiverOwnership(db: Db, options: OwnershipOptions) {
|
||||
clearInterval(ownership.renewTimer);
|
||||
ownership.renewTimer = null;
|
||||
}
|
||||
if (discordGatewayOwnerships.get(ownership.endpointId) === ownership) {
|
||||
discordGatewayOwnerships.delete(ownership.endpointId);
|
||||
}
|
||||
ownership.stopPromise = (async () => {
|
||||
if (
|
||||
removeRuntime &&
|
||||
stopOptions.removeRuntime !== false &&
|
||||
ownership.context.endpointRuntime &&
|
||||
runtime.get(ownership.endpointId) === ownership.context.endpointRuntime
|
||||
) {
|
||||
@@ -104,6 +102,10 @@ export function createChatReceiverOwnership(db: Db, options: OwnershipOptions) {
|
||||
// The listener is stopped before the lease is released. A standby can
|
||||
// therefore acquire immediately without overlapping Gateway sessions.
|
||||
await releaseDiscordGatewayLeaseRow(ownership);
|
||||
if (discordGatewayOwnerships.get(ownership.endpointId) === ownership) {
|
||||
discordGatewayOwnerships.delete(ownership.endpointId);
|
||||
}
|
||||
if (!options.isShuttingDown() && stopOptions.notifyStopped !== false) options.onStopped?.(ownership.endpointId);
|
||||
})();
|
||||
discordGatewayStopTasks.add(ownership.stopPromise);
|
||||
void ownership.stopPromise.finally(() => {
|
||||
@@ -304,7 +306,7 @@ export function createChatReceiverOwnership(db: Db, options: OwnershipOptions) {
|
||||
context: RuntimeContext,
|
||||
waitForOwnership: boolean,
|
||||
): Promise<DiscordGatewayOwnership | null> {
|
||||
const receiverLeaseKey = endpoint.provider === "imessage-photon" ? "photon_receiver_runtime" : DISCORD_GATEWAY_LEASE_KEY;
|
||||
const receiverLeaseKey = leaseKeyForProvider(endpoint.provider)!;
|
||||
const local = discordGatewayOwnerships.get(endpoint.id);
|
||||
if (
|
||||
local &&
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ChatProvider } from "@paperclipai/shared";
|
||||
|
||||
export const RECEIVER_LEASE_KEYS = {
|
||||
discord: "discord_gateway_runtime",
|
||||
"imessage-photon": "photon_receiver_runtime",
|
||||
} as const satisfies Partial<Record<ChatProvider, string>>;
|
||||
|
||||
export function receiverLeaseKey(provider: string) {
|
||||
return Object.hasOwn(RECEIVER_LEASE_KEYS, provider)
|
||||
? RECEIVER_LEASE_KEYS[provider as keyof typeof RECEIVER_LEASE_KEYS] : undefined;
|
||||
}
|
||||
|
||||
export const leasedChatProvider = (provider: string) => receiverLeaseKey(provider) !== undefined;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { and, eq, gt, inArray, ne, or } from "drizzle-orm";
|
||||
import { chatEndpoints, chatEndpointLeases, toolConnections, type Db } from "@paperclipai/db";
|
||||
import type { ChatProvider } from "@paperclipai/shared";
|
||||
import { RECEIVER_LEASE_KEYS, receiverLeaseKey } from "./receiver-policy.js";
|
||||
import type { ReceiverScope } from "./receiver-work-notifications.js";
|
||||
|
||||
export type ReceiverDeadline = { id: string; companyId: string; connectionId: string; dueAt: number | null };
|
||||
|
||||
export function chatReceiverSchedule(db: Db, receiver: {
|
||||
reconcile: (id: string) => Promise<"owned" | "foreign">;
|
||||
stop: (id: string) => Promise<void>;
|
||||
}) {
|
||||
async function load(scope?: ReceiverScope, signal?: AbortSignal): Promise<ReceiverDeadline[]> {
|
||||
const result: ReceiverDeadline[] = [];
|
||||
let cursor: string | undefined;
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const rows = await db.select({ id: chatEndpoints.id, companyId: chatEndpoints.companyId, connectionId: chatEndpoints.connectionId })
|
||||
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
|
||||
.where(and(
|
||||
inArray(chatEndpoints.provider, Object.keys(RECEIVER_LEASE_KEYS) as ChatProvider[]),
|
||||
inArray(chatEndpoints.status, ["verifying", "active", "attention"]),
|
||||
eq(toolConnections.status, "active"), eq(toolConnections.enabled, true),
|
||||
or(ne(chatEndpoints.status, "attention"), ne(toolConnections.healthStatus, "failed")),
|
||||
scope?.kind === "endpoint" ? eq(chatEndpoints.id, scope.id) : undefined,
|
||||
scope?.kind === "connection" ? eq(chatEndpoints.connectionId, scope.id) : undefined,
|
||||
scope?.kind === "company" ? eq(chatEndpoints.companyId, scope.id) : undefined,
|
||||
cursor ? gt(chatEndpoints.id, cursor) : undefined,
|
||||
)).orderBy(chatEndpoints.id).limit(100);
|
||||
result.push(...rows.map(row => ({ ...row, dueAt: 0 })));
|
||||
if (rows.length < 100) return result;
|
||||
cursor = rows[rows.length - 1].id;
|
||||
}
|
||||
}
|
||||
async function check(id: string, signal?: AbortSignal): Promise<ReceiverDeadline | null> {
|
||||
const scope = { kind: "endpoint", id } as const;
|
||||
const [before] = await load(scope, signal);
|
||||
if (!before) { await receiver.stop(id); return null; }
|
||||
signal?.throwIfAborted();
|
||||
const ownership = await receiver.reconcile(id);
|
||||
const [after] = await load(scope, signal);
|
||||
if (!after) { await receiver.stop(id); return null; }
|
||||
if (ownership === "owned") return { ...after, dueAt: null };
|
||||
const [endpoint] = await db.select({ provider: chatEndpoints.provider }).from(chatEndpoints).where(eq(chatEndpoints.id, id));
|
||||
if (!endpoint) { await receiver.stop(id); return null; }
|
||||
const key = receiverLeaseKey(endpoint.provider);
|
||||
const [lease] = key ? await db.select({ expiresAt: chatEndpointLeases.expiresAt }).from(chatEndpointLeases).where(and(
|
||||
eq(chatEndpointLeases.companyId, after.companyId), eq(chatEndpointLeases.endpointId, id), eq(chatEndpointLeases.leaseKey, key),
|
||||
)).limit(1) : [];
|
||||
return { ...after, dueAt: Math.max(Date.now() + 1000, lease?.expiresAt.getTime() ?? 0) };
|
||||
}
|
||||
return { load, check, stop: receiver.stop };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ChatProvider, ToolConnectionTransport } from "@paperclipai/shared";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../delivery-work-notifications.js";
|
||||
import { leasedChatProvider } from "./receiver-policy.js";
|
||||
|
||||
export type ReceiverScope = { kind: "endpoint" | "connection" | "company"; id: string };
|
||||
export function receiverScope(hint: string): ReceiverScope | undefined {
|
||||
if (hint === "*") return undefined;
|
||||
const [kind, id, extra] = hint.split(":");
|
||||
if (!id || extra !== undefined || (kind !== "endpoint" && kind !== "connection" && kind !== "company"))
|
||||
throw new Error("Invalid receiver discovery scope");
|
||||
return { kind, id };
|
||||
}
|
||||
export const receiverScopeHint = (scope: ReceiverScope) => `${scope.kind}:${scope.id}`;
|
||||
export async function notifyChatReceiver(transaction: object, endpoint: { id: string; provider: ChatProvider }) {
|
||||
if (leasedChatProvider(endpoint.provider))
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.chatReceivers, receiverScopeHint({ kind: "endpoint", id: endpoint.id }));
|
||||
}
|
||||
export async function notifyChatConnectionReceiver(transaction: object, connectionId: string, transport: ToolConnectionTransport = "chat_sdk") {
|
||||
if (transport === "chat_sdk")
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.chatReceivers, receiverScopeHint({ kind: "connection", id: connectionId }));
|
||||
}
|
||||
export async function notifyChatCompanyReceivers(transaction: object, companyId: string) {
|
||||
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.chatReceivers, receiverScopeHint({ kind: "company", id: companyId }));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { takeCompanyWorkBatch } from "../../company-work-batch.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../../delivery-work-notifications.js";
|
||||
import type { ReceiverDeadline, chatReceiverSchedule } from "./receiver-schedule.js";
|
||||
import { receiverScope, receiverScopeHint, type ReceiverScope } from "./receiver-work-notifications.js";
|
||||
|
||||
function matches(entry: ReceiverDeadline, scope?: ReceiverScope) {
|
||||
return !scope || (scope.kind === "endpoint" ? entry.id : scope.kind === "connection" ? entry.connectionId : entry.companyId) === scope.id;
|
||||
}
|
||||
export function registerChatReceiverWork(
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||
service: ReturnType<typeof chatReceiverSchedule>,
|
||||
canRun: () => boolean,
|
||||
) {
|
||||
const dirty = new Set<string>(["*"]);
|
||||
const entries = new Map<string, ReceiverDeadline>();
|
||||
const failures = new Map<string, { count: number; retryAt: number }>();
|
||||
const scopeFailures = new Map<string, { count: number; retryAt: number }>();
|
||||
let earliest = 0;
|
||||
let lastCompany: string | null = null;
|
||||
function failed(map: typeof failures, id: string) {
|
||||
const count = Math.min((map.get(id)?.count ?? 0) + 1, 5);
|
||||
map.set(id, { count, retryAt: Date.now() + Math.min(300_000, 30_000 * 2 ** (count - 1)) });
|
||||
}
|
||||
function dirtyScopes() {
|
||||
return [...dirty].map(hint => { try { return receiverScope(hint); } catch { return undefined; } });
|
||||
}
|
||||
function next() {
|
||||
let nextAt: number | null = null;
|
||||
const take = (at: number) => { nextAt = nextAt === null ? at : Math.min(nextAt, at); };
|
||||
for (const hint of dirty.has("*") ? ["*"] : dirty) take(Math.max(earliest, scopeFailures.get(hint)?.retryAt ?? 0));
|
||||
const unavailable = dirtyScopes();
|
||||
for (const entry of entries.values()) if (entry.dueAt !== null && !unavailable.some(scope => matches(entry, scope)))
|
||||
take(Math.max(earliest, entry.dueAt, failures.get(entry.id)?.retryAt ?? 0));
|
||||
return nextAt;
|
||||
}
|
||||
const worker = coordinator.register(DELIVERY_QUEUES.chatReceivers, {
|
||||
retryMs: 30_000,
|
||||
pausePassiveMaintenance: true,
|
||||
onSettled: hints => { for (const hint of hints ?? ["*"]) dirty.add(hint); },
|
||||
run: async signal => {
|
||||
if (!canRun() || Date.now() < earliest) return;
|
||||
earliest = Date.now() + 1000;
|
||||
const errors: unknown[] = [];
|
||||
const requested = (dirty.has("*") ? ["*"] : [...dirty]).filter(hint => (scopeFailures.get(hint)?.retryAt ?? 0) <= Date.now());
|
||||
const covered = requested.includes("*") ? [...dirty] : requested;
|
||||
for (const hint of covered) dirty.delete(hint);
|
||||
for (let index = 0; index < requested.length; index++) {
|
||||
if (signal.aborted || !canRun()) { for (const hint of requested.includes("*") ? covered : requested.slice(index)) dirty.add(hint); return; }
|
||||
const hint = requested[index];
|
||||
let scope: ReceiverScope | undefined;
|
||||
let decoded = false;
|
||||
try {
|
||||
scope = receiverScope(hint); decoded = true;
|
||||
const rows = await service.load(scope, signal), retained = new Set(rows.map(row => row.id));
|
||||
for (const entry of entries.values()) if (matches(entry, scope) && !retained.has(entry.id)) {
|
||||
await service.stop(entry.id);
|
||||
entries.delete(entry.id); failures.delete(entry.id);
|
||||
}
|
||||
for (const entry of rows) entries.set(entry.id, entry);
|
||||
if (!scope) scopeFailures.clear(); else scopeFailures.delete(hint);
|
||||
} catch (error) {
|
||||
const retryHint = decoded ? hint : "*";
|
||||
dirty.add(retryHint); failed(scopeFailures, retryHint); errors.push(error);
|
||||
}
|
||||
}
|
||||
const unavailable = dirtyScopes();
|
||||
const due = [...entries.values()].filter(entry => entry.dueAt !== null && !unavailable.some(scope => matches(entry, scope))
|
||||
&& Math.max(entry.dueAt, failures.get(entry.id)?.retryAt ?? 0) <= Date.now())
|
||||
.sort((a, b) => a.dueAt! - b.dueAt! || a.id.localeCompare(b.id));
|
||||
const batch = takeCompanyWorkBatch(due, 25, lastCompany); lastCompany = batch.lastCompany;
|
||||
for (const entry of batch.entries) {
|
||||
if (signal.aborted || !canRun()) break;
|
||||
try {
|
||||
const current = await service.check(entry.id, signal);
|
||||
if (current) entries.set(entry.id, current); else entries.delete(entry.id);
|
||||
failures.delete(entry.id);
|
||||
} catch (error) { failed(failures, entry.id); errors.push(error); }
|
||||
}
|
||||
if (errors.length) throw new AggregateError(errors, "Chat receiver discovery could not settle");
|
||||
},
|
||||
// Preserve hints/deadlines for resume without holding settled intent during drain.
|
||||
// The coordinator independently fences unresolved database transactions.
|
||||
hasPending: async () => canRun() && dirty.size > 0,
|
||||
nextRunAt: next,
|
||||
nextRunAtOnError: next,
|
||||
});
|
||||
return { ready: worker.ready, changed(id: string) {
|
||||
dirty.add(receiverScopeHint({ kind: "endpoint", id })); worker.wake();
|
||||
} };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatConnectionCommandRegistration } from "./chat-channels/command-work-notifications.js";
|
||||
import { notifyChatConnectionConfiguration } from "./chat-channels/configuration-work-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export async function notifyChatDeliveryWork(tx: object): Promise<void> {
|
||||
@@ -38,5 +38,5 @@ export async function notifyChatEndpointWork(tx: object, connectionId: string):
|
||||
for (const queue of Object.values(CHAT_ACTION_QUEUES)) await notifyDeliveryWork(tx, queue);
|
||||
await notifyChatVerificationWork(tx);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatTelegramSubscriptions, connectionId);
|
||||
await notifyChatConnectionCommandRegistration(tx, connectionId);
|
||||
await notifyChatConnectionConfiguration(tx, connectionId);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatCompanyCommandRegistration } from "./chat-channels/command-work-notifications.js";
|
||||
import { notifyChatCompanyConfiguration } from "./chat-channels/configuration-work-notifications.js";
|
||||
import { chatCompanyDeletion } from "./chat-channels/company-deletion.js";
|
||||
import { notifyToolConnectionHealth } from "./tool-connection-health-notifications.js";
|
||||
import { notifyCompanyRetentionWork } from "./retention-work-notifications.js";
|
||||
@@ -65,7 +65,7 @@ export async function deleteCompany(db: Db, id: string) {
|
||||
const [existing] = await tx.select({ id: companies.id }).from(companies)
|
||||
.where(eq(companies.id, id)).for("no key update");
|
||||
if (!existing) return null;
|
||||
await notifyChatCompanyCommandRegistration(tx, id);
|
||||
await notifyChatCompanyConfiguration(tx, id);
|
||||
await chatCompanyDeletion.deleteCompanyData(tx, id);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, id);
|
||||
|
||||
@@ -43,6 +43,7 @@ export const DELIVERY_QUEUES = {
|
||||
chatGitHubPublications: "chat-github-publications",
|
||||
chatFailedRunRetries: "chat-failed-run-retries",
|
||||
chatTelegramMaintenance: "chat-telegram-maintenance",
|
||||
chatReceivers: "chat-receivers",
|
||||
chatCommandRegistration: "chat-command-registration",
|
||||
chatTelegramSubscriptions: "chat-telegram-subscriptions",
|
||||
chatSessionSyncs: "chat-session-syncs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notifyChatConnectionCommandRegistration } from "../../chat-channels/command-work-notifications.js";
|
||||
import { notifyChatConnectionConfiguration } from "../../chat-channels/configuration-work-notifications.js";
|
||||
import { writeToolConnectionHealthChange } from "../../tool-connection-health-notifications.js";
|
||||
import { connectionActorUserId } from "../actor.js";
|
||||
import { assertSupportedConnection } from "../connections/setup-policy.js";
|
||||
@@ -50,7 +50,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
|
||||
) {
|
||||
const now = new Date();
|
||||
const [updated] = await writeToolConnectionHealthChange(db, connection, async tx => {
|
||||
await notifyChatConnectionCommandRegistration(tx, connection.id, connection.transport);
|
||||
await notifyChatConnectionConfiguration(tx, connection.id, connection.transport);
|
||||
return tx.update(toolConnections)
|
||||
.set({
|
||||
healthStatus: status,
|
||||
|
||||
Reference in new issue
Block a user