@rynx-ai/server 0.1.11-beta.2 → 0.1.11-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  import { timingSafeEqual } from "node:crypto";
10
10
  import Router from "@koa/router";
11
11
  import { skillInstallRecipeSchema, } from "@rynx-ai/core";
12
- import { DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, DAEMON_BROWSER_ARTIFACT_VERSION_PATH, DAEMON_CHROME_INSPECTION_PATH, DAEMON_CLEANUP_SESSIONS_PATH, DAEMON_SHUTDOWN_IF_IDLE_PATH, parseDaemonBrowserArtifactInstallInput, parseDaemonBrowserArtifactUpdateInput, parseDaemonChromeInspectionConfigureInput, parseDaemonCleanupSessionsInput, } from "@rynx-ai/protocol/control";
12
+ import { DAEMON_BROWSER_ARTIFACT_CLEAN_PATH, DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, DAEMON_BROWSER_ARTIFACT_UPDATE_PATH, DAEMON_BROWSER_ARTIFACT_VERSION_PATH, DAEMON_CHROME_INSPECTION_PATH, DAEMON_CLEANUP_SESSIONS_PATH, DAEMON_MAINTENANCE_LEASE_HEADER, DAEMON_MAINTENANCE_LEASE_RELEASE_PATH, DAEMON_SHUTDOWN_IF_IDLE_PATH, parseDaemonBrowserArtifactInstallInput, parseDaemonBrowserArtifactUpdateInput, parseDaemonChromeInspectionConfigureInput, parseDaemonCleanupSessionsInput, } from "@rynx-ai/protocol/control";
13
13
  import { DIRECT_RUNTIME_CONTROL_PATH, encodePairingCode, } from "@rynx-ai/protocol/direct-runtime";
14
14
  import { PLUGIN_CONSOLE_INSTALL_PREPARATIONS_PATH, PLUGIN_CONSOLE_MARKETPLACES_PATH, PLUGIN_INSTALL_PREPARATIONS_PATH, PLUGIN_MARKETPLACES_PATH, parsePluginInstallCommitInput, parsePluginInstallPrepareInput, parsePluginMarketplaceAddInput, } from "@rynx-ai/protocol/plugin-management";
15
15
  import { parseDaemonStatus } from "@rynx-ai/protocol/remote-runtime";
@@ -1918,18 +1918,42 @@ export function createControlRouter(opts) {
1918
1918
  return;
1919
1919
  ctx.body = encodeDaemonStatus(remoteRuntime.status());
1920
1920
  });
1921
- router.post(DAEMON_SHUTDOWN_IF_IDLE_PATH, (ctx) => {
1921
+ router.post(DAEMON_SHUTDOWN_IF_IDLE_PATH, async (ctx) => {
1922
1922
  if (!isConfiguredDaemonManagement(daemonManagement) ||
1923
1923
  !daemonManagement.shutdownIfIdle) {
1924
1924
  return notFound(ctx, "daemon shutdown management is not configured");
1925
1925
  }
1926
1926
  if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1927
1927
  return;
1928
- const result = daemonManagement.shutdownIfIdle();
1928
+ const maintenanceLeaseToken = ctx.get(DAEMON_MAINTENANCE_LEASE_HEADER).trim() || undefined;
1929
+ let result;
1930
+ try {
1931
+ result = await daemonManagement.shutdownIfIdle(maintenanceLeaseToken);
1932
+ }
1933
+ catch (error) {
1934
+ ctx.set("Cache-Control", "no-store");
1935
+ ctx.status = 409;
1936
+ ctx.body = {
1937
+ error: error instanceof Error ? error.message : String(error),
1938
+ };
1939
+ return;
1940
+ }
1929
1941
  ctx.set("Cache-Control", "no-store");
1930
1942
  ctx.status = result.outcome === "accepted" ? 202 : 409;
1931
1943
  ctx.body = result;
1932
1944
  });
1945
+ router.post(DAEMON_MAINTENANCE_LEASE_RELEASE_PATH, (ctx) => {
1946
+ if (!isConfiguredDaemonManagement(daemonManagement)
1947
+ || !daemonManagement.releaseMaintenanceLease) {
1948
+ return notFound(ctx, "daemon maintenance lease management is not configured");
1949
+ }
1950
+ if (!authorizeLoopbackManagementRequest(ctx, daemonManagement.managementToken))
1951
+ return;
1952
+ const result = daemonManagement.releaseMaintenanceLease(ctx.get(DAEMON_MAINTENANCE_LEASE_HEADER).trim());
1953
+ ctx.set("Cache-Control", "no-store");
1954
+ ctx.status = result.outcome === "denied" ? 403 : 200;
1955
+ ctx.body = result;
1956
+ });
1933
1957
  router.post(DAEMON_BROWSER_ARTIFACT_INSTALL_PATH, async (ctx) => {
1934
1958
  await mutateBrowserArtifact(ctx, "install");
1935
1959
  });
@@ -1,4 +1,4 @@
1
- import { type AgentRuntimeId, type ConversationRuntime, type ResolvedExecutionSnapshot, type SessionWorkspaceSnapshot, type SessionProviderId, type MachineSessionRecord, type ReasoningEffort, type RuntimeUserInput, type SessionBus, type SessionLogStore, type SessionRegistry } from "@rynx-ai/core";
1
+ import { type AdmissionReservation, type AgentRuntimeId, type ConversationRuntime, type ResolvedExecutionSnapshot, type SessionWorkspaceSnapshot, type SessionProviderId, type MachineSessionRecord, type ReasoningEffort, type RuntimeUserInput, type SessionBus, type SessionLogStore, type SessionRegistry } from "@rynx-ai/core";
2
2
  import type { SequencedSessionEvent, SessionEvent, SessionInteractionResolution, SessionItem, UserContentPart } from "@rynx-ai/protocol";
3
3
  import type { RemoteRuntimeSessionResourceDeleteParams, RemoteRuntimeSessionResourceDeleteResult, RemoteRuntimeSessionResourceGetParams, RemoteRuntimeSessionResourceGetResult, RemoteRuntimeSessionResourcePolicyResult, RemoteRuntimeSessionResourceReadParams, RemoteRuntimeSessionResourceReadResult, RemoteRuntimeSessionResourceUploadBeginParams, RemoteRuntimeSessionResourceUploadBeginResult, RemoteRuntimeSessionResourceUploadChunkParams, RemoteRuntimeSessionResourceUploadChunkResult, RemoteRuntimeSessionResourceUploadCommitParams, RemoteRuntimeSessionResourceUploadCommitResult } from "@rynx-ai/protocol/remote-runtime-rpc";
4
4
  import type { ControlAgentSummary, ControlSessionInteractionResolveDisposition, ControlSessionRuntimeSnapshot } from "@rynx-ai/protocol/control";
@@ -6,6 +6,7 @@ import { type RemoteRuntimeSessionInterruptResult, type RemoteRuntimeSessionLaun
6
6
  import type { CodexRuntimeStatus, InjectOutcome } from "@rynx-ai/runtime";
7
7
  export interface MachineSessionRuntimeStatePort {
8
8
  snapshot(sessionId: string): ControlSessionRuntimeSnapshot;
9
+ beginResponseHandoff?(sessionId: string, responseId: string): () => void;
9
10
  remove?(sessionId: string): void;
10
11
  }
11
12
  export interface MachineSessionInterruptPort {
@@ -181,6 +182,11 @@ export interface MachineSessionServiceOptions {
181
182
  directoryScanLimit?: number;
182
183
  /** Delay between setup-readiness retries for durable pending messages. */
183
184
  pendingMessageRetryMs?: number;
185
+ /** Dynamic daemon-wide admission fence used during process replacement. */
186
+ admissionOpen?: () => boolean;
187
+ /** Atomically reserves one work-producing admission until it is rejected or
188
+ * has become visible in the daemon activity projection. */
189
+ admissionReserve?: () => AdmissionReservation | undefined;
184
190
  }
185
191
  export type MachineSessionListInput = RemoteRuntimeSessionListParams;
186
192
  export interface MachineSessionListPage extends RemoteRuntimeSessionListResult {
@@ -277,6 +283,8 @@ export declare class MachineSessionService {
277
283
  private readonly snapshotPageMaxBytes;
278
284
  private readonly directoryScanLimit;
279
285
  private readonly pendingMessageRetryMs;
286
+ private readonly admissionOpen;
287
+ private readonly admissionReserve;
280
288
  private readonly pendingDeliveries;
281
289
  private readonly cancelledPendingDeliveries;
282
290
  private readonly forkTasks;
@@ -300,10 +308,12 @@ export declare class MachineSessionService {
300
308
  launchOptions(): Promise<RemoteRuntimeSessionLaunchOptionsListResult>;
301
309
  /** Create target-owned Session identity from one Agent preset or direct Provider. */
302
310
  create(input: MachineSessionCreateInput): Promise<MachineSessionCreateResult>;
311
+ private createAdmitted;
303
312
  /** Create one independent Session at the source's stable Provider/canonical
304
313
  * boundary. Project and Agent selectors are intentionally absent: the target
305
314
  * receives exact copies of the source's already-frozen snapshots. */
306
315
  fork(input: MachineSessionForkInput): Promise<MachineSessionForkResult>;
316
+ private forkAdmitted;
307
317
  /** Publish a Provider TUI `/clear` or `/fork` after its native binding has
308
318
  * already been persisted. The source snapshots remain the only workspace and
309
319
  * execution authority; Provider events cannot alter them during rotation. */
@@ -312,6 +322,7 @@ export declare class MachineSessionService {
312
322
  private assertForkableSource;
313
323
  /** Inject one turn through the target daemon's native single-writer runner. */
314
324
  sendMessage(sessionIdInput: string, messageInput: string | MachineSessionMessageInput): Promise<MachineSessionMessageResult>;
325
+ private sendMessageAdmitted;
315
326
  resourcePolicy(sessionIdInput: string): RemoteRuntimeSessionResourcePolicyResult;
316
327
  beginResourceUpload(input: RemoteRuntimeSessionResourceUploadBeginParams): Promise<RemoteRuntimeSessionResourceUploadBeginResult>;
317
328
  writeResourceUploadChunk(input: RemoteRuntimeSessionResourceUploadChunkParams): Promise<RemoteRuntimeSessionResourceUploadChunkResult>;
@@ -323,13 +334,17 @@ export declare class MachineSessionService {
323
334
  * delivery worker starts the pane now, waits for a real native thread, then
324
335
  * injects exactly once. `failed` is fenced as outcome_unknown and never retried. */
325
336
  enqueueMessage(sessionIdInput: string, messageInput: string): Promise<MachineSessionMessageEnqueueResult>;
337
+ private enqueueMessageAdmitted;
326
338
  /** Explicitly restore the target daemon's live runner without starting a turn. */
327
339
  startTerminal(sessionIdInput: string): Promise<MachineSessionTerminalStartResult>;
340
+ private startTerminalAdmitted;
328
341
  private ensureLiveSession;
329
342
  private liveSessionRequest;
330
343
  resolveInteraction(sessionIdInput: string, interactionIdInput: string, resolution: SessionInteractionResolution): Promise<MachineSessionInteractionResult>;
331
344
  delete(sessionIdInput: string): Promise<MachineSessionDeleteResult>;
332
345
  private requireAgents;
346
+ private reserveAdmission;
347
+ private withAdmission;
333
348
  private requireResources;
334
349
  private requireResourceSession;
335
350
  private resumePendingDeliveries;
@@ -47,6 +47,8 @@ export class MachineSessionService {
47
47
  snapshotPageMaxBytes;
48
48
  directoryScanLimit;
49
49
  pendingMessageRetryMs;
50
+ admissionOpen;
51
+ admissionReserve;
50
52
  pendingDeliveries = new Map();
51
53
  cancelledPendingDeliveries = new Set();
52
54
  forkTasks = new Map();
@@ -58,6 +60,8 @@ export class MachineSessionService {
58
60
  this.snapshotPageMaxBytes = boundedInteger(options.snapshotPageMaxBytes ?? DEFAULT_SNAPSHOT_PAGE_MAX_BYTES, MIN_PAGE_MAX_BYTES, MAX_PAGE_MAX_BYTES, "snapshotPageMaxBytes");
59
61
  this.directoryScanLimit = boundedInteger(options.directoryScanLimit ?? DEFAULT_DIRECTORY_SCAN_LIMIT, 1, MAX_DIRECTORY_SCAN_LIMIT, "directoryScanLimit");
60
62
  this.pendingMessageRetryMs = boundedInteger(options.pendingMessageRetryMs ?? 1_000, 10, 60_000, "pendingMessageRetryMs");
63
+ this.admissionOpen = options.admissionOpen ?? (() => true);
64
+ this.admissionReserve = options.admissionReserve;
61
65
  void this.resumePendingDeliveries();
62
66
  void this.resumeReservedForks();
63
67
  }
@@ -190,6 +194,9 @@ export class MachineSessionService {
190
194
  }
191
195
  /** Create target-owned Session identity from one Agent preset or direct Provider. */
192
196
  async create(input) {
197
+ return this.withAdmission(() => this.createAdmitted(input));
198
+ }
199
+ async createAdmitted(input) {
193
200
  const hasAgent = input.agent !== undefined;
194
201
  const hasProvider = input.provider !== undefined;
195
202
  if (hasAgent === hasProvider) {
@@ -240,6 +247,9 @@ export class MachineSessionService {
240
247
  * boundary. Project and Agent selectors are intentionally absent: the target
241
248
  * receives exact copies of the source's already-frozen snapshots. */
242
249
  async fork(input) {
250
+ return this.withAdmission(() => this.forkAdmitted(input));
251
+ }
252
+ async forkAdmitted(input) {
243
253
  const sourceSessionId = validOpaqueToken(input.sourceSessionId, "sourceSessionId", MAX_SESSION_ID_CHARS);
244
254
  const operationId = validOpaqueToken(input.operationId, "operationId", MAX_SESSION_ID_CHARS);
245
255
  const title = input.title === undefined
@@ -383,6 +393,9 @@ export class MachineSessionService {
383
393
  }
384
394
  /** Inject one turn through the target daemon's native single-writer runner. */
385
395
  async sendMessage(sessionIdInput, messageInput) {
396
+ return this.withAdmission(() => this.sendMessageAdmitted(sessionIdInput, messageInput));
397
+ }
398
+ async sendMessageAdmitted(sessionIdInput, messageInput) {
386
399
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
387
400
  await this.assertNotForkReserved(sessionId);
388
401
  const request = typeof messageInput === "string"
@@ -471,6 +484,9 @@ export class MachineSessionService {
471
484
  statusKind: "startup",
472
485
  });
473
486
  }
487
+ const abandonResponseHandoff = responseId
488
+ ? this.ports.runtimeState.beginResponseHandoff?.(sessionId, responseId)
489
+ : undefined;
474
490
  const clearStartup = async (status) => {
475
491
  if (!responseId)
476
492
  return;
@@ -486,6 +502,7 @@ export class MachineSessionService {
486
502
  execution = await this.ensureLiveSession(sessionId, meta);
487
503
  }
488
504
  catch (error) {
505
+ abandonResponseHandoff?.();
489
506
  await clearStartup("idle");
490
507
  if (request.clientMessageId) {
491
508
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error));
@@ -500,6 +517,7 @@ export class MachineSessionService {
500
517
  outcome = await execution.runner.injectMessage(sessionId, responseId || needsPreparedOperation ? runtimeInput : request.message);
501
518
  }
502
519
  catch (error) {
520
+ abandonResponseHandoff?.();
503
521
  await clearStartup("idle");
504
522
  if (request.clientMessageId) {
505
523
  await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
@@ -507,6 +525,7 @@ export class MachineSessionService {
507
525
  throw new MachineSessionServiceFailure("outcome_unknown", "live injection outcome is unknown");
508
526
  }
509
527
  if (outcome === "failed") {
528
+ abandonResponseHandoff?.();
510
529
  await clearStartup("idle");
511
530
  if (request.clientMessageId) {
512
531
  await resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, `live injection ${outcome}`);
@@ -514,6 +533,7 @@ export class MachineSessionService {
514
533
  throw new MachineSessionServiceFailure("outcome_unknown", `live injection ${outcome}`);
515
534
  }
516
535
  if (outcome !== "injected") {
536
+ abandonResponseHandoff?.();
517
537
  await clearStartup("idle");
518
538
  if (request.clientMessageId) {
519
539
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, `live injection ${outcome}`);
@@ -564,6 +584,9 @@ export class MachineSessionService {
564
584
  * delivery worker starts the pane now, waits for a real native thread, then
565
585
  * injects exactly once. `failed` is fenced as outcome_unknown and never retried. */
566
586
  async enqueueMessage(sessionIdInput, messageInput) {
587
+ return this.withAdmission(() => this.enqueueMessageAdmitted(sessionIdInput, messageInput));
588
+ }
589
+ async enqueueMessageAdmitted(sessionIdInput, messageInput) {
567
590
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
568
591
  await this.assertNotForkReserved(sessionId);
569
592
  if (typeof messageInput !== "string" || messageInput.length === 0) {
@@ -595,6 +618,9 @@ export class MachineSessionService {
595
618
  }
596
619
  /** Explicitly restore the target daemon's live runner without starting a turn. */
597
620
  async startTerminal(sessionIdInput) {
621
+ return this.withAdmission(() => this.startTerminalAdmitted(sessionIdInput));
622
+ }
623
+ async startTerminalAdmitted(sessionIdInput) {
598
624
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
599
625
  const request = await this.liveSessionRequest(sessionId);
600
626
  const start = request.execution.runner.startLiveSession?.bind(request.execution.runner) ??
@@ -662,6 +688,24 @@ export class MachineSessionService {
662
688
  }
663
689
  return this.ports.agents;
664
690
  }
691
+ reserveAdmission() {
692
+ const reservation = this.admissionReserve?.();
693
+ if (reservation)
694
+ return reservation;
695
+ if (!this.admissionReserve && this.admissionOpen()) {
696
+ return { release() { } };
697
+ }
698
+ throw new MachineSessionServiceFailure("failed_precondition", "daemon maintenance is in progress");
699
+ }
700
+ async withAdmission(operation) {
701
+ const reservation = this.reserveAdmission();
702
+ try {
703
+ return await operation();
704
+ }
705
+ finally {
706
+ reservation.release();
707
+ }
708
+ }
665
709
  requireResources() {
666
710
  if (!this.ports.resources) {
667
711
  throw new MachineSessionServiceFailure("failed_precondition", "Session resources are unavailable");
@@ -713,7 +757,9 @@ export class MachineSessionService {
713
757
  if (!pending || pending.state !== "queued")
714
758
  return;
715
759
  let injectionAttempted = false;
760
+ let admission;
716
761
  try {
762
+ admission = this.reserveAdmission();
717
763
  const execution = await this.ensureLiveSession(sessionId);
718
764
  if (this.cancelledPendingDeliveries.has(sessionId))
719
765
  return;
@@ -746,6 +792,9 @@ export class MachineSessionService {
746
792
  return;
747
793
  }
748
794
  }
795
+ finally {
796
+ admission?.release();
797
+ }
749
798
  await retryDelay(this.pendingMessageRetryMs);
750
799
  }
751
800
  }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Server as HttpServer } from "node:http";
2
2
  import Koa from "koa";
3
- import { ConversationRuntime, type AgentCapabilities, type AgentSessionStore, type AppConfig, type SessionEvent, type SessionBus, type SessionLogStore, type SessionRegistry } from "@rynx-ai/core";
3
+ import { ConversationRuntime, type AdmissionReservation, type AgentCapabilities, type AgentSessionStore, type AppConfig, type SessionEvent, type SessionBus, type SessionLogStore, type SessionRegistry } from "@rynx-ai/core";
4
4
  import type { SessionInteractionResolution } from "@rynx-ai/protocol";
5
5
  import type { ControlRuntimeShareAddress, DaemonBrowserArtifactCleanResult, DaemonBrowserArtifactInstallInput, DaemonBrowserArtifactInstallResult, DaemonBrowserArtifactUpdateInput, DaemonBrowserArtifactVersionResult, DaemonCleanupSessionsInput, DaemonCleanupSessionsResult, DaemonChromeInspectionConfigureInput, DaemonChromeInspectionStatus, DaemonShutdownIfIdleResult } from "@rynx-ai/protocol/control";
6
6
  import type { PluginInstallCommitInput, PluginInstallCommitResult, PluginInstallPreparation, PluginInstallPrepareInput, PluginManagementItem, PluginMarketplaceAddInput, PluginMarketplaceItem } from "@rynx-ai/protocol/plugin-management";
@@ -155,9 +155,14 @@ export interface PluginRuntimeHostServices {
155
155
  }): Promise<PluginResolvedSessionExecution>;
156
156
  };
157
157
  interruptSession(sessionId: string): Promise<boolean>;
158
+ terminateSession?(sessionId: string): Promise<boolean>;
158
159
  resolveSessionInteraction(sessionId: string, interactionId: string, resolution: SessionInteractionResolution): Promise<{
159
160
  disposition: "applied" | "already_resolved" | "not_found" | "invalid";
160
161
  }>;
162
+ /** Dynamic daemon-wide admission state. Plugin runtimes may start while this is
163
+ * closed, but cannot provision Sessions or begin Turns. */
164
+ admissionOpen?: () => boolean;
165
+ admissionReserve?: () => AdmissionReservation | undefined;
161
166
  }
162
167
  export interface PluginRuntimeLifecycle {
163
168
  bindHostServices(services: PluginRuntimeHostServices): void;
@@ -236,7 +241,11 @@ export interface DaemonManagementHost {
236
241
  restartRequired: boolean;
237
242
  };
238
243
  /** Perform one final activity check and schedule shutdown only when idle. */
239
- shutdownIfIdle?(): DaemonShutdownIfIdleResult;
244
+ shutdownIfIdle?(maintenanceLeaseToken?: string): DaemonShutdownIfIdleResult | Promise<DaemonShutdownIfIdleResult>;
245
+ /** Release a cross-process maintenance fence after a verified cutover. */
246
+ releaseMaintenanceLease?(maintenanceLeaseToken: string): {
247
+ outcome: "released" | "not_active" | "denied";
248
+ };
240
249
  /** Stateful plugin registry operations owned by the resident daemon. */
241
250
  pluginManagement?: PluginManagementHost;
242
251
  /** Registered Git/local plugin catalogs. */
@@ -415,6 +424,8 @@ export declare function createSessionRuntimeServices(input: {
415
424
  sessionLog?: SessionLogStore;
416
425
  onMirrorError?: (error: unknown, sessionId: string) => void;
417
426
  sessionContextProvider?: RunnerSessionContextProvider;
427
+ admissionOpen?: () => boolean;
428
+ admissionReserve?: () => AdmissionReservation | undefined;
418
429
  }): SessionRuntimeServices;
419
430
  /**
420
431
  * The HTTP surface is just a liveness probe for container orchestration — all
package/dist/server.js CHANGED
@@ -59,6 +59,8 @@ export function createSessionRuntimeServices(input) {
59
59
  ...(input.sessionContextProvider
60
60
  ? { sessionContextProvider: input.sessionContextProvider }
61
61
  : {}),
62
+ ...(input.admissionOpen ? { admissionOpen: input.admissionOpen } : {}),
63
+ ...(input.admissionReserve ? { admissionReserve: input.admissionReserve } : {}),
62
64
  });
63
65
  const sessionBus = new InMemorySessionBus();
64
66
  const sessionRuntimeIndex = new SessionRuntimeIndex();
@@ -179,6 +181,7 @@ export async function startServer({ config = loadConfig(), control, sessionLog,
179
181
  }),
180
182
  },
181
183
  interruptSession: (sessionId) => runnerManager.interruptLiveSession(sessionId),
184
+ terminateSession: (sessionId) => runnerManager.terminateLiveSession(sessionId),
182
185
  resolveSessionInteraction: (sessionId, interactionId, resolution) => runnerManager.resolveInteraction(sessionId, interactionId, resolution),
183
186
  });
184
187
  }
@@ -232,8 +235,8 @@ export async function startServer({ config = loadConfig(), control, sessionLog,
232
235
  msg: "Harness agent server listening",
233
236
  host: config.HOST,
234
237
  port: config.PORT,
235
- runtime: config.AGENT_RUNTIME,
236
- model: resolveRuntimeModel(config, config.AGENT_RUNTIME),
238
+ runtime: config.DEFAULT_RUNTIME,
239
+ model: resolveRuntimeModel(config, config.DEFAULT_RUNTIME),
237
240
  }));
238
241
  const startup = pluginRuntime?.startPluginRuntimes() ?? Promise.resolve();
239
242
  void startup.catch((error) => {
@@ -1,3 +1,4 @@
1
+ import type { AdmissionReservation } from "@rynx-ai/core";
1
2
  import { type RuntimeBrowserCloseParams, type RuntimeBrowserCloseResult, type RuntimeBrowserExecutionBackend, type RuntimeBrowserOpenParams, type RuntimeBrowserPageCreateParams, type RuntimeBrowserPageMutationParams, type RuntimeBrowserPageNavigateParams, type RuntimeBrowserState, type RuntimeBrowserStateGetParams } from "@rynx-ai/protocol/runtime-browser";
2
3
  import type { RuntimeBrowserSurfaceFormat, RuntimeBrowserSurfaceInputEvent } from "@rynx-ai/protocol/runtime-browser-surface";
3
4
  import { type RuntimeBrowserEndpointDescriptor } from "@rynx-ai/protocol/runtime-browser-bootstrap";
@@ -155,6 +156,11 @@ export interface SessionBrowserServiceOptions {
155
156
  pageIdFactory?: () => string;
156
157
  /** Injectable boot nonce; production randomization fences stale pre-restart mutations. */
157
158
  bootGenerationSeed?: number;
159
+ /**
160
+ * Synchronous daemon admission fence. A successful reservation is held until
161
+ * Browser open has either failed or registered the physical Host handle.
162
+ */
163
+ admissionReserve?: () => AdmissionReservation | undefined;
158
164
  }
159
165
  export type SessionBrowserHostErrorCode = "unavailable" | "page_not_found" | "capacity" | "outcome_unknown";
160
166
  /** Expected failure reported by a Browser Host implementation. */
@@ -181,6 +187,7 @@ export declare class SessionBrowserService {
181
187
  private readonly observers;
182
188
  private readonly pageIdFactory;
183
189
  private readonly bootGenerationSeed;
190
+ private readonly admissionReserve?;
184
191
  private shuttingDown;
185
192
  private closeAllPromise?;
186
193
  constructor(ports: SessionBrowserServicePorts, options?: SessionBrowserServiceOptions);
@@ -225,6 +232,7 @@ export declare class SessionBrowserService {
225
232
  closeAll(): Promise<void>;
226
233
  private closeEveryRecord;
227
234
  private createBrowser;
235
+ private reserveOpenAdmission;
228
236
  private closeUnavailableForRecreate;
229
237
  private pageMutation;
230
238
  private pageMutationParsed;
@@ -42,12 +42,14 @@ export class SessionBrowserService {
42
42
  observers = new Set();
43
43
  pageIdFactory;
44
44
  bootGenerationSeed;
45
+ admissionReserve;
45
46
  shuttingDown = false;
46
47
  closeAllPromise;
47
48
  constructor(ports, options = {}) {
48
49
  this.ports = ports;
49
50
  this.pageIdFactory = options.pageIdFactory ?? (() => `page_${randomUUID()}`);
50
51
  this.bootGenerationSeed = options.bootGenerationSeed ?? randomBootGenerationSeed();
52
+ this.admissionReserve = options.admissionReserve;
51
53
  if (!Number.isSafeInteger(this.bootGenerationSeed) ||
52
54
  this.bootGenerationSeed < 0 ||
53
55
  this.bootGenerationSeed >= Number.MAX_SAFE_INTEGER) {
@@ -224,29 +226,35 @@ export class SessionBrowserService {
224
226
  });
225
227
  }
226
228
  async open(input) {
227
- const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
228
- return this.exclusive(params.sessionId, async () => {
229
- this.ensureRunning();
230
- await this.ensureSessionExists(params.sessionId);
231
- const existing = this.records.get(params.sessionId);
232
- if (existing?.status === "opening") {
233
- return this.project(existing);
234
- }
235
- if (existing?.status === "ready") {
236
- try {
237
- await this.reconcileForRead(existing);
229
+ const admission = this.reserveOpenAdmission();
230
+ try {
231
+ const params = parseInput(() => parseRuntimeBrowserOpenParams(input), "Browser open request is invalid");
232
+ return await this.exclusive(params.sessionId, async () => {
233
+ this.ensureRunning();
234
+ await this.ensureSessionExists(params.sessionId);
235
+ const existing = this.records.get(params.sessionId);
236
+ if (existing?.status === "opening") {
238
237
  return this.project(existing);
239
238
  }
240
- catch {
241
- // An App Host may have gone away immediately before this open. The
242
- // failed reconciliation marks the old generation unavailable, so an
243
- // idempotent open can recreate it on the current preferred Host.
239
+ if (existing?.status === "ready") {
240
+ try {
241
+ await this.reconcileForRead(existing);
242
+ return this.project(existing);
243
+ }
244
+ catch {
245
+ // An App Host may have gone away immediately before this open. The
246
+ // failed reconciliation marks the old generation unavailable, so an
247
+ // idempotent open can recreate it on the current preferred Host.
248
+ }
244
249
  }
245
- }
246
- if (existing)
247
- await this.closeUnavailableForRecreate(existing);
248
- return this.createBrowser(params);
249
- });
250
+ if (existing)
251
+ await this.closeUnavailableForRecreate(existing);
252
+ return this.createBrowser(params);
253
+ });
254
+ }
255
+ finally {
256
+ admission?.release();
257
+ }
250
258
  }
251
259
  async close(input) {
252
260
  const params = parseInput(() => parseRuntimeBrowserCloseParams(input), "Browser close request is invalid");
@@ -438,6 +446,15 @@ export class SessionBrowserService {
438
446
  throw new SessionBrowserServiceError("host_failure", "Browser Host state could not be initialized", { cause: error });
439
447
  }
440
448
  }
449
+ reserveOpenAdmission() {
450
+ if (!this.admissionReserve)
451
+ return undefined;
452
+ const admission = this.admissionReserve();
453
+ if (!admission) {
454
+ throw new SessionBrowserServiceError("shutting_down", "Browser service is shutting down");
455
+ }
456
+ return admission;
457
+ }
441
458
  async closeUnavailableForRecreate(record) {
442
459
  record.status = "closing";
443
460
  try {
@@ -13,6 +13,12 @@ export interface SessionRuntimeActivitySnapshot {
13
13
  */
14
14
  export declare class SessionRuntimeIndex {
15
15
  private readonly sessions;
16
+ /**
17
+ * Make an accepted Turn visible before the request admission reservation is
18
+ * released. The returned callback only abandons the pre-created handoff; once
19
+ * response.created arrives, the native response remains authoritative.
20
+ */
21
+ beginResponseHandoff(sessionId: string, responseId: string): () => void;
16
22
  observe(sessionId: string, event: SessionEvent): void;
17
23
  snapshot(sessionId: string): SessionRuntimeSnapshot;
18
24
  /** Aggregate only process-live work; durable Session history is not activity. */
@@ -20,4 +26,5 @@ export declare class SessionRuntimeIndex {
20
26
  remove(sessionId: string): void;
21
27
  private state;
22
28
  private removeResponseInteractions;
29
+ private hasRunningTurn;
23
30
  }
@@ -6,35 +6,57 @@
6
6
  */
7
7
  export class SessionRuntimeIndex {
8
8
  sessions = new Map();
9
+ /**
10
+ * Make an accepted Turn visible before the request admission reservation is
11
+ * released. The returned callback only abandons the pre-created handoff; once
12
+ * response.created arrives, the native response remains authoritative.
13
+ */
14
+ beginResponseHandoff(sessionId, responseId) {
15
+ const state = this.state(sessionId);
16
+ state.pendingResponseIds.add(responseId);
17
+ state.status = "running";
18
+ state.statusKind = "startup";
19
+ let pending = true;
20
+ return () => {
21
+ if (!pending)
22
+ return;
23
+ pending = false;
24
+ state.pendingResponseIds.delete(responseId);
25
+ };
26
+ }
9
27
  observe(sessionId, event) {
10
28
  const state = this.state(sessionId);
11
29
  switch (event.type) {
12
30
  case "response.created":
31
+ state.pendingResponseIds.delete(event.responseId);
13
32
  state.activeResponseIds.add(event.responseId);
14
33
  state.status = "running";
15
34
  state.statusKind = undefined;
16
35
  return;
17
36
  case "response.completed":
37
+ state.pendingResponseIds.delete(event.responseId);
18
38
  state.activeResponseIds.delete(event.responseId);
19
39
  this.removeResponseInteractions(state, event.responseId);
20
- state.status = state.activeResponseIds.size > 0 ? "running" : "idle";
40
+ state.status = this.hasRunningTurn(state) ? "running" : "idle";
21
41
  state.statusKind = undefined;
22
42
  return;
23
43
  case "response.failed":
44
+ state.pendingResponseIds.delete(event.responseId);
24
45
  state.activeResponseIds.delete(event.responseId);
25
46
  this.removeResponseInteractions(state, event.responseId);
26
47
  // Response failure is Turn-local. It must not poison a reusable Session.
27
- state.status = state.activeResponseIds.size > 0 ? "running" : "idle";
48
+ state.status = this.hasRunningTurn(state) ? "running" : "idle";
28
49
  state.statusKind = undefined;
29
50
  return;
30
51
  case "session.status":
31
52
  state.status =
32
- state.pendingInteractions.size > 0 || state.activeResponseIds.size > 0
53
+ state.pendingInteractions.size > 0 || this.hasRunningTurn(state)
33
54
  ? "running"
34
55
  : event.status;
35
56
  state.statusKind = event.statusKind;
36
57
  return;
37
58
  case "session.interaction.requested":
59
+ state.pendingResponseIds.delete(event.responseId);
38
60
  state.pendingInteractions.set(event.interaction.interactionId, {
39
61
  responseId: event.responseId,
40
62
  interaction: event.interaction,
@@ -46,9 +68,10 @@ export class SessionRuntimeIndex {
46
68
  case "session.interaction.resolved":
47
69
  case "session.interaction.cancelled":
48
70
  state.pendingInteractions.delete(event.interactionId);
49
- state.status = state.activeResponseIds.size > 0 ? "running" : state.status;
71
+ state.status = this.hasRunningTurn(state) ? "running" : state.status;
50
72
  return;
51
73
  case "session.rotated":
74
+ state.pendingResponseIds.clear();
52
75
  state.activeResponseIds.clear();
53
76
  state.pendingInteractions.clear();
54
77
  state.status = "idle";
@@ -63,7 +86,9 @@ export class SessionRuntimeIndex {
63
86
  if (!state)
64
87
  return { status: "idle", activeResponseIds: [], pendingInteractions: [] };
65
88
  return {
66
- status: state.pendingInteractions.size > 0 ? "running" : state.status,
89
+ status: state.pendingInteractions.size > 0 || this.hasRunningTurn(state)
90
+ ? "running"
91
+ : state.status,
67
92
  ...(state.statusKind === undefined ? {} : { statusKind: state.statusKind }),
68
93
  activeResponseIds: [...state.activeResponseIds],
69
94
  pendingInteractions: [...state.pendingInteractions.values()],
@@ -74,7 +99,7 @@ export class SessionRuntimeIndex {
74
99
  let runningTurns = 0;
75
100
  let pendingInteractions = 0;
76
101
  for (const state of this.sessions.values()) {
77
- runningTurns += state.activeResponseIds.size;
102
+ runningTurns += state.pendingResponseIds.size + state.activeResponseIds.size;
78
103
  pendingInteractions += state.pendingInteractions.size;
79
104
  }
80
105
  return { runningTurns, pendingInteractions };
@@ -87,6 +112,7 @@ export class SessionRuntimeIndex {
87
112
  if (!state) {
88
113
  state = {
89
114
  status: "idle",
115
+ pendingResponseIds: new Set(),
90
116
  activeResponseIds: new Set(),
91
117
  pendingInteractions: new Map(),
92
118
  };
@@ -100,4 +126,7 @@ export class SessionRuntimeIndex {
100
126
  state.pendingInteractions.delete(interactionId);
101
127
  }
102
128
  }
129
+ hasRunningTurn(state) {
130
+ return state.pendingResponseIds.size > 0 || state.activeResponseIds.size > 0;
131
+ }
103
132
  }
@@ -1,3 +1,4 @@
1
+ import type { AdmissionReservation } from "@rynx-ai/core";
1
2
  import { type RunnerManager } from "@rynx-ai/runtime";
2
3
  export type SessionTerminalRole = "owner" | "read-only";
3
4
  export interface SessionTerminalOpenOptions {
@@ -46,6 +47,11 @@ export interface RunnerSessionTerminalHostOptions {
46
47
  } | undefined | Promise<{
47
48
  cwd: string;
48
49
  } | undefined>;
50
+ /**
51
+ * Synchronous daemon admission fence. A successful reservation is held until
52
+ * the attachment is registered and the runner confirms that it is ready.
53
+ */
54
+ admissionReserve?: () => AdmissionReservation | undefined;
49
55
  }
50
56
  /** Adapt the runner-child terminal protocol without exposing cwd or commands to clients. */
51
57
  export declare function createRunnerSessionTerminalHost(options: RunnerSessionTerminalHostOptions): RunnerSessionTerminalHost;
@@ -24,56 +24,71 @@ export function createRunnerSessionTerminalHost(options) {
24
24
  return {
25
25
  activeTerminalCount: () => attachmentCounts.size,
26
26
  async open(sessionId, openOptions) {
27
- validateOpen(sessionId, openOptions);
28
- throwIfAborted(openOptions.signal);
29
- const session = await options.resolveLiveSession(sessionId);
30
- if (!session || typeof session.cwd !== "string" || session.cwd.trim().length === 0) {
31
- throw new SessionTerminalOpenError("not_live", "Session terminal is not live");
32
- }
33
- throwIfAborted(openOptions.signal);
34
- let parent;
27
+ const admission = reserveTerminalAdmission(options.admissionReserve);
35
28
  try {
36
- parent = options.runnerManager.openLiveTerminal(sessionId, {
37
- role: openOptions.role,
38
- cwd: session.cwd,
39
- cols: openOptions.cols,
40
- rows: openOptions.rows,
41
- });
42
- }
43
- catch (error) {
44
- if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
45
- throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
46
- cause: error,
29
+ validateOpen(sessionId, openOptions);
30
+ throwIfAborted(openOptions.signal);
31
+ const session = await options.resolveLiveSession(sessionId);
32
+ if (!session || typeof session.cwd !== "string" || session.cwd.trim().length === 0) {
33
+ throw new SessionTerminalOpenError("not_live", "Session terminal is not live");
34
+ }
35
+ throwIfAborted(openOptions.signal);
36
+ let parent;
37
+ try {
38
+ parent = options.runnerManager.openLiveTerminal(sessionId, {
39
+ role: openOptions.role,
40
+ cwd: session.cwd,
41
+ cols: openOptions.cols,
42
+ rows: openOptions.rows,
47
43
  });
48
44
  }
49
- throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
50
- }
51
- const attachment = new RunnerSessionTerminalAttachment(sessionId, parent, openOptions.signal);
52
- const releaseActivity = trackTerminalAttachment(attachmentCounts, sessionId);
53
- void attachment.closed.then(releaseActivity, releaseActivity);
54
- try {
55
- const opened = await waitFor(parent.ready, openOptions.signal);
56
- attachment.opened(opened.role);
57
- attachment.throwIfOpeningFailed();
58
- return attachment;
59
- }
60
- catch (error) {
61
- await attachment.close().catch(() => undefined);
62
- if (isAbort(error)) {
63
- throw new SessionTerminalOpenError("invalid_request", "Session terminal open was cancelled", { cause: error });
45
+ catch (error) {
46
+ if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
47
+ throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
48
+ cause: error,
49
+ });
50
+ }
51
+ throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
64
52
  }
65
- if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
66
- throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
67
- cause: error,
68
- });
53
+ const attachment = new RunnerSessionTerminalAttachment(sessionId, parent, openOptions.signal);
54
+ const releaseActivity = trackTerminalAttachment(attachmentCounts, sessionId);
55
+ void attachment.closed.then(releaseActivity, releaseActivity);
56
+ try {
57
+ const opened = await waitFor(parent.ready, openOptions.signal);
58
+ attachment.opened(opened.role);
59
+ attachment.throwIfOpeningFailed();
60
+ return attachment;
61
+ }
62
+ catch (error) {
63
+ await attachment.close().catch(() => undefined);
64
+ if (isAbort(error)) {
65
+ throw new SessionTerminalOpenError("invalid_request", "Session terminal open was cancelled", { cause: error });
66
+ }
67
+ if (error instanceof TerminalOpenError && error.code === "terminal_not_live") {
68
+ throw new SessionTerminalOpenError("not_live", "Session terminal is not live", {
69
+ cause: error,
70
+ });
71
+ }
72
+ if (error instanceof SessionTerminalOpenError)
73
+ throw error;
74
+ throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
69
75
  }
70
- if (error instanceof SessionTerminalOpenError)
71
- throw error;
72
- throw new SessionTerminalOpenError("internal", "Session terminal could not be opened", { cause: error });
76
+ }
77
+ finally {
78
+ admission?.release();
73
79
  }
74
80
  },
75
81
  };
76
82
  }
83
+ function reserveTerminalAdmission(reserve) {
84
+ if (!reserve)
85
+ return undefined;
86
+ const admission = reserve();
87
+ if (!admission) {
88
+ throw new SessionTerminalOpenError("not_live", "Session terminal is unavailable during daemon maintenance");
89
+ }
90
+ return admission;
91
+ }
77
92
  function trackTerminalAttachment(attachmentCounts, sessionId) {
78
93
  attachmentCounts.set(sessionId, (attachmentCounts.get(sessionId) ?? 0) + 1);
79
94
  let released = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/server",
3
- "version": "0.1.11-beta.2",
3
+ "version": "0.1.11-beta.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -27,15 +27,15 @@
27
27
  "@koa/router": "^15.4.0",
28
28
  "koa": "^3.2.0",
29
29
  "ws": "^8.21.0",
30
- "@rynx-ai/core": "0.1.11-beta.2",
31
- "@rynx-ai/plugin-sdk": "0.1.11-beta.2",
32
- "@rynx-ai/protocol": "0.1.11-beta.2",
33
- "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.2",
34
- "@rynx-ai/runtime": "0.1.11-beta.2"
30
+ "@rynx-ai/plugin-sdk": "0.1.11-beta.4",
31
+ "@rynx-ai/core": "0.1.11-beta.4",
32
+ "@rynx-ai/protocol": "0.1.11-beta.4",
33
+ "@rynx-ai/remote-runtime-e2ee": "0.1.11-beta.4",
34
+ "@rynx-ai/runtime": "0.1.11-beta.4"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/ws": "^8.18.1",
38
- "@rynx-ai/control-web": "0.1.11-beta.2"
38
+ "@rynx-ai/control-web": "0.1.11-beta.4"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "rm -rf dist && tsc -p tsconfig.json && mkdir -p dist/control-web && cp -R ../control-web/dist/. dist/control-web/",