Skip to content

Chat delivers room mail through Interchange's run-trigger route (CL-7490) - #680

Merged
TheGreatAxios merged 64 commits into
cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-thefrom
cl-7491-chat-trigger
Sep 6, 2026
Merged

TheGreatAxios merged 64 commits into
cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-thefrom
cl-7491-chat-trigger

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Resolves CL-7490.

Cause

A provisioned run is deployed to its sidecar lazily by its first native
trigger (vendor/intx/hub-api/src/workflow-run-trigger.ts's
createWorkflowRunTrigger: anchor -> grants/principal -> durable
dispatch's agent.deploy -> deliver mail). Chat sent via
sessionService.sendUserMessage, which delivers over the sidecar's
ordinary channel directly and never reaches that durable-dispatch
branch, so an invited run that was never triggered natively never got
its first agent.deploy and stayed permanently unreachable.

Fix

createWorkflowRunTrigger is not exported from @intx/hub-api (it is
the route's own private dependency, not a published seam), so chat now
calls the mounted route itself as a client:

  • packages/chat/src/run-trigger-client.ts posts to
    POST /api/tenants/:tenantId/workflows/runs/:runId/mail in-process
    against the hub's own composed Hono app (app.request(...)).
  • The route authenticates through @intx/hub-api's ordinary session
    contract, which chat has no browser cookie to present for (most sends
    run with no inbound HTTP request in scope at all — mention fan-out, a
    relaunch resend, the mailbox-fanout persist seam). @intx/hub-api's
    GetSession is a documented pluggable seam ("so a third-party
    identity provider can be plugged in"), and apps/hub is the one that
    supplies it — so the client signs a short-lived internal token naming
    the principal to authenticate as
    (packages/chat/src/run-trigger-internal-auth.ts), and apps/hub's
    own getSession implementation verifies it before falling through to
    the real cookie-session path. Neither side touches vendor/intx.
  • packages/chat/src/send-run-mail.ts now delivers through the trigger
    client and correlates on its returned messageId, still assembling a
    local signed MIME copy for this workbench's own session_mail
    history and attachment blob reads (the trigger route returns no raw
    bytes) — never delivered anywhere a second time.
  • The raw sessionService.sendUserMessage path is gone for chat, no
    fallback beside it.

Stacks on #679.

Open gap: durable dispatch does not deploy a probe-adopted allocation

bun test scripts/e2e/chat.test.ts still fails locally and in CI's
e2e-suite (chat) job on the invited-run assertions. Root cause is
upstream of this PR's change, inside vendored
@intx/hub-sessions, not in Workbench code:

  • The mention fan-out's mail POST reaches the durable-dispatch branch of
    vendor/intx/hub-api/src/workflow-run-trigger.ts (~lines 401-486)
    correctly: it locks the anchor's allocation and enqueues a
    dispatch:<anchorRunId>:<messageId> row via workflowDispatchService,
    returning 202.
  • That dispatch is only deliverable once
    vendor/intx/hub-sessions/src/workflow-allocation-service.ts's
    deployReadyAllocation (~line 653) has actually deployed the workflow
    onto the sidecar (sendWorkflowRunDispatchToAllocation needs a routed
    agent address). deployReadyAllocation runs from onReady inside
    sidecar-allocation/reconciler.ts's waitUntilReady, fired once the
    allocation's sidecar registers as kind: "allocated".
  • prepareProvisionedDeployment (same file, ~line 452) calls
    createDeployment with adoptProbe: true whenever the deployment
    provisioner it selects equals the probe provisioner
    (~lines 617-628) — which is always true here, since apps/hub/src/index.ts
    intentionally gives workflowAllocationService one shared provisioner
    list for both deploymentPlugins and probePlugins ("Workbench has
    one provisioner list; probes and frozen deployments share it until a
    separate probe backend is configured"). The adopt path
    (~lines 391-443) writes the workflowRun/launch-spec/allocation rows,
    then calls allocationRouter.disconnectAllocation on the probe's own
    connection to force the same sidecar to reconnect under the
    allocated identity.
  • In CI run 34066885954's e2e-logs-chat artifact, that reconnect is
    exactly what happens (sc_67738d3f97572262c8c09c872e9e71c1 /
    sal_7be88209bdd221c1b6fe4812c19cd05a registers at 23:28:40.331 as
    the probe, disconnects at 23:28:42.514, and registers again at
    23:28:45.523 with the same allocation and generation) — but nothing
    follows: no workflow-host·definition-loader line, no
    Deployed agent line, no warning or error, for the rest of the run.
    e2e-logs-smoke-webhook's webhook run never hits this branch:
    its probe and deployment allocations get different ids and different
    sidecars (adoptProbe: false, a genuinely fresh allocation), and
    Deployed agent appears within ~3.7s of that fresh allocation's first
    registration.
  • Reproduced the same silent hang locally against a fresh Postgres:
    the chat e2e's three invited-run assertions time out waiting for
    agent progress that never arrives; the local hub log shows the same
    probe-adopt-then-silent-reconnect pattern with no Deployed agent
    line, no thrown error, and no scheduled retry logged anywhere in
    sidecar-allocation/reconciler.ts or
    workflow-dispatch-service.ts.

This is a genuine defect in the vendored adoptProbe path (it never
reaches session-service.ts's deployPreparedCodeSourcedWorkflow /
emits no deploy frame after the reconnect, and fails silently instead of
retrying or erroring), not something this PR's chat-side trigger change
can work around — it is upstream of the trigger call entirely, in how
the allocation gets a workflow deployed onto it in the first place.
Leaving check:structural's vendoring rule intact: no vendor edit here.
The workaround of giving probePlugins and deploymentPlugins genuinely
different provisioner lists so adoptProbe never triggers would dodge
it, but that is a separate, deliberate infra decision (today's comment
in apps/hub/src/index.ts says as much: "until a separate probe backend
is configured") and needs its own ticket rather than a silent side
effect of this PR.

Test plan

  • bunx tsc --noEmit -p packages/chat -p apps/hub — clean.
  • bun test packages/chat/src packages/chat/test apps/hub/src — all
    green (1007 pass, 0 fail, 81 DB-gated skips without DATABASE_URL).
  • bun test scripts/e2e/chat.test.ts against a local Postgres — 7 pass,
    3 fail on the invited-run assertions, root-caused above.

The native workflow deploy route now requires sourceOfferingIds/
defaultSourceOfferingId resolved against the tenant's own catalog,
rather than a raw provider/baseURL/apiKey source chain. Update
workflowDeployBody accordingly, and extract the zero-cost noop
catalog chain smoke-webhook.test.ts already built (an anthropic-
plugin provider pointed at the hub's noop-inference endpoint) into
a shared seedNoopCatalogOffering harness helper.

Every deploying e2e suite now seeds or resolves a real catalog
offering before deploying: walking-skeleton keeps a never-real
placeholder host (assertNeverRealProvider still guards it), chat
resolves the offering seedCatalog already planted rather than
seeding a second chain, and heartbeat/workbench-digest/smoke-webhook
seed the noop chain via the shared helper. cl-6324-launch-proof's
section-mode deploy now reuses the offering id the proof already
resolved when it narrowed the bench catalog to its pinned model.
seedTenant currently deploys every default workflow with a bare
provider/baseURL/apiKey source, but the hub's deploy route now requires
sourceOfferingIds/defaultSourceOfferingId resolved from the tenant's
catalog. Update the seeding, onboarding, and hub config test suites to
expect the new offering-id based deploy body and provider/model-only
ModelSource shape ahead of the implementation change.
ensureDeployment posted a bare {provider, baseURL, apiKey, model}
source, but the hub's deploy route requires sourceOfferingIds and a
defaultSourceOfferingId resolved from the tenant's catalog, so seeding
a connect flow's "assistant" workflow 400'd. seedTenant now resolves
the tenant's real catalog offerings (sorted by priority, lowest wins
the default) for a normal workflow, and plants a dedicated noop
catalog offering for a modelSource-pinned one (heartbeat), pointed at
the hub's own noop-inference endpoint. ModelSource shrinks to a bare
provider/model pair — the baseURL/apiKey it used to carry only mattered
for the old deploy body, and callers that still need a key for seeding
a catalog (system-seed's root tenant, config's env-key auto-plant) keep
it on their own local type instead.
Another workflowDeployBody caller CI's typecheck job caught: this
stack seeds one catalog offering per real inference target already,
so track each one's id and pass it through to the deploy body
instead of the raw provider/baseURL/apiKey shape.
The allocation service's initial sidecar_allocation and workflow_probe
rows start at generation 0 (@intx/db enforces generation >= 0, not >
0), so the first ensure() for a freshly allocated sidecar must be
accepted at generation 0. These tests cover that acceptance, later
generations, non-integer/negative rejection, and that stale-generation
fencing still rejects an older generation once a newer one has been
observed.
@intx/db's sidecar_allocation and workflow_probe tables both check
generation >= 0 and the allocation service records the first row for
an allocation at generation 0, but validateEnsureRequest() and
validateDestroyRequest() in packages/sandbox-sidecar rejected anything
that wasn't strictly positive, and the on-disk fencing schema in
state-store.ts required generation > 0. The first ensure() for any
newly allocated sidecar was rejected with 'generation must be a
positive integer', failing every deploy.

Accept generation >= 0 in both validators and the state-store schema;
stale-generation fencing in observeEnsure()/observeDestroy() already
compares against the stored generation rather than assuming positivity,
so it is unaffected.
…t-against-interchange-main-before-the' into cl-7461-local
… signature

Covers the new GET .../catalog/resolved-offerings route (denied grant,
and both directly-owned and ancestor-inherited offerings sorted by
priority) and updates seeding/onboarding test fixtures to the route's
response shape ahead of the implementation change.
…owned ones

resolveRealSourceOfferingIds listed only offerings a tenant owns
directly, missing anything inherited from an ancestor tenant — the
hub's own workflow deployer already resolves that wider,
ancestor-inheriting view via listVisibleOfferings. Adds
GET /api/tenants/:tenantId/catalog/resolved-offerings (mounted from
apps/hub, implemented in @corbits/inference-catalog so the deployer and
this route share one source of truth) and points seed at it instead.

Also drops the two long-dead unused parameters from
onboarding's modelSourceFor, and exports ensureNoopCatalogOffering from
@corbits/seeding for the e2e harness to reuse.
At the interchange pin this branch stacks on (692c3106), the hub
authenticates a sidecar's register/reconnect frame only against a
sidecar row that resolves to a live sidecar_allocation (or
workflow_probe) row. Upstream dropped the older "shared" credential
scope this repo's e2e harness used to provision a single, standalone
sidecar identity for, so a provisionSidecar-only row now connects and
is rejected with "invalid token" in a loop rather than ever dialing
in. This test pins that negative so a future re-vendor that silently
restores (or relies on) the old scope surfaces here first.
Every e2e suite dial-in relied on a single, harness-started sidecar
process registered against a bare `provisionSidecar` identity row.
At pin 692c3106, the hub only accepts a register/reconnect frame from
a sidecar whose identity resolves to a live sidecar_allocation (or
workflow_probe) row; a standalone identity with neither now fails the
handshake and gets rejected with "invalid token" every ~3s, forever
(chat mint 500s, deploys never dial in — CI run 34042907014).

The hub's own process-provisioner already spawns a dedicated,
correctly-allocated sidecar process per deployment on demand — the
same path production already takes for a real workflow run or a
minted agent chat. Every affected suite (walking skeleton, chat,
heartbeat, workbench-digest, smoke-chat, smoke-webhook,
greeting-delivery, channel-reconnect, local-rip, and the isolation
suite) now lets that happen instead of dialing in its own sidecar:
the existing 502/500 retry-until-ready loops already cover the wait,
and diagnostics that used to read the one sidecar process's output
now read the hub's — the process-provisioner backend spawns each
sidecar with its stdio inherited from the hub, so the hub's own
captured output already carries every spawned sidecar's log lines.

`provisionSidecar`/`startSidecar` stay in the harness (documented as
non-authenticating at this pin) for the handful of standalone proof
scripts not yet migrated off a directly-controlled sidecar process.
The e2e job's hub/sidecar output was never captured, so the CI run
that surfaced the sidecar disconnect loop (34042907014) had no
sidecar-side log to read past the transcript. Point E2E_LOG_DIR at a
runner-temp directory and upload it on every run, pass or fail.
…t-against-interchange-main-before-the' into cl-7474-cross-tenant-launch

# Conflicts:
#	scripts/e2e/harness.ts
#	scripts/e2e/heartbeat.test.ts
#	scripts/e2e/smoke-webhook.test.ts
#	scripts/e2e/walking-skeleton.test.ts
#	scripts/e2e/workbench-digest.test.ts
readBindingByAddress and resolveLiveByStableId took an address or
stable id and resolved whichever workbench_launch row matched it,
regardless of which tenant that row belonged to. Every caller that
already knows its own tenant (sendMail's requireLive, and
refreshAgentInstanceFromDefinition) now passes it through and gets
undefined instead of another tenant's launch if the two ever
disagree. The one legitimate tenant-agnostic caller — the event
stream handler discovering which tenant an inbound address even
belongs to before it has one to check against — keeps working by
simply omitting the new optional parameter.

Also fixes offeringDigest: it joined catalog offering ids with a
NUL byte, which is not valid UTF-8 to Postgres the moment a tenant's
catalog carries more than one visible offering ("invalid byte
sequence for encoding \"UTF8\": 0x00"), permanently failing the
workbench_launch insert for every such launch. This is what was
failing 4 of scripts/e2e/chat.test.ts's cases under per-allocation
sidecars, where seedCatalog now produces more than one offering.
…t-against-interchange-main-before-the' into cl-7476-run-turns

# Conflicts:
#	scripts/e2e/harness.test.ts
#	scripts/e2e/smoke-webhook.test.ts
#	scripts/e2e/walking-skeleton.test.ts
…t-against-interchange-main-before-the' into cl-7479-rebase

# Conflicts:
#	packages/agent-directory/src/one-shot-prompt.ts
#	packages/chat/src/platform-adapter.ts
#	packages/chat/src/send-run-mail.ts
#	packages/webhook-triggers/src/launch.ts
#	packages/webhook-triggers/test/launch.test.ts
#	packages/workflows/src/deliver-when-routable.ts
#	packages/workflows/src/index.ts
#	packages/workflows/src/launch/agent-session.ts
#	packages/workflows/test/agent-session.test.ts
Replaces the post-send recordAgentSessionForRun (CL-7477/CL-7479) with
one idempotent ensureRunSession, called lazily at the hub seams that
actually see a run become mail-routable: the mailbox persist wrapper
and the event-collector dispatch path, plus chat's pre-send mail
dispatch. Pre-creating the run's principal to dodge the timing isn't
an option -- Interchange's grant materialization treats a conflict on
that insert as grants already committed and throws.
…t-against-interchange-main-before-the' into cl-7486-wake-binding

# Conflicts:
#	packages/agent-directory/src/one-shot-prompt.ts
#	packages/chat/src/platform-adapter.ts
#	packages/chat/src/send-run-mail.ts
#	packages/webhook-triggers/src/launch.ts
#	packages/webhook-triggers/test/launch.test.ts
#	packages/workflows/src/index.ts
#	packages/workflows/src/launch/agent-session.ts
#	packages/workflows/test/agent-session.test.ts
…uplicate key

Merging cl-7107's repin/re-vendor audit into this branch left
packages/workflows/src/index.ts re-exporting the pre-CL-7481 agent-session
API (recordAgentSessionForRun) that no longer exists, its matching test
file testing that same stale API, and a duplicated eventCollectors key in
apps/hub/src/index.ts's webhook-trigger launch wiring. Restores the
current exports (recordAgentSessionAtProvision, ensureRunSession) and
their test, and drops the duplicate object key.
sendRunMailWithReclaimRetry retried every attempt against the address it
started with. Proves the reproduction of the production failure: a
participant already one relaunch generation in that dies again mid-retry
must have its next retry chase the run that replaced it, not keep
hammering the address that just died.
sendRunMailWithReclaimRetry retried every delivery attempt, and woke,
against the address it captured at the top of the loop. A wake in
between retries can relaunch the run (its dead-run branch repoints the
room's stable participant at a fresh address without telling this loop),
and a participant already one relaunch generation in that dies again
mid-retry leaves this loop holding an address that is no longer either
the stable participant id or the current run id — invisible to
readBindingByAddressAnyTenant. The next wake for it then threw "No
workbench_launch binding" instead of finding the run that replaced it,
which is what chat e2e's mention-fanout and invite-drives-a-turn cases
were hitting.

Re-resolves the live address (and its session) off the stable
participant id after each wake, so the retry loop always chases whichever
run is actually current.

Also hardens publishCorbitsToolsRegistry's create-then-list-on-conflict
ensure: two overlapping seed runs racing the same tenant's package
registry asset can still throw a raw asset_tenant_kind_name violation if
the substrate's own 409 mapping is bypassed; the ensure now also
recognizes that violation by name and resolves the winner's row instead
of throwing.
…486-wake-binding

# Conflicts:
#	packages/webhook-triggers/test/launch.test.ts
…thReclaimRetry

Drives the CL-7486 reclaim-retry test off routing-table transitions
instead of fixed backoff timing, and updates the CL-7214 coalescing
test's fake sidecar router to model a redeploy actually registering.
…ving up on first-turn delivery

sendRunMailWithReclaimRetry used to exhaust a ~7.75s backoff and report
the invited agent unreachable while its sidecar was still booting and
registering with the hub (CL-7476) -- fast enough to make the fan-out
relaunch a perfectly healthy run. It now shares deliverWhenRoutable
with the webhook trigger's ingress delivery: wake once on the first
unreachable failure (still chasing a mid-wait relaunch by re-resolving
the live address inside the retry's send closure), then wait on the
hub's routing table up to the same boot-and-register budget
(DEFAULT_ROUTABLE_DEADLINE_MS, now exported from @corbits/workflows)
before retrying exactly once more.
…s missing

Runs deployed straight through Interchange's own deployments route
never pass through a Workbench launcher, so recordAgentSessionAtProvision
never runs for them and no agent_session row exists for the run. By the
time persist/dispatch call ensureRunSession the run is anchored with a
principal, so the seam has everything needed to create the row itself
instead of treating the missing row as a bug.
isBeyondWake's completed-run test assumed status alone marks a run
terminal; a deployment settled "completed" before its first trigger is
still booting, not dead, so the predicate now needs a run that took a
turn to treat "completed" as terminal. Update platform-adapter's
lifecycle/ensureAwake/sendMail coverage to match: a live run that is
merely not yet routable is waited on rather than redeployed, so those
tests now exercise a genuinely terminal run instead of an offline one.
A provisioned anchor stays "deployed" until its first trigger, so a run
mid-boot is indistinguishable from an offline one by routability alone.
wakeByAddress's fallback branch treated the two the same and redeployed
either — minting a fresh run and address out from under the sidecar's
own in-flight registration, which supersedes its allocation and restarts
the boot. Repeated on every wake attempt, this produced an endless
reconnect loop with no run ever finishing its boot.

wakeByAddress now only relaunches a run whose status is genuinely
terminal; a live run that is not yet routable is left alone; the
caller's own routable wait (deliverWhenRoutable, via
sendRunMailWithReclaimRetry) is what actually resolves it once the
sidecar finishes registering. isBeyondWake also no longer treats every
"completed" status as terminal outright: a deployment settled complete
before its first trigger ever landed is still indistinguishable from one
mid-boot, so it is only terminal once the store can tell it took a turn
(a principal was ever anchored to it).
…t-against-interchange-main-before-the' into cl-7491-chat-trigger

# Conflicts:
#	packages/chat/test/platform-adapter.test.ts
Retargets platform-adapter's fan-out assertions from a raw
sessionService.sendUserMessage call to the new RunTriggerClient seam:
the fake now records anchorRunId-keyed trigger calls and mints a
messageId the same shape the real route does, so the reclaim-retry and
threading assertions exercise the seam production code will actually
use.
…490)

A provisioned run is deployed to its sidecar lazily by its first native
trigger (vendor/intx/hub-api's createWorkflowRunTrigger: anchor ->
grants/principal -> durable dispatch's agent.deploy -> deliver mail).
Chat sent via sessionService.sendUserMessage, which delivers straight
over the sidecar's ordinary channel and never reaches that
durable-dispatch branch, so an invited run never triggered natively
never got its first agent.deploy and stayed permanently unreachable.

createWorkflowRunTrigger itself is not exported from @intx/hub-api (it
backs the route, not a published seam), so chat now calls the route
itself: a new RunTriggerClient (run-trigger-client.ts) posts to
POST /api/tenants/:tenantId/workflows/runs/:runId/mail in-process
against the hub's own composed Hono app. The route authenticates
through @intx/hub-api's ordinary session contract, which chat cannot
present a browser cookie for (most sends have no inbound HTTP request
in scope at all), so the client signs a short-lived internal token
naming the principal to authenticate as (run-trigger-internal-auth.ts);
apps/hub's own getSession implementation -- a seam @intx/hub-api
documents as pluggable -- verifies that token before falling through to
the real cookie-session path. Neither side touches vendor/intx.

send-run-mail.ts now delivers through the trigger client and uses its
returned messageId for session_mail correlation, still assembling a
local signed MIME copy (with @intx/mime/@intx/crypto directly) purely
for this workbench's own history and attachment blob reads -- the
trigger route returns no raw bytes, and nothing here delivers that
local copy anywhere a second time.
@linear-code

linear-code Bot commented Sep 6, 2026

Copy link
Copy Markdown

CL-7490

Prettier formatting on index.ts/run-trigger-client.ts, and the
report-error check's one real finding: readJson's parse-failure catch
now carries a report-error-ignore note explaining that a malformed
response body is still caught (loudly) by the caller's own arktype
parse of the response.
@TheGreatAxios
TheGreatAxios changed the base branch from cl-7490-wake-booting to cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-the September 6, 2026 23:32
…t-against-interchange-main-before-the' into cl-7491-chat-trigger

# Conflicts:
#	packages/chat/test/platform-adapter.test.ts
@TheGreatAxios
TheGreatAxios merged commit f10b776 into cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-the Sep 6, 2026
22 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant