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)),
)