From 58329ee5ed1339a10ce4685496efbcfe3b899b07 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 01:23:56 -0700 Subject: [PATCH 1/2] Add a test proving a wake's redeploy survives a parked deployment's residue CL-6644 part B: nothing sidecar-side ever resumes a parked deployment on its own since 0fd3fbc8 deleted the in-place park/wake handler, and the CL-6282 boot scan skips a parked record entirely rather than restoring it -- so a hub-driven wake is always a fresh agent.deploy for the address, against whatever residue a hibernate teardown left: the deployment record (kept, marked parked, not deleted) and the step-state dir (kept for a resume). This proves that redeploy completes cleanly against that residue. --- .../test/workflow-teardown-flavor.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/sidecar/test/workflow-teardown-flavor.test.ts b/apps/sidecar/test/workflow-teardown-flavor.test.ts index 61c002930..443ee6eb2 100644 --- a/apps/sidecar/test/workflow-teardown-flavor.test.ts +++ b/apps/sidecar/test/workflow-teardown-flavor.test.ts @@ -196,4 +196,45 @@ describe("teardownDeployment reclaimDirs flavors", () => { // Any other reason still gets the destructive default. await expect(fs.stat(reclaimStepStateDir)).rejects.toThrow(); }); + + // CL-6644 part B: a hub-driven wake for a parked deployment is a fresh + // `agent.deploy` for the same address, not a resume in place -- `0fd3fbc8` + // deleted the sidecar-side in-place park/wake handler this used to be, and + // a boot scan (CL-6282) skips a parked record entirely rather than + // restoring it. Nothing sidecar-side ever resumes a parked deployment on + // its own, so the wake's redeploy must complete cleanly against whatever + // residue the parked record and its hibernate teardown left behind: the + // deployment record still on disk (parked, not deleted), the slug + // (`releaseSlug` only runs for a reclaiming teardown), and the step-state + // dir the hibernate deliberately preserved. This proves that residue + // never blocks the redeploy. + test("deploy() after a hibernate teardown completes (a wake's redeploy for a parked address)", async () => { + const { router, spawns, dataDir } = await makeLifecycleFixture(); + const frame = makeWorkflowFrame("run_teardown-wake-redeploy@example.com"); + const firstDeploy = router.deploy(frame); + await answerReadyHandshake(spawns, 0); + await firstDeploy; + + await router.teardownDeployment(frame.agentAddress, { + reclaimDirs: false, + }); + + const deploymentId = deriveDeploymentId(frame.agentAddress); + const parkedBeforeRedeploy = await readWorkflowDeploymentRecord( + dataDir, + deploymentId, + ); + expect(parkedBeforeRedeploy?.parkedAt).toBeDefined(); + + const secondDeploy = router.deploy(frame); + await answerReadyHandshake(spawns, 1); + await expect(secondDeploy).resolves.toBeDefined(); + + expect(router.activeAddresses()).toEqual([frame.agentAddress]); + const redeployedRecord = await readWorkflowDeploymentRecord( + dataDir, + deploymentId, + ); + expect(redeployedRecord?.parkedAt).toBeUndefined(); + }); }); From 7996c249a2320642294a777f10e61b78413de3c6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 22 Aug 2026 01:24:08 -0700 Subject: [PATCH 2/2] chat: bound every direct wakeByAddress call to the lifecycle wake timeout CL-6644 part B. `@corbits/agent-lifecycle`'s ensureAwake already bounds a wake to DEFAULT_WAKE_TIMEOUT_MS (CL-6643) -- but only when it is the one calling wakeByAddress. Three call sites bypassed that bound entirely by calling wakeByAddress directly: sendMail's no-lifecycle fallback, the exported ensureAwake hook's no-lifecycle fallback, and (regardless of whether lifecycle is configured) sendFoldedMailWithReclaimRetry's reclaim retry. A deploy round-trip the sidecar never acked wedged any of these forever instead of failing loud -- the confirmed second half of the "cold wake never completes" symptom. Also corrects wakeByAddress's stale CL-6267 comment, which still described the sidecar-side park/wake handler 0fd3fbc8 deleted days before CL-6282 shipped. The parked-record redeploy this comment worried about already works today (see the new sidecar test) -- the comment was just describing a mechanism that no longer exists. --- packages/chat/src/platform-adapter.ts | 61 +++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 29f993ddc..5bdae3ea7 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -8,7 +8,10 @@ // invitable listing, and participant/fromWorkbenchId send semantics. // A workbench itself is data — only invited agents have runs here. import { and, desc, eq } from "drizzle-orm"; -import { createAgentLifecycle } from "@corbits/agent-lifecycle"; +import { + createAgentLifecycle, + DEFAULT_WAKE_TIMEOUT_MS, +} from "@corbits/agent-lifecycle"; import { authoredDefinitionCandidates, createCryptoProviderCache, @@ -486,10 +489,18 @@ export function createHubChatPlatform( * `address` may be either side of the mapping — the stable address * the room holds, or the live deployment address the sidecar reports. * - * CL-6267: the sidecar's own park/wake handler owns respawning a - * parked-but-still-announced deployment the moment mail routes to it, - * so a routable address is never deployed or undeployed here for - * routability alone. + * CL-6267 (superseded by CL-6644): a parked deployment used to stay + * announced (routable), with the sidecar's own park/wake handler + * respawning it in place the moment mail routed to it. `0fd3fbc8` + * deleted that in-sidecar handler in favor of reap-and-relaunch + * teardown, and the sidecar now unregisters a parked address's + * transport routing at park time too — so a parked address reads as + * genuinely unroutable here, and this function's `isRoutable` branch + * below is reached only by an address that is actually live. A + * parked-and-now-unroutable address still never gets deployed or + * undeployed FOR ROUTABILITY ALONE here — it falls through to the + * explicit `wakeFoldedRun` redeploy a few lines down, the one wake + * path a parked deployment has left. * * CL-6365: a run that is unroutable because it DIED — the hub's own * `workflow_run.status` is terminal and it is not merely a folded run @@ -541,6 +552,40 @@ export function createHubChatPlatform( }); } + /** + * `wakeByAddress`, bounded to `DEFAULT_WAKE_TIMEOUT_MS` — the same + * bound `@corbits/agent-lifecycle`'s `ensureAwake` puts on this exact + * call when `lifecycle` is configured (CL-6643). Every call site that + * invokes `wakeByAddress` directly rather than through + * `lifecycle.ensureAwake` — `sendFoldedMailWithReclaimRetry`'s reclaim + * retry, and the exported `ensureAwake` hook's no-`lifecycle` fallback + * — used to await the underlying sidecar deploy round-trip with no + * bound at all, so a deploy the sidecar never acked wedged the caller + * (and, for the reclaim retry, every later send) forever instead of + * failing loud (CL-6644). + */ + function wakeByAddressBounded(address: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `wake for "${address}" did not settle within ${String(DEFAULT_WAKE_TIMEOUT_MS)}ms`, + ), + ); + }, DEFAULT_WAKE_TIMEOUT_MS); + wakeByAddress(address).then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (cause: unknown) => { + clearTimeout(timer); + reject(cause); + }, + ); + }); + } + /** * Redeploys `address`'s run in place if its deployed definition has * drifted from the current authored projection, otherwise no-ops. @@ -656,7 +701,7 @@ export function createHubChatPlatform( loggedRetryStart = true; } await sleep(delay); - await wakeByAddress(params.agentAddress); + await wakeByAddressBounded(params.agentAddress); } } } @@ -905,7 +950,7 @@ export function createHubChatPlatform( if (lifecycle !== undefined) { await lifecycle.ensureAwake(liveAddress); } else if (!isRoutable(liveAddress)) { - await wakeByAddress(liveAddress); + await wakeByAddressBounded(liveAddress); } // CL-6588: `lifecycle.ensureAwake` returns immediately for an // address that is already routable — routability is the only @@ -1061,7 +1106,7 @@ export function createHubChatPlatform( await reconcileDriftedRun(binding.liveAddress); return; } - await wakeByAddress(binding.liveAddress); + await wakeByAddressBounded(binding.liveAddress); }, };