@stratusagent/gateway 0.9.2 → 0.11.0

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.
package/dist/index.js CHANGED
@@ -10,13 +10,14 @@ import { createHash, randomUUID } from 'node:crypto';
10
10
  import { chmodSync, mkdirSync } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { DatabaseSync } from 'node:sqlite';
13
- import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, readPendingApproval, } from '@stratusagent/core';
14
- import { createDelegateTool, createForgetTool, createMessageSendTool, createRecallTool, createRememberTool, createScheduleTools, } from '@stratusagent/agents';
13
+ import { abortErrorFor, AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, PENDING_APPROVAL_METADATA_KEY, latestTurnReply, readPendingApproval, } from '@stratusagent/core';
14
+ import { createDelegateTool, createForgetTool, createMessageSendTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, } from '@stratusagent/agents';
15
15
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
16
- export { SqliteScheduleStore, createSchedulerRuntime, } from "./schedules.js";
16
+ export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
17
+ export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
17
18
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
18
19
  import { loadPlugins, } from '@stratusagent/plugins';
19
- import { createDemoTool, createFileMemoryStore, createRuntimeProvider, DEFAULT_STRATUS_AGENT, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, memoryFilePath, migrateLegacyMemory, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, stratusHomePath, workspacesDirPath, withLegacyDefaultMemories, } from '@stratusagent/state';
20
+ import { createDemoTool, createFileCredentialResolver, createFileMemoryStore, createRuntimeProvider, DEFAULT_STRATUS_AGENT, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, memoryFilePath, migrateLegacyMemory, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, stratusHomePath, workspacesDirPath, withLegacyDefaultMemories, } from '@stratusagent/state';
20
21
  const SESSIONS_DB_FILENAME = 'sessions.db';
21
22
  /**
22
23
  * Where a daemon with no explicit `sessionDbPath` keeps its database — the
@@ -221,6 +222,26 @@ export class SqliteSessionStore {
221
222
  * question even when the rule answering it is not gateway code.
222
223
  */
223
224
  export { applySoulPins } from '@stratusagent/state';
225
+ /** What a turn cut short by an announced restart records as its failure. */
226
+ export const RESTARTING_TURN_ERROR = 'Run aborted: stratusd is restarting';
227
+ /**
228
+ * How long an announced restart lets in-flight turns finish before aborting
229
+ * them. Matches the systemd unit's `TimeoutStopSec`, so the two windows an
230
+ * operator can hit agree.
231
+ */
232
+ export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
233
+ /**
234
+ * `restart()` on a gateway whose host gave it no way back. Exported so the
235
+ * control API can answer it as "not supported here" rather than as a
236
+ * failure of the daemon.
237
+ */
238
+ export class RestartUnsupportedError extends Error {
239
+ constructor() {
240
+ super('This daemon cannot restart itself: the host that started it did not say how. '
241
+ + 'Restart it from the service manager (stratus service stop, then start) or from the terminal it runs in.');
242
+ this.name = 'RestartUnsupportedError';
243
+ }
244
+ }
224
245
  /**
225
246
  * Recorded on a turn the previous process was still running when it died.
226
247
  *
@@ -231,6 +252,51 @@ export { applySoulPins } from '@stratusagent/state';
231
252
  * its own guess at the wording.
232
253
  */
233
254
  export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
255
+ /**
256
+ * Recorded on a delegated sub-session that was parked on a human when the
257
+ * previous process died.
258
+ *
259
+ * Its parent — the turn whose `agent.delegate` call was awaiting the reply
260
+ * — was running, so the restart fails it as abandoned (above). Recovering
261
+ * the child anyway would re-ask a person to approve a call whose result no
262
+ * turn will ever read, and then run it: a command executed, tokens spent,
263
+ * and a reply delivered to nobody. Distinguishable from the parent's error
264
+ * because the remedy differs — there is no message to send again here;
265
+ * the parent's is the one to repeat.
266
+ */
267
+ /**
268
+ * Session metadata keys the daemon writes for itself, which no external
269
+ * dispatch may supply.
270
+ *
271
+ * Metadata is one bag: the keys a channel attaches to say where a turn is
272
+ * happening sit beside the ones the kernel, the scheduler, the fallback
273
+ * wrapper, and `agent.delegate` write to remember what a session *is* —
274
+ * and the control API forwards a caller's `metadata` into a new session as
275
+ * given. A caller who can write `delegatedBy` can have the restart sweep
276
+ * fail its own parked turn as an orphan; one who can write `fallbackActive`
277
+ * bills its conversation to the fallback model from the first turn. The
278
+ * scheduler already refused to trust its own keys for exactly this reason
279
+ * (see `activeFirings`); the durable ones cannot be re-derived in memory,
280
+ * so they are refused at the door instead.
281
+ */
282
+ export const RESERVED_SESSION_METADATA_KEYS = [
283
+ PENDING_APPROVAL_METADATA_KEY,
284
+ FALLBACK_ACTIVE_METADATA_KEY,
285
+ DELEGATED_BY_METADATA_KEY,
286
+ ROOT_SESSION_ID_METADATA_KEY,
287
+ DELEGATION_DEPTH_METADATA_KEY,
288
+ SCHEDULED_TURN_METADATA_KEY,
289
+ SCHEDULE_ID_METADATA_KEY,
290
+ ];
291
+ /**
292
+ * The first reserved key `metadata` carries, or undefined when it carries
293
+ * none. Exported so the control API can refuse the request where the
294
+ * caller can see it, with the same rule the gateway applies at dispatch.
295
+ */
296
+ export const reservedSessionMetadataKey = (metadata) => metadata === undefined
297
+ ? undefined
298
+ : RESERVED_SESSION_METADATA_KEYS.find((key) => Object.prototype.hasOwnProperty.call(metadata, key));
299
+ export const ORPHANED_DELEGATION_ERROR = 'stratusd stopped while the turn that delegated this one was still running; the delegation was not resumed. Send the original message again.';
234
300
  /**
235
301
  * The always-on Stratus process. One gateway holds the roster, the durable
236
302
  * session store, the shared event bus, and a pool of runners keyed by
@@ -414,6 +480,8 @@ export const createGateway = (options = {}) => {
414
480
  agentId: request.session.agent.id,
415
481
  call: request.call,
416
482
  risk: request.risk,
483
+ ...(request.origin !== undefined ? { origin: request.origin } : {}),
484
+ ...(request.oneShot ? { oneShot: true } : {}),
417
485
  parkedAt: request.parkedAt ?? new Date().toISOString(),
418
486
  ...(expiresAt ? { expiresAt } : {}),
419
487
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
@@ -459,6 +527,8 @@ export const createGateway = (options = {}) => {
459
527
  requestId,
460
528
  call: request.call,
461
529
  risk: request.risk,
530
+ ...(request.origin !== undefined ? { origin: request.origin } : {}),
531
+ ...(request.oneShot ? { oneShot: true } : {}),
462
532
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
463
533
  ...(expiresAt ? { expiresAt } : {}),
464
534
  });
@@ -686,22 +756,73 @@ export const createGateway = (options = {}) => {
686
756
  }
687
757
  };
688
758
  /**
689
- * Roster rebuilds, one at a time.
759
+ * Rebuild the skill catalog from what is on disk right now, into a fresh
760
+ * registry, and swap it in whole. See `Gateway.reloadSkills`.
761
+ *
762
+ * Strict on the operator directory: the previous set is still serving,
763
+ * so a file that will not load is a reason to keep it, not to serve the
764
+ * rest. The plugins' skills are re-registered from the load records, in
765
+ * load order, after the operator's — the order `start()` used, which is
766
+ * what makes an operator's bare id win its alias and a bare id two
767
+ * plugins want stay contested. Nothing is imported again: a plugin is
768
+ * code, and code gets a restart.
769
+ */
770
+ const loadSkills = async () => {
771
+ const next = new SkillRegistry();
772
+ const operator = await loadOperatorSkills(env, next, warn, { strict: true });
773
+ for (const plugin of loadedPlugins) {
774
+ for (const record of plugin.skills) {
775
+ // The live catalog holds the very `Skill` the loader staged, under
776
+ // its canonical id; a rebuild re-registers that object rather than
777
+ // re-reading the plugin's file, so a plugin's skills are exactly
778
+ // what loaded with it until it is loaded again.
779
+ const skill = skillCatalog.resolve(record.id);
780
+ if (!skill) {
781
+ throw new Error(`Plugin ${plugin.package} lists skill ${record.id}, but the catalog no longer holds it. Restart the daemon.`);
782
+ }
783
+ next.register(skill);
784
+ next.registerAlias(record.bareId, skill.id);
785
+ }
786
+ }
787
+ const before = new Set(operatorSkills.map((skill) => skill.id));
788
+ const after = new Set(operator.map((skill) => skill.id));
789
+ skillCatalog.replaceWith(next);
790
+ operatorSkills = operator;
791
+ const added = operator.filter((skill) => !before.has(skill.id)).map((skill) => skill.id);
792
+ const removed = [...before].filter((id) => !after.has(id));
793
+ const change = [
794
+ ...(added.length > 0 ? [`added ${added.join(', ')}`] : []),
795
+ ...(removed.length > 0 ? [`removed ${removed.join(', ')}`] : []),
796
+ ];
797
+ log(`skills reloaded — ${skillCatalog.list().length} skill(s)${change.length > 0 ? `: ${change.join('; ')}` : ' (no change)'}`);
798
+ // The same advisory check the roster load runs: a removed skill an
799
+ // agent still lists, or a new one whose `requires:` the agent's tools
800
+ // do not cover, is worth a line now rather than at the next restart.
801
+ for (const agent of registry.list()) {
802
+ for (const { skill, missing } of missingSkillRequirements(agent, skillCatalog)) {
803
+ warn(`agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
804
+ }
805
+ }
806
+ };
807
+ /**
808
+ * Roster and skill rebuilds, one at a time.
690
809
  *
691
810
  * `loadRoster` prunes by comparing the registry against the ids *this pass*
692
811
  * saw, so two overlapping passes destroy each other's work: the older one
693
812
  * snapshots a roster without agent B, the newer one registers B and
694
813
  * finishes, and then the older one reaches its prune and unregisters B —
695
814
  * which two clients creating agents at once will do, each having been told
696
- * 201 for an agent that is no longer dispatchable.
815
+ * 201 for an agent that is no longer dispatchable. Skill reloads share the
816
+ * chain: each is a whole-catalog swap, and the roster load reads the
817
+ * catalog for its advisory warnings.
697
818
  *
698
819
  * A chain rather than a lock: a reload is idempotent, so a caller arriving
699
820
  * mid-rebuild wants the *next* complete one, and waiting for it is exactly
700
821
  * what this gives them.
701
822
  */
702
823
  let reloads = Promise.resolve();
703
- const reloadsInOrder = async () => {
704
- const next = reloads.then(loadRoster, loadRoster);
824
+ const reloadsInOrder = async (work) => {
825
+ const next = reloads.then(work, work);
705
826
  // Swallowed for the chain only — the caller still sees the rejection, but
706
827
  // a roster that failed to load must not block every reload after it.
707
828
  reloads = next.catch(() => undefined);
@@ -891,13 +1012,30 @@ export const createGateway = (options = {}) => {
891
1012
  const withWatchdog = async (sessionId, external, idleEnabled, fallbackStreams, run) => {
892
1013
  const effectiveIdleMs = idleEnabled || fallbackStreams ? idleTimeoutMs : 0;
893
1014
  if (effectiveIdleMs <= 0 && !external) {
894
- return run(new AbortController().signal);
1015
+ // No watchdog and nobody outside to cancel it — but still a turn an
1016
+ // announced restart must be able to cut short at the window.
1017
+ const idle = new AbortController();
1018
+ turnControllers.add(idle);
1019
+ if (abortingTurns) {
1020
+ idle.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1021
+ }
1022
+ try {
1023
+ return await run(idle.signal);
1024
+ }
1025
+ finally {
1026
+ turnControllers.delete(idle);
1027
+ }
895
1028
  }
896
1029
  const controller = new AbortController();
897
- let timedOut = false;
898
- const onExternalAbort = () => controller.abort();
1030
+ turnControllers.add(controller);
1031
+ if (abortingTurns) {
1032
+ controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1033
+ }
1034
+ // The external reason travels: a caller that aborted with its own
1035
+ // `RunAbortedError` gets it recorded on the session, like the watchdog's.
1036
+ const onExternalAbort = () => controller.abort(external?.reason);
899
1037
  if (external?.aborted) {
900
- controller.abort();
1038
+ controller.abort(external.reason);
901
1039
  }
902
1040
  else {
903
1041
  external?.addEventListener('abort', onExternalAbort, { once: true });
@@ -936,9 +1074,14 @@ export const createGateway = (options = {}) => {
936
1074
  // Deliberately not unref'd: while a provider await is in flight, this
937
1075
  // timer is what guarantees the process can always make progress on it.
938
1076
  timer = setTimeout(() => {
939
- timedOut = true;
940
1077
  warn(`watchdog: no activity on session ${sessionId} for ${effectiveIdleMs}ms; aborting the turn`);
941
- controller.abort();
1078
+ // The reason rides on the signal, because the runner is what fails
1079
+ // the session: it writes `lastError` and emits `session.failed`
1080
+ // before this wrapper ever sees the rejection. Rethrowing a better
1081
+ // message from here reached the dispatcher and nobody else — the
1082
+ // record and the event both said "Run aborted", which is also what
1083
+ // a person cancelling looks like.
1084
+ controller.abort(new RunAbortedError(`Run aborted: no activity for ${effectiveIdleMs}ms`));
942
1085
  }, effectiveIdleMs);
943
1086
  };
944
1087
  // Two subscriptions bracket every event's fan-out. The PREPENDED
@@ -1019,17 +1162,12 @@ export const createGateway = (options = {}) => {
1019
1162
  try {
1020
1163
  return await run(controller.signal);
1021
1164
  }
1022
- catch (error) {
1023
- if (timedOut && error instanceof RunAbortedError) {
1024
- throw new RunAbortedError(`Run aborted: no activity for ${effectiveIdleMs}ms`);
1025
- }
1026
- throw error;
1027
- }
1028
1165
  finally {
1029
1166
  suspendTimer();
1030
1167
  unsubscribeObserver();
1031
1168
  unsubscribeDrain();
1032
1169
  suspendWatchdog.delete(sessionId);
1170
+ turnControllers.delete(controller);
1033
1171
  external?.removeEventListener('abort', onExternalAbort);
1034
1172
  }
1035
1173
  };
@@ -1040,6 +1178,37 @@ export const createGateway = (options = {}) => {
1040
1178
  const sessionChains = new Map();
1041
1179
  const inflight = new Set();
1042
1180
  let stopping = false;
1181
+ /**
1182
+ * The abort handle of every turn running right now. A restart's drain
1183
+ * window ends by aborting these — the same signal the watchdog fires —
1184
+ * so a turn that outlives the window fails as aborted, its session
1185
+ * saved, instead of being cut off by a process exit and found as
1186
+ * abandoned by the next one.
1187
+ */
1188
+ const turnControllers = new Set();
1189
+ /**
1190
+ * Set once a restart's window has closed and the turns in it were
1191
+ * aborted. A turn that starts after that — one queued behind the
1192
+ * aborted turn on the same session, accepted before the announcement —
1193
+ * is aborted the moment it gets its controller, with the same reason,
1194
+ * rather than running unseen until the shutdown closes the store under
1195
+ * it and leaves it recorded as nothing at all.
1196
+ */
1197
+ let abortingTurns = false;
1198
+ /**
1199
+ * Set once `start()` has finished. A restart before that would stop the
1200
+ * channels started so far and close the stores while the rest of
1201
+ * startup is still running — reachable, since the control API can be
1202
+ * up and answering `POST /restart` while a later channel is still
1203
+ * connecting — and the startup then fails on a closed store instead of
1204
+ * the daemon coming back.
1205
+ */
1206
+ let serving = false;
1207
+ /** Set once `restart()` announced; the refusal a caller reads says why. */
1208
+ let restartStatus;
1209
+ const refusal = () => new Error(restartStatus
1210
+ ? 'stratusd is restarting and will accept new work once it is back up.'
1211
+ : 'The gateway is stopping and no longer accepts new work.');
1043
1212
  /**
1044
1213
  * The turn id running on each session, for callers that named one.
1045
1214
  *
@@ -1126,7 +1295,7 @@ export const createGateway = (options = {}) => {
1126
1295
  // load the session, append the cancelled user message, and save it as
1127
1296
  // failed — polluting future model history with input never processed.
1128
1297
  if (input.signal?.aborted) {
1129
- throw new RunAbortedError();
1298
+ throw abortErrorFor(input.signal);
1130
1299
  }
1131
1300
  // A session pins its agent: when the caller names none, an existing
1132
1301
  // conversation keeps the agent it was created with even if the
@@ -1161,12 +1330,20 @@ export const createGateway = (options = {}) => {
1161
1330
  // Recheck before the runner touches durable state, or a dispatch
1162
1331
  // cancelled mid-preflight would still persist its user message.
1163
1332
  if (signal.aborted) {
1164
- throw new RunAbortedError();
1333
+ throw abortErrorFor(signal);
1165
1334
  }
1166
1335
  if (existing) {
1167
1336
  // Refresh the stored definition before resuming, so the turn runs
1168
1337
  // with the current instructions, tools, and credentials.
1169
1338
  existing.agent = agent;
1339
+ // A sub-session reached here is being continued from outside — a
1340
+ // delegation never resumes its own child; its ids are minted once.
1341
+ // The delegating turn is over, so the markers come off: what is
1342
+ // now an ordinary conversation must not be closed as an orphan if
1343
+ // a later turn of it parks and the daemon dies.
1344
+ if (isDelegatedSession(existing) && existing.metadata) {
1345
+ existing.metadata = withoutDelegation(existing.metadata);
1346
+ }
1170
1347
  await store.save(existing);
1171
1348
  return runner.resume({ sessionId: input.sessionId, userMessage: input.userMessage, signal });
1172
1349
  }
@@ -1192,7 +1369,7 @@ export const createGateway = (options = {}) => {
1192
1369
  * one unrecoverable turn must not stop the daemon from serving, or from
1193
1370
  * recovering the others.
1194
1371
  */
1195
- const recoverParkedTurns = async () => {
1372
+ const recoverParkedTurns = async (orphaned) => {
1196
1373
  if (!store.listIdsByStatus) {
1197
1374
  return;
1198
1375
  }
@@ -1207,9 +1384,56 @@ export const createGateway = (options = {}) => {
1207
1384
  // answered — fifteen minutes later by default, and never at all with a
1208
1385
  // zero timeout. They are independent turns; only same-session work is
1209
1386
  // ordered, and onSessionChain already guarantees that.
1210
- await Promise.allSettled(parked.map((sessionId) => recoverOne(sessionId)));
1387
+ await Promise.allSettled(parked.map((sessionId) => recoverOne(sessionId, orphaned.has(sessionId))));
1388
+ };
1389
+ /**
1390
+ * The parked sub-sessions whose delegating turn the last process was
1391
+ * still inside — read BEFORE the channels start, for the same reason the
1392
+ * abandoned list is.
1393
+ *
1394
+ * The proof is the parent's transcript: only the runner writes an
1395
+ * assistant `agent.delegate` call, and only a delegation in flight leaves
1396
+ * one with no result behind it. That evidence does not survive contact
1397
+ * with a channel, though — a message for the parent arriving once the
1398
+ * adapters are up resumes it, and the resume closes the dangling call as
1399
+ * interrupted. Read live from the sweep, the parent would then look as if
1400
+ * it awaited nothing and the child would be re-asked after all, which is
1401
+ * the bug this exists to stop. So it is read while nothing can dispatch,
1402
+ * and the sweep judges from the snapshot; a child whose parent is missing
1403
+ * or not inside such a call is left to ordinary recovery.
1404
+ *
1405
+ * Matched call by call, not parent by parent: the call that awaits a
1406
+ * child names it (see `outstandingDelegationFor`), and a call can await
1407
+ * only one. Two parked children claiming the same call — one of them a
1408
+ * sub-session continued from outside under a version that kept its
1409
+ * markers, parked again on something of its own — is an ambiguity this
1410
+ * cannot settle, and both are left to ordinary recovery rather than one
1411
+ * of them being failed for a delegation that was never its.
1412
+ */
1413
+ const listOrphanedDelegations = async () => {
1414
+ const claimants = new Map();
1415
+ if (!store.listIdsByStatus) {
1416
+ return new Set();
1417
+ }
1418
+ for (const sessionId of await store.listIdsByStatus('pending_approval')) {
1419
+ const parentId = delegatingSessionIdOf(sessionId);
1420
+ if (parentId === undefined) {
1421
+ continue;
1422
+ }
1423
+ const session = await store.get(sessionId);
1424
+ if (!session || !isDelegatedSession(session)) {
1425
+ continue;
1426
+ }
1427
+ const parent = await store.get(parentId);
1428
+ const callId = parent ? outstandingDelegationFor(parent, session) : undefined;
1429
+ if (callId !== undefined) {
1430
+ const key = `${parentId}\u0000${callId}`;
1431
+ claimants.set(key, [...(claimants.get(key) ?? []), sessionId]);
1432
+ }
1433
+ }
1434
+ return new Set([...claimants.values()].filter((ids) => ids.length === 1).map(([id]) => id));
1211
1435
  };
1212
- const recoverOne = (sessionId) => onSessionChain(sessionId, async () => {
1436
+ const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId, async () => {
1213
1437
  if (stopping) {
1214
1438
  return;
1215
1439
  }
@@ -1219,6 +1443,27 @@ export const createGateway = (options = {}) => {
1219
1443
  return;
1220
1444
  }
1221
1445
  const record = readPendingApproval(session);
1446
+ // A delegated sub-session is only ever awaited by its parent's
1447
+ // `agent.delegate` call, and that parent was running when the last
1448
+ // process died — it is in the abandoned sweep, not in this one.
1449
+ // Nothing will read the reply, so the parked call is not re-asked:
1450
+ // a person would be approving a command that runs for no one.
1451
+ //
1452
+ // Judged on the re-read, on the chain: the channels are up before
1453
+ // this sweep starts, so a message for this id can have taken the
1454
+ // chain first and moved the session on — and a turn that finished
1455
+ // normally must not be rewritten as failed. The delegation metadata
1456
+ // is durable, so it proves what the session is, not what state it
1457
+ // is in; the status and the checkpoint say that.
1458
+ //
1459
+ // Whether it IS an orphan was decided before the channels started
1460
+ // (`listOrphanedDelegations`), from the parent's transcript, which
1461
+ // a message arriving since may already have rewritten.
1462
+ if (orphaned && isDelegatedSession(session) && session.status === 'pending_approval' && record) {
1463
+ log(`${sessionId}: parked on ${record.call.toolName} for a delegating turn the last stratusd was still running; failing it`);
1464
+ await failOrphanedDelegation(session);
1465
+ return;
1466
+ }
1222
1467
  // A wait that outlived its window while the daemon was down is
1223
1468
  // honoured, not restarted: the request really did go unanswered for
1224
1469
  // the whole time it was configured to wait, and downtime is not a
@@ -1248,12 +1493,51 @@ export const createGateway = (options = {}) => {
1248
1493
  session.agent = source.definition;
1249
1494
  await store.save(session);
1250
1495
  const runner = runnerFor(await runtimeForAgent(source));
1251
- await runner.recoverPendingApproval(sessionId, { denyPending: expired });
1496
+ // Tracked like a dispatched turn's controller: a recovered turn
1497
+ // that runs on past its approval is a turn like any other, and a
1498
+ // restart's window has to be able to cut it short with the same
1499
+ // reason — or it runs unseen to the second window and the restart
1500
+ // reports undrained for a turn it could have finished cleanly.
1501
+ const controller = new AbortController();
1502
+ turnControllers.add(controller);
1503
+ if (abortingTurns) {
1504
+ controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1505
+ }
1506
+ try {
1507
+ await runner.recoverPendingApproval(sessionId, { denyPending: expired, signal: controller.signal });
1508
+ }
1509
+ finally {
1510
+ turnControllers.delete(controller);
1511
+ // A recovered firing's row outlived the process that would have
1512
+ // retired it, and the firing is over either way — a recovery that
1513
+ // failed has durably failed its session, which is as finished as
1514
+ // completing. The scheduler decides whether there is a row to
1515
+ // retire, and only this firing's own.
1516
+ const scheduleId = session.metadata?.[SCHEDULE_ID_METADATA_KEY];
1517
+ if (typeof scheduleId === 'string') {
1518
+ scheduler.retireSpentOneShot(scheduleId, sessionId);
1519
+ }
1520
+ }
1252
1521
  }
1253
1522
  catch (error) {
1254
1523
  warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
1255
1524
  }
1256
1525
  });
1526
+ /**
1527
+ * Closes an orphaned sub-session the way the abandoned sweep closes its
1528
+ * parent: durably failed, with the checkpoint retired so no later sweep
1529
+ * can find a call to re-enter, and announced where surfaces can hear it.
1530
+ */
1531
+ const failOrphanedDelegation = async (session) => {
1532
+ const metadata = { ...(session.metadata ?? {}) };
1533
+ delete metadata[PENDING_APPROVAL_METADATA_KEY];
1534
+ session.metadata = metadata;
1535
+ session.status = 'failed';
1536
+ session.lastError = ORPHANED_DELEGATION_ERROR;
1537
+ await store.save(session);
1538
+ await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
1539
+ await bus.emit({ type: 'session.failed', sessionId: session.id, error: ORPHANED_DELEGATION_ERROR });
1540
+ };
1257
1541
  /**
1258
1542
  * Turns the last process was still running when it stopped being able to
1259
1543
  * finish them.
@@ -1371,13 +1655,13 @@ export const createGateway = (options = {}) => {
1371
1655
  */
1372
1656
  const dispatchScheduledFiring = (input) => {
1373
1657
  if (stopping) {
1374
- throw new Error('The gateway is stopping and no longer accepts new work.');
1658
+ throw refusal();
1375
1659
  }
1376
1660
  return onSessionChain(input.sessionId, () => dispatchInternal(input));
1377
1661
  };
1378
1662
  const dispatch = async (input) => {
1379
1663
  if (stopping) {
1380
- throw new Error('The gateway is stopping and no longer accepts new work.');
1664
+ throw refusal();
1381
1665
  }
1382
1666
  // The reserved namespace is the scheduler's alone. A caller that could
1383
1667
  // name a firing's session id — derivable from `GET /schedules` — would
@@ -1388,6 +1672,13 @@ export const createGateway = (options = {}) => {
1388
1672
  if (isScheduleSessionId(input.sessionId)) {
1389
1673
  throw new Error(`Session ids beginning with "${SCHEDULE_SESSION_ID_PREFIX}" are reserved for scheduled firings and cannot be dispatched externally.`);
1390
1674
  }
1675
+ // The daemon's own bookkeeping keys, by the same logic as the reserved
1676
+ // id namespace above: what the runner, the scheduler, and delegation
1677
+ // record about a session must be something only they can have written.
1678
+ const reserved = reservedSessionMetadataKey(input.metadata);
1679
+ if (reserved !== undefined) {
1680
+ throw new Error(`Session metadata key "${reserved}" is reserved for the daemon's own records and cannot be supplied by a dispatch. Reserved keys: ${RESERVED_SESSION_METADATA_KEYS.join(', ')}.`);
1681
+ }
1391
1682
  const { turnId } = input;
1392
1683
  if (turnId === undefined) {
1393
1684
  return onSessionChain(input.sessionId, () => dispatchInternal(input));
@@ -1429,7 +1720,16 @@ export const createGateway = (options = {}) => {
1429
1720
  tools,
1430
1721
  skills: skillCatalog,
1431
1722
  bus,
1723
+ // The file-backed resolver, so a plugin needing a key reaches the
1724
+ // *calling* agent's — its allowlist checked, its own entry before the
1725
+ // fleet's shared one.
1726
+ credentials: createFileCredentialResolver(env),
1432
1727
  workspaceRoot: workspacesDirPath(env),
1728
+ // The structured log, so a plugin's lifecycle lines — an MCP server
1729
+ // that dropped, a reconnect that failed — are in `stratus logs` and
1730
+ // not only on a stderr the service manager owns.
1731
+ log,
1732
+ warn,
1433
1733
  });
1434
1734
  loadedPlugins = result.loaded;
1435
1735
  pluginFailures = result.failures;
@@ -1470,6 +1770,10 @@ export const createGateway = (options = {}) => {
1470
1770
  // Applied further down, once there is somewhere for the failure to
1471
1771
  // be heard.
1472
1772
  const abandoned = await listAbandonedTurns();
1773
+ // Likewise: which parked sub-sessions have a parent still inside its
1774
+ // agent.delegate call is only answerable while nothing can resume that
1775
+ // parent. See listOrphanedDelegations.
1776
+ const orphaned = await listOrphanedDelegations();
1473
1777
  // Channels come up after the roster so their first inbound message
1474
1778
  // already has agents to dispatch to. One failing adapter must not
1475
1779
  // keep the rest (or the gateway) down.
@@ -1479,7 +1783,10 @@ export const createGateway = (options = {}) => {
1479
1783
  startedChannels.push(adapter);
1480
1784
  }
1481
1785
  catch (error) {
1482
- warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
1786
+ const reason = error instanceof Error ? error.message : String(error);
1787
+ if (!adapter.required) {
1788
+ warn(`channel ${adapter.name} failed to start: ${reason}`);
1789
+ }
1483
1790
  // A start() that rejected may still hold sockets or listeners it
1484
1791
  // acquired before failing; it never reaches startedChannels, so
1485
1792
  // this is its only cleanup.
@@ -1489,6 +1796,17 @@ export const createGateway = (options = {}) => {
1489
1796
  catch (stopError) {
1490
1797
  warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1491
1798
  }
1799
+ if (adapter.required) {
1800
+ // Before the scheduler and the sweeps below, deliberately: a
1801
+ // daemon that is not going to serve must not fire a catch-up
1802
+ // slot or re-ask a parked approval on its way out. The channels
1803
+ // already up are left to the shutdown this throw becomes, which
1804
+ // denies what they parked and lets those denials reach them
1805
+ // BEFORE it stops them — stopped here, a channel that had parked
1806
+ // a gated turn in the window would never learn to retract its
1807
+ // buttons.
1808
+ throw new Error(`${adapter.name} could not start, and the gateway cannot serve without it: ${reason}`);
1809
+ }
1492
1810
  }
1493
1811
  }
1494
1812
  // After the channels, so a catch-up firing has somewhere to report and
@@ -1500,7 +1818,7 @@ export const createGateway = (options = {}) => {
1500
1818
  // above already listening — but a turn parked behind a slow approver
1501
1819
  // must not hold up start(), or a daemon with one outstanding request
1502
1820
  // would refuse to finish booting.
1503
- void recoverParkedTurns().catch((error) => {
1821
+ void recoverParkedTurns(orphaned).catch((error) => {
1504
1822
  warn(`recovering parked turns failed: ${error instanceof Error ? error.message : String(error)}`);
1505
1823
  });
1506
1824
  // Not awaited for the same reason, and independent of the sweep
@@ -1510,100 +1828,295 @@ export const createGateway = (options = {}) => {
1510
1828
  warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
1511
1829
  });
1512
1830
  };
1513
- /** Release what the plugins acquired, reporting rather than throwing. */
1831
+ /**
1832
+ * Both stores, once. Reached from `stop()` and from a `start()` that
1833
+ * failed, and a host may call the one after the other; node:sqlite
1834
+ * refuses a second close, so this remembers.
1835
+ */
1836
+ let storesClosed = false;
1837
+ const closeStores = () => {
1838
+ if (storesClosed) {
1839
+ return;
1840
+ }
1841
+ storesClosed = true;
1842
+ scheduleStore.close();
1843
+ store.close();
1844
+ };
1845
+ /**
1846
+ * Release what the plugins acquired, reporting rather than throwing.
1847
+ * Resolves with whether every plugin let go: a dispose that rejected may
1848
+ * still hold the browser or socket it was closing, which a restart has
1849
+ * to know before it starts a daemon beside it.
1850
+ */
1514
1851
  const disposePlugins = async () => {
1852
+ let released = true;
1515
1853
  await Promise.allSettled(loadedPlugins.map(async (plugin) => {
1516
1854
  try {
1517
1855
  await plugin.instance.dispose?.();
1518
1856
  }
1519
1857
  catch (error) {
1858
+ released = false;
1520
1859
  warn(`plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
1521
1860
  }
1522
1861
  }));
1523
1862
  loadedPlugins = [];
1863
+ return released;
1524
1864
  };
1525
1865
  const startedChannels = [];
1866
+ /** Resolves with `true` when `work` settles before `ms` elapse. */
1867
+ const settlesWithin = (work, ms) => new Promise((resolve) => {
1868
+ const timer = setTimeout(() => resolve(false), ms);
1869
+ void work.then(() => { clearTimeout(timer); resolve(true); }, () => { clearTimeout(timer); resolve(true); });
1870
+ });
1871
+ let stopped;
1872
+ /**
1873
+ * The one shutdown sequence — for a stop, for a restart, and for a
1874
+ * start() that failed, whose caller never reaches stop().
1875
+ *
1876
+ * `abortAfterMs` is the difference: a stop waits for every in-flight
1877
+ * turn however long it takes (the service manager's own timeout is the
1878
+ * bound there), while a restart waits that long and then aborts what is
1879
+ * still running, so the daemon can come back instead of waiting on one
1880
+ * stuck turn. Resolves with whether everything finished — after an abort,
1881
+ * a turn that still ignores its signal for a second window is shut down
1882
+ * around, and the host is told so it can exit rather than start a new
1883
+ * daemon beside it. Every wait after the window opens is bounded by it,
1884
+ * the scheduler's own drain included: that drain tracks the same firing
1885
+ * the turn drain gave up on, and an unbounded wait there would hold the
1886
+ * restart forever on exactly the turn the window exists for.
1887
+ */
1888
+ const shutdown = async (abortAfterMs) => {
1889
+ // Refuse new work first; then let channels finish rendering their
1890
+ // in-flight turns (already-started dispatches keep running), drain,
1891
+ // and close.
1892
+ stopping = true;
1893
+ // No new firings from here: the tick stops before anything drains,
1894
+ // so the drain below is over a set that can only shrink. A tick
1895
+ // already past its `stopping` check loses to `dispatch`'s own
1896
+ // refusal — the second gate is why this needs no handshake.
1897
+ scheduler.stop();
1898
+ // Deny what is parked before draining, or the drain waits out every
1899
+ // outstanding approval timeout — a shutdown would hang for as long as
1900
+ // the longest request had left. Each denial is a real decision the
1901
+ // turn continues from, so the drain below still finishes those turns.
1902
+ for (const parked of [...pendingApprovals.values()]) {
1903
+ parked.settle('deny', 'cancelled');
1904
+ }
1905
+ // Everything a restart's window has to cover, in the order a stop
1906
+ // does it. The approval resolutions reach their subscribers before the
1907
+ // channels go down, or a channel that renders approvals never learns
1908
+ // to retract the buttons it is still showing; the channels' stop awaits
1909
+ // the rendering they have in flight, which is a running turn's output.
1910
+ // All three inside the window: a subscriber or a channel stalled on a
1911
+ // stuck turn would otherwise hold the restart outside it.
1912
+ // A channel whose stop rejected may still hold its connection — and a
1913
+ // replacement daemon opening another would be two of them answering
1914
+ // the same messages. Reported, and for a restart, counted as not
1915
+ // drained; a plain stop goes on closing either way.
1916
+ let channelsReleased = true;
1917
+ const drain = (async () => {
1918
+ await Promise.allSettled([...approvalEmissions]);
1919
+ const stopping = [...startedChannels];
1920
+ const stops = await Promise.allSettled(stopping.map((adapter) => adapter.stop()));
1921
+ stops.forEach((result, index) => {
1922
+ if (result.status === 'rejected') {
1923
+ channelsReleased = false;
1924
+ warn(`channel ${stopping[index]?.name} failed to stop: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
1925
+ }
1926
+ });
1927
+ startedChannels.length = 0;
1928
+ await Promise.allSettled([...inflight]);
1929
+ })();
1930
+ let drained = true;
1931
+ if (abortAfterMs === undefined) {
1932
+ await drain;
1933
+ }
1934
+ else if (await settlesWithin(drain, abortAfterMs)) {
1935
+ drained = channelsReleased;
1936
+ }
1937
+ else {
1938
+ warn(`restart: ${inflight.size} turn(s) still running after ${abortAfterMs}ms; aborting them`);
1939
+ // The watchdog's own mechanism: the runner fails each session with
1940
+ // the reason on the signal, saves it, and emits session.failed —
1941
+ // so the next daemon finds a finished turn, not an abandoned one.
1942
+ // Before the loop, so a turn queued behind one aborted here, which
1943
+ // starts as soon as that one settles, is aborted the same way.
1944
+ abortingTurns = true;
1945
+ for (const controller of turnControllers) {
1946
+ controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1947
+ }
1948
+ drained = await settlesWithin(drain, abortAfterMs);
1949
+ if (!drained) {
1950
+ warn(`restart: ${inflight.size} turn(s) did not stop after being aborted; shutting down around them`);
1951
+ }
1952
+ drained = drained && channelsReleased;
1953
+ }
1954
+ // After the turn drain: a firing's wrapper settles after its
1955
+ // dispatch does (it retires spent one-shots), so this is the last
1956
+ // writer of the schedule table. Bounded once the turn drain has given
1957
+ // up: the wrapper it would wait for is around the very turn that
1958
+ // ignored its abort.
1959
+ const schedulerDrain = scheduler.drain();
1960
+ if (drained || abortAfterMs === undefined) {
1961
+ await schedulerDrain;
1962
+ }
1963
+ else if (!(await settlesWithin(schedulerDrain, abortAfterMs))) {
1964
+ warn('restart: a scheduled firing did not stop after being aborted; shutting down around it');
1965
+ }
1966
+ // After the turns that might still be using them. A plugin holding a
1967
+ // browser is the reason this exists, and closing it out from under a
1968
+ // running screenshot would fail that turn rather than tidy up. Bounded
1969
+ // for a restart, whose likeliest reason is a plugin that misbehaves:
1970
+ // a dispose that never settles must not be what keeps the daemon from
1971
+ // coming back, and a plugin still holding on is one more thing the
1972
+ // host should not start a new daemon beside.
1973
+ const disposal = disposePlugins();
1974
+ if (abortAfterMs === undefined) {
1975
+ await disposal;
1976
+ }
1977
+ else if (await settlesWithin(disposal, abortAfterMs)) {
1978
+ // A dispose that rejected has already been named; what it may still
1979
+ // hold is the same problem as one that never answered.
1980
+ drained = drained && await disposal;
1981
+ }
1982
+ else {
1983
+ warn(`restart: a plugin did not release what it holds within ${abortAfterMs}ms; shutting down around it`);
1984
+ drained = false;
1985
+ }
1986
+ closeStores();
1987
+ log('stratusd stopped');
1988
+ return drained;
1989
+ };
1526
1990
  const gateway = {
1527
1991
  bus,
1528
1992
  store,
1529
1993
  async start() {
1530
- await migrateLegacyMemory(env);
1531
- // Before the roster and before channels: a turn must never arrive for
1532
- // an agent whose soul lists a tool the daemon has not registered yet,
1533
- // which would refuse the call as "not permitted" and blame the soul.
1534
- // Skills first for the same reason — and before the plugins, so the
1535
- // operator's bare ids win their aliases (see startSkills).
1536
- await startSkills();
1537
- await startPlugins();
1538
1994
  try {
1995
+ await migrateLegacyMemory(env);
1996
+ // Before the roster and before channels: a turn must never arrive
1997
+ // for an agent whose soul lists a tool the daemon has not
1998
+ // registered yet, which would refuse the call as "not permitted"
1999
+ // and blame the soul. Skills first for the same reason — and before
2000
+ // the plugins, so the operator's bare ids win their aliases (see
2001
+ // startSkills).
2002
+ await startSkills();
2003
+ await startPlugins();
1539
2004
  await startServing();
1540
2005
  }
1541
2006
  catch (error) {
1542
- // Everything after the plugins loaded is inside that try for one
1543
- // reason: a `start()` that rejects never reaches its caller's
1544
- // shutdown path — `stratus serve` awaits it *before* the try/finally
1545
- // that calls `stop()` — so a duplicate agent id in the roster would
1546
- // leave a plugin's browser, socket, or subscription held for the
1547
- // life of a process that is on its way out.
1548
- await disposePlugins();
2007
+ // A `start()` that rejects never reaches its caller's shutdown path
2008
+ // — `stratus serve` awaits it *before* the try/finally that calls
2009
+ // `stop()` — so it is the shutdown, in full: a duplicate agent id
2010
+ // in the roster would otherwise leave a plugin's browser, socket,
2011
+ // or subscription held for the life of a process that is on its
2012
+ // way out; the two stores opened before any of this ran would keep
2013
+ // their descriptors, four more per attempt in a host that retries
2014
+ // a port it cannot bind; and a turn a channel that did come up had
2015
+ // already accepted must finish and save before the store it saves
2016
+ // to is closed, exactly as it would under a stop().
2017
+ await shutdown(undefined);
1549
2018
  throw error;
1550
2019
  }
2020
+ serving = true;
1551
2021
  },
1552
2022
  // Drain: channels stop taking messages, in-flight turns finish, new
1553
2023
  // dispatches are refused, then the database closes. SIGTERM handling
1554
- // in `stratus serve` calls this.
1555
- async stop() {
1556
- // Refuse new work first; then let channels finish rendering their
1557
- // in-flight turns (already-started dispatches keep running), drain,
1558
- // and close.
1559
- stopping = true;
1560
- // No new firings from here: the tick stops before anything drains,
1561
- // so the drain below is over a set that can only shrink. A tick
1562
- // already past its `stopping` check loses to `dispatch`'s own
1563
- // refusal — the second gate is why this needs no handshake.
1564
- scheduler.stop();
1565
- // Deny what is parked before draining, or the drain waits out every
1566
- // outstanding approval timeout — a shutdown would hang for as long as
1567
- // the longest request had left. Each denial is a real decision the
1568
- // turn continues from, so the drain below still finishes those turns.
1569
- for (const parked of [...pendingApprovals.values()]) {
1570
- parked.settle('deny', 'cancelled');
1571
- }
1572
- // Let those resolutions reach their subscribers before the channels
1573
- // go down, or a channel that renders approvals never learns to
1574
- // retract the buttons it is still showing.
1575
- await Promise.allSettled([...approvalEmissions]);
1576
- await Promise.allSettled(startedChannels.map((adapter) => adapter.stop()));
1577
- startedChannels.length = 0;
1578
- await Promise.allSettled([...inflight]);
1579
- // After the turn drain: a firing's wrapper settles after its
1580
- // dispatch does (it retires spent one-shots), so this is the last
1581
- // writer of the schedule table.
1582
- await scheduler.drain();
1583
- // After the turns that might still be using them. A plugin holding a
1584
- // browser is the reason this exists, and closing it out from under a
1585
- // running screenshot would fail that turn rather than tidy up.
1586
- await disposePlugins();
1587
- scheduleStore.close();
1588
- store.close();
1589
- log('stratusd stopped');
2024
+ // in `stratus serve` calls this. Idempotent: a restart stops the
2025
+ // gateway itself before handing the process to the host, whose own
2026
+ // shutdown path then calls this again and must find nothing to do —
2027
+ // and a start() that failed has already run the same sequence.
2028
+ stop() {
2029
+ stopped ??= shutdown(undefined).then(() => undefined);
2030
+ return stopped;
1590
2031
  },
1591
2032
  dispatch,
1592
2033
  async sessionRouting(sessionId) {
1593
2034
  const session = await store.get(sessionId);
1594
- return session
1595
- ? { agentId: session.agent.id, metadata: session.metadata ?? {} }
1596
- : undefined;
2035
+ if (!session) {
2036
+ return undefined;
2037
+ }
2038
+ // The reply, not the record: a channel that finishes a turn it did
2039
+ // not start needs the one message to post, and the transcript stays
2040
+ // the session's own. The latest turn's text only — a turn that
2041
+ // produced none has no reply, and an earlier turn's answer is not it.
2042
+ const reply = latestTurnReply(session);
2043
+ return {
2044
+ agentId: session.agent.id,
2045
+ metadata: session.metadata ?? {},
2046
+ ...(reply !== undefined ? { reply } : {}),
2047
+ };
1597
2048
  },
1598
2049
  agents() {
1599
2050
  return registry.list();
1600
2051
  },
1601
2052
  async reloadRoster() {
1602
- await reloadsInOrder();
2053
+ await reloadsInOrder(loadRoster);
1603
2054
  const roster = registry.list();
1604
2055
  log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
1605
2056
  return roster;
1606
2057
  },
2058
+ async reloadSkills() {
2059
+ try {
2060
+ await reloadsInOrder(loadSkills);
2061
+ }
2062
+ catch (error) {
2063
+ const reason = error instanceof Error ? error.message : String(error);
2064
+ warn(`skills reload refused — ${reason}; the previously loaded skills are still serving`);
2065
+ throw new Error(`Skills were not reloaded: ${reason} The previously loaded skills are still serving; fix the file and reload again.`);
2066
+ }
2067
+ return gateway.skills();
2068
+ },
2069
+ restart(request = {}) {
2070
+ if (!options.onRestart) {
2071
+ throw new RestartUnsupportedError();
2072
+ }
2073
+ if (restartStatus) {
2074
+ return restartStatus;
2075
+ }
2076
+ if (stopping) {
2077
+ throw new Error('The gateway is already stopping, and a stop does not come back on its own.');
2078
+ }
2079
+ if (!serving) {
2080
+ throw new Error('stratusd is still starting; ask for a restart again once it is serving.');
2081
+ }
2082
+ const requested = request.drainTimeoutMs ?? options.restartDrainTimeoutMs ?? DEFAULT_RESTART_DRAIN_TIMEOUT_MS;
2083
+ // Checked here and not only at the HTTP route: a host calling this
2084
+ // directly with a negative or NaN window would get a timer that
2085
+ // fires at once, aborting every turn it meant to let finish. Refused
2086
+ // before anything is announced, so a bad argument is not a restart.
2087
+ if (!Number.isFinite(requested) || requested < 0) {
2088
+ throw new Error(`The restart drain window must be a non-negative number of milliseconds, not ${String(requested)}.`);
2089
+ }
2090
+ // Above Node's maximum timer delay a setTimeout fires at once, so a
2091
+ // window asked for as "very long" would abort every turn immediately
2092
+ // — the same clamp the approval timeout gets, for the same reason.
2093
+ const drainTimeoutMs = Math.min(requested, MAX_TIMER_DELAY_MS);
2094
+ if (requested > MAX_TIMER_DELAY_MS) {
2095
+ warn(`restart drain window ${requested}ms is above Node's maximum timer delay; using ${MAX_TIMER_DELAY_MS}ms (~24.8 days)`);
2096
+ }
2097
+ const status = {
2098
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
2099
+ drainTimeoutMs,
2100
+ inflight: inflight.size,
2101
+ };
2102
+ restartStatus = status;
2103
+ // Said now, while stdout and the structured log both still work: a
2104
+ // process on its way down is the one whose last lines matter, and
2105
+ // the one least able to write them.
2106
+ log(`restart requested${status.reason ? ` (${status.reason})` : ''} — refusing new turns; `
2107
+ + `${status.inflight} turn(s) in flight, draining for up to ${drainTimeoutMs}ms`);
2108
+ const onRestart = options.onRestart;
2109
+ const draining = shutdown(drainTimeoutMs);
2110
+ // The host's own shutdown path calls stop() next; it must find this
2111
+ // one rather than start a second.
2112
+ stopped = draining.then(() => undefined);
2113
+ void draining
2114
+ .then((drained) => onRestart({ ...(status.reason !== undefined ? { reason: status.reason } : {}), drained }))
2115
+ .catch((error) => {
2116
+ warn(`restart failed: ${error instanceof Error ? error.message : String(error)}`);
2117
+ });
2118
+ return status;
2119
+ },
1607
2120
  activeTurnId(sessionId) {
1608
2121
  return activeTurns.get(sessionId);
1609
2122
  },