@stratusagent/gateway 0.10.0 → 0.11.1

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 { abortErrorFor, AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, PENDING_APPROVAL_METADATA_KEY, readPendingApproval, } from '@stratusagent/core';
13
+ import { abortErrorFor, AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, PENDING_APPROVAL_METADATA_KEY, latestTurnReply, readPendingApproval, } from '@stratusagent/core';
14
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 { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
16
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
  *
@@ -459,6 +480,8 @@ export const createGateway = (options = {}) => {
459
480
  agentId: request.session.agent.id,
460
481
  call: request.call,
461
482
  risk: request.risk,
483
+ ...(request.origin !== undefined ? { origin: request.origin } : {}),
484
+ ...(request.oneShot ? { oneShot: true } : {}),
462
485
  parkedAt: request.parkedAt ?? new Date().toISOString(),
463
486
  ...(expiresAt ? { expiresAt } : {}),
464
487
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
@@ -504,6 +527,8 @@ export const createGateway = (options = {}) => {
504
527
  requestId,
505
528
  call: request.call,
506
529
  risk: request.risk,
530
+ ...(request.origin !== undefined ? { origin: request.origin } : {}),
531
+ ...(request.oneShot ? { oneShot: true } : {}),
507
532
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
508
533
  ...(expiresAt ? { expiresAt } : {}),
509
534
  });
@@ -724,29 +749,100 @@ export const createGateway = (options = {}) => {
724
749
  // prose and can degrade. The check itself is the kernel's
725
750
  // (`missingSkillRequirements`), shared with the CLI's local runs so
726
751
  // both hosts warn about the same configuration.
752
+ //
753
+ // The tools advisory rides along, and belongs to THIS load rather
754
+ // than the skills reload below: the registry only gains tools when
755
+ // plugins load, which happens once, before the roster (see `start`).
756
+ // An entry naming a tool nobody registered does nothing, and a whole
757
+ // allowlist of them grants an agent nothing — which is not a quiet
758
+ // degradation, because a persona that still says "use memory.remember"
759
+ // then meets a provider that was shown no such tool, and a model told
760
+ // to use a tool it has not got tends to write the call out as prose,
761
+ // with a plausible result attached.
762
+ const registeredTools = tools.describe().map((tool) => tool.name);
763
+ // Namespaces a loaded plugin is allowed to fill later, so a bridge
764
+ // whose server is still reconnecting is not reported as a typo.
765
+ const discoveredNamespaces = loadedPlugins.flatMap((plugin) => plugin.manifest.contributes.toolsDiscovered.map((declared) => declared.namespace));
727
766
  for (const agent of registry.list()) {
728
767
  for (const { skill, missing } of missingSkillRequirements(agent, skillCatalog)) {
729
768
  warn(`agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
730
769
  }
770
+ const finding = unmatchedToolAllowlist(agent, registeredTools, {
771
+ mayRegister: discoveredNamespaces,
772
+ });
773
+ for (const line of finding ? describeToolAllowlistFinding(agent.id, finding) : []) {
774
+ warn(line);
775
+ }
731
776
  }
732
777
  };
733
778
  /**
734
- * Roster rebuilds, one at a time.
779
+ * Rebuild the skill catalog from what is on disk right now, into a fresh
780
+ * registry, and swap it in whole. See `Gateway.reloadSkills`.
781
+ *
782
+ * Strict on the operator directory: the previous set is still serving,
783
+ * so a file that will not load is a reason to keep it, not to serve the
784
+ * rest. The plugins' skills are re-registered from the load records, in
785
+ * load order, after the operator's — the order `start()` used, which is
786
+ * what makes an operator's bare id win its alias and a bare id two
787
+ * plugins want stay contested. Nothing is imported again: a plugin is
788
+ * code, and code gets a restart.
789
+ */
790
+ const loadSkills = async () => {
791
+ const next = new SkillRegistry();
792
+ const operator = await loadOperatorSkills(env, next, warn, { strict: true });
793
+ for (const plugin of loadedPlugins) {
794
+ for (const record of plugin.skills) {
795
+ // The live catalog holds the very `Skill` the loader staged, under
796
+ // its canonical id; a rebuild re-registers that object rather than
797
+ // re-reading the plugin's file, so a plugin's skills are exactly
798
+ // what loaded with it until it is loaded again.
799
+ const skill = skillCatalog.resolve(record.id);
800
+ if (!skill) {
801
+ throw new Error(`Plugin ${plugin.package} lists skill ${record.id}, but the catalog no longer holds it. Restart the daemon.`);
802
+ }
803
+ next.register(skill);
804
+ next.registerAlias(record.bareId, skill.id);
805
+ }
806
+ }
807
+ const before = new Set(operatorSkills.map((skill) => skill.id));
808
+ const after = new Set(operator.map((skill) => skill.id));
809
+ skillCatalog.replaceWith(next);
810
+ operatorSkills = operator;
811
+ const added = operator.filter((skill) => !before.has(skill.id)).map((skill) => skill.id);
812
+ const removed = [...before].filter((id) => !after.has(id));
813
+ const change = [
814
+ ...(added.length > 0 ? [`added ${added.join(', ')}`] : []),
815
+ ...(removed.length > 0 ? [`removed ${removed.join(', ')}`] : []),
816
+ ];
817
+ log(`skills reloaded — ${skillCatalog.list().length} skill(s)${change.length > 0 ? `: ${change.join('; ')}` : ' (no change)'}`);
818
+ // The same advisory check the roster load runs: a removed skill an
819
+ // agent still lists, or a new one whose `requires:` the agent's tools
820
+ // do not cover, is worth a line now rather than at the next restart.
821
+ for (const agent of registry.list()) {
822
+ for (const { skill, missing } of missingSkillRequirements(agent, skillCatalog)) {
823
+ warn(`agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
824
+ }
825
+ }
826
+ };
827
+ /**
828
+ * Roster and skill rebuilds, one at a time.
735
829
  *
736
830
  * `loadRoster` prunes by comparing the registry against the ids *this pass*
737
831
  * saw, so two overlapping passes destroy each other's work: the older one
738
832
  * snapshots a roster without agent B, the newer one registers B and
739
833
  * finishes, and then the older one reaches its prune and unregisters B —
740
834
  * which two clients creating agents at once will do, each having been told
741
- * 201 for an agent that is no longer dispatchable.
835
+ * 201 for an agent that is no longer dispatchable. Skill reloads share the
836
+ * chain: each is a whole-catalog swap, and the roster load reads the
837
+ * catalog for its advisory warnings.
742
838
  *
743
839
  * A chain rather than a lock: a reload is idempotent, so a caller arriving
744
840
  * mid-rebuild wants the *next* complete one, and waiting for it is exactly
745
841
  * what this gives them.
746
842
  */
747
843
  let reloads = Promise.resolve();
748
- const reloadsInOrder = async () => {
749
- const next = reloads.then(loadRoster, loadRoster);
844
+ const reloadsInOrder = async (work) => {
845
+ const next = reloads.then(work, work);
750
846
  // Swallowed for the chain only — the caller still sees the rejection, but
751
847
  // a roster that failed to load must not block every reload after it.
752
848
  reloads = next.catch(() => undefined);
@@ -936,9 +1032,25 @@ export const createGateway = (options = {}) => {
936
1032
  const withWatchdog = async (sessionId, external, idleEnabled, fallbackStreams, run) => {
937
1033
  const effectiveIdleMs = idleEnabled || fallbackStreams ? idleTimeoutMs : 0;
938
1034
  if (effectiveIdleMs <= 0 && !external) {
939
- return run(new AbortController().signal);
1035
+ // No watchdog and nobody outside to cancel it — but still a turn an
1036
+ // announced restart must be able to cut short at the window.
1037
+ const idle = new AbortController();
1038
+ turnControllers.add(idle);
1039
+ if (abortingTurns) {
1040
+ idle.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1041
+ }
1042
+ try {
1043
+ return await run(idle.signal);
1044
+ }
1045
+ finally {
1046
+ turnControllers.delete(idle);
1047
+ }
940
1048
  }
941
1049
  const controller = new AbortController();
1050
+ turnControllers.add(controller);
1051
+ if (abortingTurns) {
1052
+ controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1053
+ }
942
1054
  // The external reason travels: a caller that aborted with its own
943
1055
  // `RunAbortedError` gets it recorded on the session, like the watchdog's.
944
1056
  const onExternalAbort = () => controller.abort(external?.reason);
@@ -1075,6 +1187,7 @@ export const createGateway = (options = {}) => {
1075
1187
  unsubscribeObserver();
1076
1188
  unsubscribeDrain();
1077
1189
  suspendWatchdog.delete(sessionId);
1190
+ turnControllers.delete(controller);
1078
1191
  external?.removeEventListener('abort', onExternalAbort);
1079
1192
  }
1080
1193
  };
@@ -1085,6 +1198,37 @@ export const createGateway = (options = {}) => {
1085
1198
  const sessionChains = new Map();
1086
1199
  const inflight = new Set();
1087
1200
  let stopping = false;
1201
+ /**
1202
+ * The abort handle of every turn running right now. A restart's drain
1203
+ * window ends by aborting these — the same signal the watchdog fires —
1204
+ * so a turn that outlives the window fails as aborted, its session
1205
+ * saved, instead of being cut off by a process exit and found as
1206
+ * abandoned by the next one.
1207
+ */
1208
+ const turnControllers = new Set();
1209
+ /**
1210
+ * Set once a restart's window has closed and the turns in it were
1211
+ * aborted. A turn that starts after that — one queued behind the
1212
+ * aborted turn on the same session, accepted before the announcement —
1213
+ * is aborted the moment it gets its controller, with the same reason,
1214
+ * rather than running unseen until the shutdown closes the store under
1215
+ * it and leaves it recorded as nothing at all.
1216
+ */
1217
+ let abortingTurns = false;
1218
+ /**
1219
+ * Set once `start()` has finished. A restart before that would stop the
1220
+ * channels started so far and close the stores while the rest of
1221
+ * startup is still running — reachable, since the control API can be
1222
+ * up and answering `POST /restart` while a later channel is still
1223
+ * connecting — and the startup then fails on a closed store instead of
1224
+ * the daemon coming back.
1225
+ */
1226
+ let serving = false;
1227
+ /** Set once `restart()` announced; the refusal a caller reads says why. */
1228
+ let restartStatus;
1229
+ const refusal = () => new Error(restartStatus
1230
+ ? 'stratusd is restarting and will accept new work once it is back up.'
1231
+ : 'The gateway is stopping and no longer accepts new work.');
1088
1232
  /**
1089
1233
  * The turn id running on each session, for callers that named one.
1090
1234
  *
@@ -1369,7 +1513,31 @@ export const createGateway = (options = {}) => {
1369
1513
  session.agent = source.definition;
1370
1514
  await store.save(session);
1371
1515
  const runner = runnerFor(await runtimeForAgent(source));
1372
- await runner.recoverPendingApproval(sessionId, { denyPending: expired });
1516
+ // Tracked like a dispatched turn's controller: a recovered turn
1517
+ // that runs on past its approval is a turn like any other, and a
1518
+ // restart's window has to be able to cut it short with the same
1519
+ // reason — or it runs unseen to the second window and the restart
1520
+ // reports undrained for a turn it could have finished cleanly.
1521
+ const controller = new AbortController();
1522
+ turnControllers.add(controller);
1523
+ if (abortingTurns) {
1524
+ controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1525
+ }
1526
+ try {
1527
+ await runner.recoverPendingApproval(sessionId, { denyPending: expired, signal: controller.signal });
1528
+ }
1529
+ finally {
1530
+ turnControllers.delete(controller);
1531
+ // A recovered firing's row outlived the process that would have
1532
+ // retired it, and the firing is over either way — a recovery that
1533
+ // failed has durably failed its session, which is as finished as
1534
+ // completing. The scheduler decides whether there is a row to
1535
+ // retire, and only this firing's own.
1536
+ const scheduleId = session.metadata?.[SCHEDULE_ID_METADATA_KEY];
1537
+ if (typeof scheduleId === 'string') {
1538
+ scheduler.retireSpentOneShot(scheduleId, sessionId);
1539
+ }
1540
+ }
1373
1541
  }
1374
1542
  catch (error) {
1375
1543
  warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
@@ -1507,13 +1675,13 @@ export const createGateway = (options = {}) => {
1507
1675
  */
1508
1676
  const dispatchScheduledFiring = (input) => {
1509
1677
  if (stopping) {
1510
- throw new Error('The gateway is stopping and no longer accepts new work.');
1678
+ throw refusal();
1511
1679
  }
1512
1680
  return onSessionChain(input.sessionId, () => dispatchInternal(input));
1513
1681
  };
1514
1682
  const dispatch = async (input) => {
1515
1683
  if (stopping) {
1516
- throw new Error('The gateway is stopping and no longer accepts new work.');
1684
+ throw refusal();
1517
1685
  }
1518
1686
  // The reserved namespace is the scheduler's alone. A caller that could
1519
1687
  // name a firing's session id — derivable from `GET /schedules` — would
@@ -1572,7 +1740,16 @@ export const createGateway = (options = {}) => {
1572
1740
  tools,
1573
1741
  skills: skillCatalog,
1574
1742
  bus,
1743
+ // The file-backed resolver, so a plugin needing a key reaches the
1744
+ // *calling* agent's — its allowlist checked, its own entry before the
1745
+ // fleet's shared one.
1746
+ credentials: createFileCredentialResolver(env),
1575
1747
  workspaceRoot: workspacesDirPath(env),
1748
+ // The structured log, so a plugin's lifecycle lines — an MCP server
1749
+ // that dropped, a reconnect that failed — are in `stratus logs` and
1750
+ // not only on a stderr the service manager owns.
1751
+ log,
1752
+ warn,
1576
1753
  });
1577
1754
  loadedPlugins = result.loaded;
1578
1755
  pluginFailures = result.failures;
@@ -1626,7 +1803,10 @@ export const createGateway = (options = {}) => {
1626
1803
  startedChannels.push(adapter);
1627
1804
  }
1628
1805
  catch (error) {
1629
- warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
1806
+ const reason = error instanceof Error ? error.message : String(error);
1807
+ if (!adapter.required) {
1808
+ warn(`channel ${adapter.name} failed to start: ${reason}`);
1809
+ }
1630
1810
  // A start() that rejected may still hold sockets or listeners it
1631
1811
  // acquired before failing; it never reaches startedChannels, so
1632
1812
  // this is its only cleanup.
@@ -1636,6 +1816,17 @@ export const createGateway = (options = {}) => {
1636
1816
  catch (stopError) {
1637
1817
  warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1638
1818
  }
1819
+ if (adapter.required) {
1820
+ // Before the scheduler and the sweeps below, deliberately: a
1821
+ // daemon that is not going to serve must not fire a catch-up
1822
+ // slot or re-ask a parked approval on its way out. The channels
1823
+ // already up are left to the shutdown this throw becomes, which
1824
+ // denies what they parked and lets those denials reach them
1825
+ // BEFORE it stops them — stopped here, a channel that had parked
1826
+ // a gated turn in the window would never learn to retract its
1827
+ // buttons.
1828
+ throw new Error(`${adapter.name} could not start, and the gateway cannot serve without it: ${reason}`);
1829
+ }
1639
1830
  }
1640
1831
  }
1641
1832
  // After the channels, so a catch-up firing has somewhere to report and
@@ -1657,100 +1848,302 @@ export const createGateway = (options = {}) => {
1657
1848
  warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
1658
1849
  });
1659
1850
  };
1660
- /** Release what the plugins acquired, reporting rather than throwing. */
1851
+ /**
1852
+ * Both stores, once. Reached from `stop()` and from a `start()` that
1853
+ * failed, and a host may call the one after the other; node:sqlite
1854
+ * refuses a second close, so this remembers.
1855
+ */
1856
+ let storesClosed = false;
1857
+ const closeStores = () => {
1858
+ if (storesClosed) {
1859
+ return;
1860
+ }
1861
+ storesClosed = true;
1862
+ scheduleStore.close();
1863
+ store.close();
1864
+ };
1865
+ /**
1866
+ * Release what the plugins acquired, reporting rather than throwing.
1867
+ * Resolves with whether every plugin let go: a dispose that rejected may
1868
+ * still hold the browser or socket it was closing, which a restart has
1869
+ * to know before it starts a daemon beside it.
1870
+ */
1661
1871
  const disposePlugins = async () => {
1872
+ let released = true;
1662
1873
  await Promise.allSettled(loadedPlugins.map(async (plugin) => {
1663
1874
  try {
1664
1875
  await plugin.instance.dispose?.();
1665
1876
  }
1666
1877
  catch (error) {
1878
+ released = false;
1667
1879
  warn(`plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
1668
1880
  }
1669
1881
  }));
1670
1882
  loadedPlugins = [];
1883
+ return released;
1671
1884
  };
1672
1885
  const startedChannels = [];
1886
+ /** Resolves with `true` when `work` settles before `ms` elapse. */
1887
+ const settlesWithin = (work, ms) => new Promise((resolve) => {
1888
+ const timer = setTimeout(() => resolve(false), ms);
1889
+ void work.then(() => { clearTimeout(timer); resolve(true); }, () => { clearTimeout(timer); resolve(true); });
1890
+ });
1891
+ let stopped;
1892
+ /**
1893
+ * The one shutdown sequence — for a stop, for a restart, and for a
1894
+ * start() that failed, whose caller never reaches stop().
1895
+ *
1896
+ * `abortAfterMs` is the difference: a stop waits for every in-flight
1897
+ * turn however long it takes (the service manager's own timeout is the
1898
+ * bound there), while a restart waits that long and then aborts what is
1899
+ * still running, so the daemon can come back instead of waiting on one
1900
+ * stuck turn. Resolves with whether everything finished — after an abort,
1901
+ * a turn that still ignores its signal for a second window is shut down
1902
+ * around, and the host is told so it can exit rather than start a new
1903
+ * daemon beside it. Every wait after the window opens is bounded by it,
1904
+ * the scheduler's own drain included: that drain tracks the same firing
1905
+ * the turn drain gave up on, and an unbounded wait there would hold the
1906
+ * restart forever on exactly the turn the window exists for.
1907
+ */
1908
+ const shutdown = async (abortAfterMs) => {
1909
+ // Refuse new work first; then let channels finish rendering their
1910
+ // in-flight turns (already-started dispatches keep running), drain,
1911
+ // and close.
1912
+ stopping = true;
1913
+ // No new firings from here: the tick stops before anything drains,
1914
+ // so the drain below is over a set that can only shrink. A tick
1915
+ // already past its `stopping` check loses to `dispatch`'s own
1916
+ // refusal — the second gate is why this needs no handshake.
1917
+ scheduler.stop();
1918
+ // Deny what is parked before draining, or the drain waits out every
1919
+ // outstanding approval timeout — a shutdown would hang for as long as
1920
+ // the longest request had left. Each denial is a real decision the
1921
+ // turn continues from, so the drain below still finishes those turns.
1922
+ for (const parked of [...pendingApprovals.values()]) {
1923
+ parked.settle('deny', 'cancelled');
1924
+ }
1925
+ // Everything a restart's window has to cover, in the order a stop
1926
+ // does it. The approval resolutions reach their subscribers before the
1927
+ // channels go down, or a channel that renders approvals never learns
1928
+ // to retract the buttons it is still showing; the channels' stop awaits
1929
+ // the rendering they have in flight, which is a running turn's output.
1930
+ // All three inside the window: a subscriber or a channel stalled on a
1931
+ // stuck turn would otherwise hold the restart outside it.
1932
+ // A channel whose stop rejected may still hold its connection — and a
1933
+ // replacement daemon opening another would be two of them answering
1934
+ // the same messages. Reported, and for a restart, counted as not
1935
+ // drained; a plain stop goes on closing either way.
1936
+ let channelsReleased = true;
1937
+ const drain = (async () => {
1938
+ await Promise.allSettled([...approvalEmissions]);
1939
+ const stopping = [...startedChannels];
1940
+ const stops = await Promise.allSettled(stopping.map((adapter) => adapter.stop()));
1941
+ stops.forEach((result, index) => {
1942
+ if (result.status === 'rejected') {
1943
+ channelsReleased = false;
1944
+ warn(`channel ${stopping[index]?.name} failed to stop: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
1945
+ }
1946
+ });
1947
+ startedChannels.length = 0;
1948
+ await Promise.allSettled([...inflight]);
1949
+ })();
1950
+ let drained = true;
1951
+ if (abortAfterMs === undefined) {
1952
+ await drain;
1953
+ }
1954
+ else if (await settlesWithin(drain, abortAfterMs)) {
1955
+ drained = channelsReleased;
1956
+ }
1957
+ else {
1958
+ warn(`restart: ${inflight.size} turn(s) still running after ${abortAfterMs}ms; aborting them`);
1959
+ // The watchdog's own mechanism: the runner fails each session with
1960
+ // the reason on the signal, saves it, and emits session.failed —
1961
+ // so the next daemon finds a finished turn, not an abandoned one.
1962
+ // Before the loop, so a turn queued behind one aborted here, which
1963
+ // starts as soon as that one settles, is aborted the same way.
1964
+ abortingTurns = true;
1965
+ for (const controller of turnControllers) {
1966
+ controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1967
+ }
1968
+ drained = await settlesWithin(drain, abortAfterMs);
1969
+ if (!drained) {
1970
+ warn(`restart: ${inflight.size} turn(s) did not stop after being aborted; shutting down around them`);
1971
+ }
1972
+ drained = drained && channelsReleased;
1973
+ }
1974
+ // After the turn drain: a firing's wrapper settles after its
1975
+ // dispatch does (it retires spent one-shots), so this is the last
1976
+ // writer of the schedule table. Bounded once the turn drain has given
1977
+ // up: the wrapper it would wait for is around the very turn that
1978
+ // ignored its abort.
1979
+ const schedulerDrain = scheduler.drain();
1980
+ if (drained || abortAfterMs === undefined) {
1981
+ await schedulerDrain;
1982
+ }
1983
+ else if (!(await settlesWithin(schedulerDrain, abortAfterMs))) {
1984
+ warn('restart: a scheduled firing did not stop after being aborted; shutting down around it');
1985
+ }
1986
+ // After the turns that might still be using them. A plugin holding a
1987
+ // browser is the reason this exists, and closing it out from under a
1988
+ // running screenshot would fail that turn rather than tidy up. Bounded
1989
+ // for a restart, whose likeliest reason is a plugin that misbehaves:
1990
+ // a dispose that never settles must not be what keeps the daemon from
1991
+ // coming back, and a plugin still holding on is one more thing the
1992
+ // host should not start a new daemon beside.
1993
+ const disposal = disposePlugins();
1994
+ if (abortAfterMs === undefined) {
1995
+ await disposal;
1996
+ }
1997
+ else if (await settlesWithin(disposal, abortAfterMs)) {
1998
+ // A dispose that rejected has already been named; what it may still
1999
+ // hold is the same problem as one that never answered.
2000
+ drained = drained && await disposal;
2001
+ }
2002
+ else {
2003
+ warn(`restart: a plugin did not release what it holds within ${abortAfterMs}ms; shutting down around it`);
2004
+ drained = false;
2005
+ }
2006
+ closeStores();
2007
+ log('stratusd stopped');
2008
+ return drained;
2009
+ };
1673
2010
  const gateway = {
1674
2011
  bus,
1675
2012
  store,
1676
2013
  async start() {
1677
- await migrateLegacyMemory(env);
1678
- // Before the roster and before channels: a turn must never arrive for
1679
- // an agent whose soul lists a tool the daemon has not registered yet,
1680
- // which would refuse the call as "not permitted" and blame the soul.
1681
- // Skills first for the same reason — and before the plugins, so the
1682
- // operator's bare ids win their aliases (see startSkills).
1683
- await startSkills();
1684
- await startPlugins();
1685
2014
  try {
2015
+ await migrateLegacyMemory(env);
2016
+ // Before the roster and before channels: a turn must never arrive
2017
+ // for an agent whose soul lists a tool the daemon has not
2018
+ // registered yet, which would refuse the call as "not permitted"
2019
+ // and blame the soul. Skills first for the same reason — and before
2020
+ // the plugins, so the operator's bare ids win their aliases (see
2021
+ // startSkills).
2022
+ await startSkills();
2023
+ await startPlugins();
1686
2024
  await startServing();
1687
2025
  }
1688
2026
  catch (error) {
1689
- // Everything after the plugins loaded is inside that try for one
1690
- // reason: a `start()` that rejects never reaches its caller's
1691
- // shutdown path — `stratus serve` awaits it *before* the try/finally
1692
- // that calls `stop()` — so a duplicate agent id in the roster would
1693
- // leave a plugin's browser, socket, or subscription held for the
1694
- // life of a process that is on its way out.
1695
- await disposePlugins();
2027
+ // A `start()` that rejects never reaches its caller's shutdown path
2028
+ // — `stratus serve` awaits it *before* the try/finally that calls
2029
+ // `stop()` — so it is the shutdown, in full: a duplicate agent id
2030
+ // in the roster would otherwise leave a plugin's browser, socket,
2031
+ // or subscription held for the life of a process that is on its
2032
+ // way out; the two stores opened before any of this ran would keep
2033
+ // their descriptors, four more per attempt in a host that retries
2034
+ // a port it cannot bind; and a turn a channel that did come up had
2035
+ // already accepted must finish and save before the store it saves
2036
+ // to is closed, exactly as it would under a stop().
2037
+ await shutdown(undefined);
1696
2038
  throw error;
1697
2039
  }
2040
+ serving = true;
1698
2041
  },
1699
2042
  // Drain: channels stop taking messages, in-flight turns finish, new
1700
2043
  // dispatches are refused, then the database closes. SIGTERM handling
1701
- // in `stratus serve` calls this.
1702
- async stop() {
1703
- // Refuse new work first; then let channels finish rendering their
1704
- // in-flight turns (already-started dispatches keep running), drain,
1705
- // and close.
1706
- stopping = true;
1707
- // No new firings from here: the tick stops before anything drains,
1708
- // so the drain below is over a set that can only shrink. A tick
1709
- // already past its `stopping` check loses to `dispatch`'s own
1710
- // refusal — the second gate is why this needs no handshake.
1711
- scheduler.stop();
1712
- // Deny what is parked before draining, or the drain waits out every
1713
- // outstanding approval timeout — a shutdown would hang for as long as
1714
- // the longest request had left. Each denial is a real decision the
1715
- // turn continues from, so the drain below still finishes those turns.
1716
- for (const parked of [...pendingApprovals.values()]) {
1717
- parked.settle('deny', 'cancelled');
1718
- }
1719
- // Let those resolutions reach their subscribers before the channels
1720
- // go down, or a channel that renders approvals never learns to
1721
- // retract the buttons it is still showing.
1722
- await Promise.allSettled([...approvalEmissions]);
1723
- await Promise.allSettled(startedChannels.map((adapter) => adapter.stop()));
1724
- startedChannels.length = 0;
1725
- await Promise.allSettled([...inflight]);
1726
- // After the turn drain: a firing's wrapper settles after its
1727
- // dispatch does (it retires spent one-shots), so this is the last
1728
- // writer of the schedule table.
1729
- await scheduler.drain();
1730
- // After the turns that might still be using them. A plugin holding a
1731
- // browser is the reason this exists, and closing it out from under a
1732
- // running screenshot would fail that turn rather than tidy up.
1733
- await disposePlugins();
1734
- scheduleStore.close();
1735
- store.close();
1736
- log('stratusd stopped');
2044
+ // in `stratus serve` calls this. Idempotent: a restart stops the
2045
+ // gateway itself before handing the process to the host, whose own
2046
+ // shutdown path then calls this again and must find nothing to do —
2047
+ // and a start() that failed has already run the same sequence.
2048
+ stop() {
2049
+ stopped ??= shutdown(undefined).then(() => undefined);
2050
+ return stopped;
1737
2051
  },
1738
2052
  dispatch,
1739
2053
  async sessionRouting(sessionId) {
1740
2054
  const session = await store.get(sessionId);
1741
- return session
1742
- ? { agentId: session.agent.id, metadata: session.metadata ?? {} }
1743
- : undefined;
2055
+ if (!session) {
2056
+ return undefined;
2057
+ }
2058
+ // The reply, not the record: a channel that finishes a turn it did
2059
+ // not start needs the one message to post, and the transcript stays
2060
+ // the session's own. The latest turn's text only — a turn that
2061
+ // produced none has no reply, and an earlier turn's answer is not it.
2062
+ const reply = latestTurnReply(session);
2063
+ // When this agent last spoke, which is a different question from when
2064
+ // its session last changed: a turn saves on tool results, approval
2065
+ // checkpoints, and a recovery resuming, all without having said
2066
+ // anything. A channel ordering two agents by "who spoke last" has to
2067
+ // read the speaking.
2068
+ const lastSpokeAt = session.messages.findLast((message) => message.role === 'assistant' && message.content.trim().length > 0)?.createdAt;
2069
+ return {
2070
+ agentId: session.agent.id,
2071
+ metadata: session.metadata ?? {},
2072
+ ...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}),
2073
+ ...(reply !== undefined ? { reply } : {}),
2074
+ };
1744
2075
  },
1745
2076
  agents() {
1746
2077
  return registry.list();
1747
2078
  },
1748
2079
  async reloadRoster() {
1749
- await reloadsInOrder();
2080
+ await reloadsInOrder(loadRoster);
1750
2081
  const roster = registry.list();
1751
2082
  log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
1752
2083
  return roster;
1753
2084
  },
2085
+ async reloadSkills() {
2086
+ try {
2087
+ await reloadsInOrder(loadSkills);
2088
+ }
2089
+ catch (error) {
2090
+ const reason = error instanceof Error ? error.message : String(error);
2091
+ warn(`skills reload refused — ${reason}; the previously loaded skills are still serving`);
2092
+ throw new Error(`Skills were not reloaded: ${reason} The previously loaded skills are still serving; fix the file and reload again.`);
2093
+ }
2094
+ return gateway.skills();
2095
+ },
2096
+ restart(request = {}) {
2097
+ if (!options.onRestart) {
2098
+ throw new RestartUnsupportedError();
2099
+ }
2100
+ if (restartStatus) {
2101
+ return restartStatus;
2102
+ }
2103
+ if (stopping) {
2104
+ throw new Error('The gateway is already stopping, and a stop does not come back on its own.');
2105
+ }
2106
+ if (!serving) {
2107
+ throw new Error('stratusd is still starting; ask for a restart again once it is serving.');
2108
+ }
2109
+ const requested = request.drainTimeoutMs ?? options.restartDrainTimeoutMs ?? DEFAULT_RESTART_DRAIN_TIMEOUT_MS;
2110
+ // Checked here and not only at the HTTP route: a host calling this
2111
+ // directly with a negative or NaN window would get a timer that
2112
+ // fires at once, aborting every turn it meant to let finish. Refused
2113
+ // before anything is announced, so a bad argument is not a restart.
2114
+ if (!Number.isFinite(requested) || requested < 0) {
2115
+ throw new Error(`The restart drain window must be a non-negative number of milliseconds, not ${String(requested)}.`);
2116
+ }
2117
+ // Above Node's maximum timer delay a setTimeout fires at once, so a
2118
+ // window asked for as "very long" would abort every turn immediately
2119
+ // — the same clamp the approval timeout gets, for the same reason.
2120
+ const drainTimeoutMs = Math.min(requested, MAX_TIMER_DELAY_MS);
2121
+ if (requested > MAX_TIMER_DELAY_MS) {
2122
+ warn(`restart drain window ${requested}ms is above Node's maximum timer delay; using ${MAX_TIMER_DELAY_MS}ms (~24.8 days)`);
2123
+ }
2124
+ const status = {
2125
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
2126
+ drainTimeoutMs,
2127
+ inflight: inflight.size,
2128
+ };
2129
+ restartStatus = status;
2130
+ // Said now, while stdout and the structured log both still work: a
2131
+ // process on its way down is the one whose last lines matter, and
2132
+ // the one least able to write them.
2133
+ log(`restart requested${status.reason ? ` (${status.reason})` : ''} — refusing new turns; `
2134
+ + `${status.inflight} turn(s) in flight, draining for up to ${drainTimeoutMs}ms`);
2135
+ const onRestart = options.onRestart;
2136
+ const draining = shutdown(drainTimeoutMs);
2137
+ // The host's own shutdown path calls stop() next; it must find this
2138
+ // one rather than start a second.
2139
+ stopped = draining.then(() => undefined);
2140
+ void draining
2141
+ .then((drained) => onRestart({ ...(status.reason !== undefined ? { reason: status.reason } : {}), drained }))
2142
+ .catch((error) => {
2143
+ warn(`restart failed: ${error instanceof Error ? error.message : String(error)}`);
2144
+ });
2145
+ return status;
2146
+ },
1754
2147
  activeTurnId(sessionId) {
1755
2148
  return activeTurns.get(sessionId);
1756
2149
  },