Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
b1e6caa
Add test for workflowDeployBody's catalog-offering shape
TheGreatAxios Sep 6, 2026
fae77c0
e2e: deploy workflows by catalog offering ids
TheGreatAxios Sep 6, 2026
b03ce16
Fix formatting
TheGreatAxios Sep 6, 2026
f981798
Add tests for deploying default workflows by catalog offering ids
TheGreatAxios Sep 6, 2026
1ce35c4
Seeding: deploy default workflows by catalog offering ids
TheGreatAxios Sep 6, 2026
2587cd2
longevity-sim: deploy workflows by catalog offering ids
TheGreatAxios Sep 6, 2026
2430449
Fix formatting: prettier
TheGreatAxios Sep 6, 2026
b65781e
Merge remote-tracking branch 'origin/cl-7461-seed-deploy-offering-ids…
TheGreatAxios Sep 6, 2026
602ad68
sandbox-sidecar: add tests for generation-0 ensure/destroy requests
TheGreatAxios Sep 6, 2026
e01feda
sandbox-sidecar: accept the allocation service's initial generation
TheGreatAxios Sep 6, 2026
c6da1fa
sandbox-sidecar: fix formatting in provisioner test
TheGreatAxios Sep 6, 2026
72d5be0
Merge remote-tracking branch 'origin/cl-7107-repin-and-re-vendor-audi…
TheGreatAxios Sep 6, 2026
824c2b6
Add tests for resolved catalog offerings and modelSourceFor's trimmed…
TheGreatAxios Sep 6, 2026
ec87551
Seed deploys against a tenant's resolved catalog offerings, not just …
TheGreatAxios Sep 6, 2026
dd1fe50
Merge remote-tracking branch 'origin/cl-7461-seed-deploy-offering-ids…
TheGreatAxios Sep 6, 2026
bcb8703
Add a regression test pinning the shared-sidecar auth gap (CL-7472)
TheGreatAxios Sep 6, 2026
ccbe497
e2e: stop pre-provisioning a shared sidecar identity (CL-7472)
TheGreatAxios Sep 6, 2026
308ab25
ci: upload hub/sidecar e2e logs as a build artifact (CL-7472)
TheGreatAxios Sep 6, 2026
27b8f84
Merge remote-tracking branch 'origin/cl-7107-repin-and-re-vendor-audi…
TheGreatAxios Sep 6, 2026
b72964e
Add tests for tenant-scoped workbench_launch address resolution (CL-7…
TheGreatAxios Sep 6, 2026
262a381
Chat: workbench_launch address/stableId lookups are tenant-scoped
TheGreatAxios Sep 6, 2026
b75eebc
Chat: run prettier on the tenant-scoping change
TheGreatAxios Sep 6, 2026
040f085
Merge remote-tracking branch 'origin/cl-7107-repin-and-re-vendor-audi…
TheGreatAxios Sep 6, 2026
75300cb
Merge the re-pin branch into cl-7474-cross-tenant-launch
TheGreatAxios Sep 6, 2026
0eef0e0
Chat: tenant-scoped binding reads require the tenant; the one unscope…
TheGreatAxios Sep 6, 2026
b677451
Add tests for the shared retry-until-routable delivery helper
TheGreatAxios Sep 6, 2026
5cb3164
Deliver a webhook trigger's opening mail once its sidecar becomes rou…
TheGreatAxios Sep 6, 2026
a0c8a27
Log a skip, not an error, when root tenant seed has no operator key c…
TheGreatAxios Sep 6, 2026
ff22eef
Merge remote-tracking branch 'origin/cl-7474-cross-tenant-launch' int…
TheGreatAxios Sep 6, 2026
1fd89dd
Run prettier
TheGreatAxios Sep 6, 2026
3f8fdc9
Chat: run prettier on the tenant-scoping test file
TheGreatAxios Sep 6, 2026
3cf5507
Merge remote-tracking branches 'origin/cl-7474-cross-tenant-launch' a…
TheGreatAxios Sep 6, 2026
46a4a93
Add a DB-gated test for recording a run's agent_session (CL-7477)
TheGreatAxios Sep 6, 2026
39ea012
Native launch records the run's agent_session so mail and spans persi…
TheGreatAxios Sep 6, 2026
5f92950
Merge remote-tracking branch 'origin/cl-7107-repin-and-re-vendor-audi…
TheGreatAxios Sep 6, 2026
f31e0e1
Add tests for the run's event collector create/abandon wiring
TheGreatAxios Sep 6, 2026
9260c3a
Native launch creates the run's event collector so turns are recorded…
TheGreatAxios Sep 6, 2026
3706a53
Merge remote-tracking branch 'origin/cl-7107-repin-and-re-vendor-audi…
TheGreatAxios Sep 6, 2026
f6369d9
Add tests for lazy, idempotent run-session ensure (CL-7480)
TheGreatAxios Sep 6, 2026
d6eda39
Hub ensures a run's session and collector on first use (CL-7480)
TheGreatAxios Sep 6, 2026
63b2d2e
Fix prettier formatting in agent-session.ts (CL-7480)
TheGreatAxios Sep 6, 2026
af790b2
Add tests for eager session recording and launch-spec session lookup
TheGreatAxios Sep 6, 2026
ae8d33b
Record a run's session at provision; re-key it when Interchange ancho…
TheGreatAxios Sep 6, 2026
2b5dd7a
Fix CI: prettier formatting, principal enum value, and workflowRunLau…
TheGreatAxios Sep 6, 2026
258cfe4
Merge remote-tracking branch 'origin/cl-7107-repin-and-re-vendor-audi…
TheGreatAxios Sep 6, 2026
8b469ac
Merge the re-pin branch into cl-7481-eager-session
TheGreatAxios Sep 6, 2026
c57dd9d
Fix merge fallout: restore CL-7481 agent-session exports and drop a d…
TheGreatAxios Sep 6, 2026
42f0511
Add tests for wake retry re-resolving the live address (CL-7486)
TheGreatAxios Sep 6, 2026
c5cbceb
Chat: wake retry chases the run a relaunch replaced it with (CL-7486)
TheGreatAxios Sep 6, 2026
485dc99
Merge remote-tracking branch 'origin/cl-7481-eager-session' into cl-7…
TheGreatAxios Sep 6, 2026
ebc173b
Fix webhook-triggers launch test fixture: eventCollectors needs has()
TheGreatAxios Sep 6, 2026
097b172
Add test coverage for routable-wait relaunch chasing in sendRunMailWi…
TheGreatAxios Sep 6, 2026
153b818
Chat: wait for the routing table instead of a fixed backoff before gi…
TheGreatAxios Sep 6, 2026
320deb2
Fix formatting
TheGreatAxios Sep 6, 2026
632b439
Merge the re-pin branch into cl-7488-chat-routable
TheGreatAxios Sep 6, 2026
3cfb6de
Add test coverage for ensureRunSession creating a session for an API-…
TheGreatAxios Sep 6, 2026
6547f36
ensureRunSession: create the session row instead of throwing when it'…
TheGreatAxios Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 119 additions & 74 deletions packages/chat/src/platform-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import { and, desc, eq } from "drizzle-orm";
import {
authoredDefinitionCandidates,
DefinitionProjectionMissingError,
deliverWhenRoutable,
endAgentSessionForRun,
ensureRunSession,
isAgentUnreachableError,
readFoldedBody,
recordAgentSessionAtProvision,
resolveNewestProjectedDefinition,
Expand Down Expand Up @@ -139,20 +141,23 @@ export type CreateHubChatPlatformDeps = {
* `ensureAwake` to redeploy a non-routable target before sending.
*/
lifecycle?: { idleSleepMs: number; sweepIntervalMs?: number };
/**
* `sendRunMailWithReclaimRetry`'s backoff between retries of a
* mail send that failed with "agent is unreachable" (see
* RECLAIM_RETRY_DELAYS_MS below). Injectable so tests exercise the
* backoff in milliseconds instead of the production ~8s budget.
*/
reclaimRetryDelaysMs?: readonly number[];
/**
* Per-attempt wall-clock bound on `sendRunMail` inside
* `sendRunMailWithReclaimRetry` (CL-6644). Defaults to
* `DEFAULT_WAKE_TIMEOUT_MS`. Injectable so tests exercise the bound
* in milliseconds instead of the production ~30s budget.
*/
mailDeliveryTimeoutMs?: number;
/**
* How long `sendRunMailWithReclaimRetry` waits for a freshly
* woken/relaunched run's address to show up in the hub's routing
* table before giving up (see `deliverWhenRoutable` in
* `@corbits/workflows`). Defaults to `DEFAULT_ROUTABLE_DEADLINE_MS`
* — the same boot-and-register budget the webhook trigger's ingress
* delivery waits on (CL-7476). Injectable so tests exercise the wait
* in milliseconds instead of the production ~20s budget.
*/
routableWaitDeadlineMs?: number;
/**
* The invite-launch model fallback (see `./inference-preferences.ts`'s
* `createWorkbenchHostInferencePreferencesResolver`): a
Expand Down Expand Up @@ -409,35 +414,17 @@ export function createHubChatPlatform(
const lifecycle =
deps.lifecycle !== undefined ? buildLifecycle(deps.lifecycle) : undefined;

// Bounded backoff for a wake racing the sidecar's own post-restart
// reclaim, and separately for a mail delivery racing the same
// window: 250ms, 500ms, 1s, 2s, 4s — a ~7.75s budget, long enough
// for a normal reconnect challenge to settle without leaving a
// sender stuck for much longer than that.
const RECLAIM_RETRY_DELAYS_MS = deps.reclaimRetryDelaysMs ?? [
250, 500, 1000, 2000, 4000,
];

// CL-6644: the reclaim-retry loop's own delays only run between
// attempts that already failed LOUD with "agent is unreachable" --
// they bound nothing about an attempt that instead stalls forever
// (a sidecar ack that never comes, a wedged promise anywhere in
// `sendRunMail`'s call chain) without ever throwing. Each
// attempt gets the same kind of wall-clock bound `wakeByAddressBounded`
// already puts on the wake itself, so that hang becomes a rejection
// CL-6644: a per-attempt bound on `sendRunMail` inside
// `sendRunMailWithReclaimRetry` -- a send that stalls forever (a
// sidecar ack that never comes, a wedged promise anywhere in
// `sendRunMail`'s call chain) without ever throwing gets the same
// kind of wall-clock bound `wakeByAddressBounded` already puts on
// the wake itself, so that hang becomes a rejection
// `dispatchTurnBatch`'s catch can report and notify on, instead of a
// promise nothing ever settles.
const MAIL_DELIVERY_TIMEOUT_MS =
deps.mailDeliveryTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;

function sleep(ms: number): Promise<void> {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}

function isAgentUnreachable(err: unknown): boolean {
return err instanceof Error && err.message.includes("agent is unreachable");
}

function isRoutable(address: string): boolean {
return deps.sidecarRouter.getRoutableAddresses().includes(address);
}
Expand Down Expand Up @@ -977,54 +964,89 @@ export function createHubChatPlatform(
}

/**
* `sendRunMail` delivers synchronously against the sidecar's
* current routable set — the same in-memory index `isRoutable` reads
* — so a send that lands in the same post-restart reclaim window
* `wakeByAddress` above tolerates can still fail with "agent is
* unreachable" even right after a successful (or no-op) wake. Each
* retry forces a fresh wake first: if the earlier reclaim tore the
* agent down, this becomes the genuine redeploy that recovers it; if
* the reclaim is still in flight, the wake itself waits it out (or
* redeploys once its own budget is exhausted) and the delay gives it
* more time regardless. Exhausting every delay means the condition
* is not transient and the caller gets a clean `AgentUnreachableError`
* rather than an unhandled 500.
* `sendRunMail` delivers synchronously against the sidecar's current
* routable set — the same in-memory index `isRoutable` reads — so a
* send lands in "agent is unreachable" for two different reasons
* that both need the same treatment: a run whose sidecar just
* finished a provisioned deploy and hasn't registered yet (CL-7476 —
* `deliverWhenRoutable` from `@corbits/workflows` is the exact same
* wait the webhook trigger's ingress delivery already uses for this),
* and a run that this loop's own wake tore down and is mid-reclaim.
* `deliverWhenRoutable` sends once, and — only on an unreachable
* failure — polls the routing table for `current.agentAddress` up to
* its shared deadline (`DEFAULT_ROUTABLE_DEADLINE_MS`, the same
* boot-and-register budget the webhook path waits on) before sending
* exactly once more.
*
* CL-7486: a wake this loop runs on the first failure can itself
* relaunch the run — `wakeByAddress`'s dead-run branch repoints the
* room's stable participant at a fresh address without telling this
* loop. Retrying `sendRunMail` against the address it started with
* would then keep hammering a run that will never become routable
* again. `reresolve` re-reads the live address (and its session) off
* the stable participant id every time `send` runs — including the
* one `deliverWhenRoutable` runs again after the routing-table wait —
* so this loop always chases whichever run is actually current, even
* one relaunched again while it was waiting.
*/
async function sendRunMailWithReclaimRetry(
params: SendRunMailParams,
reresolve: () => Promise<{ agentAddress: string; sessionId: string }>,
): Promise<Awaited<ReturnType<typeof sendRunMail>>> {
let loggedRetryStart = false;
for (let attempt = 0; ; attempt++) {
try {
// CL-7193: `sendRunMail` does a DB write plus a sidecar
// delivery that shouldn't be half-cancelled, and has no signal
// to accept regardless — the signal parameter is unused here,
// same abandon-on-timeout behavior as before.
return await withTimeout(
() => sendRunMail(runMailDeps, params),
MAIL_DELIVERY_TIMEOUT_MS,
`mail to ${params.agentAddress} did not settle within ${String(MAIL_DELIVERY_TIMEOUT_MS)}ms`,
);
} catch (err) {
const delay = RECLAIM_RETRY_DELAYS_MS[attempt];
if (!isAgentUnreachable(err) || delay === undefined) {
if (loggedRetryStart) {
wakeLogger.info`mail to ${params.agentAddress} exhausted every reclaim retry; giving up`;
}
if (isAgentUnreachable(err)) {
throw new AgentUnreachableError(params.agentAddress, {
cause: err,
});
let current = params;
let wakedOnFailure = false;
async function sendCurrent() {
// CL-7193: `sendRunMail` does a DB write plus a sidecar delivery
// that shouldn't be half-cancelled, and has no signal to accept
// regardless — the signal parameter is unused here, same
// abandon-on-timeout behavior as before.
return withTimeout(
() => sendRunMail(runMailDeps, current),
MAIL_DELIVERY_TIMEOUT_MS,
`mail to ${current.agentAddress} did not settle within ${String(MAIL_DELIVERY_TIMEOUT_MS)}ms`,
);
}
try {
return await deliverWhenRoutable({
send: async () => {
try {
return await sendCurrent();
} catch (err) {
if (!isAgentUnreachableError(err) || wakedOnFailure) throw err;
// Wake exactly once, the moment the first attempt reports
// unreachable: if the run had already died, this is the
// genuine redeploy that recovers it; if a deploy is merely
// still booting, `wakeByAddress`/`lifecycle.ensureAwake`
// no-op past the routability check and this becomes a
// cheap reconcile. Re-resolving only after the wake means a
// relaunch it just performed is exactly what the fresh read
// picks up, rather than the address that was current a
// moment before.
wakeLogger.info`mail to ${current.agentAddress} hit "agent is unreachable"; waiting for it to become routable`;
wakedOnFailure = true;
await wakeByAddressBounded(current.agentAddress);
const fresh = await reresolve();
current = {
...current,
agentAddress: fresh.agentAddress,
sessionId: fresh.sessionId,
};
throw err;
}
throw err;
}
if (!loggedRetryStart) {
wakeLogger.info`mail to ${params.agentAddress} hit "agent is unreachable"; retrying with backoff while the post-restart reclaim settles`;
loggedRetryStart = true;
}
await sleep(delay);
await wakeByAddressBounded(params.agentAddress);
},
isRoutable: () => isRoutable(current.agentAddress),
...(deps.routableWaitDeadlineMs !== undefined
? { deadlineMs: deps.routableWaitDeadlineMs }
: {}),
});
} catch (err) {
if (isAgentUnreachableError(err)) {
wakeLogger.info`mail to ${current.agentAddress} exhausted the routable-wait budget; giving up`;
throw new AgentUnreachableError(current.agentAddress, {
cause: err,
});
}
throw err;
}
}

Expand Down Expand Up @@ -1399,7 +1421,30 @@ export function createHubChatPlatform(
? { references: input.content.references }
: {}),
};
const sent = await sendRunMailWithReclaimRetry(withThreading);
const sent = await sendRunMailWithReclaimRetry(
withThreading,
async () => {
const relive = await requireLive(input.workbenchId, input.tenantId);
try {
await ensureRunSession({
db: deps.db,
eventCollectors: deps.eventCollectors,
runId: relive.run.id,
});
} catch (err) {
reportError(err, {
operation: "chat.sendMail.ensureRunSession",
tenantId: input.tenantId,
agentId: relive.binding.liveAddress,
extra: { runId: relive.run.id },
});
}
return {
agentAddress: relive.binding.liveAddress,
sessionId: await resolveRunSessionIdOrThrow(deps.db, relive.run),
};
},
);

lifecycle?.recordActivity(deliveryAddress);

Expand Down
Loading
Loading