From 60fad67246c9c0eef4395a90c45fe27066ebee7b Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:51:46 +0000 Subject: [PATCH] Count authorized requests as activity for the daemon's idle exit Attachments alone decided when the shared daemon leaves, so the Rust apps (Shape, Canvas, Query, Recall), which call the API through redrob-engine-client without attaching, saw the daemon they started exit ten minutes later -- possibly under a request still being answered. Both authorization middlewares now mark an authorized request as activity: not idle while one is in flight, and the idle clock restarts when it ends. Status polls (/api/health, /api/clients) do not count, since the launcher's engine card asks every few seconds. Live-checked with the dev entrypoint at --idle-exit 1: a /provider call at 45 s kept the daemon up at 75 s; gone at 120 s. Daemon tests cover the accounting and the status-path rule. --- docs/LOCAL-ENGINE-API.md | 8 ++- .../httpapi/middleware/authorization.ts | 15 ++++-- packages/redrob/test/daemon.test.ts | 38 +++++++++++++- packages/server/src/clients.ts | 52 ++++++++++++++++++- .../server/src/middleware/authorization.ts | 4 +- 5 files changed, 105 insertions(+), 12 deletions(-) diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index d4908a59c9..ffe95fe67f 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -172,8 +172,12 @@ replaced (an upgrade), and the new record is awaited. Attached apps are counted. `POST /api/clients {app, pid}` attaches, `POST /api/clients/:id/heartbeat` keeps it (expected within `ttlMs`, 90 s; the SDK's `attach()` does this for you), `DELETE /api/clients/:id` detaches, `GET /api/clients` lists them with `idleForMs` and -the daemon's `idleExitAfterMs`. A registered daemon exits once nobody has been attached for -`--idle-exit` minutes (default 10; 0 keeps it up), and also when another daemon has taken over +the daemon's `idleExitAfterMs`. A registered daemon exits once nobody has needed it for +`--idle-exit` minutes (default 10; 0 keeps it up): no app attached, no request in flight, and no +authorized request since -- so an app that only calls the API (the Rust apps: Shape, Canvas, Query, +Recall, through `redrob-engine-client`) keeps it alive by using it, and it never leaves under a +request still being answered. Status polls (`/api/health`, `/api/clients`) do not count: the +launcher's engine card asks every few seconds. It also exits when another daemon has taken over the record. `redrob acp --attach` makes the ACP face a thin client of the daemon instead of an engine of its own, so ACP hosts (Design, Cut, CAD, Browser) share the one process too. diff --git a/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts index 66b44f762b..b663577e9d 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,4 +1,5 @@ import { ServerAuth } from "@/server/auth" +import * as Clients from "@redrob-code/server/clients" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" @@ -40,6 +41,7 @@ function validateCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, config: ServerAuth.Info, + path: string, ) { return Effect.gen(function* () { if (!ServerAuth.required(config)) return yield* effect @@ -49,7 +51,8 @@ function validateCredential( ) return yield* new HttpApiError.Unauthorized({}) } - return yield* effect + // The shared daemon's idle clock: an authorized request is someone needing the engine. + return yield* Clients.withActivity(path, effect) }) } @@ -85,6 +88,7 @@ function validateRawCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, config: ServerAuth.Info, + path: string, ) { if (!ServerAuth.required(config)) return effect if (!ServerAuth.authorized(credential, config)) @@ -94,7 +98,7 @@ function validateRawCredential( headers: { "www-authenticate": WWW_AUTHENTICATE }, }), ) - return effect + return Clients.withActivity(path, effect) } export const authorizationRouterMiddleware = HttpRouter.middleware()( @@ -107,7 +111,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( const request = yield* HttpServerRequest.HttpServerRequest const url = new URL(request.url, "http://localhost") return yield* credentialFromURL(url, request).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + Effect.flatMap((credential) => validateRawCredential(effect, credential, config, url.pathname)), ) }) }), @@ -121,8 +125,9 @@ export const authorizationLayer = Layer.effect( return Authorization.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest + const path = new URL(request.url, "http://localhost").pathname return yield* credentialFromRequest(request).pipe( - Effect.flatMap((credential) => validateCredential(effect, credential, config)), + Effect.flatMap((credential) => validateCredential(effect, credential, config, path)), ) }), ) @@ -140,7 +145,7 @@ export const ptyConnectAuthorizationLayer = Layer.effect( const url = new URL(request.url, "http://localhost") if (hasPtyConnectTicketURL(url)) return yield* effect return yield* credentialFromURL(url, request).pipe( - Effect.flatMap((credential) => validateCredential(effect, credential, config)), + Effect.flatMap((credential) => validateCredential(effect, credential, config, url.pathname)), ) }), ) diff --git a/packages/redrob/test/daemon.test.ts b/packages/redrob/test/daemon.test.ts index 782901e0a0..7325acea38 100644 --- a/packages/redrob/test/daemon.test.ts +++ b/packages/redrob/test/daemon.test.ts @@ -75,9 +75,13 @@ describe("daemon registry", () => { test("healthy is false for a daemon that does not answer, and true for one that does", async () => { const no = await Registry.healthy(record({ url: "http://127.0.0.1:9" }), undefined, 300) expect(no).toBe(false) - const yes = await Registry.healthy(record(), (async () => Response.json({ healthy: true })) as unknown as typeof fetch) + const yes = await Registry.healthy(record(), (async () => + Response.json({ healthy: true })) as unknown as typeof fetch) expect(yes).toBe(true) - const wrong = await Registry.healthy(record(), (async () => new Response("", { status: 401 })) as unknown as typeof fetch) + const wrong = await Registry.healthy( + record(), + (async () => new Response("", { status: 401 })) as unknown as typeof fetch, + ) expect(wrong).toBe(false) }) }) @@ -97,6 +101,36 @@ describe("attached apps", () => { expect(Clients.detach(b.id)).toBe(false) }) + test("requests count as activity: not idle while one is in flight, idle timer restarts after it", () => { + Clients.reset(1_000) + expect(Clients.idleFor(4_000)).toBe(3_000) + Clients.requestStarted(5_000) + expect(Clients.idleFor(600_000)).toBeUndefined() + Clients.requestEnded(20_000) + expect(Clients.idleFor(25_000)).toBe(5_000) + // Two overlapping requests: idle only once both are answered. + Clients.requestStarted(30_000) + Clients.requestStarted(31_000) + Clients.requestEnded(32_000) + expect(Clients.idleFor(40_000)).toBeUndefined() + Clients.requestEnded(33_000) + expect(Clients.idleFor(40_000)).toBe(7_000) + // An attachment still wins over the request clock. + const a = Clients.attach({ app: "office" }, 41_000) + expect(Clients.idleFor(90_000)).toBeUndefined() + Clients.detach(a.id, 50_000) + expect(Clients.idleFor(60_000)).toBe(10_000) + }) + + test("status polls are not activity; everything else is", () => { + expect(Clients.countsAsActivity("/api/health")).toBe(false) + expect(Clients.countsAsActivity("/api/clients")).toBe(false) + expect(Clients.countsAsActivity("/api/clients/abc")).toBe(false) + expect(Clients.countsAsActivity("/v1/chat/completions")).toBe(true) + expect(Clients.countsAsActivity("/provider")).toBe(true) + expect(Clients.countsAsActivity("/api/healthz")).toBe(true) + }) + test("an app whose heartbeats stop is swept; a heartbeat keeps one alive", () => { Clients.reset(0) const live = Clients.attach({ app: "office" }, 0) diff --git a/packages/server/src/clients.ts b/packages/server/src/clients.ts index 6c9695f457..6cecc7be35 100644 --- a/packages/server/src/clients.ts +++ b/packages/server/src/clients.ts @@ -7,6 +7,7 @@ * service: the serve command polls it from outside the request context. */ import { randomUUID } from "node:crypto" +import { Effect } from "effect" export type Client = { readonly id: string @@ -67,13 +68,60 @@ export function list(): Client[] { return [...clients.values()].sort((a, b) => a.attachedAt - b.attachedAt) } -/** How long nobody has been attached, in ms; undefined while someone is. */ +/** + * Requests count as activity too. The Rust apps (Shape, Canvas, Query, Recall) call the API + * without attaching, so with attachments alone the daemon would leave ten minutes after it was + * started -- possibly under a request still being answered. Status polls (health, the client + * list) are not activity: the launcher's engine card asks every few seconds. + */ +let lastRequestAt: number | undefined +let inFlight = 0 + +const STATUS_PATHS = ["/api/health", "/api/clients"] + +/** Whether a request to this path keeps the daemon alive. */ +export function countsAsActivity(path: string): boolean { + return !STATUS_PATHS.some( + (prefix) => path === prefix || path.startsWith(prefix + "/") || path.startsWith(prefix + "?"), + ) +} + +export function requestStarted(now = Date.now()): void { + inFlight += 1 + lastRequestAt = now +} + +export function requestEnded(now = Date.now()): void { + inFlight = Math.max(0, inFlight - 1) + lastRequestAt = now +} + +/** Run an authorized request's effect, counting it as activity unless it is a status poll. */ +export function withActivity(path: string, effect: Effect.Effect): Effect.Effect { + if (!countsAsActivity(path)) return effect + return Effect.suspend(() => { + requestStarted() + return Effect.ensuring( + effect, + Effect.sync(() => requestEnded()), + ) + }) +} + +/** + * How long nobody has needed this engine, in ms: no attachment, no request in flight, and no + * request since. Undefined while someone is attached or a request is being answered. + */ export function idleFor(now = Date.now()): number | undefined { - return idleSince === undefined ? undefined : now - idleSince + if (idleSince === undefined || inFlight > 0) return undefined + const since = lastRequestAt === undefined ? idleSince : Math.max(idleSince, lastRequestAt) + return now - since } /** Test seam. */ export function reset(now = Date.now()): void { clients.clear() idleSince = now + lastRequestAt = undefined + inFlight = 0 } diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index 484131f56d..5662cf41e1 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -1,4 +1,5 @@ import { ServerAuth } from "../auth" +import * as Clients from "../clients" import { UnauthorizedError } from "@redrob-code/protocol/errors" import { Authorization } from "@redrob-code/protocol/middleware/authorization" export { Authorization } from "@redrob-code/protocol/middleware/authorization" @@ -47,7 +48,8 @@ export const authorizationLayer = Layer.effect( // credential checks here; the connect handler consumes and validates the ticket. if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect const credential = yield* credentialFromRequest(request) - if (ServerAuth.authorized(credential, config)) return yield* effect + if (ServerAuth.authorized(credential, config)) + return yield* Clients.withActivity(new URL(request.url, "http://localhost").pathname, effect) yield* HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), )