diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index 1e116a91fea73dffbbf8e0f9ce63cb24818f572e..5e3b4c6e4afe32903a74b077c17a0ebb0f90e27f 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -420,6 +420,7 @@ export const makeOrchestrationIntegrationHarness = (
Layer.provideMerge(
Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
publishThread: () => Effect.void,
+ requestCatchUp: () => Effect.void,
start: () => Effect.void,
}),
),
diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts
index 64a512a33abaec933fd48177c7af2dd3249e38b8..1d6b78e56cefc44037ad1564be85efa4df66c253 100644
--- a/apps/server/src/cloud/http.test.ts
+++ b/apps/server/src/cloud/http.test.ts
@@ -33,6 +33,7 @@ import {
type ServiceUpdateRecord,
} from "./serviceProtocol.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
import {
@@ -81,6 +82,12 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
});
const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
+// Linking wakes the awareness relay; these tests do not run it.
+const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({
+ publishThread: () => Effect.void,
+ requestCatchUp: () => Effect.void,
+ start: () => Effect.void,
+});
const decodeManagedTunnelRecoveryRegistration = Schema.decodeUnknownEffect(
Schema.fromJsonString(RelayManagedEndpointRecoveryRegistrationRequest),
);
@@ -262,6 +269,7 @@ describe("reconcileDesiredCloudLink", () => {
HttpClient.HttpClient,
HttpClient.make(() => unusedSecretStoreOperation()),
),
+ Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay),
Effect.provide(NodeServices.layer),
),
);
@@ -375,6 +383,7 @@ describe("releaseManagedTunnelOnShutdown", () => {
(effect: Effect.Effect) =>
effect.pipe(
Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store),
+ Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay),
Effect.provideService(
ServerEnvironment.ServerEnvironment,
ServerEnvironment.ServerEnvironment.of({
diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts
index 8e2d0e3614582e77da9d74e6e957aef05941afcb..a7e3de8c72831b2288f5a31cec5394bfd0fc38ea 100644
--- a/apps/server/src/cloud/http.ts
+++ b/apps/server/src/cloud/http.ts
@@ -69,6 +69,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { requireEnvironmentScope } from "../auth/http.ts";
import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
import {
SERVICE_STATE_FILE,
@@ -411,6 +412,7 @@ interface CloudHttpDependencies {
readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"];
readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"];
readonly httpClient: HttpClient.HttpClient;
+ readonly awarenessRelay: AgentAwarenessRelay.AgentAwarenessRelay["Service"];
}
const cloudHttpDependencies = Effect.gen(function* () {
@@ -421,6 +423,7 @@ const cloudHttpDependencies = Effect.gen(function* () {
environmentAuth: yield* EnvironmentAuth.EnvironmentAuth,
cliTokenManager: yield* CliTokenManager.CloudCliTokenManager,
httpClient: yield* HttpClient.HttpClient,
+ awarenessRelay: yield* AgentAwarenessRelay.AgentAwarenessRelay,
} satisfies CloudHttpDependencies;
});
@@ -668,6 +671,7 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu
CLOUD_MINT_PUBLIC_KEY,
stringToBytes(payload.cloudMintPublicKey),
);
+ yield* dependencies.awarenessRelay.requestCatchUp();
if (payload.endpointRuntime) {
const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime);
yield* dependencies.secrets.set(
@@ -1349,6 +1353,7 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")(
PUBLISH_AGENT_ACTIVITY_SECRET,
stringToBytes(String(payload.publishAgentActivity)),
);
+ yield* dependencies.awarenessRelay.requestCatchUp();
return yield* readCloudLinkState(dependencies);
},
Effect.catchIf(
diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
index d25c442ac8b23bac7c37a2f838693f7e6b0803b2..207ad9a20d3478dd6ae17746330fe62d7c552844 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
@@ -108,6 +108,7 @@ describe("OrchestrationReactor", () => {
Layer.provideMerge(
Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
publishThread: () => Effect.void,
+ requestCatchUp: () => Effect.void,
start: () => {
started.push("agent-awareness-relay");
return Effect.void;
diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts
index 80588f58858fcdf54e7d09cbe1f36b8dd226b9ff..1ec7adcd623ba8999120bce640aa213df73497b8 100644
--- a/apps/server/src/persistence/ProviderSessionRuntime.ts
+++ b/apps/server/src/persistence/ProviderSessionRuntime.ts
@@ -102,11 +102,14 @@ export class ProviderSessionRuntimeRepository extends Context.Service<
>;
/**
- * List all provider runtime rows.
+ * List provider runtime rows.
*
- * Returned in ascending last-seen order.
+ * Returned in ascending last-seen order. `excludeStopped` filters stopped
+ * rows in SQL. Long-lived installs keep thousands for their resume cursors.
*/
- readonly list: () => Effect.Effect<
+ readonly list: (options?: {
+ readonly excludeStopped?: boolean;
+ }) => Effect.Effect<
ReadonlyArray,
ProviderSessionRuntimeRepositoryError
>;
@@ -336,9 +339,9 @@ export const make = Effect.gen(function* () {
});
const listRuntimeRows = SqlSchema.findAll({
- Request: Schema.Void,
+ Request: Schema.Struct({ excludeStopped: Schema.Boolean }),
Result: ProviderSessionRuntimeRawDbRowSchema,
- execute: () =>
+ execute: ({ excludeStopped }) =>
sql`
SELECT
thread_id AS "threadId",
@@ -351,6 +354,7 @@ export const make = Effect.gen(function* () {
resume_cursor_json AS "resumeCursor",
runtime_payload_json AS "runtimePayload"
FROM provider_session_runtime
+ ${excludeStopped ? sql`WHERE status != 'stopped'` : sql``}
ORDER BY last_seen_at ASC, thread_id ASC
`,
});
@@ -414,8 +418,8 @@ export const make = Effect.gen(function* () {
),
);
- const list: ProviderSessionRuntimeRepository["Service"]["list"] = () =>
- listRuntimeRows(undefined).pipe(
+ const list: ProviderSessionRuntimeRepository["Service"]["list"] = (options) =>
+ listRuntimeRows({ excludeStopped: options?.excludeStopped === true }).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProviderSessionRuntimeRepository.list:query",
diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts
index 8b41bd3e518c0a9f3f5546ca24e3b32c38ca8d6b..45615fe7b440a04b6c8bc49c6df267fc36410a07 100644
--- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts
+++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts
@@ -372,6 +372,41 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
}),
);
+ it.effect("lists only bindings that are not stopped when asked", () =>
+ Effect.gen(function* () {
+ const directory = yield* ProviderSessionDirectory;
+ const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
+ const statuses = ["running", "starting", "error", "stopped"] as const;
+ const threadIds = new Set();
+
+ for (const status of statuses) {
+ const threadId = ThreadId.make(`thread-exclude-stopped-${status}`);
+ threadIds.add(threadId);
+ yield* runtimeRepository.upsert({
+ threadId,
+ providerName: "codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ adapterKey: "codex",
+ runtimeMode: "full-access",
+ status,
+ lastSeenAt: "2026-04-14T12:00:00.000Z",
+ resumeCursor: null,
+ runtimePayload: null,
+ });
+ }
+
+ const liveStatuses = (yield* directory.listBindings({ excludeStopped: true }))
+ .filter((binding) => threadIds.has(binding.threadId))
+ .map((binding) => binding.status);
+ const allStatuses = (yield* directory.listBindings())
+ .filter((binding) => threadIds.has(binding.threadId))
+ .map((binding) => binding.status);
+
+ assert.deepEqual(liveStatuses.toSorted(), ["error", "running", "starting"]);
+ assert.deepEqual(allStatuses.toSorted(), ["error", "running", "starting", "stopped"]);
+ }),
+ );
+
it.effect(
"resets adapterKey to the new provider when provider changes without an explicit adapter key",
() =>
diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
index 03f1ece0b5e080ccf5b7f49271e8dfef2f42f091..0e9de8fb868f0b3d66baaa8e8eebc768337d2c70 100644
--- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
+++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
@@ -182,8 +182,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
Effect.map((rows) => rows.map((row) => row.threadId)),
);
- const listBindings: ProviderSessionDirectoryShape["listBindings"] = () =>
- repository.list().pipe(
+ const listBindings: ProviderSessionDirectoryShape["listBindings"] = (options) =>
+ repository.list(options).pipe(
Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")),
Effect.flatMap((rows) =>
Effect.forEach(
diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts
index 8f04bd1821fd9aca05256c627384300cdd5097b6..bf8199f80eaccde832a9e41b4b38b9abbc70149c 100644
--- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts
+++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts
@@ -35,15 +35,13 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS);
const sweep = Effect.gen(function* () {
- const bindings = yield* directory.listBindings();
+ // Stopped rows stay for their resume cursors and far outnumber live
+ // ones, so the query skips them.
+ const bindings = yield* directory.listBindings({ excludeStopped: true });
const now = yield* Clock.currentTimeMillis;
let reapedCount = 0;
for (const binding of bindings) {
- if (binding.status === "stopped") {
- continue;
- }
-
const lastSeenMs = Date.parse(binding.lastSeenAt);
if (Number.isNaN(lastSeenMs)) {
yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", {
@@ -122,7 +120,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
if (reapedCount > 0) {
yield* Effect.logInfo("provider.session.reaper.sweep-complete", {
reapedCount,
- totalBindings: bindings.length,
+ liveBindings: bindings.length,
});
}
});
diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts
index 9dbafd3e804ee2a89d1f921f298effb278ea307c..1b5c47ec1d621e888903b74f60d8654306b85701 100644
--- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts
+++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts
@@ -70,7 +70,10 @@ export interface ProviderSessionDirectoryShape {
ProviderSessionDirectoryPersistenceError
>;
- readonly listBindings: () => Effect.Effect<
+ /** `excludeStopped` skips stopped rows in the query, not after decoding. */
+ readonly listBindings: (options?: {
+ readonly excludeStopped?: boolean;
+ }) => Effect.Effect<
ReadonlyArray,
ProviderSessionDirectoryPersistenceError
>;
diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts
index db4f1f53b7106c4246eb74a1b2d68e1c48ef6314..71ec0719325dcccdee90f9e16168add4759d2275 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.test.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts
@@ -28,6 +28,7 @@ import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import * as Tracer from "effect/Tracer";
+import * as TestClock from "effect/testing/TestClock";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
@@ -975,3 +976,114 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
),
);
});
+
+describe.sequential("startup catch-up", () => {
+ // An unlinked relay with publishing off. `link` writes the link secrets and
+ // `enablePublishing` the opt-in. Counts link checks (relay URL reads) and
+ // catch-up publishes (shell snapshot reads).
+ function makeUnlinkedRelay() {
+ const secrets = makeMemorySecretStore();
+ const counts = { linkChecks: 0, catchUpPublishes: 0 };
+ const countingStore = {
+ ...secrets.store,
+ get: (name: string) =>
+ Effect.suspend(() => {
+ if (name === RELAY_URL_SECRET) counts.linkChecks += 1;
+ return secrets.store.get(name);
+ }),
+ } satisfies ServerSecretStore.ServerSecretStore["Service"];
+
+ const layer = AgentAwarenessRelay.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.succeed(ServerSecretStore.ServerSecretStore, countingStore),
+ Layer.succeed(ServerEnvironment.ServerEnvironment, {
+ getEnvironmentId: Effect.succeed("env-1" as EnvironmentId),
+ getDescriptor: Effect.die("unused descriptor"),
+ }),
+ Layer.succeed(OrchestrationEngineService, {
+ streamDomainEvents: Stream.never,
+ } as unknown as OrchestrationEngineShape),
+ Layer.succeed(ProjectionSnapshotQuery, {
+ getShellSnapshot: () =>
+ Effect.sync(() => {
+ counts.catchUpPublishes += 1;
+ return {
+ snapshotSequence: 1,
+ projects: [],
+ threads: [],
+ updatedAt: "2026-05-25T00:00:00.000Z",
+ } satisfies OrchestrationShellSnapshot;
+ }),
+ } as unknown as ProjectionSnapshotQueryShape),
+ ),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+ );
+ const link = Effect.all(
+ [
+ secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"),
+ secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"),
+ ],
+ { discard: true },
+ );
+ const enablePublishing = secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true");
+ return { counts, layer, link, enablePublishing };
+ }
+
+ it.effect("checks an unlinked environment once a minute and still catches up once linked", () => {
+ const { counts, layer, link, enablePublishing } = makeUnlinkedRelay();
+ return Effect.gen(function* () {
+ const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
+ yield* enablePublishing;
+ yield* relay.start();
+
+ // Get past the backoff ramp, then count checks in a steady window.
+ yield* TestClock.adjust("10 minutes");
+ const checksBeforeWindow = counts.linkChecks;
+ yield* TestClock.adjust("10 minutes");
+ expect(counts.linkChecks - checksBeforeWindow).toBe(10);
+ expect(counts.catchUpPublishes).toBe(0);
+
+ yield* link;
+ yield* TestClock.adjust("1 minute");
+ expect(counts.catchUpPublishes).toBe(1);
+ }).pipe(Effect.provide(layer), Effect.scoped);
+ });
+
+ it.effect("publishes at once when this process links while the check is backed off", () => {
+ const { counts, layer, link, enablePublishing } = makeUnlinkedRelay();
+ return Effect.gen(function* () {
+ const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
+ yield* enablePublishing;
+ yield* relay.start();
+
+ // Backed off to 60 s: the next check is still seconds away.
+ yield* TestClock.adjust("10 minutes");
+ yield* link;
+ yield* TestClock.adjust("1 second");
+ expect(counts.catchUpPublishes).toBe(0);
+
+ yield* relay.requestCatchUp();
+ yield* TestClock.adjust("1 second");
+ expect(counts.catchUpPublishes).toBe(1);
+ }).pipe(Effect.provide(layer), Effect.scoped);
+ });
+
+ it.effect("catches up within 5 s when another process enables publishing on a link", () => {
+ const { counts, layer, link, enablePublishing } = makeUnlinkedRelay();
+ return Effect.gen(function* () {
+ const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
+ yield* link;
+ yield* relay.start();
+
+ yield* TestClock.adjust("10 minutes");
+ expect(counts.catchUpPublishes).toBe(0);
+
+ // `t3 connect publish` writes the opt-in without waking this process.
+ yield* enablePublishing;
+ yield* TestClock.adjust("5 seconds");
+ expect(counts.catchUpPublishes).toBe(1);
+ }).pipe(Effect.provide(layer), Effect.scoped);
+ });
+});
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
index 29d2abecebb7787344b1adc79284ec6e22f13c55..c9acf6b510fceab9a00260de26e6adca7e19582a 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -25,6 +25,7 @@ import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import type * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
@@ -51,6 +52,8 @@ export class AgentAwarenessRelay extends Context.Service<
AgentAwarenessRelay,
{
readonly publishThread: (threadId: ThreadId) => Effect.Effect;
+ /** Retries a pending catch-up publish now. Call after this process links or enables publishing. */
+ readonly requestCatchUp: () => Effect.Effect;
readonly start: () => Effect.Effect;
}
>()("t3/relay/AgentAwarenessRelay") {}
@@ -309,6 +312,8 @@ export const make = Effect.gen(function* () {
const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
const startedAt = (yield* DateTime.now).epochMilliseconds;
const activeSnapshotPublishedRef = yield* Ref.make(false);
+ // Holds at most one pending wake, so a burst of requests costs one retry.
+ const catchUpRequests = yield* Queue.dropping(1);
const publishedStateByThreadRef = yield* Ref.make(new Map());
const readSecretString = (name: string) =>
@@ -534,18 +539,29 @@ export const make = Effect.gen(function* () {
withRelayClientTracing,
);
+ // Publishes the active threads once. Returns why it did not, so the retry
+ // knows whether it is waiting on a link or on the publish setting.
const publishActiveThreadsUnsafe = Effect.gen(function* () {
+ // One secret read settles the common never-linked case; the full link
+ // config is read only once publishing is on.
+ const relayUrl = yield* readSecretString(RELAY_URL_SECRET).pipe(
+ Effect.orElseSucceed(() => null),
+ );
+ if (!relayUrl) {
+ yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable");
+ return "unlinked" as const;
+ }
const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(
Effect.orElseSucceed(() => false),
);
if (!publishAgentActivity) {
yield* Effect.logDebug("agent activity snapshot skipped; publication disabled");
- return false;
+ return "disabled" as const;
}
const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
if (!relayConfig) {
yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable");
- return false;
+ return "unlinked" as const;
}
const environmentId = yield* serverEnvironment.getEnvironmentId;
const snapshot = yield* snapshotQuery.getShellSnapshot();
@@ -557,20 +573,29 @@ export const make = Effect.gen(function* () {
});
if (activeThreadIds.length === 0) {
yield* Effect.logDebug("agent activity snapshot has no publishable threads");
- return true;
+ return "published" as const;
}
yield* Effect.logInfo("publishing active agent activity snapshot", {
count: activeThreadIds.length,
});
yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true });
- return true;
+ return "published" as const;
});
+ // Publishes the catch-up snapshot of active threads once the environment is
+ // linked and publishing is enabled. Many environments never link, so while
+ // unlinked the retry backs off from 5 s to 60 s. Only this process writes
+ // the link, and it calls `requestCatchUp`, which ends the wait early. A
+ // linked environment keeps the 5 s retry, because `t3 connect publish` can
+ // turn publishing on from another process.
const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) =>
Effect.gen(function* () {
+ let unlinkedRetryDelayMs = 5_000;
while (!(yield* Ref.get(activeSnapshotPublishedRef))) {
- const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false));
- if (published) {
+ const result = yield* publishActiveThreadsUnsafe.pipe(
+ Effect.orElseSucceed(() => "failed" as const),
+ );
+ if (result === "published") {
yield* Ref.set(activeSnapshotPublishedRef, true);
if (logEnabledWhenReady) {
const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
@@ -580,7 +605,11 @@ export const make = Effect.gen(function* () {
}
return;
}
- yield* Effect.sleep("5 seconds");
+ const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000;
+ yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests));
+ if (result === "unlinked") {
+ unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000);
+ }
}
});
@@ -657,6 +686,7 @@ export const make = Effect.gen(function* () {
return AgentAwarenessRelay.of({
publishThread,
+ requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid),
start,
});
});
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 192cdd3191f0efc4118017abdf5338bdecfa3719..43595dbafce181d338593518542de06b74530b34 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -181,6 +181,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
+import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
import * as HostResources from "./resourceTelemetry/HostResources.ts";
@@ -562,6 +563,7 @@ const buildAppUnderTest = (options?: {
CloudManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]
>;
relayClient?: Partial;
+ agentAwarenessRelay?: Partial;
cloudCliTokenManager?: Partial;
httpClient?: HttpClient.HttpClient;
nativeTelemetryClient?: Partial;
@@ -1173,14 +1175,20 @@ const buildAppUnderTest = (options?: {
}),
),
Layer.provide(
- Layer.succeed(
- CloudManagedEndpointRuntime.CloudManagedEndpointRuntime,
- CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({
- applyConfig: () => Effect.succeed({ status: "disabled" }),
- recoveryRequests: Stream.empty,
- requestRecovery: () => Effect.void,
- withLinkStateLock: (effect) => effect,
- ...options?.layers?.cloudManagedEndpointRuntime,
+ Layer.mergeAll(
+ Layer.succeed(
+ CloudManagedEndpointRuntime.CloudManagedEndpointRuntime,
+ CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({
+ applyConfig: () => Effect.succeed({ status: "disabled" }),
+ recoveryRequests: Stream.empty,
+ requestRecovery: () => Effect.void,
+ withLinkStateLock: (effect) => effect,
+ ...options?.layers?.cloudManagedEndpointRuntime,
+ }),
+ ),
+ Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({
+ requestCatchUp: () => Effect.void,
+ ...options?.layers?.agentAwarenessRelay,
}),
),
),
@@ -3114,6 +3122,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("wakes the agent awareness relay when this server links or changes publishing", () =>
+ Effect.gen(function* () {
+ let catchUpRequests = 0;
+ yield* buildAppUnderTest({
+ layers: {
+ agentAwarenessRelay: {
+ requestCatchUp: () =>
+ Effect.sync(() => {
+ catchUpRequests += 1;
+ }),
+ },
+ },
+ });
+
+ const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ privateKeyEncoding: { format: "pem", type: "pkcs8" },
+ publicKeyEncoding: { format: "pem", type: "spki" },
+ });
+ const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
+ const relayConfigResponse = yield* fetchEffect(
+ yield* getHttpServerUrl("/api/connect/relay-config"),
+ {
+ method: "POST",
+ headers: { cookie: ownerCookie, "content-type": "application/json" },
+ body: jsonRequestBody({
+ relayUrl: "https://relay.example.test",
+ cloudUserId: "user_123",
+ environmentCredential: "t3env_test_credential",
+ cloudMintPublicKey: cloudKeyPair.publicKey,
+ endpointRuntime: null,
+ }),
+ },
+ );
+ assert.equal(relayConfigResponse.status, 200);
+ assert.equal(catchUpRequests, 1);
+
+ const preferencesResponse = yield* fetchEffect(
+ yield* getHttpServerUrl("/api/connect/preferences"),
+ {
+ method: "POST",
+ headers: { cookie: ownerCookie, "content-type": "application/json" },
+ body: jsonRequestBody({ publishAgentActivity: true }),
+ },
+ );
+ assert.equal(preferencesResponse.status, 200);
+ assert.equal(catchUpRequests, 2);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("rejects relay config with an invalid cloud mint public key", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();