@stratusagent/gateway 0.3.0 → 0.6.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/README.md +1 -0
- package/dist/index.d.ts +299 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +934 -126
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
- package/dist/tsconfig.test.tsbuildinfo +0 -1
package/dist/index.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { chmodSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
-
import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, ToolRegistry, } from '@stratusagent/core';
|
|
5
|
+
import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, ToolRegistry, readPendingApproval, } from '@stratusagent/core';
|
|
6
6
|
import { createDelegateTool, createRememberTool, } from '@stratusagent/agents';
|
|
7
7
|
import { createLocalCommandExecutor } from '@stratusagent/executor-local';
|
|
8
|
-
import {
|
|
8
|
+
import { loadPlugins, } from '@stratusagent/plugins';
|
|
9
|
+
import { createDemoTool, createFileMemoryStore, createRuntimeProvider, DEFAULT_STRATUS_AGENT, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, memoryFilePath, migrateLegacyMemory, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, stratusHomePath, workspacesDirPath, withLegacyDefaultMemories, } from '@stratusagent/state';
|
|
9
10
|
const SESSIONS_DB_FILENAME = 'sessions.db';
|
|
10
11
|
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
12
|
+
/**
|
|
13
|
+
* How long a parked call waits for a person. Long enough to survive a
|
|
14
|
+
* meeting, short enough that a request nobody saw does not hold a turn (and
|
|
15
|
+
* the Slack thread it is answering) open indefinitely.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 900_000;
|
|
18
|
+
/**
|
|
19
|
+
* Node's largest `setTimeout` delay. Anything above it does not become a
|
|
20
|
+
* long wait — it silently becomes a 1ms one, so a config asking for a
|
|
21
|
+
* 30-day approval window would expire every request almost immediately.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
11
24
|
export class SqliteSessionStore {
|
|
12
25
|
db;
|
|
13
26
|
constructor(filePath, options = {}) {
|
|
@@ -84,11 +97,85 @@ export class SqliteSessionStore {
|
|
|
84
97
|
.prepare('INSERT OR REPLACE INTO sessions (id, agent_id, status, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
85
98
|
.run(updated.id, updated.agent.id, updated.status, JSON.stringify(updated), updated.createdAt, updated.updatedAt);
|
|
86
99
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Session ids in a state, oldest first — the index a restarting daemon
|
|
102
|
+
* sweeps for turns parked on a human. The `status` column carries it, so
|
|
103
|
+
* no conversation body is deserialized to answer the question.
|
|
104
|
+
*/
|
|
105
|
+
async listIdsByStatus(status) {
|
|
106
|
+
const rows = this.db
|
|
107
|
+
.prepare('SELECT id FROM sessions WHERE status = ? ORDER BY updated_at ASC')
|
|
108
|
+
.all(status);
|
|
109
|
+
return rows.map((row) => row.id);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* When each agent last did anything, and how many of its sessions are live
|
|
113
|
+
* right now.
|
|
114
|
+
*
|
|
115
|
+
* One aggregate over the indexed columns — no conversation body is
|
|
116
|
+
* deserialized, for the same reason `listIdsByStatus` returns ids: a
|
|
117
|
+
* roster view asks this on every load, and it must not cost a JSON parse
|
|
118
|
+
* per session to answer.
|
|
119
|
+
*
|
|
120
|
+
* Both halves are needed and neither is sufficient. `lastActiveAt` alone
|
|
121
|
+
* reads a turn parked on a human as idle, because the save that recorded
|
|
122
|
+
* the park is the last thing that touched the row — and a turn waiting
|
|
123
|
+
* twenty minutes on an approval is exactly when someone wants to see the
|
|
124
|
+
* agent lit. `activeSessions` alone loses an agent that finished a moment
|
|
125
|
+
* ago. The caller decides what window counts as "recent"; a daemon that
|
|
126
|
+
* baked one in would need upgrading to change it.
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* How many sessions are in each state.
|
|
130
|
+
*
|
|
131
|
+
* A grouped count, not a listing that gets counted. The table grows for the
|
|
132
|
+
* life of an install and a health endpoint is polled, so materialising one
|
|
133
|
+
* object per historical session to produce five numbers gets steadily
|
|
134
|
+
* slower at exactly the thing meant to report that the daemon is fine.
|
|
135
|
+
*/
|
|
136
|
+
countByStatus() {
|
|
137
|
+
const rows = this.db
|
|
138
|
+
.prepare('SELECT status, COUNT(*) AS total FROM sessions GROUP BY status')
|
|
139
|
+
.all();
|
|
140
|
+
const counts = {};
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
counts[row.status] = Number(row.total);
|
|
143
|
+
}
|
|
144
|
+
return counts;
|
|
145
|
+
}
|
|
146
|
+
lastActivityByAgent() {
|
|
147
|
+
const rows = this.db
|
|
148
|
+
.prepare(`
|
|
149
|
+
SELECT agent_id,
|
|
150
|
+
MAX(updated_at) AS last_active_at,
|
|
151
|
+
SUM(CASE WHEN status IN ('running', 'pending_approval') THEN 1 ELSE 0 END) AS active_sessions
|
|
152
|
+
FROM sessions
|
|
153
|
+
GROUP BY agent_id
|
|
154
|
+
`)
|
|
155
|
+
.all();
|
|
156
|
+
const activity = {};
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
activity[row.agent_id] = {
|
|
159
|
+
lastActiveAt: row.last_active_at,
|
|
160
|
+
activeSessions: Number(row.active_sessions),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return activity;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Newest-first session listing for one agent (or all agents).
|
|
167
|
+
*
|
|
168
|
+
* `limit` is not decoration: this table grows for the life of an install,
|
|
169
|
+
* and a surface that renders "recent conversations" would otherwise pull
|
|
170
|
+
* every session anyone has ever had to show ten of them.
|
|
171
|
+
*/
|
|
172
|
+
list(agentId, limit) {
|
|
173
|
+
// -1 is SQLite's "no limit", so one prepared statement serves both cases
|
|
174
|
+
// rather than four.
|
|
175
|
+
const bound = limit !== undefined && Number.isInteger(limit) && limit >= 0 ? limit : -1;
|
|
89
176
|
const rows = (agentId
|
|
90
|
-
? this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC').all(agentId)
|
|
91
|
-
: this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC').all());
|
|
177
|
+
? this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC LIMIT ?').all(agentId, bound)
|
|
178
|
+
: this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?').all(bound));
|
|
92
179
|
return rows.map((row) => ({
|
|
93
180
|
id: row.id,
|
|
94
181
|
agentId: row.agent_id,
|
|
@@ -102,64 +189,25 @@ export class SqliteSessionStore {
|
|
|
102
189
|
}
|
|
103
190
|
}
|
|
104
191
|
/**
|
|
105
|
-
*
|
|
106
|
-
* daemon-wide defaults the pins outrank.
|
|
192
|
+
* A soul's provider/model pins, and the daemon-wide defaults they demote.
|
|
107
193
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
194
|
+
* The implementation lives in `@stratusagent/state`, beside the resolver
|
|
195
|
+
* whose precedence it manipulates — it never had a gateway dependency. It is
|
|
196
|
+
* re-exported here because this is where callers were told to import it from,
|
|
197
|
+
* and because "what will a served agent actually resolve to" is a gateway
|
|
198
|
+
* question even when the rule answering it is not gateway code.
|
|
112
199
|
*/
|
|
113
|
-
export
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// them: an empty or whitespace-padded STRATUS_PROVIDER is no default at
|
|
125
|
-
// all, not a mismatching one.
|
|
126
|
-
const defaultProvider = context.selectionProvider
|
|
127
|
-
?? readNonEmptyString(processEnv.STRATUS_PROVIDER)
|
|
128
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER)
|
|
129
|
-
?? context.configProvider
|
|
130
|
-
?? (context.configPresent ? 'openai' : undefined);
|
|
131
|
-
if (pins.provider) {
|
|
132
|
-
delete selection.provider;
|
|
133
|
-
delete processEnv.STRATUS_PROVIDER;
|
|
134
|
-
delete processEnv.STRATUSCLAW_PROVIDER;
|
|
135
|
-
if (defaultProvider !== undefined && defaultProvider !== 'demo' && pins.provider !== defaultProvider) {
|
|
136
|
-
// The default model, endpoint, and generic credentials were all
|
|
137
|
-
// chosen for the default provider — none may ride along to the
|
|
138
|
-
// soul's: a base URL would point the pinned provider at the wrong
|
|
139
|
-
// service, and a generic API key would be sent to it. With NO
|
|
140
|
-
// default selected anywhere — or the credential-less demo provider
|
|
141
|
-
// as the default — there is nothing those values could have been
|
|
142
|
-
// chosen for except whatever provider the soul selects — exactly
|
|
143
|
-
// the resolver's own reading of a generic credential — so they stay.
|
|
144
|
-
delete selection.model;
|
|
145
|
-
delete processEnv.STRATUS_MODEL;
|
|
146
|
-
delete processEnv.STRATUSCLAW_MODEL;
|
|
147
|
-
delete selection.baseUrl;
|
|
148
|
-
delete processEnv.STRATUS_BASE_URL;
|
|
149
|
-
delete processEnv.STRATUSCLAW_BASE_URL;
|
|
150
|
-
delete processEnv.STRATUS_API_KEY;
|
|
151
|
-
delete processEnv.STRATUSCLAW_API_KEY;
|
|
152
|
-
delete processEnv.STRATUS_API_KEY_ENV;
|
|
153
|
-
delete processEnv.STRATUSCLAW_API_KEY_ENV;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (pins.model) {
|
|
157
|
-
delete selection.model;
|
|
158
|
-
delete processEnv.STRATUS_MODEL;
|
|
159
|
-
delete processEnv.STRATUSCLAW_MODEL;
|
|
160
|
-
}
|
|
161
|
-
return { selection, env: { ...env, processEnv } };
|
|
162
|
-
};
|
|
200
|
+
export { applySoulPins } from '@stratusagent/state';
|
|
201
|
+
/**
|
|
202
|
+
* Recorded on a turn the previous process was still running when it died.
|
|
203
|
+
*
|
|
204
|
+
* Exported because "distinguishable" is the whole point: a surface, a
|
|
205
|
+
* test, or an operator reading `lastError` has to be able to tell a turn
|
|
206
|
+
* nobody finished apart from one a provider or a tool actually failed, and
|
|
207
|
+
* matching on prose that lives in one place beats each caller inventing
|
|
208
|
+
* its own guess at the wording.
|
|
209
|
+
*/
|
|
210
|
+
export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
|
|
163
211
|
/**
|
|
164
212
|
* The always-on Stratus process. One gateway holds the roster, the durable
|
|
165
213
|
* session store, the shared event bus, and a pool of runners keyed by
|
|
@@ -181,6 +229,269 @@ export const createGateway = (options = {}) => {
|
|
|
181
229
|
? new SqliteSessionStore(options.sessionDbPath)
|
|
182
230
|
: new SqliteSessionStore(path.join(stratusHomePath(env), SESSIONS_DB_FILENAME), { ownedDirectory: true });
|
|
183
231
|
const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
|
|
232
|
+
// ---- approval brokering -------------------------------------------------
|
|
233
|
+
const approvalTimeoutMs = options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
234
|
+
// Refused at construction, not absorbed: a negative or NaN timeout fails
|
|
235
|
+
// the `> 0` test below and quietly means "never expire" — the one
|
|
236
|
+
// behavior documented for an explicit 0, and the last one a caller who
|
|
237
|
+
// typed a bad number wants. Config-file values are already rejected with
|
|
238
|
+
// a better message; this catches the programmatic path.
|
|
239
|
+
if (!Number.isFinite(approvalTimeoutMs) || approvalTimeoutMs < 0) {
|
|
240
|
+
throw new Error(`approvalTimeoutMs must be a non-negative number of milliseconds (0 to wait indefinitely); received ${String(options.approvalTimeoutMs)}.`);
|
|
241
|
+
}
|
|
242
|
+
// Clamped, not trusted: a delay past Node's timer range is not a long
|
|
243
|
+
// wait, it is a 1ms one, so an over-large value would turn every approval
|
|
244
|
+
// into an instant expiry — the opposite of what it asked for. The CLI
|
|
245
|
+
// rejects these at config load with a better message; this is the
|
|
246
|
+
// backstop for a programmatic caller.
|
|
247
|
+
const effectiveApprovalTimeoutMs = Math.min(approvalTimeoutMs, MAX_TIMER_DELAY_MS);
|
|
248
|
+
if (approvalTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
249
|
+
warn(`approval timeout ${approvalTimeoutMs}ms is above Node's maximum timer delay; using ${MAX_TIMER_DELAY_MS}ms (~24.8 days)`);
|
|
250
|
+
}
|
|
251
|
+
const pendingApprovals = new Map();
|
|
252
|
+
/**
|
|
253
|
+
* In-flight `tool.approval-resolved` emissions. `EventBus.emit` awaits its
|
|
254
|
+
* subscribers in order, so one async handler ahead of a channel adapter
|
|
255
|
+
* suspends the emission before the adapter ever sees it. Nothing today is
|
|
256
|
+
* async in front of one — which is exactly the problem: the shutdown
|
|
257
|
+
* guarantee below would rest on that staying true, and the first async
|
|
258
|
+
* subscriber anyone adds would silently leave live-looking buttons in a
|
|
259
|
+
* workspace the daemon has left.
|
|
260
|
+
*/
|
|
261
|
+
const approvalEmissions = new Set();
|
|
262
|
+
/**
|
|
263
|
+
* Publishes one parked call and settles when somebody answers it, the
|
|
264
|
+
* request expires, or the turn is cancelled.
|
|
265
|
+
*
|
|
266
|
+
* All three endings run through the same `settle`, which removes the
|
|
267
|
+
* request from the registry BEFORE resolving. That ordering is the whole
|
|
268
|
+
* point: a request that is no longer pending cannot be resolved twice,
|
|
269
|
+
* so a click racing the timeout — or arriving after an abort — is refused
|
|
270
|
+
* rather than executing a tool for a turn that has already moved on.
|
|
271
|
+
*/
|
|
272
|
+
const requestApproval = (request) => new Promise((resolve) => {
|
|
273
|
+
// A shutdown denies what is parked once, at the top of stop(). Turns
|
|
274
|
+
// already running keep going through the drain, though, and one of them
|
|
275
|
+
// can reach a gated tool AFTER that snapshot — finishing a provider
|
|
276
|
+
// call, or moving to the next call in the same response. Parking it
|
|
277
|
+
// would deadlock the drain against a question nobody is left to answer:
|
|
278
|
+
// stop() waits for the turn, the turn waits for the approval, and the
|
|
279
|
+
// approval waits out its timeout — forever when there is none.
|
|
280
|
+
if (stopping) {
|
|
281
|
+
resolve('deny');
|
|
282
|
+
void bus.emit({
|
|
283
|
+
type: 'tool.approval-resolved',
|
|
284
|
+
sessionId: request.session.id,
|
|
285
|
+
requestId: randomUUID(),
|
|
286
|
+
answer: 'deny',
|
|
287
|
+
reason: 'cancelled',
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const requestId = randomUUID();
|
|
292
|
+
let timer;
|
|
293
|
+
const onAbort = () => settle('deny', 'cancelled');
|
|
294
|
+
/**
|
|
295
|
+
* Settles once the request has finished being announced.
|
|
296
|
+
*
|
|
297
|
+
* Both emissions go through the same subscriber list, in order, and
|
|
298
|
+
* `EventBus.emit` awaits each one — so an async subscriber ahead of a
|
|
299
|
+
* channel can hold the announcement while a timeout or abort fires
|
|
300
|
+
* behind it, and the channel would hear that a request it has never
|
|
301
|
+
* heard of was resolved. It drops that, then renders the announcement
|
|
302
|
+
* when it finally arrives, leaving buttons for a settled request that
|
|
303
|
+
* nothing will ever retract.
|
|
304
|
+
*
|
|
305
|
+
* Chaining is the fix rather than remembering orphaned resolutions in
|
|
306
|
+
* the channel: a resolution that arrives before its request is not
|
|
307
|
+
* something a consumer can act on, so it should not be able to happen.
|
|
308
|
+
* Starts resolved, because a turn already aborted never announces at
|
|
309
|
+
* all and its denial should not wait on an emission that never runs.
|
|
310
|
+
*/
|
|
311
|
+
let announced = Promise.resolve();
|
|
312
|
+
const settle = (answer, reason, actor) => {
|
|
313
|
+
// Identity, not presence: a request that already settled is gone from
|
|
314
|
+
// the registry, and comparing against this exact function is what
|
|
315
|
+
// makes every ending — click, timeout, abort, shutdown — idempotent.
|
|
316
|
+
if (pendingApprovals.get(requestId)?.settle !== settle) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
pendingApprovals.delete(requestId);
|
|
320
|
+
if (timer) {
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
}
|
|
323
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
324
|
+
resolve(answer);
|
|
325
|
+
const emitted = announced.then(() => bus.emit({
|
|
326
|
+
type: 'tool.approval-resolved',
|
|
327
|
+
sessionId: request.session.id,
|
|
328
|
+
requestId,
|
|
329
|
+
answer,
|
|
330
|
+
reason,
|
|
331
|
+
...(actor ? { actor } : {}),
|
|
332
|
+
}));
|
|
333
|
+
approvalEmissions.add(emitted);
|
|
334
|
+
void emitted.finally(() => approvalEmissions.delete(emitted));
|
|
335
|
+
};
|
|
336
|
+
// What is left of the window, not a fresh one: a re-asked request
|
|
337
|
+
// carries when it originally parked, and the elapsed time counts.
|
|
338
|
+
// Computed before registration because the summary below quotes the
|
|
339
|
+
// deadline, and a listing that disagreed with the announcing event about
|
|
340
|
+
// when a request expires would be worse than one that said nothing.
|
|
341
|
+
const alreadyWaitedMs = request.parkedAt ? Date.now() - Date.parse(request.parkedAt) : 0;
|
|
342
|
+
const remainingTimeoutMs = effectiveApprovalTimeoutMs > 0
|
|
343
|
+
? effectiveApprovalTimeoutMs - (Number.isFinite(alreadyWaitedMs) ? Math.max(0, alreadyWaitedMs) : 0)
|
|
344
|
+
: 0;
|
|
345
|
+
const expiresAt = remainingTimeoutMs > 0
|
|
346
|
+
? new Date(Date.now() + remainingTimeoutMs).toISOString()
|
|
347
|
+
: undefined;
|
|
348
|
+
pendingApprovals.set(requestId, {
|
|
349
|
+
settle,
|
|
350
|
+
pending: {
|
|
351
|
+
requestId,
|
|
352
|
+
sessionId: request.session.id,
|
|
353
|
+
agentId: request.session.agent.id,
|
|
354
|
+
call: request.call,
|
|
355
|
+
risk: request.risk,
|
|
356
|
+
parkedAt: request.parkedAt ?? new Date().toISOString(),
|
|
357
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
358
|
+
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
|
|
362
|
+
// Nothing left to wait with. Settled here rather than armed with a
|
|
363
|
+
// zero timer, which `setTimeout` would still run a tick later — long
|
|
364
|
+
// enough to announce a request that is already over.
|
|
365
|
+
settle('deny', 'timeout');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (remainingTimeoutMs > 0) {
|
|
369
|
+
// Deliberately NOT unref'd. A parked turn is real outstanding work,
|
|
370
|
+
// and its timer is the only thing that will ever finish it — letting
|
|
371
|
+
// the process exit out from under one would abandon the turn instead
|
|
372
|
+
// of denying it. Shutdown is handled where it belongs, in stop(),
|
|
373
|
+
// which settles every outstanding request before draining.
|
|
374
|
+
timer = setTimeout(() => settle('deny', 'timeout'), remainingTimeoutMs);
|
|
375
|
+
}
|
|
376
|
+
// The abort listener is attached before the request is announced, so a
|
|
377
|
+
// turn cancelled while the event is still being delivered still ends
|
|
378
|
+
// the request rather than leaving it pending forever.
|
|
379
|
+
if (request.signal) {
|
|
380
|
+
if (request.signal.aborted) {
|
|
381
|
+
settle('deny', 'cancelled');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
385
|
+
}
|
|
386
|
+
// `announced` is assigned BEFORE the emission starts, because emit runs
|
|
387
|
+
// its first subscribers synchronously — one of them settling the
|
|
388
|
+
// request inline would otherwise chain onto the already-resolved
|
|
389
|
+
// placeholder and race the very announcement it is answering.
|
|
390
|
+
let markAnnounced = () => { };
|
|
391
|
+
announced = new Promise((resolveAnnounced) => {
|
|
392
|
+
markAnnounced = resolveAnnounced;
|
|
393
|
+
});
|
|
394
|
+
const announcing = bus.emit({
|
|
395
|
+
type: 'tool.approval-requested',
|
|
396
|
+
sessionId: request.session.id,
|
|
397
|
+
agentId: request.session.agent.id,
|
|
398
|
+
requestId,
|
|
399
|
+
call: request.call,
|
|
400
|
+
risk: request.risk,
|
|
401
|
+
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
402
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
403
|
+
});
|
|
404
|
+
// Drained at shutdown alongside the resolutions: a channel that has not
|
|
405
|
+
// yet been told a request exists cannot be told it was denied either.
|
|
406
|
+
approvalEmissions.add(announcing);
|
|
407
|
+
void announcing.finally(() => {
|
|
408
|
+
approvalEmissions.delete(announcing);
|
|
409
|
+
markAnnounced();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
const resolveApproval = (input) => {
|
|
413
|
+
const parked = pendingApprovals.get(input.requestId);
|
|
414
|
+
if (!parked) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
parked.settle(input.answer, input.reason ?? 'decided', input.actor);
|
|
418
|
+
return true;
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* How a turn's watchdog stops its own clock, registered while it runs.
|
|
422
|
+
*
|
|
423
|
+
* Recording that a tool phase is open is not enough on its own: the
|
|
424
|
+
* timer is only re-evaluated when an event arrives, so a phase opened by
|
|
425
|
+
* something that emits nothing — an approval policy handed straight to
|
|
426
|
+
* the gateway — would leave an already-armed timer running to fire
|
|
427
|
+
* mid-wait.
|
|
428
|
+
*/
|
|
429
|
+
const suspendWatchdog = new Map();
|
|
430
|
+
/**
|
|
431
|
+
* Approval waits in progress, per session.
|
|
432
|
+
*
|
|
433
|
+
* Deliberately a balanced counter and not a set of call ids. An earlier
|
|
434
|
+
* shape had the approval wrapper *open* a phase that the watchdog's
|
|
435
|
+
* event observer was expected to close, and that handoff was wrong in
|
|
436
|
+
* every way it could be: the observer does not exist when no watchdog is
|
|
437
|
+
* armed, so nothing closed it; a policy that threw closed nothing; and a
|
|
438
|
+
* stale entry did not merely leak, it suppressed the watchdog for the
|
|
439
|
+
* next turn on that session. Four separate exits, each patched in turn.
|
|
440
|
+
*
|
|
441
|
+
* A counter incremented and decremented in one `finally` cannot have any
|
|
442
|
+
* of those bugs, because the thing that opens the phase is the thing
|
|
443
|
+
* that closes it, in the same scope, on every path out.
|
|
444
|
+
*/
|
|
445
|
+
const approvalWaits = new Map();
|
|
446
|
+
const holdApprovalWait = (sessionId) => {
|
|
447
|
+
approvalWaits.set(sessionId, (approvalWaits.get(sessionId) ?? 0) + 1);
|
|
448
|
+
// Stop any clock already running: this wait produces no events, so
|
|
449
|
+
// nothing else would.
|
|
450
|
+
suspendWatchdog.get(sessionId)?.();
|
|
451
|
+
let released = false;
|
|
452
|
+
return () => {
|
|
453
|
+
if (released) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
released = true;
|
|
457
|
+
const remaining = (approvalWaits.get(sessionId) ?? 1) - 1;
|
|
458
|
+
if (remaining > 0) {
|
|
459
|
+
approvalWaits.set(sessionId, remaining);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
approvalWaits.delete(sessionId);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
const configuredApprovals = typeof options.approvals === 'function'
|
|
467
|
+
? options.approvals({ request: requestApproval })
|
|
468
|
+
: options.approvals;
|
|
469
|
+
/**
|
|
470
|
+
* Every approval opens a tool phase, whatever policy answers it.
|
|
471
|
+
*
|
|
472
|
+
* `tool.approval-requested` is the broker's event, so it exists only for
|
|
473
|
+
* the remote path; a policy handed straight to the gateway emits nothing
|
|
474
|
+
* at all, and `tool.called` comes only *after* the answer. Without this,
|
|
475
|
+
* a slow custom policy on a streaming provider that hosts its own loop
|
|
476
|
+
* would be indistinguishable from a stalled turn — the case this
|
|
477
|
+
* watchdog work exists to stop, arriving through a different door.
|
|
478
|
+
*/
|
|
479
|
+
const approvals = configuredApprovals
|
|
480
|
+
? {
|
|
481
|
+
async approve(context) {
|
|
482
|
+
const release = holdApprovalWait(context.session.id);
|
|
483
|
+
try {
|
|
484
|
+
return await configuredApprovals.approve(context);
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
// Every path out, including a policy that throws. What happens
|
|
488
|
+
// after the answer is the tool events' business, and they are
|
|
489
|
+
// observed by the watchdog itself.
|
|
490
|
+
release();
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
: undefined;
|
|
184
495
|
const registry = new AgentRegistry();
|
|
185
496
|
const sources = new Map();
|
|
186
497
|
// ---- roster -------------------------------------------------------------
|
|
@@ -245,26 +556,79 @@ export const createGateway = (options = {}) => {
|
|
|
245
556
|
lastDefaultAgentId = id;
|
|
246
557
|
return id;
|
|
247
558
|
};
|
|
559
|
+
/**
|
|
560
|
+
* Build the roster from what is on disk right now.
|
|
561
|
+
*
|
|
562
|
+
* Runs at start and again on every `reloadRoster()`, which is why it
|
|
563
|
+
* tracks what it registered rather than only adding: a soul file deleted
|
|
564
|
+
* since the last pass has to stop being dispatchable, and re-registering
|
|
565
|
+
* the survivors over a map nothing removes from would leave it addressable
|
|
566
|
+
* — by id, and from every channel — for the rest of the daemon's life.
|
|
567
|
+
*/
|
|
248
568
|
const loadRoster = async () => {
|
|
569
|
+
const seen = new Set();
|
|
570
|
+
const registerFresh = (source) => {
|
|
571
|
+
registerSource(source);
|
|
572
|
+
seen.add(source.definition.id);
|
|
573
|
+
};
|
|
249
574
|
// The built-in id is reserved BEFORE ordinary roster entries load: a
|
|
250
575
|
// roster file that happens to declare id "stratus" must not hijack
|
|
251
576
|
// the documented built-in fallback for agentId-less dispatches — the
|
|
252
|
-
//
|
|
577
|
+
// guard below skips it with a warning. Only the explicitly
|
|
253
578
|
// configured default soul may take the id over (defaultAgentId
|
|
254
579
|
// replaces a pathless source).
|
|
255
|
-
|
|
580
|
+
registerFresh({ definition: { ...DEFAULT_STRATUS_AGENT } });
|
|
581
|
+
// Throws on two roster files claiming one id: that is a collision with
|
|
582
|
+
// no correct winner, and refusing to serve beats letting sort order
|
|
583
|
+
// decide whose sessions, memory, and credentials an agent inherits.
|
|
256
584
|
const entries = await loadRosterSouls(env, warn);
|
|
257
585
|
for (const entry of entries) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
586
|
+
// Against ids claimed by THIS pass, not by any earlier one: a reload
|
|
587
|
+
// must be free to re-register the agent it just re-read, and only the
|
|
588
|
+
// built-in above can legitimately block a roster file.
|
|
589
|
+
if (seen.has(entry.soul.agent.id)) {
|
|
590
|
+
// Only reachable for the reserved built-in id now — roster-vs-
|
|
591
|
+
// roster duplicates never get this far. Reserved is not the same
|
|
592
|
+
// failure as duplicated: a soul may not take the fallback over,
|
|
593
|
+
// and must not be able to take the daemon down by trying.
|
|
594
|
+
warn(`agent id ${entry.soul.agent.id} is reserved for the built-in agent; ignoring ${entry.path}`);
|
|
261
595
|
continue;
|
|
262
596
|
}
|
|
263
|
-
|
|
597
|
+
registerFresh({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
|
|
264
598
|
}
|
|
265
599
|
// The configured default soul is part of the roster too — it is what
|
|
266
|
-
// an agentId-less dispatch answers as.
|
|
267
|
-
|
|
600
|
+
// an agentId-less dispatch answers as. It may live outside the agents
|
|
601
|
+
// directory, and it survives a read failure by name, so what it
|
|
602
|
+
// resolves to is kept whether or not this pass re-registered it.
|
|
603
|
+
seen.add(await defaultAgentId());
|
|
604
|
+
for (const id of [...sources.keys()]) {
|
|
605
|
+
if (!seen.has(id)) {
|
|
606
|
+
sources.delete(id);
|
|
607
|
+
registry.unregister(id);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
/**
|
|
612
|
+
* Roster rebuilds, one at a time.
|
|
613
|
+
*
|
|
614
|
+
* `loadRoster` prunes by comparing the registry against the ids *this pass*
|
|
615
|
+
* saw, so two overlapping passes destroy each other's work: the older one
|
|
616
|
+
* snapshots a roster without agent B, the newer one registers B and
|
|
617
|
+
* finishes, and then the older one reaches its prune and unregisters B —
|
|
618
|
+
* which two clients creating agents at once will do, each having been told
|
|
619
|
+
* 201 for an agent that is no longer dispatchable.
|
|
620
|
+
*
|
|
621
|
+
* A chain rather than a lock: a reload is idempotent, so a caller arriving
|
|
622
|
+
* mid-rebuild wants the *next* complete one, and waiting for it is exactly
|
|
623
|
+
* what this gives them.
|
|
624
|
+
*/
|
|
625
|
+
let reloads = Promise.resolve();
|
|
626
|
+
const reloadsInOrder = async () => {
|
|
627
|
+
const next = reloads.then(loadRoster, loadRoster);
|
|
628
|
+
// Swallowed for the chain only — the caller still sees the rejection, but
|
|
629
|
+
// a roster that failed to load must not block every reload after it.
|
|
630
|
+
reloads = next.catch(() => undefined);
|
|
631
|
+
await next;
|
|
268
632
|
};
|
|
269
633
|
/**
|
|
270
634
|
* A session pins its agent id, never a snapshot of the agent: the soul
|
|
@@ -306,6 +670,11 @@ export const createGateway = (options = {}) => {
|
|
|
306
670
|
};
|
|
307
671
|
// ---- runner pool --------------------------------------------------------
|
|
308
672
|
const tools = new ToolRegistry();
|
|
673
|
+
// Which package each tool came from. Kernel tools are absent from this
|
|
674
|
+
// map, which is how `tools()` reports them as the kernel's.
|
|
675
|
+
const toolProvenance = new Map();
|
|
676
|
+
let loadedPlugins = [];
|
|
677
|
+
let pluginFailures = [];
|
|
309
678
|
tools.register(createDemoTool());
|
|
310
679
|
tools.register(createRememberTool(memory));
|
|
311
680
|
// Delegation is dispatcher-backed, not runner-capturing: the target agent
|
|
@@ -357,7 +726,7 @@ export const createGateway = (options = {}) => {
|
|
|
357
726
|
provider,
|
|
358
727
|
tools,
|
|
359
728
|
executor: createLocalCommandExecutor(),
|
|
360
|
-
...(
|
|
729
|
+
...(approvals ? { approvals } : {}),
|
|
361
730
|
store,
|
|
362
731
|
bus,
|
|
363
732
|
agents: registry,
|
|
@@ -379,8 +748,16 @@ export const createGateway = (options = {}) => {
|
|
|
379
748
|
* that kills slow-but-healthy turns, so it stays off for them (their
|
|
380
749
|
* tool phases are covered by executor timeouts regardless).
|
|
381
750
|
*/
|
|
382
|
-
const fallbackStreamsDeltas = (fallback) =>
|
|
383
|
-
|
|
751
|
+
const fallbackStreamsDeltas = (fallback) =>
|
|
752
|
+
// The same two modes as a primary. A subscription fallback that is not
|
|
753
|
+
// recognised as streaming gets no watchdog at all once a session
|
|
754
|
+
// switches to it, so a stall there would run to the provider's own
|
|
755
|
+
// ten-minute timer instead of the idle timeout the operator set.
|
|
756
|
+
fallback.provider === 'anthropic' && Boolean(fallback.apiKey || fallback.authToken);
|
|
757
|
+
const streamsDeltas = (config) =>
|
|
758
|
+
// Both Anthropic modes stream now: an API key through the Messages
|
|
759
|
+
// API, a subscription token through the Agent SDK's partial messages.
|
|
760
|
+
config.provider === 'anthropic' && Boolean(config.apiKey || config.authToken);
|
|
384
761
|
/**
|
|
385
762
|
* Progress-based abort, armed only while the turn is actually awaiting a
|
|
386
763
|
* streaming provider — the one phase where silence means a stall. Tool
|
|
@@ -413,6 +790,18 @@ export const createGateway = (options = {}) => {
|
|
|
413
790
|
// the primary until a reset delta announces the sticky fallback switch.
|
|
414
791
|
let streamingActive = idleEnabled;
|
|
415
792
|
let pendingTools = 0;
|
|
793
|
+
/**
|
|
794
|
+
* Tool calls this turn has started and not finished.
|
|
795
|
+
*
|
|
796
|
+
* `pendingTools` above reads a count off `provider.response`, which
|
|
797
|
+
* only a provider that hands its calls back to the kernel loop emits.
|
|
798
|
+
* A provider hosting its own loop dispatches inside `generate`, so that
|
|
799
|
+
* count is zero for the whole of a hosted tool. These are the kernel's
|
|
800
|
+
* own tool events, which every path emits because every path runs its
|
|
801
|
+
* tools through the same executor — and they are turn-local, so an
|
|
802
|
+
* aborted turn takes them with it rather than leaving anything behind.
|
|
803
|
+
*/
|
|
804
|
+
const openToolCalls = new Set();
|
|
416
805
|
// Set at a text-only provider.response: the turn is wrapping up
|
|
417
806
|
// (saves, completion events); nothing left for the timer to guard.
|
|
418
807
|
let turnSettling = false;
|
|
@@ -450,9 +839,13 @@ export const createGateway = (options = {}) => {
|
|
|
450
839
|
}
|
|
451
840
|
switch (event.type) {
|
|
452
841
|
case 'provider.delta':
|
|
453
|
-
//
|
|
454
|
-
// the timer follows
|
|
455
|
-
|
|
842
|
+
// Only a *fallback* reset marks the mid-turn, session-sticky
|
|
843
|
+
// switch that the timer follows. A provider retrying its own
|
|
844
|
+
// attempt abandons its partial output too, but nothing about
|
|
845
|
+
// who is serving the turn changed — reading that as a switch
|
|
846
|
+
// would disarm the watchdog for the rest of a turn that is
|
|
847
|
+
// still running on the provider it started on.
|
|
848
|
+
if (event.delta.type === 'reset' && event.delta.reason === 'fallback') {
|
|
456
849
|
streamingActive = fallbackStreams;
|
|
457
850
|
}
|
|
458
851
|
break;
|
|
@@ -466,8 +859,23 @@ export const createGateway = (options = {}) => {
|
|
|
466
859
|
turnSettling = true;
|
|
467
860
|
}
|
|
468
861
|
break;
|
|
862
|
+
case 'tool.approval-requested':
|
|
863
|
+
// Redundant with the approval wrapper for a gateway-owned
|
|
864
|
+
// transport, and not redundant for a recovered turn, whose
|
|
865
|
+
// request is re-announced without going through a policy again.
|
|
866
|
+
openToolCalls.add(event.call.id);
|
|
867
|
+
break;
|
|
868
|
+
case 'tool.called':
|
|
869
|
+
openToolCalls.add(event.call.id);
|
|
870
|
+
break;
|
|
469
871
|
case 'tool.completed':
|
|
872
|
+
// Completion names the call through its result; denial carries
|
|
873
|
+
// the call itself. Both settle it.
|
|
874
|
+
openToolCalls.delete(event.result.callId);
|
|
875
|
+
pendingTools = Math.max(0, pendingTools - 1);
|
|
876
|
+
break;
|
|
470
877
|
case 'tool.denied':
|
|
878
|
+
openToolCalls.delete(event.call.id);
|
|
471
879
|
pendingTools = Math.max(0, pendingTools - 1);
|
|
472
880
|
break;
|
|
473
881
|
default:
|
|
@@ -479,10 +887,15 @@ export const createGateway = (options = {}) => {
|
|
|
479
887
|
if (!('sessionId' in event) || event.sessionId !== sessionId) {
|
|
480
888
|
return;
|
|
481
889
|
}
|
|
482
|
-
if (streamingActive
|
|
890
|
+
if (streamingActive
|
|
891
|
+
&& pendingTools === 0
|
|
892
|
+
&& openToolCalls.size === 0
|
|
893
|
+
&& (approvalWaits.get(sessionId) ?? 0) === 0
|
|
894
|
+
&& !turnSettling) {
|
|
483
895
|
armTimer();
|
|
484
896
|
}
|
|
485
897
|
});
|
|
898
|
+
suspendWatchdog.set(sessionId, suspendTimer);
|
|
486
899
|
if (streamingActive) {
|
|
487
900
|
armTimer();
|
|
488
901
|
}
|
|
@@ -499,6 +912,7 @@ export const createGateway = (options = {}) => {
|
|
|
499
912
|
suspendTimer();
|
|
500
913
|
unsubscribeObserver();
|
|
501
914
|
unsubscribeDrain();
|
|
915
|
+
suspendWatchdog.delete(sessionId);
|
|
502
916
|
external?.removeEventListener('abort', onExternalAbort);
|
|
503
917
|
}
|
|
504
918
|
};
|
|
@@ -509,23 +923,26 @@ export const createGateway = (options = {}) => {
|
|
|
509
923
|
const sessionChains = new Map();
|
|
510
924
|
const inflight = new Set();
|
|
511
925
|
let stopping = false;
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
926
|
+
/**
|
|
927
|
+
* The turn id running on each session, for callers that named one.
|
|
928
|
+
*
|
|
929
|
+
* Set when the chained work actually starts — not when the dispatch was
|
|
930
|
+
* queued — because a message waiting behind another turn has not begun,
|
|
931
|
+
* and stamping events with it would attribute the running turn's output to
|
|
932
|
+
* the queued one. Single-flight per session is what makes one entry per
|
|
933
|
+
* session sufficient.
|
|
934
|
+
*/
|
|
935
|
+
const activeTurns = new Map();
|
|
936
|
+
/**
|
|
937
|
+
* The runtime one agent would run on right now — config snapshot, soul
|
|
938
|
+
* pins, degradation and all.
|
|
939
|
+
*
|
|
940
|
+
* Extracted so recovery resolves it exactly as a dispatch does. A second
|
|
941
|
+
* copy of this chain is the difference between a recovered turn finishing
|
|
942
|
+
* on its agent's provider and finishing on the daemon's default, and it
|
|
943
|
+
* would drift the first time either side gained a rule.
|
|
944
|
+
*/
|
|
945
|
+
const runtimeForAgent = async (source) => {
|
|
529
946
|
// Config re-resolves per dispatch, so a changed model, credential, or
|
|
530
947
|
// soul pin applies without a restart. The pool dedupes runners by the
|
|
531
948
|
// resolved configuration. The gateway-wide selection and the daemon's
|
|
@@ -584,7 +1001,26 @@ export const createGateway = (options = {}) => {
|
|
|
584
1001
|
}
|
|
585
1002
|
// The preset snapshot means resolution reads no config file — every
|
|
586
1003
|
// failure from here is a credential or provider problem and surfaces.
|
|
587
|
-
|
|
1004
|
+
return resolveRuntimeConfig(selection, resolveEnv);
|
|
1005
|
+
};
|
|
1006
|
+
const dispatchInternal = async (input) => {
|
|
1007
|
+
// A dispatch whose signal fired while it queued behind another turn
|
|
1008
|
+
// must not touch durable state: without this check, the runner would
|
|
1009
|
+
// load the session, append the cancelled user message, and save it as
|
|
1010
|
+
// failed — polluting future model history with input never processed.
|
|
1011
|
+
if (input.signal?.aborted) {
|
|
1012
|
+
throw new RunAbortedError();
|
|
1013
|
+
}
|
|
1014
|
+
// A session pins its agent: when the caller names none, an existing
|
|
1015
|
+
// conversation keeps the agent it was created with even if the
|
|
1016
|
+
// configured default soul has changed since — only a brand-new session
|
|
1017
|
+
// takes the current default. (Loaded here, before the watchdog, also
|
|
1018
|
+
// because whether this turn streams depends on durable session state.)
|
|
1019
|
+
const existing = await store.get(input.sessionId);
|
|
1020
|
+
const agentId = input.agentId ?? existing?.agent.id ?? await defaultAgentId();
|
|
1021
|
+
const source = await refreshAgent(agentId);
|
|
1022
|
+
const agent = source.definition;
|
|
1023
|
+
const config = await runtimeForAgent(source);
|
|
588
1024
|
const runner = runnerFor(config);
|
|
589
1025
|
const metadata = {
|
|
590
1026
|
...(config.provider === 'demo'
|
|
@@ -626,52 +1062,328 @@ export const createGateway = (options = {}) => {
|
|
|
626
1062
|
});
|
|
627
1063
|
});
|
|
628
1064
|
};
|
|
629
|
-
|
|
1065
|
+
/**
|
|
1066
|
+
* Finishes the turns that were parked on a human when this daemon last
|
|
1067
|
+
* went down.
|
|
1068
|
+
*
|
|
1069
|
+
* Run after the channels are up, deliberately: recovery re-asks, and a
|
|
1070
|
+
* request emitted into a gateway with no live adapter would be declined
|
|
1071
|
+
* for want of anywhere to put it — turning "your approval survived the
|
|
1072
|
+
* restart" into "your approval was denied by the restart".
|
|
1073
|
+
*
|
|
1074
|
+
* Each session is recovered independently and failures are per-session:
|
|
1075
|
+
* one unrecoverable turn must not stop the daemon from serving, or from
|
|
1076
|
+
* recovering the others.
|
|
1077
|
+
*/
|
|
1078
|
+
const recoverParkedTurns = async () => {
|
|
1079
|
+
if (!store.listIdsByStatus) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const parked = await store.listIdsByStatus('pending_approval');
|
|
1083
|
+
if (parked.length === 0) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
log(`recovering ${parked.length} turn(s) parked on approval`);
|
|
1087
|
+
// Started together, not one after another. Each recovery re-asks and
|
|
1088
|
+
// then waits on a human, so awaiting them in sequence would mean the
|
|
1089
|
+
// second parked turn is not even *announced* until the first is
|
|
1090
|
+
// answered — fifteen minutes later by default, and never at all with a
|
|
1091
|
+
// zero timeout. They are independent turns; only same-session work is
|
|
1092
|
+
// ordered, and onSessionChain already guarantees that.
|
|
1093
|
+
await Promise.allSettled(parked.map((sessionId) => recoverOne(sessionId)));
|
|
1094
|
+
};
|
|
1095
|
+
const recoverOne = (sessionId) => onSessionChain(sessionId, async () => {
|
|
630
1096
|
if (stopping) {
|
|
631
|
-
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
const session = await store.get(sessionId);
|
|
1101
|
+
if (!session) {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const record = readPendingApproval(session);
|
|
1105
|
+
// A wait that outlived its window while the daemon was down is
|
|
1106
|
+
// honoured, not restarted: the request really did go unanswered for
|
|
1107
|
+
// the whole time it was configured to wait, and downtime is not a
|
|
1108
|
+
// reason to extend a security decision. Measured from when the turn
|
|
1109
|
+
// parked against this daemon's timeout — the transport's original
|
|
1110
|
+
// deadline was chosen after the checkpoint was written and is gone
|
|
1111
|
+
// with the process that chose it. Denying goes through the same
|
|
1112
|
+
// recovery path, so the queue behind it still drains.
|
|
1113
|
+
const parkedAt = record ? Date.parse(record.parkedAt) : Number.NaN;
|
|
1114
|
+
const expired = effectiveApprovalTimeoutMs > 0
|
|
1115
|
+
&& Number.isFinite(parkedAt)
|
|
1116
|
+
&& Date.now() - parkedAt >= effectiveApprovalTimeoutMs;
|
|
1117
|
+
if (expired) {
|
|
1118
|
+
log(`${sessionId}: the approval for ${record?.call.toolName} outlived its window while the daemon was down; denying it`);
|
|
1119
|
+
}
|
|
1120
|
+
// Resolved the way a dispatch for this agent would resolve it, so a
|
|
1121
|
+
// recovered turn finishes on the same provider, model, and
|
|
1122
|
+
// credentials it was parked on.
|
|
1123
|
+
const source = await refreshAgent(session.agent.id);
|
|
1124
|
+
// And on the CURRENT definition, saved before recovery reads the
|
|
1125
|
+
// session back. An allowlist is a permission boundary: a soul that
|
|
1126
|
+
// dropped a tool while the daemon was down must not have that tool
|
|
1127
|
+
// executed by the turn that outlived the change, and
|
|
1128
|
+
// `allowedToolsFor` reads the definition frozen into the session
|
|
1129
|
+
// ahead of the registry. Dispatch refreshes it the same way before
|
|
1130
|
+
// resuming.
|
|
1131
|
+
session.agent = source.definition;
|
|
1132
|
+
await store.save(session);
|
|
1133
|
+
const runner = runnerFor(await runtimeForAgent(source));
|
|
1134
|
+
await runner.recoverPendingApproval(sessionId, { denyPending: expired });
|
|
1135
|
+
}
|
|
1136
|
+
catch (error) {
|
|
1137
|
+
warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
632
1138
|
}
|
|
633
|
-
|
|
634
|
-
|
|
1139
|
+
});
|
|
1140
|
+
/**
|
|
1141
|
+
* Turns the last process was still running when it stopped being able to
|
|
1142
|
+
* finish them.
|
|
1143
|
+
*
|
|
1144
|
+
* `pending_approval` is the one window a turn can be picked up from, and
|
|
1145
|
+
* it has its own sweep above. Everything else a turn does is saved as
|
|
1146
|
+
* `running`: the provider call, the executor, and — on a provider that
|
|
1147
|
+
* hosts its own loop — the approval wait too, since a hosted call is
|
|
1148
|
+
* deliberately not checkpointed (`executeHostedToolCall` passes
|
|
1149
|
+
* `recoverable: false`, because the SDK's inner loop is not something a
|
|
1150
|
+
* restart can rebuild). None of those can be resumed, and nothing else
|
|
1151
|
+
* sweeps them, so the record goes on claiming the turn is running for as
|
|
1152
|
+
* long as the session exists.
|
|
1153
|
+
*
|
|
1154
|
+
* Note what this is *not* for. A graceful stop denies everything parked
|
|
1155
|
+
* and drains the turns those denials release, so an operator restarting
|
|
1156
|
+
* the daemon normally has nothing here. These are the turns a crash, a
|
|
1157
|
+
* SIGKILL, or a lost machine left behind.
|
|
1158
|
+
*/
|
|
1159
|
+
const listAbandonedTurns = async () => {
|
|
1160
|
+
if (!store.listIdsByStatus) {
|
|
1161
|
+
return [];
|
|
1162
|
+
}
|
|
1163
|
+
return store.listIdsByStatus('running');
|
|
1164
|
+
};
|
|
1165
|
+
/**
|
|
1166
|
+
* Marks each of them failed, with a reason that says what happened.
|
|
1167
|
+
*
|
|
1168
|
+
* Read BEFORE the channels start and applied after, which is the only
|
|
1169
|
+
* ordering that is both honest and safe. Reading first means the list is
|
|
1170
|
+
* exactly what the last process left, with no chance of a turn this
|
|
1171
|
+
* process started being in it. Applying after means the failure is
|
|
1172
|
+
* emitted into a gateway whose surfaces are listening, rather than into
|
|
1173
|
+
* one where nothing is.
|
|
1174
|
+
*
|
|
1175
|
+
* The gap between the two is real, though: an inbound message for one of
|
|
1176
|
+
* these sessions can arrive as soon as the channels are up. What makes
|
|
1177
|
+
* that safe is single-flight — every writer of a session goes through
|
|
1178
|
+
* `onSessionChain`, so this runs either wholly before that turn (it
|
|
1179
|
+
* fails, and `resume()` then clears `lastError` and starts fresh) or
|
|
1180
|
+
* wholly after it (the status has moved off `running`, and the re-read
|
|
1181
|
+
* below leaves it alone). There is no interleaving in which this
|
|
1182
|
+
* overwrites a live turn.
|
|
1183
|
+
*
|
|
1184
|
+
* What that invariant does NOT cover is a second daemon on the same
|
|
1185
|
+
* database, whose turns are running with no chain of ours in front of
|
|
1186
|
+
* them. Two processes sharing a session store is already unsupported —
|
|
1187
|
+
* the parked sweep above would re-ask that daemon's approvals for the
|
|
1188
|
+
* same reason — and no check here would make it supported.
|
|
1189
|
+
*/
|
|
1190
|
+
const failAbandonedTurns = async (abandoned) => {
|
|
1191
|
+
if (abandoned.length === 0) {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
log(`failing ${abandoned.length} turn(s) left running by the last stratusd`);
|
|
1195
|
+
await Promise.allSettled(abandoned.map((id) => failAbandonedTurn(id)));
|
|
1196
|
+
};
|
|
1197
|
+
const failAbandonedTurn = (id) => onSessionChain(id, async () => {
|
|
1198
|
+
if (stopping) {
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
try {
|
|
1202
|
+
const session = await store.get(id);
|
|
1203
|
+
// Re-read on the chain, because the snapshot is from before the
|
|
1204
|
+
// channels came up: a message that arrived since has already taken
|
|
1205
|
+
// this session somewhere else, and that turn owns it now.
|
|
1206
|
+
if (!session || session.status !== 'running') {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
session.status = 'failed';
|
|
1210
|
+
session.lastError = ABANDONED_TURN_ERROR;
|
|
1211
|
+
await store.save(session);
|
|
1212
|
+
await bus.emit({ type: 'session.updated', sessionId: id, status: 'failed' });
|
|
1213
|
+
await bus.emit({ type: 'session.failed', sessionId: id, error: ABANDONED_TURN_ERROR });
|
|
1214
|
+
}
|
|
1215
|
+
catch (error) {
|
|
1216
|
+
warn(`could not fail abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
/**
|
|
1220
|
+
* Runs `work` as the session's next turn: queued behind whatever that
|
|
1221
|
+
* session is already doing, and tracked so shutdown drains it.
|
|
1222
|
+
*
|
|
1223
|
+
* Recovery goes through here as well as dispatch, and must. Channels are
|
|
1224
|
+
* live before a sweep finishes, so an inbound message for a parked
|
|
1225
|
+
* session could otherwise `resume()` alongside `recoverPendingApproval()`
|
|
1226
|
+
* — one closing the parked call as interrupted while the other re-enters
|
|
1227
|
+
* it, both saving divergent copies of the same transcript, and the tool
|
|
1228
|
+
* possibly running twice. Single-flight per session is the invariant that
|
|
1229
|
+
* stops that, and it only holds if everything that writes a session goes
|
|
1230
|
+
* through it.
|
|
1231
|
+
*/
|
|
1232
|
+
const onSessionChain = (sessionId, work) => {
|
|
1233
|
+
const previous = sessionChains.get(sessionId) ?? Promise.resolve();
|
|
1234
|
+
const turn = previous.then(work, work);
|
|
635
1235
|
const settled = turn.catch(() => { });
|
|
636
|
-
sessionChains.set(
|
|
1236
|
+
sessionChains.set(sessionId, settled);
|
|
637
1237
|
inflight.add(settled);
|
|
638
1238
|
void settled.finally(() => {
|
|
639
1239
|
inflight.delete(settled);
|
|
640
|
-
if (sessionChains.get(
|
|
641
|
-
sessionChains.delete(
|
|
1240
|
+
if (sessionChains.get(sessionId) === settled) {
|
|
1241
|
+
sessionChains.delete(sessionId);
|
|
642
1242
|
}
|
|
643
1243
|
});
|
|
644
1244
|
return turn;
|
|
645
1245
|
};
|
|
1246
|
+
const dispatch = async (input) => {
|
|
1247
|
+
if (stopping) {
|
|
1248
|
+
throw new Error('The gateway is stopping and no longer accepts new work.');
|
|
1249
|
+
}
|
|
1250
|
+
const { turnId } = input;
|
|
1251
|
+
if (turnId === undefined) {
|
|
1252
|
+
return onSessionChain(input.sessionId, () => dispatchInternal(input));
|
|
1253
|
+
}
|
|
1254
|
+
return onSessionChain(input.sessionId, async () => {
|
|
1255
|
+
activeTurns.set(input.sessionId, turnId);
|
|
1256
|
+
try {
|
|
1257
|
+
return await dispatchInternal(input);
|
|
1258
|
+
}
|
|
1259
|
+
finally {
|
|
1260
|
+
// Unconditional, and safe to be: the chain guarantees no other turn
|
|
1261
|
+
// for this session ran between the set above and here, so this can
|
|
1262
|
+
// only ever be clearing its own entry.
|
|
1263
|
+
activeTurns.delete(input.sessionId);
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
};
|
|
1267
|
+
/**
|
|
1268
|
+
* Load what the config asked for, and say what did not load.
|
|
1269
|
+
*
|
|
1270
|
+
* A plugin that fails is a warning rather than a refusal to start: a
|
|
1271
|
+
* daemon that will not boot because of a mistyped package name takes the
|
|
1272
|
+
* whole fleet down over one line of config, and the agents whose tools
|
|
1273
|
+
* did load are still worth serving. What is never degraded is the
|
|
1274
|
+
* security half — an undeclared name or a collision refuses that plugin
|
|
1275
|
+
* whole, inside the loader.
|
|
1276
|
+
*/
|
|
1277
|
+
const startPlugins = async () => {
|
|
1278
|
+
const configured = options.plugins ?? {};
|
|
1279
|
+
if (Object.keys(configured).length === 0) {
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const result = await loadPlugins({
|
|
1283
|
+
config: configured,
|
|
1284
|
+
host: options.pluginHost ?? {
|
|
1285
|
+
resolve: (specifier) => import.meta.resolve(specifier),
|
|
1286
|
+
import: (specifier) => import(specifier),
|
|
1287
|
+
},
|
|
1288
|
+
tools,
|
|
1289
|
+
bus,
|
|
1290
|
+
workspaceRoot: workspacesDirPath(env),
|
|
1291
|
+
});
|
|
1292
|
+
loadedPlugins = result.loaded;
|
|
1293
|
+
pluginFailures = result.failures;
|
|
1294
|
+
for (const plugin of result.loaded) {
|
|
1295
|
+
for (const tool of plugin.tools) {
|
|
1296
|
+
toolProvenance.set(tool.name, { package: plugin.package, trusted: plugin.trusted });
|
|
1297
|
+
}
|
|
1298
|
+
log(`plugin ${plugin.package} loaded — ${plugin.tools.map((tool) => `${tool.name} (${tool.risk})`).join(', ') || 'no tools'}`);
|
|
1299
|
+
}
|
|
1300
|
+
for (const failure of result.failures) {
|
|
1301
|
+
warn(`plugin ${failure.package} did not load: ${failure.reason}`);
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
/**
|
|
1305
|
+
* Everything after the plugins: the roster, the channels, and the sweeps
|
|
1306
|
+
* that need both. Split out so `start()` can wrap it in the cleanup a
|
|
1307
|
+
* failure here requires — see the call site.
|
|
1308
|
+
*/
|
|
1309
|
+
const startServing = async () => {
|
|
1310
|
+
await loadRoster();
|
|
1311
|
+
const named = registry.list().map((agent) => agent.name).join(', ');
|
|
1312
|
+
log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
|
|
1313
|
+
// Read before anything can dispatch, so this is exactly what the
|
|
1314
|
+
// last process left running and not a turn of ours caught in flight.
|
|
1315
|
+
// Applied further down, once there is somewhere for the failure to
|
|
1316
|
+
// be heard.
|
|
1317
|
+
const abandoned = await listAbandonedTurns();
|
|
1318
|
+
// Channels come up after the roster so their first inbound message
|
|
1319
|
+
// already has agents to dispatch to. One failing adapter must not
|
|
1320
|
+
// keep the rest (or the gateway) down.
|
|
1321
|
+
for (const adapter of options.channels ?? []) {
|
|
1322
|
+
try {
|
|
1323
|
+
await adapter.start(gateway);
|
|
1324
|
+
startedChannels.push(adapter);
|
|
1325
|
+
}
|
|
1326
|
+
catch (error) {
|
|
1327
|
+
warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
1328
|
+
// A start() that rejected may still hold sockets or listeners it
|
|
1329
|
+
// acquired before failing; it never reaches startedChannels, so
|
|
1330
|
+
// this is its only cleanup.
|
|
1331
|
+
try {
|
|
1332
|
+
await adapter.stop();
|
|
1333
|
+
}
|
|
1334
|
+
catch (stopError) {
|
|
1335
|
+
warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// Last, and not awaited: recovery re-asks, so it needs the channels
|
|
1340
|
+
// above already listening — but a turn parked behind a slow approver
|
|
1341
|
+
// must not hold up start(), or a daemon with one outstanding request
|
|
1342
|
+
// would refuse to finish booting.
|
|
1343
|
+
void recoverParkedTurns().catch((error) => {
|
|
1344
|
+
warn(`recovering parked turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1345
|
+
});
|
|
1346
|
+
// Not awaited for the same reason, and independent of the sweep
|
|
1347
|
+
// above: these sessions are not parked, so the two never touch the
|
|
1348
|
+
// same one.
|
|
1349
|
+
void failAbandonedTurns(abandoned).catch((error) => {
|
|
1350
|
+
warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1351
|
+
});
|
|
1352
|
+
};
|
|
1353
|
+
/** Release what the plugins acquired, reporting rather than throwing. */
|
|
1354
|
+
const disposePlugins = async () => {
|
|
1355
|
+
await Promise.allSettled(loadedPlugins.map(async (plugin) => {
|
|
1356
|
+
try {
|
|
1357
|
+
await plugin.instance.dispose?.();
|
|
1358
|
+
}
|
|
1359
|
+
catch (error) {
|
|
1360
|
+
warn(`plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
|
|
1361
|
+
}
|
|
1362
|
+
}));
|
|
1363
|
+
loadedPlugins = [];
|
|
1364
|
+
};
|
|
646
1365
|
const startedChannels = [];
|
|
647
1366
|
const gateway = {
|
|
648
1367
|
bus,
|
|
649
1368
|
store,
|
|
650
1369
|
async start() {
|
|
651
1370
|
await migrateLegacyMemory(env);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
try {
|
|
669
|
-
await adapter.stop();
|
|
670
|
-
}
|
|
671
|
-
catch (stopError) {
|
|
672
|
-
warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
1371
|
+
// Before the roster and before channels: a turn must never arrive for
|
|
1372
|
+
// an agent whose soul lists a tool the daemon has not registered yet,
|
|
1373
|
+
// which would refuse the call as "not permitted" and blame the soul.
|
|
1374
|
+
await startPlugins();
|
|
1375
|
+
try {
|
|
1376
|
+
await startServing();
|
|
1377
|
+
}
|
|
1378
|
+
catch (error) {
|
|
1379
|
+
// Everything after the plugins loaded is inside that try for one
|
|
1380
|
+
// reason: a `start()` that rejects never reaches its caller's
|
|
1381
|
+
// shutdown path — `stratus serve` awaits it *before* the try/finally
|
|
1382
|
+
// that calls `stop()` — so a duplicate agent id in the roster would
|
|
1383
|
+
// leave a plugin's browser, socket, or subscription held for the
|
|
1384
|
+
// life of a process that is on its way out.
|
|
1385
|
+
await disposePlugins();
|
|
1386
|
+
throw error;
|
|
675
1387
|
}
|
|
676
1388
|
},
|
|
677
1389
|
// Drain: channels stop taking messages, in-flight turns finish, new
|
|
@@ -682,16 +1394,112 @@ export const createGateway = (options = {}) => {
|
|
|
682
1394
|
// in-flight turns (already-started dispatches keep running), drain,
|
|
683
1395
|
// and close.
|
|
684
1396
|
stopping = true;
|
|
1397
|
+
// Deny what is parked before draining, or the drain waits out every
|
|
1398
|
+
// outstanding approval timeout — a shutdown would hang for as long as
|
|
1399
|
+
// the longest request had left. Each denial is a real decision the
|
|
1400
|
+
// turn continues from, so the drain below still finishes those turns.
|
|
1401
|
+
for (const parked of [...pendingApprovals.values()]) {
|
|
1402
|
+
parked.settle('deny', 'cancelled');
|
|
1403
|
+
}
|
|
1404
|
+
// Let those resolutions reach their subscribers before the channels
|
|
1405
|
+
// go down, or a channel that renders approvals never learns to
|
|
1406
|
+
// retract the buttons it is still showing.
|
|
1407
|
+
await Promise.allSettled([...approvalEmissions]);
|
|
685
1408
|
await Promise.allSettled(startedChannels.map((adapter) => adapter.stop()));
|
|
686
1409
|
startedChannels.length = 0;
|
|
687
1410
|
await Promise.allSettled([...inflight]);
|
|
1411
|
+
// After the turns that might still be using them. A plugin holding a
|
|
1412
|
+
// browser is the reason this exists, and closing it out from under a
|
|
1413
|
+
// running screenshot would fail that turn rather than tidy up.
|
|
1414
|
+
await disposePlugins();
|
|
688
1415
|
store.close();
|
|
689
1416
|
log('stratusd stopped');
|
|
690
1417
|
},
|
|
691
1418
|
dispatch,
|
|
1419
|
+
async sessionRouting(sessionId) {
|
|
1420
|
+
const session = await store.get(sessionId);
|
|
1421
|
+
return session
|
|
1422
|
+
? { agentId: session.agent.id, metadata: session.metadata ?? {} }
|
|
1423
|
+
: undefined;
|
|
1424
|
+
},
|
|
692
1425
|
agents() {
|
|
693
1426
|
return registry.list();
|
|
694
1427
|
},
|
|
1428
|
+
async reloadRoster() {
|
|
1429
|
+
await reloadsInOrder();
|
|
1430
|
+
const roster = registry.list();
|
|
1431
|
+
log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
|
|
1432
|
+
return roster;
|
|
1433
|
+
},
|
|
1434
|
+
activeTurnId(sessionId) {
|
|
1435
|
+
return activeTurns.get(sessionId);
|
|
1436
|
+
},
|
|
1437
|
+
pendingApprovals() {
|
|
1438
|
+
return [...pendingApprovals.values()]
|
|
1439
|
+
.map((parked) => parked.pending)
|
|
1440
|
+
.sort((a, b) => a.parkedAt.localeCompare(b.parkedAt));
|
|
1441
|
+
},
|
|
1442
|
+
tools() {
|
|
1443
|
+
// From the registry, not from the load records: the risk reported is
|
|
1444
|
+
// the one a call will actually be judged at, floor applied, and a
|
|
1445
|
+
// second list assembled from manifests would drift from it the first
|
|
1446
|
+
// time a floor was raised.
|
|
1447
|
+
return tools.describe().map((descriptor) => {
|
|
1448
|
+
const provenance = toolProvenance.get(descriptor.name);
|
|
1449
|
+
return {
|
|
1450
|
+
name: descriptor.name,
|
|
1451
|
+
...(descriptor.description ? { description: descriptor.description } : {}),
|
|
1452
|
+
risk: descriptor.risk ?? 'gated',
|
|
1453
|
+
...(provenance ? { package: provenance.package, trusted: provenance.trusted } : {}),
|
|
1454
|
+
};
|
|
1455
|
+
});
|
|
1456
|
+
},
|
|
1457
|
+
plugins() {
|
|
1458
|
+
const loaded = loadedPlugins.map((plugin) => ({
|
|
1459
|
+
package: plugin.package,
|
|
1460
|
+
name: plugin.name,
|
|
1461
|
+
trusted: plugin.trusted,
|
|
1462
|
+
tools: plugin.tools.map((tool) => ({
|
|
1463
|
+
name: tool.name,
|
|
1464
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
1465
|
+
risk: tool.risk,
|
|
1466
|
+
package: plugin.package,
|
|
1467
|
+
trusted: plugin.trusted,
|
|
1468
|
+
})),
|
|
1469
|
+
}));
|
|
1470
|
+
const failed = pluginFailures.map((failure) => ({
|
|
1471
|
+
package: failure.package,
|
|
1472
|
+
error: failure.reason,
|
|
1473
|
+
}));
|
|
1474
|
+
return [...loaded, ...failed];
|
|
1475
|
+
},
|
|
1476
|
+
async servedSouls() {
|
|
1477
|
+
// From the registry rather than `sources` directly, so the order and
|
|
1478
|
+
// the membership are the roster's own — one entry per agent `agents()`
|
|
1479
|
+
// reports, and no entry for a source the registry no longer serves.
|
|
1480
|
+
return Promise.all(registry.list().map(async (agent) => {
|
|
1481
|
+
const source = sources.get(agent.id);
|
|
1482
|
+
// Re-read, not re-registered: this is a read, and a caller asking
|
|
1483
|
+
// what the daemon would bill must not reshape the roster as a side
|
|
1484
|
+
// effect. `refreshAgent` still owns that on the dispatch path.
|
|
1485
|
+
const fresh = source?.soulPath
|
|
1486
|
+
? await loadSoulFile(source.soulPath).catch(() => undefined)
|
|
1487
|
+
: undefined;
|
|
1488
|
+
if (fresh && fresh.agent.id !== agent.id) {
|
|
1489
|
+
// The file was given to another agent. `refreshAgent` refuses every
|
|
1490
|
+
// dispatch for this id until the roster reloads, so there is no
|
|
1491
|
+
// runtime being served under it — reporting the cached pins would
|
|
1492
|
+
// claim billing for turns that cannot run, and reporting no soul at
|
|
1493
|
+
// all would claim the daemon-wide default instead. It is omitted.
|
|
1494
|
+
return undefined;
|
|
1495
|
+
}
|
|
1496
|
+
// Unreadable is the one case the cache answers for: the gateway goes
|
|
1497
|
+
// on dispatching from the soul it loaded, so those pins are live.
|
|
1498
|
+
const soul = fresh ?? source?.soul;
|
|
1499
|
+
return { id: agent.id, ...(soul ? { soul } : {}) };
|
|
1500
|
+
})).then((entries) => entries.filter((entry) => entry !== undefined));
|
|
1501
|
+
},
|
|
1502
|
+
resolveApproval,
|
|
695
1503
|
};
|
|
696
1504
|
return gateway;
|
|
697
1505
|
};
|