@stratusagent/gateway 0.11.9 → 0.12.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
@@ -6,9 +6,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import { createHash, randomUUID } from 'node:crypto';
10
- import { filePathsOf, isUnaddressedTurn, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, } from '@stratusagent/core';
11
- import { createCredentialRequestTool, createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, 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, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
9
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
10
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { filePathsOf, isUnaddressedTurn, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, matchesToolAllowlist, } from '@stratusagent/core';
13
+ import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, 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, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
12
14
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
13
15
  import { ShardedSessionStore } from "./sessions.js";
14
16
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
@@ -16,7 +18,7 @@ export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTa
16
18
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
17
19
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
18
20
  import { loadPlugins, } from '@stratusagent/plugins';
19
- import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, } from '@stratusagent/state';
21
+ import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, } from '@stratusagent/state';
20
22
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
21
23
  /**
22
24
  * How long a parked call waits for a person. Long enough to survive a
@@ -48,6 +50,92 @@ export const RESTARTING_TURN_ERROR = 'Run aborted: stratusd is restarting';
48
50
  * operator can hit agree.
49
51
  */
50
52
  export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
53
+ /** What a paused daemon says when nobody gave it a sentence of its own. */
54
+ export const DEFAULT_PAUSED_MESSAGE = "I'm paused right now and not taking new work. Please try again later.";
55
+ /** What a held gateway answers a dispatch with, before it is promoted. */
56
+ export const HELD_MESSAGE = "I'm starting up and not taking work yet. Please try again in a moment.";
57
+ /** The longest pause message, so a reply to a refused message stays one. */
58
+ export const MAX_PAUSED_MESSAGE_LENGTH = 500;
59
+ /** A pause message the gateway will not take: empty, or too long. */
60
+ export class PauseMessageError extends Error {
61
+ constructor(message) {
62
+ super(message);
63
+ this.name = 'PauseMessageError';
64
+ }
65
+ }
66
+ /**
67
+ * A dispatch refused because intake is paused. Its message is the sentence
68
+ * to show whoever sent the work, as it is: a channel posts it as the reply,
69
+ * not as a failure.
70
+ */
71
+ export class IntakePausedError extends Error {
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = 'IntakePausedError';
75
+ }
76
+ }
77
+ /**
78
+ * The intake state a previous process left, or open when there is none.
79
+ *
80
+ * Fails closed: a file that exists but cannot be read or parsed starts the
81
+ * daemon paused, with a warning naming it. A pause exists to stop work (a
82
+ * spending cap, a maintenance window), and a file nobody can read is no
83
+ * evidence that it was lifted.
84
+ */
85
+ const readIntakeState = async (env, warn) => {
86
+ const file = intakeStatePath(env);
87
+ let raw;
88
+ try {
89
+ raw = await readFile(file, 'utf8');
90
+ }
91
+ catch (error) {
92
+ if (error.code === 'ENOENT') {
93
+ return { paused: false };
94
+ }
95
+ warn(`intake: ${file} could not be read (${error.message}); starting paused. Resume intake to clear it.`);
96
+ return { paused: true, since: new Date().toISOString() };
97
+ }
98
+ try {
99
+ const parsed = JSON.parse(raw);
100
+ // Only a pause is ever written here (resuming removes the file), so
101
+ // anything else in it is not a record this build wrote, and the same
102
+ // rule as an unreadable file applies.
103
+ if (typeof parsed !== 'object' || parsed === null || parsed.paused !== true) {
104
+ warn(`intake: ${file} does not hold a pause record; starting paused. Resume intake to clear it.`);
105
+ return { paused: true, since: new Date().toISOString() };
106
+ }
107
+ return {
108
+ paused: true,
109
+ ...(typeof parsed.message === 'string' && parsed.message.trim().length > 0 ? { message: parsed.message } : {}),
110
+ since: typeof parsed.since === 'string' ? parsed.since : new Date().toISOString(),
111
+ };
112
+ }
113
+ catch {
114
+ warn(`intake: ${file} is not valid JSON; starting paused. Resume intake to clear it.`);
115
+ return { paused: true, since: new Date().toISOString() };
116
+ }
117
+ };
118
+ /**
119
+ * Record a pause durably (0600, replaced in one atomic step), or remove the
120
+ * record when intake reopens: open is the default, so nothing on disk
121
+ * means open.
122
+ */
123
+ const writeIntakeState = async (env, state) => {
124
+ const file = intakeStatePath(env);
125
+ if (!state.paused) {
126
+ await rm(file, { force: true });
127
+ return;
128
+ }
129
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
130
+ const staging = `${file}.${randomBytes(8).toString('hex')}`;
131
+ try {
132
+ await writeFile(staging, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600 });
133
+ await rename(staging, file);
134
+ }
135
+ finally {
136
+ await rm(staging, { force: true });
137
+ }
138
+ };
51
139
  /**
52
140
  * `restart()` on a gateway whose host gave it no way back. Exported so the
53
141
  * control API can answer it as "not supported here" rather than as a
@@ -256,7 +344,7 @@ export const createGateway = (options = {}) => {
256
344
  * "cannot address" rather than a stale reference — and rejecting is how
257
345
  * both callers (schedule creation, `message.send`) hear why.
258
346
  */
259
- const outboundFor = async (agentId, destination) => {
347
+ const outboundFor = async (agentId, destination, thread) => {
260
348
  const { adapter, carriesOthers } = channelCarrying(destination.channel, agentId, (candidate) => candidate.resolveOutbound !== undefined);
261
349
  if (!adapter?.resolveOutbound && carriesOthers) {
262
350
  throw new Error(`No running '${destination.channel}' channel carries agent ${agentId} — `
@@ -268,7 +356,7 @@ export const createGateway = (options = {}) => {
268
356
  ? 'no channel adapters are running.'
269
357
  : `running channels: ${startedChannels.map((candidate) => candidate.name).join(', ')}.`));
270
358
  }
271
- return adapter.resolveOutbound({ agentId, to: destination.to });
359
+ return adapter.resolveOutbound({ agentId, to: destination.to, ...(thread !== undefined ? { thread } : {}) });
272
360
  };
273
361
  const scheduler = createSchedulerRuntime({
274
362
  store: scheduleStore,
@@ -286,6 +374,7 @@ export const createGateway = (options = {}) => {
286
374
  // The same stamp check every turn runs, before a slot is claimed — see
287
375
  // `SchedulerRuntimeOptions.ready`.
288
376
  ready: () => assertStateCompatible(env),
377
+ paused: () => intakeState.paused,
289
378
  log,
290
379
  warn,
291
380
  });
@@ -329,7 +418,39 @@ export const createGateway = (options = {}) => {
329
418
  * so a click racing the timeout — or arriving after an abort — is refused
330
419
  * rather than executing a tool for a turn that has already moved on.
331
420
  */
332
- const requestApproval = (request) => new Promise((resolve) => {
421
+ /**
422
+ * The conversation a delegated sub-session's work was asked from, read
423
+ * from the store rather than from anything the caller wrote: the root
424
+ * session `agent.delegate` recorded, which is where the person waiting
425
+ * on the answer is. Undefined for an ordinary session, or a root that no
426
+ * longer exists — the request is then asked the way it always was.
427
+ */
428
+ const delegationOriginOf = async (session) => {
429
+ if (!isDelegatedSession(session)) {
430
+ return undefined;
431
+ }
432
+ const rootId = session.metadata?.[ROOT_SESSION_ID_METADATA_KEY];
433
+ if (typeof rootId !== 'string' || rootId === session.id) {
434
+ return undefined;
435
+ }
436
+ let root;
437
+ try {
438
+ root = await store.get(rootId);
439
+ }
440
+ catch (error) {
441
+ warn(`${session.id}: could not read the delegating session ${rootId} to route an approval (${error instanceof Error ? error.message : String(error)})`);
442
+ return undefined;
443
+ }
444
+ if (!root) {
445
+ return undefined;
446
+ }
447
+ return { agentId: root.agent.id, sessionId: root.id, ...(root.metadata ? { metadata: root.metadata } : {}) };
448
+ };
449
+ const requestApproval = async (request) => {
450
+ const delegatedFrom = await delegationOriginOf(request.session);
451
+ return requestApprovalFrom(request, delegatedFrom);
452
+ };
453
+ const requestApprovalFrom = (request, delegatedFrom) => new Promise((resolve) => {
333
454
  // A shutdown denies what is parked once, at the top of stop(). Turns
334
455
  // already running keep going through the drain, though, and one of them
335
456
  // can reach a gated tool AFTER that snapshot — finishing a provider
@@ -369,7 +490,17 @@ export const createGateway = (options = {}) => {
369
490
  * all and its denial should not wait on an emission that never runs.
370
491
  */
371
492
  let announced = Promise.resolve();
372
- const settle = (answer, reason, actor) => {
493
+ // Work one agent does for another is allowed one call at a time. The
494
+ // people asked are the delegating conversation's, and a standing grant
495
+ // would widen what the other agent may do on its own work for good —
496
+ // so nothing offers **Always allow** here, and an `always` that
497
+ // arrives anyway (the control API takes all three answers) runs the
498
+ // call once, recorded as exactly that.
499
+ const forAnotherAgent = delegatedFrom !== undefined && delegatedFrom.agentId !== request.session.agent.id;
500
+ const oneShot = request.oneShot === true || forAnotherAgent;
501
+ const always = forAnotherAgent ? undefined : request.always;
502
+ const settle = (given, reason, actor) => {
503
+ const answer = forAnotherAgent && given === 'always' ? 'once' : given;
373
504
  // Identity, not presence: a request that already settled is gone from
374
505
  // the registry, and comparing against this exact function is what
375
506
  // makes every ending — click, timeout, abort, shutdown — idempotent.
@@ -414,11 +545,12 @@ export const createGateway = (options = {}) => {
414
545
  call: request.call,
415
546
  risk: request.risk,
416
547
  ...(request.origin !== undefined ? { origin: request.origin } : {}),
417
- ...(request.oneShot ? { oneShot: true } : {}),
418
- ...(request.always !== undefined ? { always: request.always } : {}),
548
+ ...(oneShot ? { oneShot: true } : {}),
549
+ ...(always !== undefined ? { always } : {}),
419
550
  parkedAt: request.parkedAt ?? new Date().toISOString(),
420
551
  ...(expiresAt ? { expiresAt } : {}),
421
552
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
553
+ ...(delegatedFrom ? { delegatedFrom } : {}),
422
554
  },
423
555
  });
424
556
  if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
@@ -462,9 +594,10 @@ export const createGateway = (options = {}) => {
462
594
  call: request.call,
463
595
  risk: request.risk,
464
596
  ...(request.origin !== undefined ? { origin: request.origin } : {}),
465
- ...(request.oneShot ? { oneShot: true } : {}),
466
- ...(request.always !== undefined ? { always: request.always } : {}),
597
+ ...(oneShot ? { oneShot: true } : {}),
598
+ ...(always !== undefined ? { always } : {}),
467
599
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
600
+ ...(delegatedFrom ? { delegatedFrom } : {}),
468
601
  ...(expiresAt ? { expiresAt } : {}),
469
602
  });
470
603
  // Drained at shutdown alongside the resolutions: a channel that has not
@@ -993,10 +1126,15 @@ export const createGateway = (options = {}) => {
993
1126
  for (const scheduleTool of createScheduleTools(scheduler.handle)) {
994
1127
  tools.register(scheduleTool);
995
1128
  }
996
- tools.register(createMessageSendTool(async ({ agentId, destination, text }) => {
997
- const connection = await outboundFor(agentId, destination);
998
- await connection.post(text);
999
- }));
1129
+ tools.register(createMessageSendTool(async ({ agentId, destination, text, thread }) => {
1130
+ const connection = await outboundFor(agentId, destination, thread);
1131
+ const posted = await connection.post(text);
1132
+ // The channel contract's `OutboundMessageRef`, read structurally: the
1133
+ // gateway's mirror of the adapter types `post` loosely, and an adapter
1134
+ // that reports no id simply has none to hand back.
1135
+ const ts = typeof posted === 'object' && posted !== null ? posted.ts : undefined;
1136
+ return typeof ts === 'string' && ts.length > 0 ? { id: ts } : {};
1137
+ }, { defaultThread: (session, destination) => scheduler.reportThreadFor(session, destination) }));
1000
1138
  tools.register(createMessageReadTool(async ({ agentId, source, ...window }) => {
1001
1139
  // The same carrying rule as outbound: a read goes through the app of
1002
1140
  // the agent asking, never whichever adapter of that kind is running,
@@ -1056,6 +1194,12 @@ export const createGateway = (options = {}) => {
1056
1194
  ...(session.metadata ? { metadata: session.metadata } : {}),
1057
1195
  });
1058
1196
  };
1197
+ // A link only for an agent whose soul lists the tool. Every agent may
1198
+ // ask through a form, because a form goes to an approver; a link is a
1199
+ // bearer credential the model is handed, and whoever it reaches can
1200
+ // store a key and amend the soul. That is the operator's to allow.
1201
+ const allowlist = session.agent.tools ?? registry.get(agentId)?.tools;
1202
+ const linkAllowed = allowlist === undefined || matchesToolAllowlist(CREDENTIAL_REQUEST_TOOL_NAME, allowlist);
1059
1203
  // Why no form went up, said to the agent beside the link that replaces
1060
1204
  // it, so it can tell the person why they are getting a link instead.
1061
1205
  let formUnavailable;
@@ -1099,9 +1243,13 @@ export const createGateway = (options = {}) => {
1099
1243
  }
1100
1244
  }
1101
1245
  if (request.via === 'form') {
1102
- throw new Error(`${formUnavailable} Nothing is pending. Ask for a link instead (via: "link"), or ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1246
+ throw new Error(`${formUnavailable} Nothing is pending. ${linkAllowed ? 'Ask for a link instead (via: "link"), or ask' : 'Ask'} your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1103
1247
  }
1104
1248
  }
1249
+ if (!linkAllowed) {
1250
+ throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}A one-time link needs ${CREDENTIAL_REQUEST_TOOL_NAME} in your soul's tools: list, and it is not there. Nothing is pending. `
1251
+ + `Ask in a conversation that can show your operator a form, or ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1252
+ }
1105
1253
  // A link where no form can go, from whichever adapter issues them: the
1106
1254
  // control API, which is not tied to any one agent's transport.
1107
1255
  const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
@@ -1289,7 +1437,8 @@ export const createGateway = (options = {}) => {
1289
1437
  // runtimes take it as their inner limit), so it is part of the key:
1290
1438
  // two agents on one model with different budgets get their own runners.
1291
1439
  const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
1292
- const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}`;
1440
+ const autoContinue = options.autoContinueFor?.(agentId);
1441
+ const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}`;
1293
1442
  const existing = runners.get(key);
1294
1443
  if (existing) {
1295
1444
  return existing;
@@ -1319,10 +1468,16 @@ export const createGateway = (options = {}) => {
1319
1468
  store,
1320
1469
  bus,
1321
1470
  agents: registry,
1471
+ // Asking a person for a key is harmless to grant: they decide
1472
+ // everything after the question, so no soul has to list it.
1473
+ grantedToEveryAgent: [CREDENTIAL_REQUEST_TOOL_NAME],
1322
1474
  skills: skillCatalog,
1323
1475
  memory,
1324
1476
  streaming: true,
1325
1477
  ...(maxTurns !== undefined ? { maxTurns } : {}),
1478
+ ...(autoContinue !== undefined
1479
+ ? { autoContinue, waitingInput: (sessionId) => (waitingDispatches.get(sessionId) ?? 0) > 0 }
1480
+ : {}),
1326
1481
  });
1327
1482
  hostedRunner = runner;
1328
1483
  runners.set(key, runner);
@@ -1585,8 +1740,30 @@ export const createGateway = (options = {}) => {
1585
1740
  // behind the in-flight turn instead of interleaving with it. Sessions
1586
1741
  // are independent — different conversations run concurrently.
1587
1742
  const sessionChains = new Map();
1743
+ /**
1744
+ * Dispatches queued behind a session's running turn, per session: what
1745
+ * tells an auto-continuing turn that somebody wrote meanwhile, so it
1746
+ * wraps up and lets them in rather than keep the session to itself.
1747
+ */
1748
+ const waitingDispatches = new Map();
1588
1749
  const inflight = new Set();
1589
1750
  let stopping = false;
1751
+ // Open until `startServing` reads what the last process left, which it
1752
+ // does before any channel or the scheduler can deliver work.
1753
+ let intakeState = { paused: false };
1754
+ let heldState = options.held === true;
1755
+ /** What a held start left for `promote()`: the channels it did not start, and the sweeps it did not run. */
1756
+ let deferredStart;
1757
+ // Pause and resume run one at a time, each writing the file and then the
1758
+ // state, so the two always agree: overlapping, a pause's write could land
1759
+ // after a resume removed the file, leaving the daemon paused in memory
1760
+ // and open on disk — open again after the next restart.
1761
+ let intakeChain = Promise.resolve();
1762
+ const intakeTransition = (work) => {
1763
+ const run = intakeChain.then(work);
1764
+ intakeChain = run.catch(() => undefined);
1765
+ return run;
1766
+ };
1590
1767
  /**
1591
1768
  * The abort handle of every turn running right now. A restart's drain
1592
1769
  * window ends by aborting these — the same signal the watchdog fires —
@@ -2461,6 +2638,17 @@ export const createGateway = (options = {}) => {
2461
2638
  input.onRepeat?.('live');
2462
2639
  return live.turn;
2463
2640
  }
2641
+ // The one door every external dispatch comes through, so a pause holds
2642
+ // for every channel at once. Work already accepted is not behind this
2643
+ // check: a turn running, a redelivery of a message whose turn is live
2644
+ // (answered above), a message queued behind it, a delegation or
2645
+ // an approval resuming a parked turn all go on.
2646
+ if (intakeState.paused) {
2647
+ throw new IntakePausedError(intakeState.message ?? DEFAULT_PAUSED_MESSAGE);
2648
+ }
2649
+ if (heldState) {
2650
+ throw new IntakePausedError(HELD_MESSAGE);
2651
+ }
2464
2652
  // The live dispatch named no agent, so whose session it is is known
2465
2653
  // only once its turn resolves. Queueing instead would make this a
2466
2654
  // `finished` repeat of a turn the live caller is still rendering — two
@@ -2503,7 +2691,16 @@ export const createGateway = (options = {}) => {
2503
2691
  items.set(key, { turn, agentId: input.agentId, confirmed, addressed: input.addressed !== false });
2504
2692
  liveWorkItems.set(input.sessionId, items);
2505
2693
  };
2694
+ waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
2506
2695
  const turn = onSessionChain(input.sessionId, async () => {
2696
+ // No longer waiting once it reaches the head of the chain.
2697
+ const left = (waitingDispatches.get(input.sessionId) ?? 1) - 1;
2698
+ if (left > 0) {
2699
+ waitingDispatches.set(input.sessionId, left);
2700
+ }
2701
+ else {
2702
+ waitingDispatches.delete(input.sessionId);
2703
+ }
2507
2704
  // Before `activeTurns` is touched: a repeat that runs nothing must
2508
2705
  // not claim the session's turn id, even for the instant it takes.
2509
2706
  const repeated = idempotencyKey !== undefined ? await settleRepeat(input.sessionId, idempotencyKey, input.agentId, turnId, input.onRepeat) : undefined;
@@ -2767,6 +2964,10 @@ export const createGateway = (options = {}) => {
2767
2964
  + `${reconciled.released.length} claim(s) released`);
2768
2965
  }
2769
2966
  await loadRoster();
2967
+ intakeState = await readIntakeState(env, warn);
2968
+ if (intakeState.paused) {
2969
+ log(`intake is paused (since ${intakeState.since ?? 'unknown'}); new work will be refused until it resumes`);
2970
+ }
2770
2971
  const named = registry.list().map((agent) => agent.name).join(', ');
2771
2972
  log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
2772
2973
  // Read before anything can dispatch, so this is exactly what the
@@ -2788,7 +2989,25 @@ export const createGateway = (options = {}) => {
2788
2989
  ...(options.channels ?? []),
2789
2990
  ...channelContributions.list().map((contribution) => contribution.adapter),
2790
2991
  ];
2992
+ if (heldState) {
2993
+ // Held: only the operator's surface comes up, and everything that
2994
+ // would touch work waits for promote(), in the same order.
2995
+ await startChannels(adapters.filter((adapter) => adapter.servesWhileHeld === true));
2996
+ deferredStart = { adapters: adapters.filter((adapter) => adapter.servesWhileHeld !== true), orphaned, abandoned };
2997
+ log('stratusd is held: answering health only, until promoted');
2998
+ return;
2999
+ }
3000
+ await startChannels(adapters);
3001
+ await beginWork(orphaned, abandoned);
3002
+ };
3003
+ const startChannels = async (adapters) => {
2791
3004
  for (const adapter of adapters) {
3005
+ // A stop that began while an earlier channel was starting stops what
3006
+ // is up; starting more after it would leave one it never sees, or
3007
+ // one whose start never returns holding the stop open.
3008
+ if (stopping) {
3009
+ return;
3010
+ }
2792
3011
  try {
2793
3012
  await adapter.start(gateway);
2794
3013
  startedChannels.push(adapter);
@@ -2820,6 +3039,8 @@ export const createGateway = (options = {}) => {
2820
3039
  }
2821
3040
  }
2822
3041
  }
3042
+ };
3043
+ const beginWork = async (orphaned, abandoned) => {
2823
3044
  // After the channels, so a catch-up firing has somewhere to report and
2824
3045
  // the missed-window log lines land in a gateway whose surfaces are
2825
3046
  // listening. The sweep inside is quick (store reads); the firings it
@@ -2839,6 +3060,59 @@ export const createGateway = (options = {}) => {
2839
3060
  warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
2840
3061
  });
2841
3062
  };
3063
+ let promoting;
3064
+ /**
3065
+ * Why the work of a promotion could not begin, once it could not. Final:
3066
+ * the scheduler stops for good when its readiness check fails (a newer
3067
+ * build stamped the home), so a retry would report success with every
3068
+ * schedule silently off. The daemon stays held and says why until it is
3069
+ * restarted.
3070
+ */
3071
+ let promotionFailure;
3072
+ const promote = async () => {
3073
+ if (!heldState) {
3074
+ return;
3075
+ }
3076
+ if (stopping) {
3077
+ throw refusal();
3078
+ }
3079
+ // One promotion at a time: a second call while the first is connecting
3080
+ // channels waits for it rather than starting them twice.
3081
+ if (promotionFailure !== undefined) {
3082
+ throw promotionFailure;
3083
+ }
3084
+ promoting ??= (async () => {
3085
+ const pending = deferredStart;
3086
+ if (pending === undefined) {
3087
+ throw new Error('The gateway is held but has not finished starting; promote it once health answers.');
3088
+ }
3089
+ // Skipping any a failed earlier promotion already started, so a retry
3090
+ // never starts one twice.
3091
+ await startChannels(pending.adapters.filter((adapter) => !startedChannels.includes(adapter)));
3092
+ // A stop that began while the channels were connecting has waited
3093
+ // for this (see shutdown), and now stops them all. Nothing past here
3094
+ // may start once it has.
3095
+ if (stopping) {
3096
+ throw refusal();
3097
+ }
3098
+ // Held until the work has actually begun: a scheduler that refuses to
3099
+ // start (a newer build stamped the home meanwhile) must leave the
3100
+ // daemon held, reporting so, and a retry able to try again.
3101
+ try {
3102
+ await beginWork(pending.orphaned, pending.abandoned);
3103
+ }
3104
+ catch (error) {
3105
+ promotionFailure = new Error(`This daemon could not begin its work and stays held until it is restarted: ${error instanceof Error ? error.message : String(error)}`);
3106
+ throw promotionFailure;
3107
+ }
3108
+ deferredStart = undefined;
3109
+ heldState = false;
3110
+ log('stratusd promoted: channels connected, schedules running');
3111
+ })().finally(() => {
3112
+ promoting = undefined;
3113
+ });
3114
+ return promoting;
3115
+ };
2842
3116
  /**
2843
3117
  * Both stores, once. Reached from `stop()` and from a `start()` that
2844
3118
  * failed, and a host may call the one after the other; node:sqlite
@@ -2926,6 +3200,10 @@ export const createGateway = (options = {}) => {
2926
3200
  // drained; a plain stop goes on closing either way.
2927
3201
  let channelsReleased = true;
2928
3202
  const drain = (async () => {
3203
+ // A promotion still connecting channels finishes first, so every
3204
+ // channel it started is in the list stopped below; seeing `stopping`,
3205
+ // it starts nothing after that.
3206
+ await promoting?.catch(() => undefined);
2929
3207
  await Promise.allSettled([...approvalEmissions]);
2930
3208
  const stopping = [...startedChannels];
2931
3209
  const stops = await Promise.allSettled(stopping.map((adapter) => adapter.stop()));
@@ -3395,6 +3673,49 @@ export const createGateway = (options = {}) => {
3395
3673
  })).then((entries) => entries.filter((entry) => entry !== undefined));
3396
3674
  },
3397
3675
  resolveApproval,
3676
+ intake() {
3677
+ return { ...intakeState };
3678
+ },
3679
+ async pauseIntake(input = {}) {
3680
+ let message;
3681
+ if (input.message !== undefined) {
3682
+ const trimmed = input.message.trim();
3683
+ if (trimmed.length === 0) {
3684
+ throw new PauseMessageError('A pause message cannot be empty; leave it out to use the default.');
3685
+ }
3686
+ if (trimmed.length > MAX_PAUSED_MESSAGE_LENGTH) {
3687
+ throw new PauseMessageError(`A pause message is at most ${MAX_PAUSED_MESSAGE_LENGTH} characters; this one is ${trimmed.length}.`);
3688
+ }
3689
+ message = trimmed;
3690
+ }
3691
+ return intakeTransition(async () => {
3692
+ const next = {
3693
+ paused: true,
3694
+ ...(message !== undefined ? { message } : {}),
3695
+ since: intakeState.paused && intakeState.since ? intakeState.since : new Date().toISOString(),
3696
+ };
3697
+ // Durable first: a pause the process reports but would forget at
3698
+ // the next crash is the gap the file exists to close.
3699
+ await writeIntakeState(env, next);
3700
+ intakeState = next;
3701
+ log('intake paused: new work will be refused until it resumes');
3702
+ return { ...intakeState };
3703
+ });
3704
+ },
3705
+ held() {
3706
+ return heldState;
3707
+ },
3708
+ promote,
3709
+ async resumeIntake() {
3710
+ return intakeTransition(async () => {
3711
+ await writeIntakeState(env, { paused: false });
3712
+ if (intakeState.paused) {
3713
+ log('intake resumed');
3714
+ }
3715
+ intakeState = { paused: false };
3716
+ return { ...intakeState };
3717
+ });
3718
+ },
3398
3719
  provideCredential,
3399
3720
  };
3400
3721
  return gateway;