@stratusagent/gateway 0.11.10 → 0.12.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/README.md +1 -1
- package/dist/index.d.ts +77 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +324 -14
- package/dist/index.js.map +1 -1
- package/dist/schedules.d.ts +16 -0
- package/dist/schedules.d.ts.map +1 -1
- package/dist/schedules.js +31 -0
- package/dist/schedules.js.map +1 -1
- package/dist/sessions.js +1 -1
- package/package.json +7 -7
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 {
|
|
11
|
-
import
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
418
|
-
...(
|
|
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
|
-
...(
|
|
466
|
-
...(
|
|
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
|
|
@@ -1001,7 +1134,7 @@ export const createGateway = (options = {}) => {
|
|
|
1001
1134
|
// that reports no id simply has none to hand back.
|
|
1002
1135
|
const ts = typeof posted === 'object' && posted !== null ? posted.ts : undefined;
|
|
1003
1136
|
return typeof ts === 'string' && ts.length > 0 ? { id: ts } : {};
|
|
1004
|
-
}));
|
|
1137
|
+
}, { defaultThread: (session, destination) => scheduler.reportThreadFor(session, destination) }));
|
|
1005
1138
|
tools.register(createMessageReadTool(async ({ agentId, source, ...window }) => {
|
|
1006
1139
|
// The same carrying rule as outbound: a read goes through the app of
|
|
1007
1140
|
// the agent asking, never whichever adapter of that kind is running,
|
|
@@ -1061,6 +1194,12 @@ export const createGateway = (options = {}) => {
|
|
|
1061
1194
|
...(session.metadata ? { metadata: session.metadata } : {}),
|
|
1062
1195
|
});
|
|
1063
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);
|
|
1064
1203
|
// Why no form went up, said to the agent beside the link that replaces
|
|
1065
1204
|
// it, so it can tell the person why they are getting a link instead.
|
|
1066
1205
|
let formUnavailable;
|
|
@@ -1104,9 +1243,13 @@ export const createGateway = (options = {}) => {
|
|
|
1104
1243
|
}
|
|
1105
1244
|
}
|
|
1106
1245
|
if (request.via === 'form') {
|
|
1107
|
-
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.`);
|
|
1108
1247
|
}
|
|
1109
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
|
+
}
|
|
1110
1253
|
// A link where no form can go, from whichever adapter issues them: the
|
|
1111
1254
|
// control API, which is not tied to any one agent's transport.
|
|
1112
1255
|
const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
|
|
@@ -1325,6 +1468,9 @@ export const createGateway = (options = {}) => {
|
|
|
1325
1468
|
store,
|
|
1326
1469
|
bus,
|
|
1327
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],
|
|
1328
1474
|
skills: skillCatalog,
|
|
1329
1475
|
memory,
|
|
1330
1476
|
streaming: true,
|
|
@@ -1602,6 +1748,22 @@ export const createGateway = (options = {}) => {
|
|
|
1602
1748
|
const waitingDispatches = new Map();
|
|
1603
1749
|
const inflight = new Set();
|
|
1604
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
|
+
};
|
|
1605
1767
|
/**
|
|
1606
1768
|
* The abort handle of every turn running right now. A restart's drain
|
|
1607
1769
|
* window ends by aborting these — the same signal the watchdog fires —
|
|
@@ -2476,6 +2638,17 @@ export const createGateway = (options = {}) => {
|
|
|
2476
2638
|
input.onRepeat?.('live');
|
|
2477
2639
|
return live.turn;
|
|
2478
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
|
+
}
|
|
2479
2652
|
// The live dispatch named no agent, so whose session it is is known
|
|
2480
2653
|
// only once its turn resolves. Queueing instead would make this a
|
|
2481
2654
|
// `finished` repeat of a turn the live caller is still rendering — two
|
|
@@ -2791,6 +2964,10 @@ export const createGateway = (options = {}) => {
|
|
|
2791
2964
|
+ `${reconciled.released.length} claim(s) released`);
|
|
2792
2965
|
}
|
|
2793
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
|
+
}
|
|
2794
2971
|
const named = registry.list().map((agent) => agent.name).join(', ');
|
|
2795
2972
|
log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
|
|
2796
2973
|
// Read before anything can dispatch, so this is exactly what the
|
|
@@ -2812,7 +2989,25 @@ export const createGateway = (options = {}) => {
|
|
|
2812
2989
|
...(options.channels ?? []),
|
|
2813
2990
|
...channelContributions.list().map((contribution) => contribution.adapter),
|
|
2814
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) => {
|
|
2815
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
|
+
}
|
|
2816
3011
|
try {
|
|
2817
3012
|
await adapter.start(gateway);
|
|
2818
3013
|
startedChannels.push(adapter);
|
|
@@ -2844,6 +3039,8 @@ export const createGateway = (options = {}) => {
|
|
|
2844
3039
|
}
|
|
2845
3040
|
}
|
|
2846
3041
|
}
|
|
3042
|
+
};
|
|
3043
|
+
const beginWork = async (orphaned, abandoned) => {
|
|
2847
3044
|
// After the channels, so a catch-up firing has somewhere to report and
|
|
2848
3045
|
// the missed-window log lines land in a gateway whose surfaces are
|
|
2849
3046
|
// listening. The sweep inside is quick (store reads); the firings it
|
|
@@ -2863,6 +3060,68 @@ export const createGateway = (options = {}) => {
|
|
|
2863
3060
|
warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2864
3061
|
});
|
|
2865
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
|
+
if (options.onPromoted) {
|
|
3112
|
+
// Synchronous throws too: the promotion has happened either way.
|
|
3113
|
+
try {
|
|
3114
|
+
await options.onPromoted();
|
|
3115
|
+
}
|
|
3116
|
+
catch (error) {
|
|
3117
|
+
warn(`recording the promotion failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
})().finally(() => {
|
|
3121
|
+
promoting = undefined;
|
|
3122
|
+
});
|
|
3123
|
+
return promoting;
|
|
3124
|
+
};
|
|
2866
3125
|
/**
|
|
2867
3126
|
* Both stores, once. Reached from `stop()` and from a `start()` that
|
|
2868
3127
|
* failed, and a host may call the one after the other; node:sqlite
|
|
@@ -2950,6 +3209,10 @@ export const createGateway = (options = {}) => {
|
|
|
2950
3209
|
// drained; a plain stop goes on closing either way.
|
|
2951
3210
|
let channelsReleased = true;
|
|
2952
3211
|
const drain = (async () => {
|
|
3212
|
+
// A promotion still connecting channels finishes first, so every
|
|
3213
|
+
// channel it started is in the list stopped below; seeing `stopping`,
|
|
3214
|
+
// it starts nothing after that.
|
|
3215
|
+
await promoting?.catch(() => undefined);
|
|
2953
3216
|
await Promise.allSettled([...approvalEmissions]);
|
|
2954
3217
|
const stopping = [...startedChannels];
|
|
2955
3218
|
const stops = await Promise.allSettled(stopping.map((adapter) => adapter.stop()));
|
|
@@ -3116,8 +3379,12 @@ export const createGateway = (options = {}) => {
|
|
|
3116
3379
|
// said, for text and file alike, not what the channel delivered: a
|
|
3117
3380
|
// post Slack refused or an upload that failed reads as spoken here.
|
|
3118
3381
|
// The channel's own record has the delivery, and this is the
|
|
3119
|
-
// reconstruction for a process that lost it
|
|
3120
|
-
//
|
|
3382
|
+
// reconstruction for a process that lost it. Still open: a delivery
|
|
3383
|
+
// record the channel writes back into the session would make this
|
|
3384
|
+
// exact (a refused post, a failed upload, a line streamed into a
|
|
3385
|
+
// placeholder by a process that died before saving its reply). It is
|
|
3386
|
+
// a new gateway seam, and it has to be ordered against the overhears
|
|
3387
|
+
// queued on the same session chain, so it is not built yet.
|
|
3121
3388
|
const spoken = (message) => (message.role === 'assistant' && message.content.trim().length > 0)
|
|
3122
3389
|
|| (message.role === 'tool' && message.toolResult !== undefined && filePathsOf(message.toolResult).length > 0);
|
|
3123
3390
|
const spokeIndex = session.messages.findLastIndex(spoken);
|
|
@@ -3419,6 +3686,49 @@ export const createGateway = (options = {}) => {
|
|
|
3419
3686
|
})).then((entries) => entries.filter((entry) => entry !== undefined));
|
|
3420
3687
|
},
|
|
3421
3688
|
resolveApproval,
|
|
3689
|
+
intake() {
|
|
3690
|
+
return { ...intakeState };
|
|
3691
|
+
},
|
|
3692
|
+
async pauseIntake(input = {}) {
|
|
3693
|
+
let message;
|
|
3694
|
+
if (input.message !== undefined) {
|
|
3695
|
+
const trimmed = input.message.trim();
|
|
3696
|
+
if (trimmed.length === 0) {
|
|
3697
|
+
throw new PauseMessageError('A pause message cannot be empty; leave it out to use the default.');
|
|
3698
|
+
}
|
|
3699
|
+
if (trimmed.length > MAX_PAUSED_MESSAGE_LENGTH) {
|
|
3700
|
+
throw new PauseMessageError(`A pause message is at most ${MAX_PAUSED_MESSAGE_LENGTH} characters; this one is ${trimmed.length}.`);
|
|
3701
|
+
}
|
|
3702
|
+
message = trimmed;
|
|
3703
|
+
}
|
|
3704
|
+
return intakeTransition(async () => {
|
|
3705
|
+
const next = {
|
|
3706
|
+
paused: true,
|
|
3707
|
+
...(message !== undefined ? { message } : {}),
|
|
3708
|
+
since: intakeState.paused && intakeState.since ? intakeState.since : new Date().toISOString(),
|
|
3709
|
+
};
|
|
3710
|
+
// Durable first: a pause the process reports but would forget at
|
|
3711
|
+
// the next crash is the gap the file exists to close.
|
|
3712
|
+
await writeIntakeState(env, next);
|
|
3713
|
+
intakeState = next;
|
|
3714
|
+
log('intake paused: new work will be refused until it resumes');
|
|
3715
|
+
return { ...intakeState };
|
|
3716
|
+
});
|
|
3717
|
+
},
|
|
3718
|
+
held() {
|
|
3719
|
+
return heldState;
|
|
3720
|
+
},
|
|
3721
|
+
promote,
|
|
3722
|
+
async resumeIntake() {
|
|
3723
|
+
return intakeTransition(async () => {
|
|
3724
|
+
await writeIntakeState(env, { paused: false });
|
|
3725
|
+
if (intakeState.paused) {
|
|
3726
|
+
log('intake resumed');
|
|
3727
|
+
}
|
|
3728
|
+
intakeState = { paused: false };
|
|
3729
|
+
return { ...intakeState };
|
|
3730
|
+
});
|
|
3731
|
+
},
|
|
3422
3732
|
provideCredential,
|
|
3423
3733
|
};
|
|
3424
3734
|
return gateway;
|