diff --git a/.changeset/config.json b/.changeset/config.json index 89ddb43a41..99b0ccec4a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,7 +10,6 @@ "ignore": [ "@corbits/agent-directory", "@corbits/agent-events", - "@corbits/agent-lifecycle", "@corbits/agent-runtime", "@corbits/api-query", "@corbits/approvals", diff --git a/apps/hub/package.json b/apps/hub/package.json index f900543976..4e410d1312 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -14,7 +14,6 @@ "dependencies": { "@chat-adapter/state-memory": "^4.35.0", "@corbits/agent-directory": "workspace:*", - "@corbits/agent-lifecycle": "workspace:*", "@corbits/agent-runtime": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938", diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 40f2ed410e..15f1f8d06a 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -139,9 +139,6 @@ const HubEnv = type({ "HUB_SIDECAR_WEBSOCKET_URL?": type(/^wss?:\/\/.+$/).describe( "the ws(s):// URL a provisioned sidecar container dials back to reach this hub; unset (default) derives it from BASE_URL, which is wrong for a docker sidecar provisioner — that container's own localhost is itself, not the hub host — so set this whenever SIDECAR_PROVISIONER=docker", ), - "HUB_CHAT_IDLE_REAP_MS?": type("string").describe( - "how long a chat resident (host or invited agent) may sit idle before the hub reaps it via a state-preserving undeploy, in milliseconds; unset defaults to 30 minutes", - ), }); const DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS = 60; @@ -161,34 +158,6 @@ const DEFAULT_SIGNUP_RATE_LIMIT_MAX = 5; const DEFAULT_SIGNIN_RATE_LIMIT_WINDOW_SECONDS = 60; const DEFAULT_SIGNIN_RATE_LIMIT_MAX = 10; -/** - * Production default for `HUB_CHAT_IDLE_REAP_MS`: 30 minutes, - * matching the old sidecar-side `WORKBENCH_CHILD_IDLE_REAP_MS` default - * this replaces for chat. - */ -export const DEFAULT_CHAT_IDLE_REAP_MS = 30 * 60_000; - -/** - * Parse an optional positive-integer-milliseconds env value, defaulting - * to `fallback` when unset. Rejects zero and negative values — unlike - * the sidecar's now-deleted reap knob, there is no "disable reaping" - * mode here. - */ -function parsePositiveMsEnv( - raw: string | undefined, - name: string, - fallback: number, -): number { - if (raw === undefined) return fallback; - const n = Number(raw); - if (!Number.isInteger(n) || n <= 0) { - throw new Error( - `invalid hub environment: ${name} must be a positive integer (milliseconds); got ${JSON.stringify(raw)}`, - ); - } - return n; -} - // One member per implemented `SidecarProvisioner` backend. Adding a new // backend (e.g. a remote sandbox) is: implement the contract in its own // package, add a member here with its settings, add its id to @@ -289,9 +258,6 @@ export type HubConfig = { /** Overrides the ws(s):// URL a provisioned sidecar dials back to reach * this hub. Unset derives it from baseUrl instead. */ readonly sidecarWebSocketUrl?: string; - /** How long an idle chat resident may sit before the hub reaps it via - * a state-preserving undeploy. Defaults to `DEFAULT_CHAT_IDLE_REAP_MS`. */ - readonly chatIdleReapMs: number; }; type ParsedHubEnv = typeof HubEnv.infer; @@ -535,11 +501,6 @@ export function readHubConfig( ? Number(parsed.SIGNIN_RATE_LIMIT_MAX) : DEFAULT_SIGNIN_RATE_LIMIT_MAX, }, - chatIdleReapMs: parsePositiveMsEnv( - parsed.HUB_CHAT_IDLE_REAP_MS, - "HUB_CHAT_IDLE_REAP_MS", - DEFAULT_CHAT_IDLE_REAP_MS, - ), }; if (parsed.HUB_ALLOW_GIT_INSIDE_WORK_TREE !== undefined) hubConfig.allowGitInsideWorkTree = true; diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index b254351dbe..90151fb536 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -281,20 +281,12 @@ const TENANT_PREFIX = "/api/tenants/:tenantId"; const SIGN_UP_EMAIL_PATH = "/sign-up/email"; const SIGN_IN_EMAIL_PATH = "/sign-in/email"; const SignInEmailBody = type({ email: "string" }); -// Chat residents carry a real hub-driven idle-reap again (reversing -// CL-5477's removal): the sidecar's own park/wake scheme it was meant to -// replace has itself been retired in favor of a simpler reap-and-relaunch -// model. `createHubChatPlatform`'s `lifecycle` binding below tags every -// idle eviction with `@corbits/agent-lifecycle`'s -// `IDLE_HIBERNATE_UNDEPLOY_REASON`, which the sidecar's `agent.undeploy` -// handler matches to choose the state-preserving teardown flavor -// (`reclaimDirs: false` — deployment record, step-state, and slug all -// survive) rather than the destructive default a caller-initiated -// undeploy gets. That is what makes this safe to re-enable where the old -// bare `"idle"`-tagged undeploy this ticket's `CHAT_IDLE_SLEEP_MS` -// emergency bump (8ca85543) band-aided around was genuinely lossy: a -// later `wakeByAddress` relaunch resumes the same run rather than -// starting a fresh one. +// CL-8187: idle hibernation is the sidecar's own decision now — it tracks +// per-run last activity itself and tears an idle run down with a +// state-preserving teardown, keeping the deployment record, step-state, +// and slug so a later `wakeByAddress` relaunch resumes the same run +// rather than starting a fresh one. The hub no longer drives this at +// all: `createHubChatPlatform` carries no idle-reap wiring. // Email+password signup stays available through better-auth; the hub // applies only coarse throttling around it, never an operator gate. @@ -1301,13 +1293,6 @@ export async function createHub(config: HubConfig) { ), hubUrl: config.baseUrl, }), - // Chat residents are undeployed on idle again (see the comment above - // this function): `chatIdleReapMs` (env-overridable via - // `HUB_CHAT_IDLE_REAP_MS`, default 30 minutes) is - // state-preserving (`IDLE_HIBERNATE_UNDEPLOY_REASON`), unlike a - // destructive undeploy. - lifecycle: { idleSleepMs: config.chatIdleReapMs }, - // // A hand-authored definition with no model requirements of its own // (see `@corbits/agent-directory`'s `createAgentDefinitionCore` // doc) still launches on invite by falling back to this same @@ -1351,11 +1336,7 @@ export async function createHub(config: HubConfig) { // and a gate-blocked run's approval park into an in-chat approve // block, by subscribing to the sidecar's own event stream, replacing // the old per-agent reply-bridge machinery armed (and re-armed) from - // inside the routes. `chatPlatform.recordActivity` is the same - // idle-sleep lifecycle `chatPlatform` itself drives — wiring it here - // too is what keeps a replying agent's activity clock current even - // though the reply never goes through `chatPlatform.sendMail`'s own - // `recordActivity` call. `approvals` is the same `ApprovalStore` the + // inside the routes. `approvals` is the same `ApprovalStore` the // platform's own approve/reject routes read and write — this // orchestrator only ever reads it. const agentTurns = createDrizzleAgentTurnStore(db); @@ -1368,7 +1349,6 @@ export async function createHub(config: HubConfig) { platform: chatPlatform, events: sidecarRouter.events, approvals: createApprovalStore(db), - recordActivity: chatPlatform.recordActivity, claims: writeClaims, threads: threadStore, turnMailCorrelation, @@ -1554,12 +1534,10 @@ export async function createHub(config: HubConfig) { return userRow?.name ?? undefined; }, commands: commandRegistry, - // The same native undeploy call the idle-sleep lifecycle uses to - // tear an invited agent's instance down (see `chatPlatform`'s own - // `lifecycle.undeploy` above) — wired here too so removing an agent - // from a chat's participants releases its running instance the - // same way, rather than leaving it deployed with nothing routing - // messages to it. + // The native undeploy call this hub uses to tear an invited agent's + // instance down when it is removed from a chat's participants, + // rather than leaving it deployed with nothing routing messages to + // it. releaseAgentInstance: (address, reason) => sidecarRouter.sendAgentUndeploy(address, reason), // CL-7450: fans a sent human message into every human participant's diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts index 2a1e646247..bd909409ae 100644 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -175,7 +175,6 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { socialProviders: {}, allowPlaintextSecrets: true, sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, }; const hub = await createHub(config); const stop = async () => { @@ -348,7 +347,6 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { socialProviders: {}, allowPlaintextSecrets: true, sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, }; // Phase 1: mint the operator tenant the old plant targeted. diff --git a/apps/hub/test/chat-mount.test.ts b/apps/hub/test/chat-mount.test.ts index c58a8856bc..cd35576662 100644 --- a/apps/hub/test/chat-mount.test.ts +++ b/apps/hub/test/chat-mount.test.ts @@ -36,7 +36,6 @@ const config: HubConfig = { // credential-cipher seam, so the dev opt-in keeps boot working. allowPlaintextSecrets: true, sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, }; const closers: (() => Promise)[] = []; diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index f7e4127bb7..31dc6540c3 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -38,7 +38,6 @@ const config: HubConfig = { // credential-cipher seam, so the dev opt-in keeps boot working. allowPlaintextSecrets: true, sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, }; const closers: (() => Promise)[] = []; diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index d02733e2cb..6702da958a 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -34,7 +34,6 @@ describe("readHubConfig", () => { allowPlaintextSecrets: false, sidecarProvisioners: [{ id: "process" }], defaultSidecarProvisionerId: "process", - chatIdleReapMs: 30 * 60_000, }); }); @@ -51,7 +50,6 @@ describe("readHubConfig", () => { [ "allowPlaintextSecrets", "baseUrl", - "chatIdleReapMs", "databaseUrl", "defaultSidecarProvisionerId", "hubDataDir", @@ -459,36 +457,6 @@ describe("readHubConfig", () => { }); }); - describe("chatIdleReapMs", () => { - test("defaults to 30 minutes when HUB_CHAT_IDLE_REAP_MS is unset", () => { - expect(readHubConfig(validEnv).chatIdleReapMs).toBe(30 * 60_000); - }); - - test("is read from HUB_CHAT_IDLE_REAP_MS when set", () => { - const config = readHubConfig({ - ...validEnv, - HUB_CHAT_IDLE_REAP_MS: "5000", - }); - expect(config.chatIdleReapMs).toBe(5000); - }); - - test("rejects zero", () => { - const message = readExpectingError({ - ...validEnv, - HUB_CHAT_IDLE_REAP_MS: "0", - }); - expect(message).toContain("HUB_CHAT_IDLE_REAP_MS"); - }); - - test("rejects a non-integer value", () => { - const message = readExpectingError({ - ...validEnv, - HUB_CHAT_IDLE_REAP_MS: "not-a-number", - }); - expect(message).toContain("HUB_CHAT_IDLE_REAP_MS"); - }); - }); - test("an empty environment reports every variable in one error", () => { const message = readExpectingError({}); for (const name of [ diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index d691653668..3e1f8dcf23 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -23,7 +23,6 @@ const baseConfig: HubConfig = { socialProviders: {}, allowPlaintextSecrets: false, sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, }; describe("credentialCipherFrom", () => { diff --git a/apps/hub/test/presence-mount.test.ts b/apps/hub/test/presence-mount.test.ts index 2c92436b39..9bef1ba299 100644 --- a/apps/hub/test/presence-mount.test.ts +++ b/apps/hub/test/presence-mount.test.ts @@ -34,7 +34,6 @@ const config: HubConfig = { socialProviders: {}, allowPlaintextSecrets: true, sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, }; const closers: (() => Promise)[] = []; diff --git a/apps/hub/test/relaunch-sweep-close.test.ts b/apps/hub/test/relaunch-sweep-close.test.ts index f3d956790e..3a9795a8d8 100644 --- a/apps/hub/test/relaunch-sweep-close.test.ts +++ b/apps/hub/test/relaunch-sweep-close.test.ts @@ -37,7 +37,6 @@ const config: HubConfig = { signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, sidecarProvisioners: [], - chatIdleReapMs: 30 * 60_000, allowPlaintextSecrets: true, }; diff --git a/apps/sidecar/package.json b/apps/sidecar/package.json index b827f21076..262a3f4949 100644 --- a/apps/sidecar/package.json +++ b/apps/sidecar/package.json @@ -15,7 +15,6 @@ "test": "bun test" }, "dependencies": { - "@corbits/agent-lifecycle": "workspace:*", "@corbits/codex-provider": "workspace:*", "@corbits/credential-providers": "workspace:*", "@corbits/error-sink": "workspace:*", diff --git a/apps/sidecar/src/config.ts b/apps/sidecar/src/config.ts index 7d6a1f7c94..2f16a7929c 100644 --- a/apps/sidecar/src/config.ts +++ b/apps/sidecar/src/config.ts @@ -92,6 +92,13 @@ const SidecarEnv = type({ // `SIDECAR_CACHE_MAX_BYTES`-style numeric env keys. "CONSUMED_RETENTION_MS?": "string", "CHILD_READY_TIMEOUT_MS?": "string", + // CL-8187: how long a deployment may sit with no observed inbound mail + // before this sidecar's own idle-hibernate sweep tears it down with a + // state-preserving undeploy (deployment record, step-state, and slug + // survive so a later wake resumes the same run). Unset disables the + // sweep entirely -- nothing is tracked, no interval runs -- matching + // the behavior before this knob existed. + "CHAT_IDLE_HIBERNATE_MS?": "string", }); /** @@ -148,6 +155,12 @@ export type SidecarConfig = { * override it; the supervisor applies `DEFAULT_READY_TIMEOUT_MS` (30s). */ readonly readyTimeoutMs: number | undefined; + /** + * How long a deployment may sit idle before this sidecar's own + * idle-hibernate sweep tears it down. `undefined` means the operator + * did not set `CHAT_IDLE_HIBERNATE_MS`; the sweep does not run at all. + */ + readonly idleHibernateMs: number | undefined; }; /** @@ -219,5 +232,9 @@ export function readSidecarConfig( parsed.CHILD_READY_TIMEOUT_MS, "CHILD_READY_TIMEOUT_MS", ), + idleHibernateMs: parsePositiveMsEnv( + parsed.CHAT_IDLE_HIBERNATE_MS, + "CHAT_IDLE_HIBERNATE_MS", + ), }; } diff --git a/apps/sidecar/src/index.ts b/apps/sidecar/src/index.ts index 07efc9c345..5ed30d44a8 100644 --- a/apps/sidecar/src/index.ts +++ b/apps/sidecar/src/index.ts @@ -346,8 +346,15 @@ const orchestrator = createSidecarOrchestrator({ readyTimeoutMs: config.readyTimeoutMs, } : deployRouterConfigWithConsumedRetentionMs; + const deployRouterConfigWithIdleHibernateMs = + config.idleHibernateMs !== undefined + ? { + ...deployRouterConfigWithReadyTimeoutMs, + idleHibernateMs: config.idleHibernateMs, + } + : deployRouterConfigWithReadyTimeoutMs; const router = createSidecarDeployRouter( - deployRouterConfigWithReadyTimeoutMs, + deployRouterConfigWithIdleHibernateMs, ); capturedRouter = router; return router; diff --git a/apps/sidecar/src/workflow-host-wiring/index.ts b/apps/sidecar/src/workflow-host-wiring/index.ts index 55048aaeef..298ac2354e 100644 --- a/apps/sidecar/src/workflow-host-wiring/index.ts +++ b/apps/sidecar/src/workflow-host-wiring/index.ts @@ -29,7 +29,6 @@ import { type SuspensionRegistration, } from "@intx/workflow-host"; import { hexEncode, isRunAddress } from "@intx/types"; -import { IDLE_HIBERNATE_UNDEPLOY_REASON } from "@corbits/agent-lifecycle"; import { parseInferenceEvent, type CryptoProvider, @@ -167,6 +166,21 @@ export const RESTORE_CONCURRENCY = 8; */ export const RESTORE_ATTEMPT_TIMEOUT_MS = 30_000; +/** + * The `reason` this router's own idle-hibernate sweep passes to + * `teardownDeployment` for every idle eviction, and the reason + * `undeploy(frame)` matches on to choose the state-preserving teardown + * flavor (`reclaimDirs: false` — deployment record, step-state, and slug + * all survive) over the destructive default a caller-initiated undeploy + * gets. CL-8187: idle hibernation is this router's own decision now — it + * tracks per-deployment last activity itself (see `lastActivityMs`) and + * tears an idle run down directly, rather than waiting for a hub-driven + * `agent.undeploy` tagged with this reason. The tag stays a named export + * so a caller-initiated undeploy can still choose to hibernate rather + * than reclaim. + */ +export const IDLE_HIBERNATE_UNDEPLOY_REASON = "idle-hibernate"; + /** * Bounds a boot-restore attempt to `timeoutMs` -- so `restoreWorkflowDeployments`'s * bounded worker pool frees this slot and moves on to the next record rather @@ -319,12 +333,13 @@ export interface SidecarDeployRouter extends DeployRouter { /** * Shared teardown body. `undeploy` calls this itself, choosing * `reclaimDirs` from `frame.reason`: `IDLE_HIBERNATE_UNDEPLOY_REASON` - * (`@corbits/agent-lifecycle`, tagged by the hub's idle-reap sweep) gets - * `false` (a state-preserving "hibernate"); every other reason gets the - * destructive `true`. Also exposed directly on the router surface for a - * caller that wants to choose the flavor itself. See `reclaimDirs`'s doc - * comment on the internal `teardownDeployment` for the exact split - * between the two flavors. + * gets `false` (a state-preserving "hibernate"); every other reason + * gets the destructive `true`. This router's own idle-hibernate sweep + * calls it directly with `reclaimDirs: false`, the same flavor. Also + * exposed directly on the router surface for a caller that wants to + * choose the flavor itself. See `reclaimDirs`'s doc comment on the + * internal `teardownDeployment` for the exact split between the two + * flavors. */ teardownDeployment( agentAddress: string, @@ -602,6 +617,23 @@ export function createSidecarDeployRouter(deps: { * overrides it. */ materializeDeploymentClosure?: typeof materializeDeploymentClosure; + /** + * How long a deployment may sit with no observed inbound mail before + * this router's own sweep hibernates it (`teardownDeployment` with + * `reclaimDirs: false`, tagged `IDLE_HIBERNATE_UNDEPLOY_REASON`). + * CL-8187: idle hibernation moved from a hub-driven decision to this + * router's own — the boot edge threads the operator's + * `CHAT_IDLE_HIBERNATE_MS` here. Undefined (the default) disables the + * sweep entirely: nothing is tracked, no interval runs, matching the + * behavior before this option existed. + */ + idleHibernateMs?: number; + /** + * How often the idle-hibernate sweep runs. Defaults to half + * `idleHibernateMs`, floored at 5s. Ignored when `idleHibernateMs` is + * unset. + */ + idleHibernateSweepIntervalMs?: number; }): SidecarDeployRouter { // Validate the signing seed at construction so a malformed key fails // sidecar boot rather than the first multi-step deploy, where the @@ -652,6 +684,44 @@ export function createSidecarDeployRouter(deps: { // with the deployment. const activeSupervisors = new Map(); + // CL-8187: per-deployment last-activity clock for the idle-hibernate + // sweep below. Stamped at spawn (a freshly deployed run starts + // "active") and on every inbound mail frame the multistep mail router + // dispatches -- the same choke point every triggered turn passes + // through. An address with no entry (never spawned by this process, + // or already torn down) is never swept. + const lastActivityMs = new Map(); + + function recordActivity(agentAddress: string): void { + lastActivityMs.set(agentAddress, Date.now()); + } + + let idleHibernateInterval: ReturnType | undefined; + if (deps.idleHibernateMs !== undefined) { + const idleHibernateMs = deps.idleHibernateMs; + const sweepIntervalMs = + deps.idleHibernateSweepIntervalMs ?? Math.max(idleHibernateMs / 2, 5_000); + idleHibernateInterval = setInterval(() => { + void (async () => { + const now = Date.now(); + for (const agentAddress of [...activeSupervisors.keys()]) { + const last = lastActivityMs.get(agentAddress); + if (last === undefined || now - last < idleHibernateMs) continue; + try { + await teardownDeployment(agentAddress, { reclaimDirs: false }); + logger.info`idle-hibernate sweep: torn down ${agentAddress} after ${String(idleHibernateMs)}ms of inactivity`; + } catch (cause) { + reportError(cause, { + operation: "workflow-host-wiring.idleHibernateSweep", + agentId: agentAddress, + }); + } + } + })(); + }, sweepIntervalMs); + idleHibernateInterval.unref?.(); + } + // CL-7215: per-deployment FIFO serialization for the boot-restore // failure record. Three independent writers can touch the same on-disk // `deployment.json` around a timed-out restore -- the boot loop's own @@ -756,6 +826,7 @@ export function createSidecarDeployRouter(deps: { deps.multistepSourcesRouter?.unregister(args.agentAddress); deps.multistepCredentialsRouter?.unregister(args.agentAddress); activeSupervisors.delete(args.agentAddress); + lastActivityMs.delete(args.agentAddress); deps.transport.unregister(args.agentAddress); releaseSlug(args.deploymentId, args.agentAddress); } @@ -1141,6 +1212,7 @@ export function createSidecarDeployRouter(deps: { wiredForUnwind = wired; activeSupervisors.set(spec.agentAddress, wired); supervisorRegistered = true; + recordActivity(spec.agentAddress); // Bind the deployment's mail address to this supervisor's // `routeInbound` so the hub-link dispatches inbound mail into the @@ -1148,6 +1220,7 @@ export function createSidecarDeployRouter(deps: { // `spawn` succeeds so a spawn-time rejection leaves the registry // untouched. deps.multistepMailRouter?.register(spec.agentAddress, async (message) => { + recordActivity(spec.agentAddress); return wired.routeInbound(message); }); // Register the signal-delivery handler so a hub `signal.deliver` frame @@ -1669,8 +1742,8 @@ export function createSidecarDeployRouter(deps: { * Returns `"deferred-to-wake"` instead of restoring for a single-step * ("warm-keep") deployment (CL-6648): that shape is exactly what a * chat launch deploys, and it already has a working lazy-wake port - * (`@corbits/agent-lifecycle`'s `ensureAwake` -> `@corbits/chat`'s - * `wakeByAddress` -> `prepareProvisionedDeployment`) that re-resolves + * (`@corbits/chat`'s `wakeByAddressBounded` -> `wakeByAddress` -> + * `prepareProvisionedDeployment`) that re-resolves * inference sources fresh against the tenant's LIVE catalog * on every wake -- `record.sources` is only ever a snapshot of what * resolved at the deployment's last deploy or rotation. Restoring one @@ -2000,6 +2073,7 @@ export function createSidecarDeployRouter(deps: { deps.multistepSourcesRouter?.unregister(agentAddress); deps.multistepCredentialsRouter?.unregister(agentAddress); + lastActivityMs.delete(agentAddress); const wired = activeSupervisors.get(agentAddress); if (wired !== undefined) { activeSupervisors.delete(agentAddress); @@ -2088,13 +2162,12 @@ export function createSidecarDeployRouter(deps: { }, async undeploy(frame): Promise { // `frame.reason` rides the wire from `sendAgentUndeploy(address, - // reason)` verbatim (`AgentUndeployFrame.reason`, `@intx/types`). The - // hub's idle-reap lifecycle (`@corbits/agent-lifecycle`'s sweep) tags - // its own reap-driven undeploys with `IDLE_HIBERNATE_UNDEPLOY_REASON` - // specifically so this router can tell "sleep it, a relaunch will - // resume it" apart from every other undeploy reason (channel - // deletion, member removal, ...), which still gets the destructive - // default. + // reason)` verbatim (`AgentUndeployFrame.reason`, `@intx/types`). + // `IDLE_HIBERNATE_UNDEPLOY_REASON` (this router's own tag, also + // used by its idle-hibernate sweep above) tells "sleep it, a + // relaunch will resume it" apart from every other undeploy reason + // (channel deletion, member removal, ...), which still gets the + // destructive default. const reclaimDirs = frame.reason !== IDLE_HIBERNATE_UNDEPLOY_REASON; await teardownDeployment(frame.agentAddress, { reclaimDirs }); }, @@ -2240,6 +2313,9 @@ export function createSidecarDeployRouter(deps: { return [...activeSupervisors.keys()]; }, async shutdownAll(): Promise { + if (idleHibernateInterval !== undefined) { + clearInterval(idleHibernateInterval); + } // Process-exit drain, NOT an undeploy: every live supervisor is shut // down so each workflow-process child, its IPC pipes, and its // event-channel fd are released before the host exits, but every diff --git a/apps/sidecar/test/config.test.ts b/apps/sidecar/test/config.test.ts index df326b36d0..f6c53b0f7e 100644 --- a/apps/sidecar/test/config.test.ts +++ b/apps/sidecar/test/config.test.ts @@ -41,6 +41,7 @@ test("parses a complete environment into config", () => { adapterManifest: DEFAULT_MANIFEST, consumedRetentionMs: undefined, readyTimeoutMs: undefined, + idleHibernateMs: undefined, }); }); diff --git a/apps/sidecar/test/workflow-teardown-flavor.test.ts b/apps/sidecar/test/workflow-teardown-flavor.test.ts index 443ee6eb20..ecad18a7b9 100644 --- a/apps/sidecar/test/workflow-teardown-flavor.test.ts +++ b/apps/sidecar/test/workflow-teardown-flavor.test.ts @@ -11,9 +11,10 @@ import { describe, test, expect } from "bun:test"; import fs from "node:fs/promises"; import path from "node:path"; -import { IDLE_HIBERNATE_UNDEPLOY_REASON } from "@corbits/agent-lifecycle"; - -import { deriveDeploymentId } from "../src/workflow-host-wiring"; +import { + deriveDeploymentId, + IDLE_HIBERNATE_UNDEPLOY_REASON, +} from "../src/workflow-host-wiring"; import { readWorkflowDeploymentRecord } from "../src/workflow-deployment-record"; import { answerReadyHandshake, diff --git a/apps/sidecar/tsconfig.json b/apps/sidecar/tsconfig.json index 7ce2ae3a6a..5ce2eadb7a 100644 --- a/apps/sidecar/tsconfig.json +++ b/apps/sidecar/tsconfig.json @@ -13,9 +13,6 @@ "include": ["src", "test"], "exclude": [], "references": [ - { - "path": "../../packages/agent-lifecycle/tsconfig.src.json" - }, { "path": "../../packages/codex-provider/tsconfig.src.json" }, diff --git a/apps/sidecar/tsconfig.src.json b/apps/sidecar/tsconfig.src.json index 6163aa3b45..a79b093994 100644 --- a/apps/sidecar/tsconfig.src.json +++ b/apps/sidecar/tsconfig.src.json @@ -10,9 +10,6 @@ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "references": [ - { - "path": "../../packages/agent-lifecycle/tsconfig.src.json" - }, { "path": "../../packages/codex-provider/tsconfig.src.json" }, diff --git a/bun.lock b/bun.lock index 01e52cb607..ed0f8f8d84 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,6 @@ "dependencies": { "@chat-adapter/state-memory": "^4.35.0", "@corbits/agent-directory": "workspace:*", - "@corbits/agent-lifecycle": "workspace:*", "@corbits/agent-runtime": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938", @@ -115,7 +114,6 @@ "workflow-child": "./bin/workflow-child", }, "dependencies": { - "@corbits/agent-lifecycle": "workspace:*", "@corbits/codex-provider": "workspace:*", "@corbits/credential-providers": "workspace:*", "@corbits/error-sink": "workspace:*", @@ -221,7 +219,6 @@ "version": "0.0.1", "dependencies": { "@corbits/agent-events": "workspace:*", - "@corbits/agent-lifecycle": "workspace:*", "@corbits/agent-runtime": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/error-sink": "workspace:*", @@ -274,17 +271,6 @@ "typescript": "catalog:", }, }, - "packages/agent-lifecycle": { - "name": "@corbits/agent-lifecycle", - "version": "0.0.1", - "dependencies": { - "@intx/log": "0.3.0", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/agent-runtime": { "name": "@corbits/agent-runtime", "version": "0.0.1", @@ -437,7 +423,6 @@ "version": "0.0.1", "dependencies": { "@corbits/agent-events": "workspace:*", - "@corbits/agent-lifecycle": "workspace:*", "@corbits/agent-runtime": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/collections": "workspace:*", @@ -1693,7 +1678,7 @@ "name": "@corbits/assistant-workflow", "version": "0.0.2", "dependencies": { - "@corbits/catalog-tools": "0.0.6", + "@corbits/catalog-tools": "0.1.0", }, "devDependencies": { "@corbits/agent-directory-tools": "workspace:*", @@ -2101,8 +2086,6 @@ "@corbits/agent-events": ["@corbits/agent-events@workspace:packages/agent-events"], - "@corbits/agent-lifecycle": ["@corbits/agent-lifecycle@workspace:packages/agent-lifecycle"], - "@corbits/agent-runtime": ["@corbits/agent-runtime@workspace:packages/agent-runtime"], "@corbits/api-query": ["@corbits/api-query@workspace:packages/api-query"], @@ -3661,8 +3644,6 @@ "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], - "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/agent-directory/package.json b/packages/agent-directory/package.json index cbfce8661c..6cacf34843 100644 --- a/packages/agent-directory/package.json +++ b/packages/agent-directory/package.json @@ -15,7 +15,6 @@ }, "dependencies": { "@corbits/agent-events": "workspace:*", - "@corbits/agent-lifecycle": "workspace:*", "@corbits/agent-runtime": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/error-sink": "workspace:*", diff --git a/packages/agent-directory/src/one-shot-prompt.test.ts b/packages/agent-directory/src/one-shot-prompt.test.ts index 7f810e0a5c..54f7ce329b 100644 --- a/packages/agent-directory/src/one-shot-prompt.test.ts +++ b/packages/agent-directory/src/one-shot-prompt.test.ts @@ -139,29 +139,6 @@ function createFakeUndeploy() { }; } -/** A tiny fake `lifecycle` recording track/recordActivity/untrack calls. */ -function createFakeLifecycle() { - const tracked: string[] = []; - const activity: string[] = []; - const untracked: string[] = []; - return { - tracked, - activity, - untracked, - lifecycle: { - track: (address: string) => { - tracked.push(address); - }, - recordActivity: (address: string) => { - activity.push(address); - }, - untrack: (address: string) => { - untracked.push(address); - }, - }, - }; -} - function createBaseDeps() { return { db: fakeDb(), @@ -192,14 +169,12 @@ describe("runOneShotPrompt", () => { const fakeSend = createFakeSend(); const { sendMail } = fakeSend; const { undeploy, calls: undeployCalls } = createFakeUndeploy(); - const { lifecycle, tracked, activity, untracked } = createFakeLifecycle(); const deps = { ...createBaseDeps(), events: fake.emitter, provision, sendMail, undeploy, - lifecycle, } as never; const promise = runOneShotPrompt(deps, INPUT); @@ -235,9 +210,6 @@ describe("runOneShotPrompt", () => { expect(undeployCalls).toEqual([ { address: triggerAddress, reason: "planning-run-complete" }, ]); - expect(tracked).toEqual([triggerAddress]); - expect(activity).toEqual([triggerAddress]); - expect(untracked).toEqual([triggerAddress]); }); test("a failed run rejects with OneShotRunFailedError, unsubscribes, and tears the run down", async () => { diff --git a/packages/agent-directory/src/one-shot-prompt.ts b/packages/agent-directory/src/one-shot-prompt.ts index 85dab5a3f3..de35b47896 100644 --- a/packages/agent-directory/src/one-shot-prompt.ts +++ b/packages/agent-directory/src/one-shot-prompt.ts @@ -4,7 +4,6 @@ // soon as the run starts; this module turns the event stream into an // awaitable promise for a caller that has no later-delivery surface. import { and, eq } from "drizzle-orm"; -import type { AgentLifecycle } from "@corbits/agent-lifecycle"; import { connectorReplyContent, messageRunEnded } from "@corbits/agent-events"; import { reportError } from "@corbits/error-sink"; import { @@ -50,10 +49,6 @@ export type OneShotRunnerDeps = { readonly events: SidecarEventEmitter; readonly cryptoProviders: CryptoProviderCache; readonly undeploy: (address: string, reason: string) => Promise; - readonly lifecycle?: Pick< - AgentLifecycle, - "track" | "recordActivity" | "untrack" - >; readonly repoStore: Pick; readonly workflowAllocationService: Pick< WorkflowAllocationService, @@ -265,9 +260,6 @@ export async function runOneShotPrompt( domain: tenantRow.domain, })); - deps.lifecycle?.track(launched.address); - deps.lifecycle?.recordActivity(launched.address); - return new Promise((resolve, reject) => { let settled = false; let accumulated = ""; @@ -323,7 +315,6 @@ export async function runOneShotPrompt( extra: { address: launched.address, reason }, }); } - deps.lifecycle?.untrack(launched.address); finish(); } diff --git a/packages/agent-lifecycle/LICENSE b/packages/agent-lifecycle/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/agent-lifecycle/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/agent-lifecycle/README.md b/packages/agent-lifecycle/README.md deleted file mode 100644 index 156e78f817..0000000000 --- a/packages/agent-lifecycle/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# @corbits/agent-lifecycle - -A host-agnostic idle-sleep and wake-on-mail scheduler for launched agent -instances: a periodic sweep that tears down addresses idle past a -configured threshold, and `ensureAwake`, which redeploys an address on -demand and coalesces concurrent callers onto one in-flight wake. Every -side effect that touches a real host — whether an address is currently -deployed, tearing one down, redeploying one, whether one is mid-turn — -arrives as an injected port; this package never imports a hub, a sidecar, -or `@corbits/chat`. - -## Composing with `@intx/*` - -Only `@intx/log` — this package takes a `Logger` (the type returned by -`@intx/log`'s `getLogger`) for the sweep's info/error lines, and holds no -other Interchange dependency. It reimplements no Interchange capability; -deployment, undeploy, and wake are entirely the host's own closures. - -## Key modules - -- **`src/index.ts`** — the whole package: `createAgentLifecycle`, its - `AgentLifecycle` return type (`track`, `untrack`, `recordActivity`, - `ensureAwake`, `stop`), and the `CreateAgentLifecycleOptions` the host - supplies (`idleSleepMs`, `isRoutable`, `undeploy`, `wake`, optional - `sweepIntervalMs`/`isBusy`, `log`). - -## What the host must inject - -`isRoutable(address)`, `undeploy(address, reason)`, `wake(address)`, and a -`log`; `sweepIntervalMs` and `isBusy` are optional. `packages/chat`'s -platform adapter wires its own closures in exactly this shape. - -## Running tests - -```sh -cd packages/agent-lifecycle && bun test -``` - -Tests exercise the sweep and wake-coalescing logic directly against fake -`isRoutable`/`undeploy`/`wake` closures; no database is involved. diff --git a/packages/agent-lifecycle/package.json b/packages/agent-lifecycle/package.json deleted file mode 100644 index e0b2b86ef7..0000000000 --- a/packages/agent-lifecycle/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@corbits/agent-lifecycle", - "private": true, - "description": "Idle-sleep and wake-on-mail lifecycle for launched agent instances, hosted-service-agnostic", - "version": "0.0.1", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@intx/log": "0.3.0" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/agent-lifecycle/src/index.ts b/packages/agent-lifecycle/src/index.ts deleted file mode 100644 index 603c65e5cd..0000000000 --- a/packages/agent-lifecycle/src/index.ts +++ /dev/null @@ -1,240 +0,0 @@ -// A host-agnostic idle-sleep/wake-on-mail scheduler for launched agent -// instances. `createAgentLifecycle` owns two things: a periodic sweep -// that tears down addresses idle past `idleSleepMs`, and `ensureAwake`, -// which redeploys an address on demand and coalesces concurrent callers -// onto one in-flight wake. Every side effect that touches a real -// host — whether an address is currently deployed, tearing one down, -// redeploying one, whether one is mid-turn — arrives as an injected -// port; this package never imports a hub, a sidecar, or -// `@corbits/chat`. A caller wires its own -// `isRoutable`/`undeploy`/`wake` closures in, Scout-style, exactly as -// `packages/chat`'s adapter does. -import type { getLogger } from "@intx/log"; - -type Logger = ReturnType; - -/** - * The `reason` this package's sweep passes to `undeploy` for every idle - * eviction. A host's `undeploy` port (e.g. the sidecar's `agent.undeploy` - * handler) matches on this exact string to choose a STATE-PRESERVING - * teardown (keep the deployment record, step-state scratch, and slug so a - * later relaunch resumes the same run) rather than the destructive default - * a caller-initiated undeploy (channel deletion, member removal, ...) gets. - * Exported so every `undeploy` port this package's sweep calls can agree on - * the same tag without each host inventing its own string. - */ -export const IDLE_HIBERNATE_UNDEPLOY_REASON = "idle-hibernate"; - -/** - * `ensureAwake`'s default bound on one call to the injected `wake` port. - * A cold wake for a run whose deployed record was parked aside is a real - * deploy round-trip to the host, not a local check — long enough to allow - * for that, short enough that a wake the host never acks turns into a - * rejection (CL-6643) instead of a promise nothing ever observes. - */ -export const DEFAULT_WAKE_TIMEOUT_MS = 30_000; - -export type CreateAgentLifecycleOptions = { - /** How long an address may sit idle (no `recordActivity`) before the sweep sleeps it. Required — no default hidden in here. */ - idleSleepMs: number; - /** How often the sweep runs. Defaults to half the sleep threshold, floored at 5s. */ - sweepIntervalMs?: number; - /** - * How long one `ensureAwake` call may wait on the injected `wake` port - * before treating it as failed. Defaults to `DEFAULT_WAKE_TIMEOUT_MS`. - * `wake` itself keeps running past the timeout — this only bounds how - * long a caller (and everyone coalesced behind it) waits before this - * package gives up and reports a rejection, so a wake the host never - * acks can never wedge an address's mail silently for the rest of the - * process's life (CL-6643). - */ - wakeTimeoutMs?: number; - /** Whether the host currently has this address deployed/connected. */ - isRoutable(address: string): boolean; - /** Tears the address down on the host. Errors are the caller's to throw; the sweep catches and logs them per-address. */ - undeploy(address: string, reason: string): Promise; - /** Redeploys the address at head. Errors propagate to `ensureAwake`'s caller. */ - wake(address: string): Promise; - /** Optional in-flight-turn guard: an address the sweep must never sleep out from under itself. Defaults to never-busy. */ - isBusy?(address: string): boolean; - log: Logger; -}; - -export type AgentLifecycle = { - /** Marks an address as tracked by the sweep. Idempotent. */ - track(address: string): void; - /** Stops tracking an address; the sweep will never sleep it again. */ - untrack(address: string): void; - /** Bumps an address's last-activity clock to now. */ - recordActivity(address: string): void; - /** - * No-ops when the address is already routable. Otherwise calls the - * injected `wake` and resolves once it settles; concurrent callers - * for the same address coalesce onto the one in-flight wake rather - * than each redeploying it. - */ - ensureAwake(address: string): Promise; - /** Stops the sweep interval. Safe to call more than once. */ - stop(): void; -}; - -/** - * Composes the idle-sleep sweep and wake-on-mail coalescing described - * above. The sweep only ever considers `track`ed addresses, and sleeps - * one only when every gate agrees, in this order: it is currently - * routable (nothing to sleep otherwise); it has been seen before (a - * freshly tracked address gets one sweep's grace before its idle clock - * counts against it — the restart case, where nothing has recorded - * activity yet but the address may have been deployed for a while); - * it has actually been idle for `idleSleepMs`; and it is not - * `isBusy`. - */ -export function createAgentLifecycle( - options: CreateAgentLifecycleOptions, -): AgentLifecycle { - const { idleSleepMs, isRoutable, undeploy, wake, log } = options; - const sweepIntervalMs = - options.sweepIntervalMs ?? Math.max(idleSleepMs / 2, 5_000); - const wakeTimeoutMs = options.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS; - const isBusy = options.isBusy ?? (() => false); - - const tracked = new Set(); - const lastActivityMs = new Map(); - const pendingWakes = new Map>(); - - function track(address: string): void { - tracked.add(address); - } - - function untrack(address: string): void { - tracked.delete(address); - lastActivityMs.delete(address); - } - - function recordActivity(address: string): void { - lastActivityMs.set(address, Date.now()); - } - - async function sweepOnce(): Promise { - const now = Date.now(); - for (const address of tracked) { - if (!isRoutable(address)) continue; - - const last = lastActivityMs.get(address); - if (last === undefined) { - // First sighting: seed the clock rather than sleeping - // immediately. Without this grace, an address that has been - // deployed since before this process started (a restart) would - // read as infinitely idle and sleep on the very first sweep. - lastActivityMs.set(address, now); - continue; - } - - if (now - last < idleSleepMs) continue; - if (isBusy(address)) continue; - - try { - await undeploy(address, IDLE_HIBERNATE_UNDEPLOY_REASON); - log.info`sleeping idle instance ${address} after ${String(idleSleepMs)}ms of inactivity`; - } catch (err) { - log.error`lifecycle sweep failed to undeploy ${address}: ${ - err instanceof Error ? err.message : String(err) - }`; - } - } - } - - let sweepInFlight = false; - - async function sweepTick(): Promise { - // A slow `undeploy` can outlive a single sweepIntervalMs tick. - // Without this guard, the next tick would start a second sweep - // over the same tracked addresses while the first is still - // awaiting its undeploys, double-undeploying whatever the first - // sweep hasn't gotten to yet. Ticks that land while a sweep is - // still running are dropped; the next one after it finishes picks - // up wherever state has landed. - if (sweepInFlight) return; - sweepInFlight = true; - try { - await sweepOnce(); - } finally { - sweepInFlight = false; - } - } - - const interval = setInterval(() => { - void sweepTick(); - }, sweepIntervalMs); - if (typeof interval.unref === "function") interval.unref(); - - /** - * Races `pending` against `wakeTimeoutMs`, rejecting with a distinct - * error so a caller always gets a settled outcome — never a hang — - * even though `pending` itself keeps running unobserved past the - * deadline. Called once per `ensureAwake` caller (not once per - * address): every caller coalesced onto the same in-flight `pending` - * gets its own fresh timer, so one caller's timeout never shortens - * another's wait. - */ - function boundToTimeout( - pending: Promise, - address: string, - ): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject( - new Error( - `wake for "${address}" did not settle within ${String(wakeTimeoutMs)}ms`, - ), - ); - }, wakeTimeoutMs); - pending.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (cause: unknown) => { - clearTimeout(timer); - reject(cause); - }, - ); - }); - } - - /** - * `pendingWakes` tracks the raw, untimed `wake(address)` call — never - * the timeout-bound promise a caller sees. Clearing the map entry only - * when the real wake settles (not when a caller's own timeout fires) - * is what keeps a later caller, arriving after an earlier one already - * timed out, coalescing onto the same in-flight wake instead of - * dispatching a second, concurrent one (CL-7217) — the earlier version - * of this function cleared the entry on timeout, which let exactly - * that race through. The trade-off: an address whose injected `wake` - * never settles, ever, stays permanently deduped — no later caller can - * ever trigger a fresh attempt for it — which is an accepted cost - * against a concurrent-wake race that can corrupt host state. - */ - async function ensureAwake(address: string): Promise { - if (isRoutable(address)) return; - - let inFlight = pendingWakes.get(address); - if (inFlight === undefined) { - inFlight = wake(address) - .then(() => { - recordActivity(address); - }) - .finally(() => { - pendingWakes.delete(address); - }); - pendingWakes.set(address, inFlight); - } - return boundToTimeout(inFlight, address); - } - - function stop(): void { - clearInterval(interval); - } - - return { track, untrack, recordActivity, ensureAwake, stop }; -} diff --git a/packages/agent-lifecycle/test/index.test.ts b/packages/agent-lifecycle/test/index.test.ts deleted file mode 100644 index c0cc3a5311..0000000000 --- a/packages/agent-lifecycle/test/index.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -// Proves each gate `createAgentLifecycle`'s sweep applies, in order, -// and that `ensureAwake` coalesces concurrent wakes for the same -// address into a single call to the injected `wake`. The sweep itself -// is driven directly (no `setInterval` involved) by reaching into the -// module's internals is avoided; instead these tests configure a short -// `sweepIntervalMs` and await one real tick, since the gates are the -// contract under test, not the timer plumbing. -import { afterEach, describe, expect, test } from "bun:test"; -import { getLogger } from "@intx/log"; -import { - createAgentLifecycle, - IDLE_HIBERNATE_UNDEPLOY_REASON, -} from "../src/index"; - -const log = getLogger(["chat", "lifecycle", "test"]); - -function waitForTicks(count = 1): Promise { - return new Promise((resolve) => { - let remaining = count; - const tick = () => { - remaining -= 1; - if (remaining <= 0) { - resolve(); - } else { - setTimeout(tick, 40); - } - }; - setTimeout(tick, 40); - }); -} - -describe("createAgentLifecycle", () => { - let stop: (() => void) | undefined; - afterEach(() => { - stop?.(); - stop = undefined; - }); - - test("sleeps a tracked, routable, idle instance", async () => { - const undeployCalls: { address: string; reason: string }[] = []; - const routable = new Set(["agent-1@t.test"]); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 80, - sweepIntervalMs: 40, - isRoutable: (address) => routable.has(address), - undeploy: async (address, reason) => { - undeployCalls.push({ address, reason }); - // Mirrors reality: once undeployed, the host no longer - // reports the address as routable, so a second sweep tick - // must not undeploy it again. - routable.delete(address); - }, - wake: async () => undefined, - log, - }); - stop = lifecycle.stop; - - lifecycle.track("agent-1@t.test"); - lifecycle.recordActivity("agent-1@t.test"); - - // First sweep tick happens quickly (sweepIntervalMs=40) but the - // instance is not idle for the full 80ms yet. - await waitForTicks(1); - expect(undeployCalls).toEqual([]); - - // Wait past idleSleepMs and let another sweep tick run. - await new Promise((resolve) => setTimeout(resolve, 120)); - await waitForTicks(1); - - expect(undeployCalls).toEqual([ - { address: "agent-1@t.test", reason: IDLE_HIBERNATE_UNDEPLOY_REASON }, - ]); - }); - - test("spares an active instance", async () => { - const undeployCalls: { address: string; reason: string }[] = []; - const routable = new Set(["agent-1@t.test"]); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 1_000, - sweepIntervalMs: 40, - isRoutable: (address) => routable.has(address), - undeploy: async (address, reason) => { - undeployCalls.push({ address, reason }); - }, - wake: async () => undefined, - log, - }); - stop = lifecycle.stop; - - lifecycle.track("agent-1@t.test"); - lifecycle.recordActivity("agent-1@t.test"); - - await waitForTicks(3); - expect(undeployCalls).toEqual([]); - }); - - test("spares a busy instance even when idle past the threshold", async () => { - const undeployCalls: { address: string; reason: string }[] = []; - const routable = new Set(["agent-1@t.test"]); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 80, - sweepIntervalMs: 40, - isRoutable: (address) => routable.has(address), - undeploy: async (address, reason) => { - undeployCalls.push({ address, reason }); - }, - wake: async () => undefined, - isBusy: (address) => address === "agent-1@t.test", - log, - }); - stop = lifecycle.stop; - - lifecycle.track("agent-1@t.test"); - lifecycle.recordActivity("agent-1@t.test"); - - await new Promise((resolve) => setTimeout(resolve, 120)); - await waitForTicks(1); - - expect(undeployCalls).toEqual([]); - }); - - test("never sleeps an untracked instance", async () => { - const undeployCalls: { address: string; reason: string }[] = []; - const routable = new Set(["agent-1@t.test"]); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 80, - sweepIntervalMs: 40, - isRoutable: (address) => routable.has(address), - undeploy: async (address, reason) => { - undeployCalls.push({ address, reason }); - }, - wake: async () => undefined, - log, - }); - stop = lifecycle.stop; - - // Never tracked -- recordActivity alone must not be enough to be - // swept. - lifecycle.recordActivity("agent-1@t.test"); - - await new Promise((resolve) => setTimeout(resolve, 120)); - await waitForTicks(1); - - expect(undeployCalls).toEqual([]); - }); - - test("a freshly tracked instance gets one sweep's grace before its idle clock counts", async () => { - const undeployCalls: { address: string; reason: string }[] = []; - const routable = new Set(["agent-1@t.test"]); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 80, - sweepIntervalMs: 40, - isRoutable: (address) => routable.has(address), - undeploy: async (address, reason) => { - undeployCalls.push({ address, reason }); - routable.delete(address); - }, - wake: async () => undefined, - log, - }); - stop = lifecycle.stop; - - // Tracked with no recordActivity -- as if rehydrated after a - // restart, with no activity observed yet in this process. - lifecycle.track("agent-1@t.test"); - - // The very first sweep must seed the clock, not sleep immediately. - await waitForTicks(1); - expect(undeployCalls).toEqual([]); - - // Only after the grace-seeded clock itself goes idle does it sleep. - await new Promise((resolve) => setTimeout(resolve, 120)); - await waitForTicks(1); - expect(undeployCalls).toEqual([ - { address: "agent-1@t.test", reason: IDLE_HIBERNATE_UNDEPLOY_REASON }, - ]); - }); - - test("untracked or non-routable instances are skipped even when idle", async () => { - const undeployCalls: { address: string; reason: string }[] = []; - const routable = new Set(); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 80, - sweepIntervalMs: 40, - isRoutable: (address) => routable.has(address), - undeploy: async (address, reason) => { - undeployCalls.push({ address, reason }); - }, - wake: async () => undefined, - log, - }); - stop = lifecycle.stop; - - lifecycle.track("agent-1@t.test"); - lifecycle.recordActivity("agent-1@t.test"); - - await new Promise((resolve) => setTimeout(resolve, 120)); - await waitForTicks(1); - - expect(undeployCalls).toEqual([]); - }); - - test("a sweep tick that blocks on undeploy is not re-entered by the next tick", async () => { - const undeployCalls: string[] = []; - const routable = new Set(["agent-1@t.test"]); - let releaseUndeploy: (() => void) | undefined; - let concurrentUndeploys = 0; - let maxConcurrentUndeploys = 0; - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 80, - sweepIntervalMs: 40, - isRoutable: (address) => routable.has(address), - undeploy: async (address) => { - concurrentUndeploys += 1; - maxConcurrentUndeploys = Math.max( - maxConcurrentUndeploys, - concurrentUndeploys, - ); - undeployCalls.push(address); - await new Promise((resolve) => { - releaseUndeploy = resolve; - }); - concurrentUndeploys -= 1; - routable.delete(address); - }, - wake: async () => undefined, - log, - }); - stop = lifecycle.stop; - - lifecycle.track("agent-1@t.test"); - lifecycle.recordActivity("agent-1@t.test"); - - // Let the address go idle, then let several sweepIntervalMs ticks - // land while the first sweep's undeploy is still pending. If ticks - // overlapped, this would call undeploy on the same address a - // second time before the first has resolved. - await new Promise((resolve) => setTimeout(resolve, 120)); - await waitForTicks(1); - expect(undeployCalls).toEqual(["agent-1@t.test"]); - - await waitForTicks(5); - expect(undeployCalls).toEqual(["agent-1@t.test"]); - expect(maxConcurrentUndeploys).toBe(1); - - releaseUndeploy?.(); - await new Promise((resolve) => setTimeout(resolve, 40)); - }); - - test("ensureAwake no-ops when already routable", async () => { - const wakeCalls: string[] = []; - const routable = new Set(["agent-1@t.test"]); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 1_000, - isRoutable: (address) => routable.has(address), - undeploy: async () => undefined, - wake: async (address) => { - wakeCalls.push(address); - }, - log, - }); - stop = lifecycle.stop; - - await lifecycle.ensureAwake("agent-1@t.test"); - expect(wakeCalls).toEqual([]); - }); - - test("ensureAwake wakes a non-routable instance and coalesces concurrent callers", async () => { - const wakeCalls: string[] = []; - const routable = new Set(); - let resolveWake: (() => void) | undefined; - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 1_000, - isRoutable: (address) => routable.has(address), - undeploy: async () => undefined, - wake: async (address) => { - wakeCalls.push(address); - await new Promise((resolve) => { - resolveWake = resolve; - }); - }, - log, - }); - stop = lifecycle.stop; - - const first = lifecycle.ensureAwake("agent-1@t.test"); - const second = lifecycle.ensureAwake("agent-1@t.test"); - - // Give both calls a tick to have entered `ensureAwake` and observed - // the same pending wake. - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(wakeCalls).toEqual(["agent-1@t.test"]); - - resolveWake?.(); - await Promise.all([first, second]); - - expect(wakeCalls).toEqual(["agent-1@t.test"]); - }); - - test("ensureAwake propagates a wake failure", async () => { - const routable = new Set(); - const failure = new Error("sidecar unreachable"); - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 1_000, - isRoutable: (address) => routable.has(address), - undeploy: async () => undefined, - wake: async () => { - throw failure; - }, - log, - }); - stop = lifecycle.stop; - - await expect(lifecycle.ensureAwake("agent-1@t.test")).rejects.toThrow( - failure, - ); - }); - - // CL-6643: a run whose deployed record was parked aside (no sidecar - // record, cold wake required) can hang inside the injected `wake` port - // forever — the sidecar never acks a redeploy it has no state for. - // Without a bound, a caller waiting on that hung call would hang too; - // `ensureAwake` bounds each caller's own wait so it always sees a - // settled outcome. This is the mechanism behind "message accepted, - // then silence: no fanout, no wake, no error" — the caller (`sendMail`) - // never sees a rejection to report as an undelivered notice, because - // nothing ever rejects. - // - // CL-7217: a still-hung `wake()` must not let a second caller dispatch - // a SECOND, concurrent `wake()` for the same address — that races both - // redeploys against each other on the host. So every later caller for - // this address keeps coalescing onto the one real `wake()` call for as - // long as it stays in flight, each with its own fresh timeout. The - // trade-off this accepts: an address whose underlying `wake()` never - // settles, ever, is permanently deduped (unwakeable via this dedup - // path) for the rest of the process's life, since nothing ever clears - // `pendingWakes` for it. That is intentional — a silently wedged - // address is a lesser failure than every retry redeploying it - // concurrently forever — and is proven below by a third call still - // seeing no new `wake()` dispatch. - test("a wake that never settles times out every caller but never dispatches a second concurrent wake", async () => { - const routable = new Set(); - let wakeCalls = 0; - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 1_000, - wakeTimeoutMs: 160, - isRoutable: (address) => routable.has(address), - undeploy: async () => undefined, - wake: async () => { - wakeCalls += 1; - // Never resolves and never rejects — the hung sidecar RPC. - await new Promise(() => {}); - }, - log, - }); - stop = lifecycle.stop; - - // The first caller must not hang forever: the bounded wake times out - // and the caller sees a rejection it can turn into an undelivered - // notice, instead of an unsettled promise nothing ever observes. - await expect(lifecycle.ensureAwake("agent-1@t.test")).rejects.toThrow( - /wake/i, - ); - expect(wakeCalls).toBe(1); - - // A second, later call for the same address — the next message sent - // to the same workbench — coalesces onto the still-hung first wake - // rather than dispatching a concurrent one; it gets its own timeout - // and rejects too, but `wake` is not called again. - await expect(lifecycle.ensureAwake("agent-1@t.test")).rejects.toThrow( - /wake/i, - ); - expect(wakeCalls).toBe(1); - - // A third call proves this isn't a one-off race: the dedup holds for - // as long as the underlying wake never settles. - await expect(lifecycle.ensureAwake("agent-1@t.test")).rejects.toThrow( - /wake/i, - ); - expect(wakeCalls).toBe(1); - }); - - // CL-7217: the earlier version of this fix cleared `pendingWakes` the - // moment the per-caller timeout fired, not when `wake()` itself - // settled — so a caller arriving after a timeout, but before the real - // wake finished, dispatched a brand-new concurrent `wake()` call. This - // proves the corrected coalescing: a second caller arriving in that - // window joins the one real wake in flight and observes its outcome, - // instead of triggering its own. - test("a caller arriving after a timeout coalesces onto the still-in-flight wake instead of dispatching a second one", async () => { - const routable = new Set(); - let wakeCalls = 0; - let resolveWake: (() => void) | undefined; - - const lifecycle = createAgentLifecycle({ - idleSleepMs: 1_000, - wakeTimeoutMs: 160, - isRoutable: (address) => routable.has(address), - undeploy: async () => undefined, - wake: async (address) => { - wakeCalls += 1; - await new Promise((resolve) => { - resolveWake = resolve; - }); - routable.add(address); - }, - log, - }); - stop = lifecycle.stop; - - const first = lifecycle.ensureAwake("agent-1@t.test"); - - // The first caller's own 160ms budget expires while the underlying - // wake is still running. - await expect(first).rejects.toThrow(/wake/i); - expect(wakeCalls).toBe(1); - - // A second caller arrives after that timeout but before the real - // wake has settled. It must join the one in-flight wake rather than - // starting a new one. - const second = lifecycle.ensureAwake("agent-1@t.test"); - resolveWake?.(); - await expect(second).resolves.toBeUndefined(); - expect(wakeCalls).toBe(1); - }); -}); diff --git a/packages/agent-lifecycle/tsconfig.json b/packages/agent-lifecycle/tsconfig.json deleted file mode 100644 index a8c2b9966d..0000000000 --- a/packages/agent-lifecycle/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src", "test"], - "exclude": [] -} diff --git a/packages/agent-lifecycle/tsconfig.src.json b/packages/agent-lifecycle/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/packages/agent-lifecycle/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/packages/chat/README.md b/packages/chat/README.md index f1d64060ba..33bc256a83 100644 --- a/packages/chat/README.md +++ b/packages/chat/README.md @@ -24,8 +24,7 @@ from, `@intx/workflow` and `@intx/workflow-deploy` for the workbench-host workflow definition and its deployment, `@intx/db` for schema/query primitives, `@intx/authz` for grant checks, `@intx/crypto` for signing, `@intx/mime` for attachment validation, `@intx/log` for logging, and -`@intx/types` for shared wire types. It also depends on -`@corbits/agent-lifecycle` (idle-sleep/wake-on-mail). +`@intx/types` for shared wire types. ## Key modules diff --git a/packages/chat/package.json b/packages/chat/package.json index 3f90f9d9a2..b60726061e 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -38,7 +38,6 @@ }, "dependencies": { "@corbits/agent-events": "workspace:*", - "@corbits/agent-lifecycle": "workspace:*", "@corbits/agent-runtime": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/collections": "workspace:*", diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index b5420b65d4..e74acd3f28 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -7,10 +7,6 @@ // chat's own: `workbench_launch` persistence, invitable listing, and // participant/fromWorkbenchId send semantics. // A workbench itself is data — only invited agents have runs here. -import { - createAgentLifecycle, - DEFAULT_WAKE_TIMEOUT_MS, -} from "@corbits/agent-lifecycle"; import { reportError } from "@corbits/error-sink"; import { and, desc, eq, inArray } from "drizzle-orm"; import { @@ -85,6 +81,15 @@ import { type SentMail, } from "./platform-port"; +/** + * Bound on one wake dispatch (`wakeByAddress`, via `wakeByAddressBounded`). + * A cold wake for a run whose deployed record was parked aside is a real + * deploy round-trip to the sidecar, not a local check — long enough to + * allow for that, short enough that a wake the sidecar never acks turns + * into a rejection (CL-6643) instead of a promise nothing ever observes. + */ +const DEFAULT_WAKE_TIMEOUT_MS = 30_000; + function domainOf(address: string): string { const domain = addressDomainOf(address); if (domain === undefined) { @@ -125,8 +130,7 @@ export type CreateHubChatPlatformDeps = { * Every caller of `createHubChatPlatform` builds this via * `createEventCollectorRegistry` and passes it through — without it, * an agent's runtime status/readiness (health, SSE replay) reads as - * permanently "not_ready", and the idle-sweep's `isBusy` guard (see - * the lifecycle construction below) has no signal at all. + * permanently "not_ready". */ eventCollectors: EventCollectorRegistry; /** @@ -146,18 +150,6 @@ export type CreateHubChatPlatformDeps = { * Best-effort: absent or throwing, mail proceeds exactly as before. */ refreshServingCredentials?: (tenantId: string) => Promise; - /** - * Opt-in idle-sleep for every launched instance: absent here, the adapter keeps today's - * behavior exactly (nothing ever sleeps, no interval runs). When - * present, this adapter builds a `@corbits/agent-lifecycle` instance - * from it, wiring its `isRoutable`/`undeploy`/`wake` ports onto - * `sidecarRouter` and this adapter's `provisionOnAsset` wake — - * `@corbits/agent-lifecycle` itself never imports the hub or this - * package. Its sweep tears down instances idle for `idleSleepMs` via - * `sidecarRouter.sendAgentUndeploy`, and `sendMail` calls - * `ensureAwake` to redeploy a non-routable target before sending. - */ - lifecycle?: { idleSleepMs: number; sweepIntervalMs?: number }; /** * Per-attempt wall-clock bound on `sendRunMail` inside * `sendRunMailWithReclaimRetry` (CL-6644). Defaults to @@ -209,16 +201,9 @@ const RELAUNCH_SWEEP_LIMIT = 100; /** * The concrete object `createHubChatPlatform` returns: the `ChatPlatform` - * port itself, plus a `recordActivity` hook the host wires into - * `createChatOrchestrator` (see `chat-orchestrator.ts`) so an invited - * agent's `connector.reply` traffic — observed on the orchestrator's - * own event subscription, not this adapter's `sendMail` — still counts - * as activity against the idle-sleep lifecycle built here. A no-op - * when `deps.lifecycle` is unset, matching every other lifecycle hook - * on this adapter. + * port itself, plus the extra hooks the host wires up around it. */ export type HubChatPlatform = ChatPlatform & { - recordActivity(address: string): void; /** * Redeploys `address` if it is not currently routable, otherwise * no-ops — the same wake path `sendMail` runs ahead of every send, @@ -456,39 +441,6 @@ export function createHubChatPlatform( const cryptoProviders = deps.cryptoProviders; const wakeLogger = getLogger(["chat", "wake"]); - // Built from `@corbits/agent-lifecycle` — the idle-sleep sweep and - // wake-coalescing logic live entirely in that package, imported as a - // published dependency rather than reimplemented here; this adapter - // only wires its ports onto `sidecarRouter` (routability, undeploy), - // `deps.eventCollectors` (busy detection), and `wakeByAddress` below - // (a plain `function` declaration, so it is already hoisted by the - // time this closure is called). `undefined` when `deps.lifecycle` is - // unset, matching today's behavior exactly: nothing is tracked, no - // sweep runs, `sendMail` never calls `ensureAwake`. - function buildLifecycle( - lifecycleDeps: NonNullable, - ) { - const base = { - idleSleepMs: lifecycleDeps.idleSleepMs, - isRoutable: (address: string) => - deps.sidecarRouter.getRoutableAddresses().includes(address), - undeploy: (address: string, reason: string) => - deps.sidecarRouter.sendAgentUndeploy(address, reason), - wake: wakeByAddress, - isBusy: (address: string) => - typeof deps.eventCollectors.getCurrentTurnId(address) === "string", - log: getLogger(["chat", "lifecycle"]), - }; - return createAgentLifecycle( - lifecycleDeps.sweepIntervalMs !== undefined - ? { ...base, sweepIntervalMs: lifecycleDeps.sweepIntervalMs } - : base, - ); - } - - const lifecycle = - deps.lifecycle !== undefined ? buildLifecycle(deps.lifecycle) : undefined; - // 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 @@ -550,8 +502,6 @@ export function createHubChatPlatform( prepared.runId, prepared.sourcesDigest, ); - lifecycle?.untrack(binding.liveAddress); - lifecycle?.track(prepared.address); deps.relaunchNotice?.current?.({ tenantId: binding.tenantId, @@ -825,43 +775,66 @@ export function createHubChatPlatform( } } + // CL-7214: two concurrent wakes for the same address (a reclaim retry + // racing an independent `ensureAwake` call) must not both call + // `wakeByAddress` — a terminal run's redeploy would collide on the + // same `session_asset` primary key and git ref. `pendingWakes` tracks + // the raw, untimed `wakeByAddress(address)` call so every caller + // arriving while one is in flight coalesces onto it instead of + // dispatching a second one; the entry is cleared only once the real + // wake settles, never when a caller's own timeout fires below, so a + // later caller cannot slip through the window between a timeout and + // the real wake's actual completion (CL-6643/CL-7217). + const pendingWakes = new Map>(); + /** - * `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), so a deploy the - * sidecar never acked fails loud instead of wedging the caller forever - * (CL-6644). - * - * When `lifecycle` is configured, this routes through - * `lifecycle.ensureAwake` rather than calling `wakeByAddress` itself — - * `sendRunMailWithReclaimRetry`'s reclaim retry used to call - * `wakeByAddress` directly, bypassing `lifecycle.ensureAwake`'s - * per-address coalescing entirely. Two wakes for the same instance - * racing in through this bypass could both pass `provisionOnAsset`'s - * populate and both prepare, colliding on the same - * primary key and git ref (CL-7214). Every wake path now funnels - * through the one coalescing map `@corbits/agent-lifecycle` owns, - * rather than this package growing a second one beside it. - * `reconcileDriftedRun` mirrors the pattern `sendMail` and the - * exported `ensureAwake` hook already use: `lifecycle.ensureAwake` - * no-ops on an address that is already routable, so a staleness check - * needs to run unconditionally alongside it (CL-6588). Only the - * no-`lifecycle` fallback still calls `wakeByAddress` directly. + * Races `pending` against `DEFAULT_WAKE_TIMEOUT_MS`, rejecting with a + * distinct error so a caller always gets a settled outcome — never a + * hang — even though `pending` itself keeps running unobserved past + * the deadline. Called once per `wakeByAddressBounded` caller, so one + * caller's timeout never shortens another's wait on the same + * coalesced wake. */ - async function wakeByAddressBounded(address: string): Promise { - if (lifecycle === undefined) { - // CL-7193: `wakeByAddress` has no cancellable primitive to hook a - // signal into, so a timeout here still abandons the underlying wake - // exactly as before — the signal parameter is unused on purpose. - await withTimeout( - () => wakeByAddress(address), - DEFAULT_WAKE_TIMEOUT_MS, - `wake for "${address}" did not settle within ${String(DEFAULT_WAKE_TIMEOUT_MS)}ms`, + function boundToWakeTimeout( + pending: Promise, + 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); + pending.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (cause: unknown) => { + clearTimeout(timer); + reject(cause); + }, ); - return; + }); + } + + /** + * `wakeByAddress`, bounded to `DEFAULT_WAKE_TIMEOUT_MS` (CL-6643) — a + * deploy the sidecar never acked fails loud instead of wedging the + * caller forever (CL-6644) — and coalesced across concurrent callers + * for the same address (CL-7214, see `pendingWakes` above). + */ + async function wakeByAddressBounded(address: string): Promise { + let inFlight = pendingWakes.get(address); + if (inFlight === undefined) { + inFlight = wakeByAddress(address).finally(() => { + pendingWakes.delete(address); + }); + pendingWakes.set(address, inFlight); } - await lifecycle.ensureAwake(address); - await reconcileDriftedRun(address); + return boundToWakeTimeout(inFlight, address); } /** @@ -1078,9 +1051,9 @@ export function createHubChatPlatform( // 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 + // still booting, `wakeByAddressBounded` no-ops 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. @@ -1369,9 +1342,9 @@ export function createHubChatPlatform( const { binding } = await requireLive(input.workbenchId, input.tenantId); const liveAddress = binding.liveAddress; - // Wake before send: a sleeping instance (the lifecycle package's - // own sweep) or one that never came back up after a stack - // restart is not in the sidecar's routable set. Re-deploying it + // Wake before send: a run the sidecar idle-hibernated on its own, + // or one that never came back up after a stack restart, is not in + // the sidecar's routable set. Re-deploying it // here — and letting a wake failure propagate — means the send // fails loud rather than vanishing into an agent nothing is // listening on. This is also how a mention fan-out copy reaches @@ -1404,9 +1377,7 @@ export function createHubChatPlatform( // `chat.agent` stream, and into `useStreamingReply` — real plumbing // across this package and the sidecar, not a client-side fix, so // it is left as a follow-up rather than done here. - if (lifecycle !== undefined) { - await lifecycle.ensureAwake(liveAddress); - } else if (!isRoutable(liveAddress)) { + if (!isRoutable(liveAddress)) { await wakeByAddressBounded(liveAddress); } // CL-7505: serve on a live token. Any `oauth_token` credential the @@ -1426,12 +1397,10 @@ export function createHubChatPlatform( logger.warn`serving-time credential refresh failed (mail proceeding): ${String(cause)}`; } } - // CL-6588: `lifecycle.ensureAwake` returns immediately for an - // address that is already routable — routability is the only - // thing it checks — so an already-live-but-stale run would never - // reach `wakeByAddress`'s drift check above through that branch. - // Run it unconditionally so every send through this choke point - // — not only the ones that needed waking — reconciles staleness. + // CL-6588: an already-routable address never reaches + // `wakeByAddress`'s drift check above through the wake gate. Run + // it unconditionally so every send through this choke point — + // not only the ones that needed waking — reconciles staleness. try { await reconcileDriftedRun(liveAddress); } catch (error) { @@ -1439,11 +1408,6 @@ export function createHubChatPlatform( } const delivery = await requireLive(input.workbenchId, input.tenantId); const deliveryAddress = delivery.binding.liveAddress; - // Tracking here (not only at launch) brings instances that were - // already resident before this hub process started — restored by - // a sidecar reconnect, launched by an earlier run — under the - // idle sweep the moment they see traffic. - lifecycle?.track(deliveryAddress); // CL-7480: a run just woken/reconciled above can be routable // before anything ever recorded its session — waking it is not @@ -1561,8 +1525,6 @@ export function createHubChatPlatform( }, ); - lifecycle?.recordActivity(deliveryAddress); - return sent; }, @@ -1637,21 +1599,13 @@ export function createHubChatPlatform( async ensureAwake(address: string): Promise { // The caller may hold either side of the mapping (the hub's // undelivered-mail handler holds whatever the envelope named), so - // the lifecycle is driven on the LIVE address it resolves to — - // that is the only address the sidecar ever announces. + // the wake is driven on the LIVE address it resolves to — that is + // the only address the sidecar ever announces. const binding = await readBindingByAddressAnyTenant(deps.db, address); if (binding === undefined) { throw new Error(`No workbench_launch binding for address "${address}"`); } try { - if (lifecycle !== undefined) { - await lifecycle.ensureAwake(binding.liveAddress); - // CL-6588: see the matching note in `sendMail` — routability - // alone is what `lifecycle.ensureAwake` checks, so an - // already-routable-but-stale run needs this run unconditionally. - await reconcileDriftedRun(binding.liveAddress); - return; - } if (isRoutable(binding.liveAddress)) { await reconcileDriftedRun(binding.liveAddress); return; @@ -1664,7 +1618,6 @@ export function createHubChatPlatform( }; return Object.assign(platform, { - recordActivity: (address: string) => lifecycle?.recordActivity(address), sweepTerminalRuns, reconcileInferenceSources, reconcilePinnedToolPackages, diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index 2cb234be8e..6c0222d43f 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -21,7 +21,6 @@ // exercised without a real Postgres. import { beforeEach, describe, expect, mock, test } from "bun:test"; -import { IDLE_HIBERNATE_UNDEPLOY_REASON } from "@corbits/agent-lifecycle"; import { DefinitionProjectionMissingError, WORKFLOW_SOURCE_ENTRY, @@ -2106,15 +2105,12 @@ describe("createHubChatPlatform", () => { unsubscribe(); }); - // The idle-sleep sweep's own gates (idle sleeps, active/busy/untracked - // spared, first-sighting grace) and `ensureAwake`'s coalescing are - // `@corbits/agent-lifecycle`'s own contract, proven in - // `packages/agent-lifecycle/test/index.test.ts`, not re-proven here. - // What belongs here is the wiring: that `createHubChatPlatform` only - // builds a lifecycle (and only ever calls `ensureAwake`/`recordActivity`) - // when `deps.lifecycle` is configured, and that `sendMail` actually - // redeploys a non-routable target before sending. - describe("lifecycle wiring", () => { + // CL-8187: idle hibernation is now the sidecar's own decision — it is + // no longer wired through `createHubChatPlatform` at all. What belongs + // here is `sendMail`'s wake-before-send behavior for a target the + // sidecar has already taken down (idle-hibernated, or never came back + // after a restart). + describe("wake before send", () => { // CL-7490: a provisioned anchor stays "deployed" until its first // trigger, so a run mid-boot looks exactly like this — live, not yet // routable. `wakeByAddress` must not relaunch it; the run is waited @@ -2202,7 +2198,6 @@ describe("createHubChatPlatform", () => { runTrigger, sidecarRouter, eventCollectors, - lifecycle: { idleSleepMs: 60_000 }, }); const sent = await platform.sendMail({ @@ -2291,7 +2286,6 @@ describe("createHubChatPlatform", () => { runTrigger, sidecarRouter, eventCollectors, - lifecycle: { idleSleepMs: 60_000 }, }); const sent = await platform.sendMail({ @@ -2306,275 +2300,11 @@ describe("createHubChatPlatform", () => { expect(sidecarRouter.sendAgentUndeployCalls).toHaveLength(0); expect(runTrigger.triggerCalls).toHaveLength(1); }); - - test("the idle sweep never undeploys an address the event collector reports as busy", async () => { - // The sweep's `setInterval` otherwise keeps the process's event - // loop alive past this test; `unref` it exactly as the - // sweep-interval tests below do. - const originalSetInterval = globalThis.setInterval; - globalThis.setInterval = ((...args: Parameters) => { - const timer = originalSetInterval(...args); - timer.unref?.(); - return timer; - }) as typeof setInterval; - - const address = "ins_workbench1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address, - principalId: "prin_run1", - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: [address], - }); - // The registry reports a live turn for this address -- the - // event-activity heuristic ("any event counts as activity") is - // not the only thing standing between a mid-turn agent and the - // idle sweep; `isBusy` must independently spare it too, and stay - // spared even once `recordActivity`'s own clock goes stale. - const eventCollectors = createFakeEventCollectors({ - busyAddresses: new Set([address]), - }); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter, - eventCollectors, - lifecycle: { idleSleepMs: 5, sweepIntervalMs: 5 }, - }); - - // A single send tracks the address and records one activity - // timestamp; nothing else touches it afterwards, so by the time - // the sweep ticks past `idleSleepMs` the event-activity heuristic - // alone would no longer spare it. - await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hello" }, - }); - - await new Promise((resolve) => setTimeout(resolve, 40)); - - expect(sidecarRouter.sendAgentUndeployCalls).toEqual([]); - globalThis.setInterval = originalSetInterval; - }); - - test("the idle sweep reaps a genuinely idle address with the state-preserving reason", async () => { - const originalSetInterval = globalThis.setInterval; - globalThis.setInterval = ((...args: Parameters) => { - const timer = originalSetInterval(...args); - timer.unref?.(); - return timer; - }) as typeof setInterval; - - const address = "ins_workbench1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address, - principalId: "prin_run1", - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: [address], - }); - // No open turn on this address -- unlike the busy-guard test above, - // nothing spares it once its recorded activity goes stale past - // `idleSleepMs`. - const eventCollectors = createFakeEventCollectors({ - busyAddresses: new Set(), - }); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter, - eventCollectors, - lifecycle: { idleSleepMs: 5, sweepIntervalMs: 5 }, - }); - - await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hello" }, - }); - - await new Promise((resolve) => setTimeout(resolve, 40)); - - expect(sidecarRouter.sendAgentUndeployCalls).toEqual([ - { address, reason: IDLE_HIBERNATE_UNDEPLOY_REASON }, - ]); - globalThis.setInterval = originalSetInterval; - }); - - // CL-6164 regression pin: the anchor's `workflow_run` row must stay - // "running" (never end/un-anchor) across an idle-reap-then-relaunch - // cycle. Reap is a sidecar-local `sendAgentUndeploy` call -- it never - // touches `workflow_run` at all -- and `wakeByAddress` only reads the - // run, never updates its `status`/`endedAt`. This test pins that - // invariant against a regression, not against a bug this lane found: - // see the final report for the file/line evidence. - test("idle-reap-then-relaunch never updates workflow_run's status or endedAt", async () => { - const originalSetInterval = globalThis.setInterval; - globalThis.setInterval = ((...args: Parameters) => { - const timer = originalSetInterval(...args); - timer.unref?.(); - return timer; - }) as typeof setInterval; - - const address = "ins_workbench1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address, - principalId: "prin_run1", - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: [address], - }); - const eventCollectors = createFakeEventCollectors({ - busyAddresses: new Set(), - }); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter, - eventCollectors, - lifecycle: { idleSleepMs: 5, sweepIntervalMs: 5 }, - }); - - await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hello" }, - }); - - await new Promise((resolve) => setTimeout(resolve, 40)); - - expect(sidecarRouter.sendAgentUndeployCalls).toEqual([ - { address, reason: IDLE_HIBERNATE_UNDEPLOY_REASON }, - ]); - expect(db.updated.some((call) => call.table === workflowRun)).toBe(false); - globalThis.setInterval = originalSetInterval; - }); - - test("createHubChatPlatform installs no sweep interval when lifecycle is not configured", () => { - const originalSetInterval = globalThis.setInterval; - let setIntervalCalls = 0; - globalThis.setInterval = ((...args: Parameters) => { - setIntervalCalls += 1; - return originalSetInterval(...args); - }) as typeof setInterval; - - try { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - }); - createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - }); - expect(setIntervalCalls).toBe(0); - } finally { - globalThis.setInterval = originalSetInterval; - } - }); - - test("createHubChatPlatform installs a sweep interval when lifecycle is configured", () => { - const originalSetInterval = globalThis.setInterval; - let setIntervalCalls = 0; - globalThis.setInterval = ((...args: Parameters) => { - setIntervalCalls += 1; - const timer = originalSetInterval(...args); - timer.unref?.(); - return timer; - }) as typeof setInterval; - - try { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - }); - createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - lifecycle: { idleSleepMs: 60_000 }, - }); - expect(setIntervalCalls).toBe(1); - } finally { - globalThis.setInterval = originalSetInterval; - } - }); }); // `ensureAwake` is the primitive a caller outside this adapter (the // hub's `mail.outbound.undelivered` handler) uses to wake a chat - // resident before re-attempting delivery itself, over both - // lifecycle configurations `sendMail` itself branches on. + // resident before re-attempting delivery itself. describe("ensureAwake", () => { test("no-ops for an already-routable address", async () => { const address = "ins_workbench1@ten1.workbench.test"; @@ -2623,69 +2353,7 @@ describe("createHubChatPlatform", () => { // here (no unit test needed for that: `wakeByAddress` becomes a // pure no-op, nothing to assert beyond "did not call prepare", which // the failed-run tests below cover by contrast). - test("relaunches a failed, non-routable address exactly once when lifecycle is configured", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - const address = "ins_workbench1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address, - principalId: "prin_run1", - status: "failed", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_workbench1", - foldedBody: { - systemPrompt: "host prompt", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const runTrigger = createFakeRunTrigger(); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - lifecycle: { idleSleepMs: 60_000 }, - }); - - await platform.ensureAwake(address); - - expect(lastAllocationService.prepareCalls).toHaveLength(1); - }); - - test("relaunches a failed, non-routable address exactly once when lifecycle is not configured", async () => { + test("relaunches a failed, non-routable address exactly once", async () => { resolveDefinitionSourcesResult = { ok: true, materials: [], @@ -2777,9 +2445,8 @@ describe("createHubChatPlatform", () => { // redeploy is no longer wakeByAddress's behavior for a live, merely // not-yet-routable run (CL-7490) — the only thing left to redeploy is a // genuinely terminal run, and the coalescing that guards a concurrent - // redeploy of it is `@corbits/agent-lifecycle`'s `pendingWakes` map, - // proven directly in that package's own test suite (see the note above - // `describe("lifecycle wiring", ...)`), not re-proven here. + // redeploy of it is `wakeByAddressBounded`'s own `pendingWakes` map, + // defined next to it in `platform-adapter.ts`. // CL-7486: `sendRunMailWithReclaimRetry` used to retry every attempt // against the address it started with, even after a wake in between @@ -3040,7 +2707,6 @@ describe("createHubChatPlatform", () => { runTrigger, sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), eventCollectors: createFakeEventCollectors(), - lifecycle: { idleSleepMs: 60_000 }, }); await platform.refreshAgentInstanceFromDefinition( @@ -3309,7 +2975,7 @@ describe("createHubChatPlatform stale-definition reconciliation", () => { expect(repointOf(db)).toBeUndefined(); }); - test("sendMail redeploys an already-routable-but-drifted target before delivering — lifecycle.ensureAwake's routability check alone would have missed it", async () => { + test("sendMail redeploys an already-routable-but-drifted target before delivering — a routability check alone would have missed it", async () => { resolveDefinitionSourcesResult = { ok: true, materials: [], @@ -3342,7 +3008,6 @@ describe("createHubChatPlatform stale-definition reconciliation", () => { routableAddresses: ["run_stale@ten1.workbench.test"], }), eventCollectors: createFakeEventCollectors(), - lifecycle: { idleSleepMs: 60_000 }, }); await platform.sendMail({ diff --git a/packages/e2b-sandbox-sidecar/README.md b/packages/e2b-sandbox-sidecar/README.md index 2c9cc5b739..1f33518008 100644 --- a/packages/e2b-sandbox-sidecar/README.md +++ b/packages/e2b-sandbox-sidecar/README.md @@ -12,7 +12,7 @@ allowlist: - `apps/sidecar` — full source, the entrypoint (`src/index.ts`). - The workspace packages `apps/sidecar` transitively depends on — full - source (`packages/agent-lifecycle`, `packages/credential-providers`, and + source (`packages/credential-providers`, and the vendored Interchange packages under `vendor/intx/*` it uses). - Every other workspace member declared under the root `package.json`'s `workspaces` globs (`apps/*`, `packages/*`, `tools/*`, `vendor/intx/*`, diff --git a/packages/e2b-sandbox-sidecar/template/build-context.ts b/packages/e2b-sandbox-sidecar/template/build-context.ts index 9cf4f1ae15..c14bbb3878 100644 --- a/packages/e2b-sandbox-sidecar/template/build-context.ts +++ b/packages/e2b-sandbox-sidecar/template/build-context.ts @@ -31,7 +31,6 @@ export const WORKSPACE_GLOB_ROOTS = [ // package.json-only stub. export const SIDECAR_FULL_SOURCE_DIRS = [ "apps/sidecar", - "packages/agent-lifecycle", "packages/credential-providers", "vendor/intx/db", "vendor/intx/hub-sessions", diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 325b266322..fbbf333248 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -86,7 +86,6 @@ packages/agent-directory-tools/src/tool.ts 1 // The agent was genuinely created packages/agent-directory-tools/src/tool.ts 1 if (err instanceof CreateAgentDefinitionError) { packages/agent-directory-tools/src/tool.ts 1 return errorResult(call.id, err); packages/agent-directory/src/definition-history.ts 1 return null; -packages/agent-lifecycle/src/index.ts 1 log.error`lifecycle sweep failed to undeploy ${address}: ${ packages/approvals/src/allowance.ts 1 deps.log( packages/approvals/src/allowance.ts 1 return { outcome: "park", reason: "classification_failed" }; packages/artifacts-hub/src/routes.ts 1 return c.json( diff --git a/tsconfig.build.json b/tsconfig.build.json index d1bfe5f52e..4ec1aabad2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,9 +13,6 @@ { "path": "./packages/agent-events/tsconfig.src.json" }, - { - "path": "./packages/agent-lifecycle/tsconfig.src.json" - }, { "path": "./packages/api-query/tsconfig.src.json" },