operon-agents 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +42 -0
- package/dist/app-server/client.js +8 -5
- package/dist/app-server/main.js +1 -1
- package/dist/app-server/params.d.ts +115 -0
- package/dist/app-server/params.js +81 -0
- package/dist/app-server/protocol.d.ts +10 -4
- package/dist/app-server/protocol.js +45 -37
- package/dist/app-server/server.d.ts +0 -1
- package/dist/app-server/server.js +46 -35
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/cron/cron-expr.js +1 -1
- package/dist/cron/extension.js +2 -2
- package/dist/extensions/index.d.ts +19 -5
- package/dist/extensions/index.js +66 -10
- package/dist/extensions/loader.d.ts +2 -2
- package/dist/extensions/loader.js +23 -15
- package/dist/extensions/manager.d.ts +15 -13
- package/dist/extensions/manager.js +28 -25
- package/dist/extensions/runtime.d.ts +69 -74
- package/dist/extensions/runtime.js +183 -381
- package/dist/extensions/types.d.ts +55 -45
- package/dist/harness.d.ts +339 -180
- package/dist/harness.js +673 -420
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/local.d.ts +25 -8
- package/dist/local.js +103 -33
- package/dist/providers.d.ts +144 -0
- package/dist/providers.js +280 -0
- package/dist/telemetry-posthog.d.ts +88 -0
- package/dist/telemetry-posthog.js +142 -0
- package/dist/telemetry.d.ts +4 -0
- package/dist/telemetry.js +3 -0
- package/dist/workspace-mcp.d.ts +62 -0
- package/dist/workspace-mcp.js +222 -0
- package/package.json +10 -3
package/dist/harness.js
CHANGED
|
@@ -11,11 +11,19 @@
|
|
|
11
11
|
* low-level "assemble it yourself / stateless server" path, import from
|
|
12
12
|
* `operon-agents/core` instead.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { readSessionTails, SessionProjection, ListenerSink, agentEventFromRecord, newAgentEventId, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, isGuardrailTripwireError, askUserQuestionTool, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalEnvironment, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, envLogger, noopLogger, } from "operon-agents-core";
|
|
15
15
|
import { mkdirSync } from "node:fs";
|
|
16
|
+
import { workspaceMcpCapability } from "./workspace-mcp.js";
|
|
16
17
|
import { join } from "node:path";
|
|
17
|
-
import {
|
|
18
|
+
import { extensionCapability, extensionRuntimeOf, extensionsCapability, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
|
|
18
19
|
import { createExtensionCommandRegistry } from "operon-agents-core";
|
|
20
|
+
/** How long `HarnessSession.close()` waits for aborted runs to settle before continuing teardown. */
|
|
21
|
+
let CLOSE_RUN_WAIT_MS = 5_000;
|
|
22
|
+
/** Test-only (exported via `operon-agents/internal`): shrink the close deadlines. */
|
|
23
|
+
export function setHarnessCloseTimeoutsForTest(ms) {
|
|
24
|
+
if (ms.runSettle !== undefined)
|
|
25
|
+
CLOSE_RUN_WAIT_MS = ms.runSettle;
|
|
26
|
+
}
|
|
19
27
|
/** Rendezvous budget for `replaceExtension` when the caller names none. */
|
|
20
28
|
const DEFAULT_REPLACE_TIMEOUT_MS = 30_000;
|
|
21
29
|
/** Session-store key holding the per-session extension params map (see `createSession({ params })`). */
|
|
@@ -26,21 +34,34 @@ const EXTENSION_PARAMS_STATE_KEY = "extensions:params";
|
|
|
26
34
|
* on the agent (`filesystemTools()`), not a capability; so is AskUserQuestion
|
|
27
35
|
* (`askUserQuestionTool`, wired through ToolRunContext.responder).
|
|
28
36
|
*
|
|
29
|
-
* With `mcpServers` and/or `pluginManager`, an MCP capability is added; with `pluginManager`, the
|
|
37
|
+
* With `mcpServers`, `sessionMcpServers` and/or `pluginManager`, an MCP capability is added; with `pluginManager`, the
|
|
30
38
|
* plugin's skills/MCP/session-start are wired in too. Intended to be called fresh per session (via
|
|
31
39
|
* a harness capability factory) so per-session state (goal/plan/todo/…) and the plugin set are
|
|
32
40
|
* isolated per session, mirroring a fresh-Session-per-create model.
|
|
33
41
|
*/
|
|
34
42
|
export function defaultCapabilities(options = {}) {
|
|
35
|
-
const manager = options.pluginManager;
|
|
43
|
+
const manager = options.pluginManager ?? options.shared?.pluginManager;
|
|
44
|
+
// A session on its own environment ignores the process-wide registry: it was scanned elsewhere.
|
|
45
|
+
const strict = options.strictIsolation === true;
|
|
46
|
+
// A session on its own environment, or one tenant among many, gets nothing the process shares.
|
|
47
|
+
const isolated = strict || options.ownEnvironment === true;
|
|
48
|
+
const sharedSkills = isolated ? undefined : options.shared?.skillRegistry;
|
|
49
|
+
const sharedServers = isolated ? undefined : options.shared?.mcpServers;
|
|
50
|
+
const oauthService = options.oauthService ?? (strict ? undefined : options.shared?.oauthService);
|
|
51
|
+
const allowedMcpTransports = strict
|
|
52
|
+
? (options.allowedMcpTransports ?? ["http", "stdio"]).filter((kind) => kind === "http")
|
|
53
|
+
: options.allowedMcpTransports;
|
|
36
54
|
// Shared registry so the plugin session-start injector can render a skill the skills capability
|
|
37
|
-
// loaded (its roots include the plugin skill dirs below).
|
|
38
|
-
|
|
55
|
+
// loaded (its roots include the plugin skill dirs below). A process-wide registry is scanned
|
|
56
|
+
// once for every session; a session-level one is scanned here.
|
|
57
|
+
const registry = sharedSkills ?? (manager !== undefined ? new SkillRegistry() : undefined);
|
|
39
58
|
// `includeDefaultRoots` because the plugin dirs ADD to the project/user `.agents/skills` roots —
|
|
40
59
|
// without it, enabling a single skill-bearing plugin would hide every local skill.
|
|
41
|
-
const skills =
|
|
42
|
-
? skillsCapability({ registry
|
|
43
|
-
:
|
|
60
|
+
const skills = sharedSkills !== undefined
|
|
61
|
+
? skillsCapability({ registry: sharedSkills, scan: false })
|
|
62
|
+
: manager !== undefined
|
|
63
|
+
? skillsCapability({ registry, roots: manager.skillRoots(), includeDefaultRoots: true })
|
|
64
|
+
: skillsCapability();
|
|
44
65
|
const capabilities = [
|
|
45
66
|
goalCapability(),
|
|
46
67
|
workflowCapability(),
|
|
@@ -51,9 +72,40 @@ export function defaultCapabilities(options = {}) {
|
|
|
51
72
|
compactionCapability({ maxContextTokens: options.maxContextTokens ?? 200_000 }),
|
|
52
73
|
];
|
|
53
74
|
// Cron is a local-only capability (Invariant 7): the server host doesn't install it.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
const sessionMcp = options.sessionMcpServers ?? {};
|
|
76
|
+
const mcpOptions = {
|
|
77
|
+
...(oauthService !== undefined ? { oauthService } : {}),
|
|
78
|
+
...(allowedMcpTransports !== undefined ? { allowedTransports: allowedMcpTransports } : {}),
|
|
79
|
+
...(options.mcpCredentials !== undefined ? { credentials: options.mcpCredentials } : {}),
|
|
80
|
+
};
|
|
81
|
+
// Everything this session would connect that is not private to it: the directory's servers
|
|
82
|
+
// last, so they win over a same-named plugin server the way they did as a session overlay.
|
|
83
|
+
const shareable = {
|
|
84
|
+
...(options.mcpServers ?? {}),
|
|
85
|
+
...(manager?.mcpServerConfigs() ?? {}),
|
|
86
|
+
...(options.workspaceMcp?.servers ?? {}),
|
|
87
|
+
};
|
|
88
|
+
if (strict) {
|
|
89
|
+
// The process environment has no tenant boundary: a token read from it is every tenant's.
|
|
90
|
+
for (const [name, config] of Object.entries({ ...shareable, ...sessionMcp })) {
|
|
91
|
+
if (config.bearerTokenEnvVar !== undefined) {
|
|
92
|
+
throw new Error(`MCP server "${name}" reads its token from the process environment (bearerTokenEnvVar), which every tenant on this host shares; hand it out per session with mcpCredentials instead.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const pooled = sharedServers === undefined && !isolated ? options.workspaceMcp : undefined;
|
|
97
|
+
if (pooled !== undefined && Object.keys(shareable).length > 0) {
|
|
98
|
+
// Borrowed per directory: the capability views the pool's set, `sessionMcp` is its overlay.
|
|
99
|
+
capabilities.push(workspaceMcpCapability({ pool: pooled.pool, workDir: pooled.workDir, servers: shareable, overlay: sessionMcp, overlayOptions: mcpOptions }));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// With process-shared connections, the capability is a VIEW over them and `configs` is this
|
|
103
|
+
// session's overlay. Without, everything this session sees is its own — session servers last,
|
|
104
|
+
// so the same name still resolves to the session's.
|
|
105
|
+
const mcpConfigs = sharedServers !== undefined ? sessionMcp : { ...shareable, ...sessionMcp };
|
|
106
|
+
if (sharedServers !== undefined || Object.keys(mcpConfigs).length > 0) {
|
|
107
|
+
capabilities.push(mcpServersCapability(mcpConfigs, { ...mcpOptions, ...(sharedServers !== undefined ? { sharedServers } : {}) }));
|
|
108
|
+
}
|
|
57
109
|
}
|
|
58
110
|
// Plugins: load the manager + render the plugin's session-start skill from the shared registry
|
|
59
111
|
// (skills opens before this, so by injection time the registry holds the plugin skills).
|
|
@@ -98,6 +150,11 @@ class MutableResponder {
|
|
|
98
150
|
isLiveApprover() {
|
|
99
151
|
return this.approvalHandler !== undefined;
|
|
100
152
|
}
|
|
153
|
+
/** Same rule for questions: no handler ⇒ nobody attached ⇒ AskUserQuestion suspends durably
|
|
154
|
+
* (the question is on the paused run's pending list) instead of being dismissed unanswered. */
|
|
155
|
+
isLiveQuestioner() {
|
|
156
|
+
return this.questionHandler !== undefined;
|
|
157
|
+
}
|
|
101
158
|
async requestApproval(request, options) {
|
|
102
159
|
if (this.approvalHandler)
|
|
103
160
|
return this.approvalHandler(request, options);
|
|
@@ -133,22 +190,36 @@ export class HarnessSession {
|
|
|
133
190
|
responder;
|
|
134
191
|
onClosed;
|
|
135
192
|
runContext;
|
|
136
|
-
currentRun;
|
|
137
193
|
/** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
|
|
138
194
|
* session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
|
|
139
195
|
* run is never touched. */
|
|
140
196
|
runGate;
|
|
141
|
-
/**
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Every run this facade has begun and not yet seen settle, by its controller. More than one
|
|
199
|
+
* entry means runs are queued behind the core's run lock (`prompt` while a turn is in flight):
|
|
200
|
+
* "busy" is this set being non-empty, `cancel()` aborts ALL of them, and the barrier's
|
|
201
|
+
* quiescence is ALL of them settling — tracking only the last-begun run would abort the
|
|
202
|
+
* queued one and leave the running one untouched.
|
|
203
|
+
*/
|
|
204
|
+
runs = new Map();
|
|
145
205
|
lastRunInterrupted = false;
|
|
146
|
-
closed
|
|
147
|
-
|
|
206
|
+
/** open → closing (runs being drained, resources going down) → closed. Never goes back. */
|
|
207
|
+
lifecycle = "open";
|
|
208
|
+
/** The one close in flight (or finished): every `close()` call returns THIS promise. */
|
|
209
|
+
closing;
|
|
210
|
+
/** Resolves the moment close() begins — what wakes a caller parked at the barrier gate so it
|
|
211
|
+
* can fail with the closed error instead of hanging on a gate nobody will release. */
|
|
212
|
+
notifyClosing;
|
|
213
|
+
closingNotified = new Promise((resolve) => {
|
|
214
|
+
this.notifyClosing = resolve;
|
|
215
|
+
});
|
|
148
216
|
/** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
|
|
149
217
|
wakeScheduled = false;
|
|
150
218
|
/** Per-session turn cap; the Runner's own config is the fallback. */
|
|
151
219
|
maxTurns;
|
|
220
|
+
/** Builds the capability an extension attached to THIS session runs as — bound to the
|
|
221
|
+
* session's harness reach and its per-session params. */
|
|
222
|
+
mountExtension;
|
|
152
223
|
constructor(args) {
|
|
153
224
|
this.core = args.core;
|
|
154
225
|
this.id = args.core.id;
|
|
@@ -162,6 +233,7 @@ export class HarnessSession {
|
|
|
162
233
|
this.maxTurns = args.maxTurns;
|
|
163
234
|
this.lastRunInterrupted = args.interrupted ?? false;
|
|
164
235
|
this.onClosed = args.onClosed;
|
|
236
|
+
this.mountExtension = args.mountExtension;
|
|
165
237
|
// Nothing drains a queue that fills while the session sits idle — the run loop's
|
|
166
238
|
// turn-boundary drain only exists while a run is in flight. Without this, a background
|
|
167
239
|
// task settling minutes after its spawning turn ended (the normal case: spawning to the
|
|
@@ -173,7 +245,7 @@ export class HarnessSession {
|
|
|
173
245
|
* Start a turn to consume queued follow-ups, once the current synchronous work is done.
|
|
174
246
|
*
|
|
175
247
|
* Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
|
|
176
|
-
* wake, and a settle that lands in a finishing run's own stack sees
|
|
248
|
+
* wake, and a settle that lands in a finishing run's own stack sees its run already
|
|
177
249
|
* cleared. Re-checked at fire time because either can change in between — and re-checked
|
|
178
250
|
* again after every run, since the queue may have filled during one whose drain had passed.
|
|
179
251
|
*/
|
|
@@ -191,7 +263,7 @@ export class HarnessSession {
|
|
|
191
263
|
return;
|
|
192
264
|
// A run in flight drains at its own turn boundary; an interrupted one must be resumed,
|
|
193
265
|
// not talked over.
|
|
194
|
-
if (this.
|
|
266
|
+
if (this.hasActiveRuns())
|
|
195
267
|
return;
|
|
196
268
|
// Either channel counts. A "steering" item is an interruption of work in flight, but with
|
|
197
269
|
// nothing in flight there is nothing to interrupt — it is simply the next input, and the
|
|
@@ -203,6 +275,22 @@ export class HarnessSession {
|
|
|
203
275
|
void this.runPrompt([]).catch(() => undefined);
|
|
204
276
|
});
|
|
205
277
|
}
|
|
278
|
+
/** Not open: closing or closed. Every run entry point refuses from here on. */
|
|
279
|
+
get closed() {
|
|
280
|
+
return this.lifecycle !== "open";
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Park at the barrier gate until released — or until this session starts closing, in which
|
|
284
|
+
* case fail with the closed error rather than wait on a gate no coordinator will release.
|
|
285
|
+
* Re-checked in a loop: a new hold can land between a release and this task resuming.
|
|
286
|
+
*/
|
|
287
|
+
async parkAtGate() {
|
|
288
|
+
while (this.runGate !== undefined) {
|
|
289
|
+
await Promise.race([this.runGate.released, this.closingNotified]);
|
|
290
|
+
if (this.closed)
|
|
291
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
206
294
|
/** Run options every run on this session carries — currently the session's turn cap. */
|
|
207
295
|
get runOverrides() {
|
|
208
296
|
return this.maxTurns !== undefined ? { maxTurns: this.maxTurns } : {};
|
|
@@ -215,7 +303,7 @@ export class HarnessSession {
|
|
|
215
303
|
return {
|
|
216
304
|
state: this.closed
|
|
217
305
|
? "closed"
|
|
218
|
-
: this.
|
|
306
|
+
: this.hasActiveRuns()
|
|
219
307
|
? "running"
|
|
220
308
|
: this.lastRunInterrupted
|
|
221
309
|
? "interrupted"
|
|
@@ -238,16 +326,26 @@ export class HarnessSession {
|
|
|
238
326
|
}
|
|
239
327
|
// ── Run driving (the methods core Session does not have) ──
|
|
240
328
|
beginRun(controller) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
329
|
+
let resolve;
|
|
330
|
+
const settled = new Promise((r) => {
|
|
331
|
+
resolve = r;
|
|
244
332
|
});
|
|
333
|
+
this.runs.set(controller, { settled, resolve });
|
|
245
334
|
}
|
|
246
335
|
endRun(controller) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
this.
|
|
250
|
-
|
|
336
|
+
this.runs.get(controller)?.resolve();
|
|
337
|
+
this.runs.delete(controller);
|
|
338
|
+
// Input can arrive after the last drain while this run still counts as active. Every
|
|
339
|
+
// entry point must recheck on exit; the wake itself respects pauses, gates and other runs.
|
|
340
|
+
this.scheduleIdleWake();
|
|
341
|
+
}
|
|
342
|
+
/** A run is in flight or queued behind the core's run lock. */
|
|
343
|
+
hasActiveRuns() {
|
|
344
|
+
return this.runs.size > 0;
|
|
345
|
+
}
|
|
346
|
+
/** Settles when every run begun so far has settled (trivially, when idle). */
|
|
347
|
+
allRunsSettled() {
|
|
348
|
+
return Promise.all([...this.runs.values()].map((run) => run.settled)).then(() => undefined);
|
|
251
349
|
}
|
|
252
350
|
/**
|
|
253
351
|
* Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
|
|
@@ -264,7 +362,7 @@ export class HarnessSession {
|
|
|
264
362
|
});
|
|
265
363
|
const gate = { released, release: releaseGate };
|
|
266
364
|
this.runGate = gate;
|
|
267
|
-
const quiescent = this.
|
|
365
|
+
const quiescent = this.allRunsSettled();
|
|
268
366
|
return {
|
|
269
367
|
quiescent,
|
|
270
368
|
release: () => {
|
|
@@ -283,10 +381,11 @@ export class HarnessSession {
|
|
|
283
381
|
async runPrompt(input, inputOrigin) {
|
|
284
382
|
if (this.closed)
|
|
285
383
|
throw new Error(`session "${this.id}" is closed`);
|
|
286
|
-
// Barrier gate: park
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
384
|
+
// Barrier gate: park until released (a hold is "wait", not "fail") — unless the session
|
|
385
|
+
// closes meanwhile, which is the one thing that turns the wait into a failure. Ungated, the
|
|
386
|
+
// run is registered SYNCHRONOUSLY: a cancel() or close() on the next line must find it.
|
|
387
|
+
if (this.runGate !== undefined)
|
|
388
|
+
await this.parkAtGate();
|
|
290
389
|
const controller = new AbortController();
|
|
291
390
|
this.beginRun(controller);
|
|
292
391
|
try {
|
|
@@ -309,141 +408,61 @@ export class HarnessSession {
|
|
|
309
408
|
}
|
|
310
409
|
finally {
|
|
311
410
|
this.endRun(controller);
|
|
312
|
-
// A follow-up can land after this run's last drain but before it settles — that enqueue
|
|
313
|
-
// saw `currentRun` set and stood down, so the run itself has to look once on the way out.
|
|
314
|
-
this.scheduleIdleWake();
|
|
315
411
|
}
|
|
316
412
|
}
|
|
317
413
|
/**
|
|
318
414
|
* Hand a message to the frame running at `address` in this session (`main` for the root agent,
|
|
319
|
-
* `main/<agentId>` for a subagent);
|
|
320
|
-
* coordinator uses to address a specific subagent.
|
|
415
|
+
* `main/<agentId>` for a subagent); undefined when nobody is there, or the session is paused
|
|
416
|
+
* on a question. The seam an out-of-process coordinator uses to address a specific subagent.
|
|
417
|
+
*
|
|
418
|
+
* The receipt's `journaled` settles once the `steer.queued` record is on disk — the moment a
|
|
419
|
+
* caller may answer "accepted" to someone else and mean it survives a crash. A running frame
|
|
420
|
+
* drains the message at its next boundary; an idle root frame is woken to take it.
|
|
321
421
|
*/
|
|
322
|
-
steerTo(address, content, origin) {
|
|
323
|
-
if (this.closed
|
|
324
|
-
return
|
|
422
|
+
steerTo(address, content, origin, options) {
|
|
423
|
+
if (this.closed)
|
|
424
|
+
return undefined;
|
|
325
425
|
// A run in flight drains its own queues, so handing the message to the frame is enough.
|
|
326
|
-
|
|
327
|
-
|
|
426
|
+
// A resume keeps the prior interruption flag until it settles, but is already running.
|
|
427
|
+
if (this.hasActiveRuns())
|
|
428
|
+
return this.core.steerTo(address, content, origin, options);
|
|
429
|
+
if (this.lastRunInterrupted)
|
|
430
|
+
return undefined;
|
|
328
431
|
// Idle: only the root frame can be woken. A subagent whose frame has ended is not a teammate
|
|
329
432
|
// waiting for mail — it is a finished delegation, and only the parent that delegated it can
|
|
330
433
|
// decide to continue it (`Agent(resume=...)`). Its store, capabilities, permissions and
|
|
331
434
|
// lifetime are all the parent's; there is no independent agent here to start.
|
|
332
435
|
if (address !== DEFAULT_ADDRESS)
|
|
333
|
-
return
|
|
436
|
+
return undefined;
|
|
334
437
|
// Queue it and let the idle-wake listener start the turn. Going through the bus (rather
|
|
335
|
-
// than synthesizing a prompt here) is what gives the message
|
|
336
|
-
// record, same as every other producer
|
|
337
|
-
this.core.steer.steer(content, origin);
|
|
338
|
-
return true;
|
|
438
|
+
// than synthesizing a prompt here) is what gives the message its `steer.queued` record and
|
|
439
|
+
// a `steerId` on its journal record, same as every other producer.
|
|
440
|
+
return this.core.steer.steer(content, origin, options);
|
|
339
441
|
}
|
|
340
442
|
/**
|
|
341
|
-
*
|
|
342
|
-
* requested SteerBus channel; an idle session starts a new turn immediately.
|
|
343
|
-
*
|
|
344
|
-
* ACCEPTANCE IS DURABLE BEFORE THIS RESOLVES. The input is journaled as `inbox.received`
|
|
345
|
-
* and that write is awaited before a receipt exists, so the receipt means "this survives a
|
|
346
|
-
* crash", not merely "this reached a process". Previously the message lived only in memory
|
|
347
|
-
* (SteerBus, or an argument to `runPrompt`) until the run that consumed it journaled it —
|
|
348
|
-
* a crash in that window lost a delivery the caller had already been told was accepted.
|
|
443
|
+
* Put a message whose `steer.queued` record ALREADY exists back on the bus.
|
|
349
444
|
*
|
|
350
|
-
*
|
|
351
|
-
* `
|
|
352
|
-
*
|
|
353
|
-
*
|
|
445
|
+
* For whoever holds a reopened session and finds, in its log, accepted messages no turn ever
|
|
446
|
+
* consumed — a `steer.queued` with no `message.appended` carrying its `steerId`. That happens
|
|
447
|
+
* when acceptance and execution are different processes (a managed API node writes the record,
|
|
448
|
+
* a worker runs it later, possibly on another machine) or when the process that accepted a
|
|
449
|
+
* message died before a turn drained it. Nothing is journaled again: the record and its
|
|
450
|
+
* `steer.queued` event exist from the first acceptance. Run state is read NOW, not then — a
|
|
451
|
+
* running turn drains it at its next boundary, an idle session is woken to take it.
|
|
354
452
|
*/
|
|
355
|
-
|
|
453
|
+
requeue(item) {
|
|
356
454
|
if (this.closed)
|
|
357
455
|
throw new Error(`session "${this.id}" is closed`);
|
|
358
|
-
|
|
359
|
-
throw new Error(`session "${this.id}" is interrupted; resume it before delivering new work`);
|
|
360
|
-
}
|
|
361
|
-
if (!options.source.trim())
|
|
362
|
-
throw new Error("delivery source must not be empty");
|
|
363
|
-
this.deliveryCounter += 1;
|
|
364
|
-
const deliveryId = `delivery_${Date.now().toString(36)}_${this.deliveryCounter.toString(36)}`;
|
|
365
|
-
const mode = options.mode ?? "auto";
|
|
366
|
-
const channel = mode === "follow_up" ? "follow_up" : "steering";
|
|
367
|
-
const external = {
|
|
368
|
-
kind: "external",
|
|
369
|
-
source: options.source,
|
|
370
|
-
deliveryId,
|
|
371
|
-
actor: options.actor,
|
|
372
|
-
metadata: options.metadata,
|
|
373
|
-
channel,
|
|
374
|
-
};
|
|
375
|
-
const acceptedAt = Date.now();
|
|
376
|
-
// Durable acceptance, before anything is dispatched. A stored session gets its receipt from
|
|
377
|
-
// this record (the store is publication-aware, so the append also produces the
|
|
378
|
-
// `delivery.accepted` event); a storeless one has nothing to be durable about and emits the
|
|
379
|
-
// event directly.
|
|
380
|
-
if (this.core.store !== undefined) {
|
|
381
|
-
await this.core.store.appendRecord({
|
|
382
|
-
type: "inbox.received",
|
|
383
|
-
time: acceptedAt,
|
|
384
|
-
address: "main",
|
|
385
|
-
input,
|
|
386
|
-
origin: external,
|
|
387
|
-
mode,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
void this.core.events.emit({
|
|
392
|
-
type: "delivery.accepted",
|
|
393
|
-
deliveryId,
|
|
394
|
-
source: options.source,
|
|
395
|
-
channel,
|
|
396
|
-
address: "main",
|
|
397
|
-
sessionId: this.id,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
return this.dispatchAccepted(input, external, acceptedAt);
|
|
456
|
+
return this.core.steer.requeue(item);
|
|
401
457
|
}
|
|
402
458
|
/**
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
* so going back through `deliver` would write a second one and count the delivery twice. The
|
|
407
|
-
* split is the whole reason acceptance and dispatch are separable — accepting is a durable
|
|
408
|
-
* write that must happen before a receipt exists, dispatching is what someone holding the
|
|
409
|
-
* session's lease does with it afterwards, possibly on another machine, possibly much later.
|
|
410
|
-
*
|
|
411
|
-
* Run state is read HERE, not at acceptance: by now a turn may have started or ended, so
|
|
412
|
-
* whether this steers work in flight or starts a fresh turn can only be decided now.
|
|
459
|
+
* Settles when no run is in flight. Yields once first, so a run that an enqueue on the
|
|
460
|
+
* previous line scheduled (the idle wake fires from a microtask) is counted as in flight
|
|
461
|
+
* rather than missed. A run that starts afterwards is not waited for.
|
|
413
462
|
*/
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const deliveryId = origin.deliveryId;
|
|
418
|
-
const channel = origin.channel ?? "steering";
|
|
419
|
-
if (this.currentRun !== undefined) {
|
|
420
|
-
const receipt = this.core.steer.steer(input, origin);
|
|
421
|
-
return {
|
|
422
|
-
deliveryId,
|
|
423
|
-
sessionId: this.id,
|
|
424
|
-
acceptedAt,
|
|
425
|
-
status: "queued",
|
|
426
|
-
channel,
|
|
427
|
-
steerId: receipt.steerId,
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
const promptOrigin = {
|
|
431
|
-
kind: "external",
|
|
432
|
-
source: origin.source,
|
|
433
|
-
deliveryId,
|
|
434
|
-
...(origin.actor !== undefined ? { actor: origin.actor } : {}),
|
|
435
|
-
...(origin.metadata !== undefined ? { metadata: origin.metadata } : {}),
|
|
436
|
-
};
|
|
437
|
-
const message = {
|
|
438
|
-
role: "user",
|
|
439
|
-
content: [{ type: "text", text: renderSteerText(origin, input) }],
|
|
440
|
-
timestamp: acceptedAt,
|
|
441
|
-
};
|
|
442
|
-
const completion = this.runPrompt([message], promptOrigin);
|
|
443
|
-
// Callers may only need acceptance. Keep a fire-and-observe delivery from becoming an
|
|
444
|
-
// unhandled rejection; awaiting `completion` still receives the original rejection.
|
|
445
|
-
completion.catch(() => undefined);
|
|
446
|
-
return { deliveryId, sessionId: this.id, acceptedAt, status: "started", channel: "turn", completion };
|
|
463
|
+
async whenIdle() {
|
|
464
|
+
await Promise.resolve();
|
|
465
|
+
await this.allRunsSettled();
|
|
447
466
|
}
|
|
448
467
|
/**
|
|
449
468
|
* Resume a durably-interrupted run. When a `prompt` returns `status:"interrupted"` — which
|
|
@@ -465,15 +484,17 @@ export class HarnessSession {
|
|
|
465
484
|
* copy see a "stale interruption state" error; re-fetching the persisted state clears it.
|
|
466
485
|
*/
|
|
467
486
|
async resume(answers) {
|
|
487
|
+
if (this.closed)
|
|
488
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
468
489
|
// Barrier gate first (a coordinator holding the barrier should not be resumed against —
|
|
469
|
-
// the call parks here until release rather than failing).
|
|
470
|
-
|
|
471
|
-
await this.
|
|
472
|
-
if (this.
|
|
490
|
+
// the call parks here until release rather than failing; closing meanwhile does fail it).
|
|
491
|
+
if (this.runGate !== undefined)
|
|
492
|
+
await this.parkAtGate();
|
|
493
|
+
if (this.hasActiveRuns())
|
|
473
494
|
throw new Error(`session "${this.id}" already has an active run`);
|
|
474
495
|
const controller = new AbortController();
|
|
475
496
|
// Set this BEFORE the first await (past the gate). A host returning 202 from resume can
|
|
476
|
-
// immediately observe `running`, and a concurrent
|
|
497
|
+
// immediately observe `running`, and a concurrent steer joins this resumed turn instead
|
|
477
498
|
// of seeing stale interrupted state while the control record is being read.
|
|
478
499
|
this.beginRun(controller);
|
|
479
500
|
try {
|
|
@@ -509,6 +530,8 @@ export class HarnessSession {
|
|
|
509
530
|
}
|
|
510
531
|
/** Streaming variant: iterate events, await `.completed` for the result. */
|
|
511
532
|
promptStream(input) {
|
|
533
|
+
if (this.closed)
|
|
534
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
512
535
|
const controller = new AbortController();
|
|
513
536
|
// Ungated: start synchronously, byte-identical to the pre-barrier behavior. Gated: the
|
|
514
537
|
// handle returns immediately but the underlying run starts only after release (lazy start —
|
|
@@ -518,8 +541,7 @@ export class HarnessSession {
|
|
|
518
541
|
this.beginRun(controller);
|
|
519
542
|
const start = (async () => {
|
|
520
543
|
if (gated) {
|
|
521
|
-
|
|
522
|
-
await this.runGate.released;
|
|
544
|
+
await this.parkAtGate();
|
|
523
545
|
this.beginRun(controller);
|
|
524
546
|
}
|
|
525
547
|
return this.runner.runStream(this.agent, input, {
|
|
@@ -580,26 +602,30 @@ export class HarnessSession {
|
|
|
580
602
|
};
|
|
581
603
|
}
|
|
582
604
|
/**
|
|
583
|
-
* Inject a user message into the in-flight (or next) turn.
|
|
584
|
-
*
|
|
585
|
-
* `origin.steerId` matches it when the model actually sees the message
|
|
605
|
+
* Inject a user message into the in-flight (or next) turn. The receipt's `steerId` is the
|
|
606
|
+
* correlation key: a `steer.queued` event carries it now, and the consuming `message.appended`
|
|
607
|
+
* event's `origin.steerId` matches it when the model actually sees the message; `journaled`
|
|
608
|
+
* settles once the `steer.queued` record is durable.
|
|
586
609
|
*/
|
|
587
610
|
steer(input) {
|
|
588
|
-
|
|
611
|
+
if (this.closed)
|
|
612
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
613
|
+
return this.core.steer.steer(input, { kind: "user" });
|
|
589
614
|
}
|
|
590
615
|
/**
|
|
591
616
|
* Queue a user message for a new turn after the current run's active turn finishes.
|
|
592
|
-
* Returns the
|
|
593
|
-
*
|
|
617
|
+
* Returns the receipt (see `steer`), or null when the session has no active run — use
|
|
618
|
+
* `prompt` instead in that case.
|
|
594
619
|
*/
|
|
595
620
|
followUp(input) {
|
|
596
|
-
if (this.
|
|
621
|
+
if (!this.hasActiveRuns())
|
|
597
622
|
return null;
|
|
598
|
-
return this.core.steer.followUp(input)
|
|
623
|
+
return this.core.steer.followUp(input);
|
|
599
624
|
}
|
|
600
|
-
/** Abort the
|
|
625
|
+
/** Abort every run this session has accepted: the one in flight AND any queued behind it. */
|
|
601
626
|
cancel() {
|
|
602
|
-
this.
|
|
627
|
+
for (const controller of [...this.runs.keys()])
|
|
628
|
+
controller.abort();
|
|
603
629
|
}
|
|
604
630
|
/** Subscribe to this session's event stream. Returns an unsubscribe fn. */
|
|
605
631
|
onEvent(listener) {
|
|
@@ -643,82 +669,103 @@ export class HarnessSession {
|
|
|
643
669
|
setPermissionMode(mode) {
|
|
644
670
|
return this.core.setPermissionMode(mode);
|
|
645
671
|
}
|
|
646
|
-
|
|
647
|
-
|
|
672
|
+
/** The model this session runs on, as last set; undefined means the agent's own. */
|
|
673
|
+
get modelSetting() {
|
|
674
|
+
return this.core.modelSetting;
|
|
648
675
|
}
|
|
649
|
-
|
|
650
|
-
|
|
676
|
+
/** The thinking level this session runs at, as last set; undefined means the model's default. */
|
|
677
|
+
get thinkingSetting() {
|
|
678
|
+
return this.core.thinkingSetting;
|
|
651
679
|
}
|
|
652
|
-
|
|
653
|
-
|
|
680
|
+
/** The permission mode in force right now — what a status line reads instead of tracking it. */
|
|
681
|
+
get permissionMode() {
|
|
682
|
+
return this.core.permissionModeSetting;
|
|
654
683
|
}
|
|
655
|
-
|
|
656
|
-
|
|
684
|
+
// ── capability services ───────────────────────────────────────────────────────────────
|
|
685
|
+
// The same accessors the core `Session` exposes, forwarded. There used to be one wrapper
|
|
686
|
+
// method per operation here, mirroring one facade method per operation there — two layers
|
|
687
|
+
// that had to be kept in step by hand every time a capability grew an operation. Forwarding
|
|
688
|
+
// the SERVICE means a new operation needs no change in either layer.
|
|
689
|
+
get goal() {
|
|
690
|
+
return this.core.goal;
|
|
657
691
|
}
|
|
658
|
-
|
|
659
|
-
return this.core.
|
|
692
|
+
get plan() {
|
|
693
|
+
return this.core.plan;
|
|
660
694
|
}
|
|
661
|
-
|
|
662
|
-
return this.core.
|
|
695
|
+
get skills() {
|
|
696
|
+
return this.core.skills;
|
|
663
697
|
}
|
|
664
|
-
|
|
665
|
-
return this.core.
|
|
698
|
+
get plugins() {
|
|
699
|
+
return this.core.plugins;
|
|
666
700
|
}
|
|
667
|
-
|
|
668
|
-
return this.core.
|
|
701
|
+
get compaction() {
|
|
702
|
+
return this.core.compaction;
|
|
669
703
|
}
|
|
670
|
-
|
|
671
|
-
|
|
704
|
+
/** PROBE tier: absent without the background capability — see `Session.background`. */
|
|
705
|
+
get background() {
|
|
706
|
+
return this.core.background;
|
|
672
707
|
}
|
|
673
|
-
|
|
674
|
-
|
|
708
|
+
/** PROBE tier: absent without an MCP capability. */
|
|
709
|
+
get mcp() {
|
|
710
|
+
return this.core.mcp;
|
|
675
711
|
}
|
|
676
712
|
// ── Conversation log (flat, linear) — read the record stream for transcript rendering ──
|
|
677
713
|
getRecords(...args) {
|
|
678
714
|
return this.core.getRecords(...args);
|
|
679
715
|
}
|
|
680
|
-
|
|
681
|
-
return this.core.
|
|
682
|
-
}
|
|
683
|
-
reconcileSubagents(...args) {
|
|
684
|
-
return this.core.reconcileSubagents(...args);
|
|
716
|
+
getAuditRecords() {
|
|
717
|
+
return this.core.getAuditRecords();
|
|
685
718
|
}
|
|
686
719
|
// ── context introspection — where the model's window is spent, as of the last turn ──
|
|
687
720
|
getContextBreakdown() {
|
|
688
721
|
return this.core.getContextBreakdown();
|
|
689
722
|
}
|
|
723
|
+
/** Where this session's tools run. A host uses it to run a command in the same place the agent
|
|
724
|
+
* does — one shell, one filesystem, one working directory. */
|
|
725
|
+
get environment() {
|
|
726
|
+
return this.core.environment;
|
|
727
|
+
}
|
|
728
|
+
/** The current todo list (the `TodoList` tool's latest state); empty without the todo capability. */
|
|
729
|
+
getTodos() {
|
|
730
|
+
return this.core.service("todo")?.get() ?? [];
|
|
731
|
+
}
|
|
732
|
+
/** Every slash command this session answers to — the static registry plus the ones its
|
|
733
|
+
* capabilities and extensions contribute — for a UI's palette and autocompletion. */
|
|
734
|
+
listCommands() {
|
|
735
|
+
return sharedCommands()
|
|
736
|
+
.list(this.core)
|
|
737
|
+
.map((command) => ({ name: command.name, aliases: command.aliases ?? [], description: command.description }));
|
|
738
|
+
}
|
|
690
739
|
// ── extensions — hot attach/detach on the live session (host API; never a model tool) ──
|
|
691
740
|
/**
|
|
692
741
|
* Add an extension mid-session. Applied at the next quiet point: immediately when idle,
|
|
693
742
|
* at the run's stop boundary when one is in flight — its tools appear at the next run's
|
|
694
743
|
* assembly. The change is journaled; it does NOT survive `resumeSession` (re-attach, or
|
|
695
744
|
* register it in `createHarness({ extensions })`, which every open re-evaluates). A definition
|
|
696
|
-
* with a `
|
|
745
|
+
* with a `harness` half is accepted only once registered — its service must already exist.
|
|
697
746
|
*/
|
|
698
|
-
attachExtension(definition) {
|
|
699
|
-
|
|
747
|
+
async attachExtension(definition) {
|
|
748
|
+
await this.core.attachCapability(this.mountExtension(definition));
|
|
700
749
|
}
|
|
701
750
|
/** Remove an extension mid-session: `session.end("detach")` fires, then its scope unwinds. */
|
|
702
|
-
detachExtension(extensionId) {
|
|
703
|
-
|
|
751
|
+
async detachExtension(extensionId) {
|
|
752
|
+
if (this.extensionRuntime(extensionId) === undefined)
|
|
753
|
+
throw new Error(`unknown extension "${extensionId}"`);
|
|
754
|
+
await this.core.detachCapability(extensionId);
|
|
704
755
|
}
|
|
705
|
-
/** Ids of currently attached extensions
|
|
756
|
+
/** Ids of currently attached extensions, in mount order. */
|
|
706
757
|
attachedExtensionIds() {
|
|
707
|
-
|
|
708
|
-
return this.extensionRuntime().attachedIds();
|
|
709
|
-
}
|
|
710
|
-
catch {
|
|
711
|
-
return [];
|
|
712
|
-
}
|
|
758
|
+
return this.attachedExtensions().map((extension) => extension.id);
|
|
713
759
|
}
|
|
714
|
-
/** Currently attached extensions with what each `uses
|
|
760
|
+
/** Currently attached extensions with what each `uses`. */
|
|
715
761
|
attachedExtensions() {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
762
|
+
const out = [];
|
|
763
|
+
for (const capability of this.core.capabilities) {
|
|
764
|
+
const runtime = extensionRuntimeOf(capability);
|
|
765
|
+
if (runtime !== undefined)
|
|
766
|
+
out.push({ id: runtime.id, uses: runtime.definition.uses ?? [] });
|
|
721
767
|
}
|
|
768
|
+
return out;
|
|
722
769
|
}
|
|
723
770
|
/** Run a slash command against this session: the static registry plus capability-contributed
|
|
724
771
|
* dynamic commands (extensions' `registerCommand`). Commands are the SERIALIZABLE control
|
|
@@ -731,108 +778,82 @@ export class HarnessSession {
|
|
|
731
778
|
* is absent, detached, or exposed nothing. The generic replacement for per-feature session
|
|
732
779
|
* facade methods (e.g. cron's — see `cronExtension`). */
|
|
733
780
|
extensionHandle(extensionId) {
|
|
781
|
+
return this.extensionRuntime(extensionId)?.exposedHandle();
|
|
782
|
+
}
|
|
783
|
+
extensionRuntime(extensionId) {
|
|
784
|
+
const capability = this.core.capabilities.find((open) => open.name === extensionId);
|
|
785
|
+
return capability === undefined ? undefined : extensionRuntimeOf(capability);
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Close the session and release its resources. Order: stop accepting runs, abort the ones
|
|
789
|
+
* accepted (in flight and queued) and wait for them to settle — their tools' finally blocks,
|
|
790
|
+
* their journal writes — then detach the projection and take the core down. Every call
|
|
791
|
+
* returns the SAME promise, so nobody returns while the teardown is still running.
|
|
792
|
+
*
|
|
793
|
+
* The wait for runs is bounded (`CLOSE_RUN_WAIT_MS`, 5s): a run that ignores its abort signal
|
|
794
|
+
* is logged and left behind, and the teardown continues — the deadline caps the wait for
|
|
795
|
+
* runs, not the whole close (flushes and disposes carry their own deadlines).
|
|
796
|
+
*/
|
|
797
|
+
close() {
|
|
798
|
+
if (this.closing !== undefined)
|
|
799
|
+
return this.closing;
|
|
800
|
+
this.lifecycle = "closing";
|
|
801
|
+
this.notifyClosing();
|
|
802
|
+
this.closing = this.runClose();
|
|
803
|
+
return this.closing;
|
|
804
|
+
}
|
|
805
|
+
async runClose() {
|
|
734
806
|
try {
|
|
735
|
-
|
|
807
|
+
const settled = this.allRunsSettled();
|
|
808
|
+
this.cancel();
|
|
809
|
+
let timer;
|
|
810
|
+
const timedOut = await Promise.race([
|
|
811
|
+
settled.then(() => false),
|
|
812
|
+
new Promise((resolve) => {
|
|
813
|
+
timer = setTimeout(() => resolve(true), CLOSE_RUN_WAIT_MS);
|
|
814
|
+
}),
|
|
815
|
+
]);
|
|
816
|
+
clearTimeout(timer);
|
|
817
|
+
if (timedOut) {
|
|
818
|
+
this.core.logger.log("warn", "session close: runs still settling after the deadline; continuing teardown", {
|
|
819
|
+
sessionId: this.id,
|
|
820
|
+
runs: this.runs.size,
|
|
821
|
+
deadlineMs: CLOSE_RUN_WAIT_MS,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
this.projection.detach();
|
|
825
|
+
await this.core.close();
|
|
826
|
+
await this.onClosed(this.id);
|
|
736
827
|
}
|
|
737
|
-
|
|
738
|
-
|
|
828
|
+
finally {
|
|
829
|
+
this.lifecycle = "closed";
|
|
739
830
|
}
|
|
740
831
|
}
|
|
741
|
-
extensionRuntime() {
|
|
742
|
-
const runtime = this.core.service("extensions");
|
|
743
|
-
if (!runtime)
|
|
744
|
-
throw new Error("this session has no extensions capability");
|
|
745
|
-
return runtime;
|
|
746
|
-
}
|
|
747
|
-
// ── goal lifecycle ──
|
|
748
|
-
pauseGoal(...args) {
|
|
749
|
-
return this.core.pauseGoal(...args);
|
|
750
|
-
}
|
|
751
|
-
resumeGoal(...args) {
|
|
752
|
-
return this.core.resumeGoal(...args);
|
|
753
|
-
}
|
|
754
|
-
cancelGoal(...args) {
|
|
755
|
-
return this.core.cancelGoal(...args);
|
|
756
|
-
}
|
|
757
|
-
setGoalBudget(...args) {
|
|
758
|
-
return this.core.setGoalBudget(...args);
|
|
759
|
-
}
|
|
760
|
-
// ── plan + compaction ──
|
|
761
|
-
clearPlan(...args) {
|
|
762
|
-
return this.core.clearPlan(...args);
|
|
763
|
-
}
|
|
764
|
-
pendingCompaction(...args) {
|
|
765
|
-
return this.core.pendingCompaction(...args);
|
|
766
|
-
}
|
|
767
|
-
cancelCompaction(...args) {
|
|
768
|
-
return this.core.cancelCompaction(...args);
|
|
769
|
-
}
|
|
770
|
-
// ── background tasks ──
|
|
771
|
-
readBackgroundTaskOutput(...args) {
|
|
772
|
-
return this.core.readBackgroundTaskOutput(...args);
|
|
773
|
-
}
|
|
774
|
-
readBackgroundTaskOutputDelta(...args) {
|
|
775
|
-
return this.core.readBackgroundTaskOutputDelta(...args);
|
|
776
|
-
}
|
|
777
|
-
stopBackgroundTask(...args) {
|
|
778
|
-
return this.core.stopBackgroundTask(...args);
|
|
779
|
-
}
|
|
780
|
-
/** Move a running detachable tool call (bash/subagent/workflow) into a background task. */
|
|
781
|
-
detachTool(...args) {
|
|
782
|
-
return this.core.detachTool(...args);
|
|
783
|
-
}
|
|
784
|
-
// ── plugins (full lifecycle) ──
|
|
785
|
-
getPluginInfo(...args) {
|
|
786
|
-
return this.core.getPluginInfo(...args);
|
|
787
|
-
}
|
|
788
|
-
setPluginEnabled(...args) {
|
|
789
|
-
return this.core.setPluginEnabled(...args);
|
|
790
|
-
}
|
|
791
|
-
setPluginMcpServerEnabled(...args) {
|
|
792
|
-
return this.core.setPluginMcpServerEnabled(...args);
|
|
793
|
-
}
|
|
794
|
-
removePlugin(...args) {
|
|
795
|
-
return this.core.removePlugin(...args);
|
|
796
|
-
}
|
|
797
|
-
reloadPlugins(...args) {
|
|
798
|
-
return this.core.reloadPlugins(...args);
|
|
799
|
-
}
|
|
800
|
-
// ── MCP ──
|
|
801
|
-
listMcpServers(...args) {
|
|
802
|
-
return this.core.listMcpServers(...args);
|
|
803
|
-
}
|
|
804
|
-
reconnectMcpServer(...args) {
|
|
805
|
-
return this.core.reconnectMcpServer(...args);
|
|
806
|
-
}
|
|
807
|
-
/** Close the session and release its resources. */
|
|
808
|
-
async close() {
|
|
809
|
-
if (this.closed)
|
|
810
|
-
return;
|
|
811
|
-
this.closed = true;
|
|
812
|
-
this.cancel();
|
|
813
|
-
this.projection.detach();
|
|
814
|
-
await this.core.close();
|
|
815
|
-
this.onClosed(this.id);
|
|
816
|
-
}
|
|
817
832
|
}
|
|
818
833
|
/** Manages a fleet of in-process sessions over a shared engine config. */
|
|
819
834
|
export class Harness {
|
|
820
835
|
options;
|
|
821
836
|
runner;
|
|
822
|
-
repo;
|
|
823
837
|
/** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
|
|
824
838
|
toolPalette;
|
|
825
839
|
activeSessions = new Map();
|
|
826
|
-
/**
|
|
827
|
-
*
|
|
828
|
-
|
|
840
|
+
/** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
|
|
841
|
+
* whole of it, reconcile and failure cleanup included — instead of each building an instance. */
|
|
842
|
+
opening = new Map();
|
|
843
|
+
/** The one close in flight (or finished): every `close()` call returns THIS promise. */
|
|
844
|
+
closing;
|
|
845
|
+
/** The process-lived objects this harness runs on (`HarnessOptions.harness`), plus defaults. */
|
|
846
|
+
parts = {};
|
|
847
|
+
/** Process-level extension services by name (a facade over `scope`). The host replaces
|
|
848
|
+
* providers here (`services.replace`); sessions only ever see handles (`ctx.shared`, `ctx.services`). */
|
|
849
|
+
services;
|
|
829
850
|
/** The one in-flight reshape barrier (mutex: a second barrier operation rejects). Sessions
|
|
830
851
|
* appearing while it holds are gated at birth — see the tail of `openFromStore`. */
|
|
831
852
|
activeBarrier;
|
|
832
853
|
/** File-extension manager (both tiers) — present only when the host opted in with
|
|
833
854
|
* `extensionDir`. NOT the Codex-compatible skill/MCP plugin system (`pluginManager`). */
|
|
834
855
|
extensions;
|
|
835
|
-
/** Service ids published by by-value `
|
|
856
|
+
/** Service ids published by by-value `harness` halves, in creation order — unregistered (and
|
|
836
857
|
* disposed) by `close()`, newest first. */
|
|
837
858
|
createdServices = [];
|
|
838
859
|
/** The by-value definitions this harness currently mounts into newborn sessions, in
|
|
@@ -843,12 +864,13 @@ export class Harness {
|
|
|
843
864
|
valueDefs = new Map();
|
|
844
865
|
/** Root of extensions' data folders (`host.dataDir` = `<root>/<id>`); absent ⇒ none handed out. */
|
|
845
866
|
dataRoot;
|
|
846
|
-
/** Settles once every by-value `
|
|
867
|
+
/** Settles once every by-value `harness` half has published its service; sessions open after it. */
|
|
847
868
|
sharedReady;
|
|
848
|
-
/** The extension whose `
|
|
869
|
+
/** The extension whose `harness` is running right now — its `createSession` must refuse. */
|
|
849
870
|
runningCreate;
|
|
850
871
|
constructor(options) {
|
|
851
872
|
this.options = options;
|
|
873
|
+
this.services = new ServiceRegistry();
|
|
852
874
|
const agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
|
|
853
875
|
this.toolPalette = Object.fromEntries(agentTools.map((tool) => [tool.schema.name, tool]));
|
|
854
876
|
// The Agent/Workflow tools only appear when the run has subagents to spawn. Default the fleet
|
|
@@ -865,8 +887,6 @@ export class Harness {
|
|
|
865
887
|
maxStepsPerTurn: options.maxStepsPerTurn,
|
|
866
888
|
...(options.maxConcurrentSubagents !== undefined ? { maxConcurrentSubagents: options.maxConcurrentSubagents } : {}),
|
|
867
889
|
});
|
|
868
|
-
// Injected repository (disk locally, Pg/Redis on a server); in-memory when omitted.
|
|
869
|
-
this.repo = options.repository ?? new MemorySessionRepository();
|
|
870
890
|
this.dataRoot = options.extensionDataDir ?? (options.extensionDir !== undefined ? join(options.extensionDir, ".data") : undefined);
|
|
871
891
|
this.extensions = options.extensionDir === undefined
|
|
872
892
|
? undefined
|
|
@@ -897,18 +917,65 @@ export class Harness {
|
|
|
897
917
|
throw new Error(`duplicate by-value extension id "${definition.id}" in createHarness({ extensions })`);
|
|
898
918
|
this.valueDefs.set(definition.id, definition);
|
|
899
919
|
}
|
|
900
|
-
|
|
920
|
+
// The host's process-tier registrations come first (extension `harness` halves may consume
|
|
921
|
+
// them); a synchronous hook keeps the whole chain synchronous, so a synchronous extension's
|
|
922
|
+
// service exists the moment `createHarness` returns.
|
|
923
|
+
const composed = options.harness?.();
|
|
924
|
+
const absorb = (parts) => {
|
|
925
|
+
this.parts = {
|
|
926
|
+
...(parts ?? {}),
|
|
927
|
+
// Explicit options win over what the preset built, and both win over the defaults below.
|
|
928
|
+
...(options.telemetry !== undefined ? { telemetry: options.telemetry } : {}),
|
|
929
|
+
...(options.eventPublication !== undefined ? { eventPublication: options.eventPublication } : {}),
|
|
930
|
+
};
|
|
931
|
+
};
|
|
932
|
+
this.sharedReady = composed instanceof Promise
|
|
933
|
+
? composed.then((parts) => {
|
|
934
|
+
absorb(parts);
|
|
935
|
+
return this.registerDefinitions(options.extensions);
|
|
936
|
+
})
|
|
937
|
+
: (absorb(composed), this.registerDefinitions(options.extensions));
|
|
901
938
|
// Surfaced to whoever opens a session; never an unhandled rejection on its own.
|
|
902
939
|
this.sharedReady.catch(() => undefined);
|
|
903
940
|
}
|
|
904
941
|
/**
|
|
905
|
-
*
|
|
906
|
-
* not
|
|
942
|
+
* Which session-facing parts the `harness` hook built for the whole process — what a
|
|
943
|
+
* multi-tenant host must not have, since every session on the process would share them.
|
|
944
|
+
* Infrastructure (repository, logger, model runtime) is not listed: sharing it is the point.
|
|
945
|
+
*/
|
|
946
|
+
async processSharedParts() {
|
|
947
|
+
await this.sharedReady;
|
|
948
|
+
const parts = [];
|
|
949
|
+
if (this.parts.mcpServers !== undefined)
|
|
950
|
+
parts.push("mcpServers");
|
|
951
|
+
if (this.parts.oauthService !== undefined)
|
|
952
|
+
parts.push("oauthService");
|
|
953
|
+
if (this.parts.skillRegistry !== undefined)
|
|
954
|
+
parts.push("skillRegistry");
|
|
955
|
+
return parts;
|
|
956
|
+
}
|
|
957
|
+
/** The session repository, once the `harness` hook has had its say. */
|
|
958
|
+
async repository() {
|
|
959
|
+
await this.sharedReady;
|
|
960
|
+
this.memoryRepository ??= new MemorySessionRepository();
|
|
961
|
+
return this.parts.sessionRepository ?? this.memoryRepository;
|
|
962
|
+
}
|
|
963
|
+
/** The fallback repository, built once so two opens share one in-memory store. */
|
|
964
|
+
memoryRepository;
|
|
965
|
+
/** The harness logger: the preset's, else the `AGENTS_LOG` env logger, else silent. */
|
|
966
|
+
get logger() {
|
|
967
|
+
this.defaultLogger ??= envLogger() ?? noopLogger;
|
|
968
|
+
return this.parts.logger ?? this.defaultLogger;
|
|
969
|
+
}
|
|
970
|
+
defaultLogger;
|
|
971
|
+
/**
|
|
972
|
+
* Run one by-value definition's `harness` half against a staging host: the service is COLLECTED,
|
|
973
|
+
* not registered, so a throwing `harness` publishes nothing. Stays synchronous when `harness` is
|
|
907
974
|
* — what lets a synchronous extension's service exist the moment `createHarness` returns.
|
|
908
975
|
* Shared by construction and `replaceExtension`.
|
|
909
976
|
*/
|
|
910
977
|
stageValue(definition) {
|
|
911
|
-
// The data folder exists before `
|
|
978
|
+
// The data folder exists before `harness` runs (synchronously: `harness` may be too).
|
|
912
979
|
const dataDir = this.dataRoot !== undefined ? join(this.dataRoot, definition.id) : undefined;
|
|
913
980
|
if (dataDir !== undefined)
|
|
914
981
|
mkdirSync(dataDir, { recursive: true });
|
|
@@ -931,6 +998,7 @@ export class Harness {
|
|
|
931
998
|
}
|
|
932
999
|
return this.createSession(sessionOptions);
|
|
933
1000
|
},
|
|
1001
|
+
service: (name) => this.services.handle(name),
|
|
934
1002
|
warn: (message) => console.warn(`[extensions] ${message}`),
|
|
935
1003
|
});
|
|
936
1004
|
return staged instanceof Promise ? staged.then(done, failed) : done(staged);
|
|
@@ -941,12 +1009,12 @@ export class Harness {
|
|
|
941
1009
|
}
|
|
942
1010
|
/**
|
|
943
1011
|
* Register the by-value definitions, in order: check each one's `uses` against the registry
|
|
944
|
-
* (a consumer must come after its provider), then run its `
|
|
1012
|
+
* (a consumer must come after its provider), then run its `harness` half if it has one and
|
|
945
1013
|
* register the result as a service under the extension's `id`. Stays synchronous as long as
|
|
946
|
-
* every `
|
|
947
|
-
* already registered; the first async `
|
|
1014
|
+
* every `harness` is — so `createHarness` returns with a synchronous extension's service
|
|
1015
|
+
* already registered; the first async `harness` turns the rest of the sequence into a promise
|
|
948
1016
|
* chain that `openFromStore` awaits. Staging (`stageDefinition`) collects before registering,
|
|
949
|
-
* so a throwing `
|
|
1017
|
+
* so a throwing `harness` publishes nothing.
|
|
950
1018
|
*/
|
|
951
1019
|
registerDefinitions(extensions) {
|
|
952
1020
|
if (extensions === undefined)
|
|
@@ -957,7 +1025,7 @@ export class Harness {
|
|
|
957
1025
|
throw new Error(`extension "${definition.id}" uses service "${name}", which is not registered — list its provider earlier in createHarness({ extensions }) (or register it in services)`);
|
|
958
1026
|
}
|
|
959
1027
|
}
|
|
960
|
-
if (definition.
|
|
1028
|
+
if (definition.harness === undefined)
|
|
961
1029
|
return;
|
|
962
1030
|
const staged = this.stageValue(definition);
|
|
963
1031
|
const publish = (result) => {
|
|
@@ -990,8 +1058,8 @@ export class Harness {
|
|
|
990
1058
|
* session's capabilities.
|
|
991
1059
|
*
|
|
992
1060
|
* Built fresh per session. Runtime-specific prompt data is not captured here: `instructions`
|
|
993
|
-
* resolves the current frame's
|
|
994
|
-
* prompts stay isolated without coupling Agent lifetime to
|
|
1061
|
+
* resolves the current frame's environment + cwd through the Session cache, so root/subagent/worktree
|
|
1062
|
+
* prompts stay isolated without coupling Agent lifetime to environment lifetime.
|
|
995
1063
|
*/
|
|
996
1064
|
buildDefaultAgent(appendSystemPrompt, maxStepsPerTurn) {
|
|
997
1065
|
const additional = appendSystemPrompt ?? this.options.appendSystemPrompt;
|
|
@@ -1008,36 +1076,91 @@ export class Harness {
|
|
|
1008
1076
|
return this.activeSessions;
|
|
1009
1077
|
}
|
|
1010
1078
|
async createSession(opts = {}) {
|
|
1079
|
+
this.assertAcceptingSessions();
|
|
1011
1080
|
const workDir = opts.workDir ?? this.options.workDir ?? process.cwd();
|
|
1012
|
-
const handle = await this.
|
|
1013
|
-
return this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1081
|
+
const handle = await (await this.repository()).create({ id: opts.id, workDir, ownerKey: opts.ownerKey, title: opts.title });
|
|
1082
|
+
return this.trackOpen(handle.id, () => this.openFromStore(handle.id, handle.workDir, handle.store, opts));
|
|
1014
1083
|
}
|
|
1015
|
-
|
|
1084
|
+
/**
|
|
1085
|
+
* Register an open in flight under its id for as long as it runs. Every path that builds a
|
|
1086
|
+
* session goes through here — create, resume, fork — so a `resumeSession` for an id that is
|
|
1087
|
+
* being created joins that open instead of building a twin over the same store, and
|
|
1088
|
+
* `closeSession` / `harness.close()` wait for it instead of closing its scope under it.
|
|
1089
|
+
*/
|
|
1090
|
+
trackOpen(id, open) {
|
|
1091
|
+
const promise = open().finally(() => {
|
|
1092
|
+
if (this.opening.get(id) === promise)
|
|
1093
|
+
this.opening.delete(id);
|
|
1094
|
+
});
|
|
1095
|
+
this.opening.set(id, promise);
|
|
1096
|
+
return promise;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* The open session with this id, or reopen it from the store. Concurrent calls for one id share
|
|
1100
|
+
* a single open (`opening`): the second caller waits for the first's — reconcile included — and
|
|
1101
|
+
* receives the same instance, never a second one that a close would then unregister. An
|
|
1102
|
+
* instance that is closing is not handed out: the reopen waits for its close to finish first.
|
|
1103
|
+
*/
|
|
1104
|
+
resumeSession(id, opts = {}) {
|
|
1105
|
+
// Not async (the in-flight/active fast paths return existing promises), so refuse by rejecting.
|
|
1106
|
+
if (this.closing !== undefined)
|
|
1107
|
+
return Promise.reject(new Error("harness is closed"));
|
|
1108
|
+
const inflight = this.opening.get(id);
|
|
1109
|
+
if (inflight !== undefined) {
|
|
1110
|
+
return hasOwnContext(opts)
|
|
1111
|
+
? inflight.then((session) => {
|
|
1112
|
+
session.setContext(opts.context);
|
|
1113
|
+
return session;
|
|
1114
|
+
})
|
|
1115
|
+
: inflight;
|
|
1116
|
+
}
|
|
1016
1117
|
const existing = this.activeSessions.get(id);
|
|
1017
|
-
if (existing) {
|
|
1118
|
+
if (existing !== undefined && existing.status.state !== "closed") {
|
|
1018
1119
|
if (hasOwnContext(opts))
|
|
1019
1120
|
existing.setContext(opts.context);
|
|
1020
|
-
return existing;
|
|
1121
|
+
return Promise.resolve(existing);
|
|
1021
1122
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1123
|
+
return this.trackOpen(id, async () => {
|
|
1124
|
+
// `existing` here is one that is closing: wait for that close (its registration goes
|
|
1125
|
+
// with it) rather than open a twin beside it.
|
|
1126
|
+
if (existing !== undefined)
|
|
1127
|
+
await existing.close();
|
|
1128
|
+
const handle = await (await this.repository()).open(id);
|
|
1129
|
+
if (handle === undefined)
|
|
1130
|
+
throw new SessionRepositoryNotFoundError(id);
|
|
1131
|
+
const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1132
|
+
// Reopening: orphaned background subagents (running from a dead process) → lost. A failure
|
|
1133
|
+
// here is an open failure: the session is torn down, not left registered and unreturned.
|
|
1134
|
+
try {
|
|
1135
|
+
await session.background?.reconcileSubagents() ?? [];
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
await session.close();
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
return session;
|
|
1142
|
+
});
|
|
1029
1143
|
}
|
|
1030
1144
|
/** Fork an existing session into a new one (copies its log + state) and open it. */
|
|
1031
1145
|
async forkSession(sourceId, opts = {}) {
|
|
1032
|
-
|
|
1146
|
+
this.assertAcceptingSessions();
|
|
1147
|
+
const handle = await (await this.repository()).fork(sourceId, {
|
|
1033
1148
|
...(opts.id !== undefined ? { id: opts.id } : {}),
|
|
1034
1149
|
...(opts.ownerKey !== undefined ? { ownerKey: opts.ownerKey } : {}),
|
|
1035
1150
|
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
1036
1151
|
});
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1152
|
+
return this.trackOpen(handle.id, async () => {
|
|
1153
|
+
const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1154
|
+
// A fork copies the subagent ledger; its background "running" rows have no live task → lost.
|
|
1155
|
+
try {
|
|
1156
|
+
await session.background?.reconcileSubagents() ?? [];
|
|
1157
|
+
}
|
|
1158
|
+
catch (error) {
|
|
1159
|
+
await session.close();
|
|
1160
|
+
throw error;
|
|
1161
|
+
}
|
|
1162
|
+
return session;
|
|
1163
|
+
});
|
|
1041
1164
|
}
|
|
1042
1165
|
/** The currently-open session with this id, or undefined (does not reopen — use resumeSession). */
|
|
1043
1166
|
getSession(id) {
|
|
@@ -1046,15 +1169,22 @@ export class Harness {
|
|
|
1046
1169
|
/** Read a session's durable handle without constructing a Session, opening capabilities,
|
|
1047
1170
|
* reconciling background tasks, or subscribing Projection. Intended for read-only servers.
|
|
1048
1171
|
* Soft-deleted sessions resolve only with `{ includeDeleted: true }` — audit/purge tooling. */
|
|
1049
|
-
inspectSession(id, options) {
|
|
1050
|
-
return this.
|
|
1172
|
+
async inspectSession(id, options) {
|
|
1173
|
+
return (await this.repository()).open(id, options);
|
|
1051
1174
|
}
|
|
1052
1175
|
/** Read one catalog row without the O(N) `listSessions()` scan. */
|
|
1053
|
-
getSessionSummary(id) {
|
|
1054
|
-
return this.
|
|
1176
|
+
async getSessionSummary(id) {
|
|
1177
|
+
return (await this.repository()).get(id);
|
|
1055
1178
|
}
|
|
1056
|
-
/** Close one open session by id (no-op if it isn't open).
|
|
1179
|
+
/** Close one open session by id (no-op if it isn't open). An open in flight for this id is
|
|
1180
|
+
* waited for and then closed, so a close/delete never races a half-built session. */
|
|
1057
1181
|
async closeSession(id) {
|
|
1182
|
+
const inflight = this.opening.get(id);
|
|
1183
|
+
if (inflight !== undefined) {
|
|
1184
|
+
const session = await inflight.catch(() => undefined);
|
|
1185
|
+
await session?.close();
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1058
1188
|
await this.activeSessions.get(id)?.close();
|
|
1059
1189
|
}
|
|
1060
1190
|
/**
|
|
@@ -1063,30 +1193,72 @@ export class Harness {
|
|
|
1063
1193
|
*/
|
|
1064
1194
|
async deleteSession(id, options) {
|
|
1065
1195
|
await this.closeSession(id);
|
|
1066
|
-
await this.
|
|
1196
|
+
await (await this.repository()).delete(id, options);
|
|
1067
1197
|
}
|
|
1068
1198
|
/** Undo a soft delete. No-op when the session is absent or was never deleted. */
|
|
1069
|
-
restoreSession(id) {
|
|
1070
|
-
return this.
|
|
1199
|
+
async restoreSession(id) {
|
|
1200
|
+
return (await this.repository()).restore(id);
|
|
1071
1201
|
}
|
|
1072
1202
|
async listSessions(filter) {
|
|
1073
|
-
return this.
|
|
1203
|
+
return (await this.repository()).list(filter);
|
|
1204
|
+
}
|
|
1205
|
+
/** Set a session's title (an empty string clears it). Works on open and closed sessions alike. */
|
|
1206
|
+
async renameSession(id, title) {
|
|
1207
|
+
await (await this.repository()).rename(id, title.trim());
|
|
1074
1208
|
}
|
|
1075
|
-
|
|
1209
|
+
/**
|
|
1210
|
+
* Close every open session, then the harness scope — which disposes everything registered
|
|
1211
|
+
* there in reverse order: by-value `harness` halves die with the harness that ran them (each
|
|
1212
|
+
* drained, then its dispose hook — default the instance's close()), then the host's own
|
|
1213
|
+
* registrations, then the defaults.
|
|
1214
|
+
*/
|
|
1215
|
+
close() {
|
|
1216
|
+
if (this.closing !== undefined)
|
|
1217
|
+
return this.closing;
|
|
1218
|
+
this.closing = this.runClose();
|
|
1219
|
+
return this.closing;
|
|
1220
|
+
}
|
|
1221
|
+
async runClose() {
|
|
1222
|
+
// Opens in flight finish (or fail) first: closing the scope tree under a half-built session
|
|
1223
|
+
// would tear its scope out from under `Session.open`. New opens are refused from here on
|
|
1224
|
+
// (`assertAcceptingSessions`), so the set only shrinks.
|
|
1225
|
+
await Promise.allSettled([...this.opening.values()]);
|
|
1076
1226
|
for (const session of [...this.activeSessions.values()])
|
|
1077
1227
|
await session.close();
|
|
1078
|
-
// By-value `create` halves die with the harness that ran them: unregister each service,
|
|
1079
|
-
// newest first, which drains it and runs its dispose hook (default: the instance's close()).
|
|
1080
1228
|
await this.sharedReady.catch(() => undefined);
|
|
1081
|
-
|
|
1082
|
-
|
|
1229
|
+
// The `harness` halves' services, newest first: each is drained, then disposed (default, the
|
|
1230
|
+
// instance's own close()). A failure here is logged, never allowed to skip the rest.
|
|
1231
|
+
for (const name of [...this.createdServices].reverse()) {
|
|
1232
|
+
try {
|
|
1233
|
+
await this.services.unregister(name);
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
this.logger.log("warn", `service "${name}" unregister failed during harness close`, {
|
|
1237
|
+
service: name,
|
|
1238
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1083
1242
|
this.createdServices.length = 0;
|
|
1243
|
+
// Last: the process-shared objects the preset opened (MCP connections, handles). Every
|
|
1244
|
+
// session is closed by now, so nothing is still reading them.
|
|
1245
|
+
try {
|
|
1246
|
+
await this.parts.close?.();
|
|
1247
|
+
}
|
|
1248
|
+
catch (error) {
|
|
1249
|
+
this.logger.log("warn", "harness parts close failed", { error: error instanceof Error ? error.message : String(error) });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/** Every path that opens a session starts here: a closing harness opens nothing new. */
|
|
1253
|
+
assertAcceptingSessions() {
|
|
1254
|
+
if (this.closing !== undefined)
|
|
1255
|
+
throw new Error("harness is closed");
|
|
1084
1256
|
}
|
|
1085
1257
|
/**
|
|
1086
1258
|
* Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
|
|
1087
1259
|
* `harness.extensions.reload()` (docs/architecture.md §5.5). Every session holding
|
|
1088
1260
|
* one of them rendezvous at its run boundary (in-flight runs finish untouched — nothing is
|
|
1089
|
-
* aborted); in that global quiet moment the old halves detach, each `
|
|
1261
|
+
* aborted); in that global quiet moment the old halves detach, each `harness` half re-runs and
|
|
1090
1262
|
* its service is swapped, and the new halves attach; then everyone resumes. Sessions born from
|
|
1091
1263
|
* the quiet moment on are born with the new definitions. On rendezvous timeout NOTHING changes:
|
|
1092
1264
|
* the barrier lifts, `BarrierTimeout` names the stuck sessions, and the caller retries later
|
|
@@ -1098,7 +1270,7 @@ export class Harness {
|
|
|
1098
1270
|
*
|
|
1099
1271
|
* `services` swaps HOST-registered services (`createHarness({ services })`) in the same quiet
|
|
1100
1272
|
* moment, for when the shape that changed is the host's rather than an extension's. Those obey
|
|
1101
|
-
* their `replaceable` flag; an extension's own `
|
|
1273
|
+
* their `replaceable` flag; an extension's own `harness` service swaps unconditionally, since
|
|
1102
1274
|
* its owner is being replaced along with it.
|
|
1103
1275
|
*
|
|
1104
1276
|
* Implementation-only swaps need none of this — `harness.services.replace` (layer 1) lands on
|
|
@@ -1114,14 +1286,14 @@ export class Harness {
|
|
|
1114
1286
|
throw new Error(`cannot replace extension "${definition.id}": it is not registered on this harness by value — pass it in createHarness({ extensions }), or use harness.extensions.reload("${definition.id}") for a file extension`);
|
|
1115
1287
|
}
|
|
1116
1288
|
}
|
|
1117
|
-
// A `
|
|
1289
|
+
// A `harness` half still publishing would race the swap.
|
|
1118
1290
|
await this.sharedReady;
|
|
1119
|
-
// Every new `
|
|
1291
|
+
// Every new `harness` runs BEFORE the barrier: staging COLLECTS, so one that throws leaves the
|
|
1120
1292
|
// world completely untouched (no session detached, no service swapped) — the same discipline
|
|
1121
1293
|
// construction and the file manager use. Whatever a failed act staged is disposed below.
|
|
1122
1294
|
const staged = new Map();
|
|
1123
1295
|
for (const definition of definitions) {
|
|
1124
|
-
if (definition.
|
|
1296
|
+
if (definition.harness !== undefined)
|
|
1125
1297
|
staged.set(definition.id, await this.stageValue(definition));
|
|
1126
1298
|
}
|
|
1127
1299
|
const published = new Set();
|
|
@@ -1160,7 +1332,7 @@ export class Harness {
|
|
|
1160
1332
|
}
|
|
1161
1333
|
catch (error) {
|
|
1162
1334
|
// Rendezvous timed out, or a step failed: nothing staged was published, so dispose it —
|
|
1163
|
-
// a `
|
|
1335
|
+
// a `harness` that opened a pool must not leak it because the barrier never converged.
|
|
1164
1336
|
for (const [id, entry] of staged) {
|
|
1165
1337
|
if (!published.has(id) && entry.service !== undefined)
|
|
1166
1338
|
await this.disposeStaged(id, entry.service.instance);
|
|
@@ -1169,7 +1341,7 @@ export class Harness {
|
|
|
1169
1341
|
}
|
|
1170
1342
|
}
|
|
1171
1343
|
/**
|
|
1172
|
-
* The service half of one replaced extension: publish what its `
|
|
1344
|
+
* The service half of one replaced extension: publish what its `harness` staged. `replace`
|
|
1173
1345
|
* rather than unregister + register, so consumers this barrier never gated — other extensions
|
|
1174
1346
|
* holding a `uses` handle — see no gap; `force` because the owner is being replaced along with
|
|
1175
1347
|
* it (see `ServiceRegistry.replace`).
|
|
@@ -1188,13 +1360,13 @@ export class Harness {
|
|
|
1188
1360
|
}
|
|
1189
1361
|
return;
|
|
1190
1362
|
}
|
|
1191
|
-
// The new version dropped its `
|
|
1363
|
+
// The new version dropped its `harness` half: its service goes away with it.
|
|
1192
1364
|
if (previous >= 0) {
|
|
1193
1365
|
await this.services.unregister(id, drain);
|
|
1194
1366
|
this.createdServices.splice(previous, 1);
|
|
1195
1367
|
}
|
|
1196
1368
|
}
|
|
1197
|
-
/** Dispose a `
|
|
1369
|
+
/** Dispose a `harness` result that was staged but never published (default rule: its `close()`). */
|
|
1198
1370
|
async disposeStaged(id, instance) {
|
|
1199
1371
|
try {
|
|
1200
1372
|
const close = instance?.close;
|
|
@@ -1202,7 +1374,7 @@ export class Harness {
|
|
|
1202
1374
|
await close.call(instance);
|
|
1203
1375
|
}
|
|
1204
1376
|
catch (error) {
|
|
1205
|
-
console.warn(`[extensions] extension "${id}": disposing the unpublished
|
|
1377
|
+
console.warn(`[extensions] extension "${id}": disposing the unpublished harness() result failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1206
1378
|
}
|
|
1207
1379
|
}
|
|
1208
1380
|
/**
|
|
@@ -1239,20 +1411,33 @@ export class Harness {
|
|
|
1239
1411
|
this.activeBarrier = undefined;
|
|
1240
1412
|
}
|
|
1241
1413
|
}
|
|
1242
|
-
/** Resolve the capability set for one session —
|
|
1414
|
+
/** Resolve the capability set for one session — the `session` hook, called fresh every time. */
|
|
1243
1415
|
async resolveCapabilities(ctx, params = {}) {
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1416
|
+
// A session on its own environment (a sandbox) does not get the process's MCP connections or
|
|
1417
|
+
// skill scan: both describe the HARNESS's machine, not the one this session's tools run on.
|
|
1418
|
+
const base = this.options.session !== undefined
|
|
1419
|
+
? await this.options.session(ctx)
|
|
1420
|
+
: defaultCapabilities({
|
|
1421
|
+
shared: this.parts,
|
|
1422
|
+
ownEnvironment: ctx.ownEnvironment,
|
|
1423
|
+
strictIsolation: ctx.strictIsolation,
|
|
1424
|
+
...(ctx.mcpServers !== undefined ? { sessionMcpServers: ctx.mcpServers } : {}),
|
|
1425
|
+
...(ctx.mcpCredentials !== undefined ? { mcpCredentials: ctx.mcpCredentials } : {}),
|
|
1426
|
+
});
|
|
1427
|
+
// Every definition registered on this harness: by value (`extensions`, harness halves
|
|
1428
|
+
// included — their per-session `session` is mounted here like any other), then from files.
|
|
1250
1429
|
const extensions = [...this.valueDefs.values(), ...(this.extensions?.sessionDefinitions() ?? [])];
|
|
1430
|
+
// An extension is mounted under its id, so one named like a capability the session already
|
|
1431
|
+
// has would displace it — refused by name rather than resolved by order.
|
|
1432
|
+
const baseNames = new Set(base.map((capability) => capability.name));
|
|
1433
|
+
for (const definition of extensions) {
|
|
1434
|
+
if (baseNames.has(definition.id)) {
|
|
1435
|
+
throw new Error(`extension "${definition.id}" has the name of one of this session's capabilities; give it another id`);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1251
1438
|
// Put extension transforms first. Shell PreToolUse remains in the staged permission policy,
|
|
1252
1439
|
// so it evaluates the final rewritten args before normal authorization.
|
|
1253
|
-
|
|
1254
|
-
// somewhere to land on sessions born without extensions.
|
|
1255
|
-
return [extensionsCapability(extensions, { host: this.extensionHost(ctx.sessionId), params }), ...base];
|
|
1440
|
+
return [...extensionsCapability(extensions, { host: this.extensionHost(ctx.sessionId), params }), ...base];
|
|
1256
1441
|
}
|
|
1257
1442
|
/**
|
|
1258
1443
|
* The harness-tier reach handed to a session's extensions: other sessions, and the model
|
|
@@ -1264,7 +1449,7 @@ export class Harness {
|
|
|
1264
1449
|
*/
|
|
1265
1450
|
extensionHost(sessionId) {
|
|
1266
1451
|
const self = this;
|
|
1267
|
-
const runtime = this.
|
|
1452
|
+
const runtime = this.parts.modelRuntime;
|
|
1268
1453
|
return {
|
|
1269
1454
|
sessionId,
|
|
1270
1455
|
newSession: (options) => self.createSession({ ...(options?.title !== undefined ? { title: options.title } : {}) }),
|
|
@@ -1281,7 +1466,10 @@ export class Harness {
|
|
|
1281
1466
|
throw new Error("unregisterProvider() requires the harness to be built with a `modelRuntime`");
|
|
1282
1467
|
runtime.models.deleteProvider(id);
|
|
1283
1468
|
},
|
|
1284
|
-
services:
|
|
1469
|
+
services: {
|
|
1470
|
+
has: (name) => self.services.has(name),
|
|
1471
|
+
handle: (name) => self.services.handle(name),
|
|
1472
|
+
},
|
|
1285
1473
|
isIdle: () => self.activeSessions.get(sessionId)?.status.state !== "running",
|
|
1286
1474
|
waitForIdle: async () => {
|
|
1287
1475
|
const session = self.activeSessions.get(sessionId);
|
|
@@ -1293,8 +1481,8 @@ export class Harness {
|
|
|
1293
1481
|
};
|
|
1294
1482
|
}
|
|
1295
1483
|
async openFromStore(id, workDir, store, opts) {
|
|
1296
|
-
// A session's `
|
|
1297
|
-
//
|
|
1484
|
+
// A session's `session` reaches a harness half through its service, so none opens before the
|
|
1485
|
+
// harness halves have registered them.
|
|
1298
1486
|
await this.sharedReady;
|
|
1299
1487
|
// Per-session extension params: given at create, persisted, and read back on every later
|
|
1300
1488
|
// open (resume, fork) so a session keeps them without the caller repeating them.
|
|
@@ -1303,65 +1491,130 @@ export class Harness {
|
|
|
1303
1491
|
await store.putState(EXTENSION_PARAMS_STATE_KEY, params);
|
|
1304
1492
|
else
|
|
1305
1493
|
params = (await store.getState(EXTENSION_PARAMS_STATE_KEY)) ?? undefined;
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
preloadedLog
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1494
|
+
await this.sharedReady;
|
|
1495
|
+
// From here the open is a transaction: the session is registered as active only once every
|
|
1496
|
+
// step has succeeded, and any failure tears down exactly what was built — the projection, the
|
|
1497
|
+
// core when it got that far — before rethrowing.
|
|
1498
|
+
let projection;
|
|
1499
|
+
let core;
|
|
1500
|
+
try {
|
|
1501
|
+
const events = new ListenerSink();
|
|
1502
|
+
const responder = new MutableResponder();
|
|
1503
|
+
// Environment: this call's override → the harness's default → a LocalEnvironment at the
|
|
1504
|
+
// session's own workDir. The session only OPERATES a caller-supplied environment.
|
|
1505
|
+
const environment = opts.environment ?? this.parts.environment ?? new LocalEnvironment(workDir);
|
|
1506
|
+
// ONE open-time log read, shared: every shard from its newest checkpoint on (each
|
|
1507
|
+
// checkpoint carries the history and lifecycle before it). The projection seeds from it,
|
|
1508
|
+
// and Session.open receives it as `preloadedLog` so its capability restore + context
|
|
1509
|
+
// pre-build fold the same records — same IO, and the same `Message` objects.
|
|
1510
|
+
// Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
|
|
1511
|
+
// race the subscription, and anything capabilities emit during open is already folded.
|
|
1512
|
+
let preloadedLog = await readSessionTails(store);
|
|
1513
|
+
// A fresh session has an empty log; anything else is a reopen. Telemetry is the only reader.
|
|
1514
|
+
const resumed = preloadedLog.length > 0;
|
|
1515
|
+
projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
|
|
1516
|
+
const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
|
|
1517
|
+
if (!interrupted) {
|
|
1518
|
+
const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
|
|
1519
|
+
if (recoveryRecords.length > 0)
|
|
1520
|
+
preloadedLog = [...preloadedLog, ...recoveryRecords];
|
|
1521
|
+
}
|
|
1522
|
+
const capabilities = await this.resolveCapabilities({
|
|
1329
1523
|
sessionId: id,
|
|
1330
1524
|
workDir,
|
|
1525
|
+
ownEnvironment: opts.environment !== undefined,
|
|
1526
|
+
strictIsolation: opts.strictIsolation === true,
|
|
1331
1527
|
...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1528
|
+
...(opts.mcpCredentials !== undefined ? { mcpCredentials: opts.mcpCredentials } : {}),
|
|
1529
|
+
}, params);
|
|
1530
|
+
// Everything the session runs on, by value. The repository owns the store's lifetime.
|
|
1531
|
+
core = await Session.open({
|
|
1532
|
+
capabilities,
|
|
1533
|
+
preloadedLog,
|
|
1534
|
+
resumed,
|
|
1535
|
+
sessionId: id,
|
|
1536
|
+
store,
|
|
1537
|
+
events,
|
|
1538
|
+
responder,
|
|
1539
|
+
environment,
|
|
1540
|
+
permissionOptions: opts.permission ?? this.options.permission ?? { mode: "yolo" },
|
|
1541
|
+
...(this.parts.logger !== undefined ? { logger: this.parts.logger } : {}),
|
|
1542
|
+
...(this.parts.tracing !== undefined ? { tracing: this.parts.tracing } : {}),
|
|
1543
|
+
...(this.parts.telemetry !== undefined ? { telemetry: this.parts.telemetry } : {}),
|
|
1544
|
+
...(opts.eventPublication ?? this.parts.eventPublication) !== undefined
|
|
1545
|
+
? { eventPublication: (opts.eventPublication ?? this.parts.eventPublication) }
|
|
1546
|
+
: {},
|
|
1547
|
+
});
|
|
1548
|
+
const agent = opts.agent ??
|
|
1549
|
+
this.options.agent ??
|
|
1550
|
+
(await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
|
|
1551
|
+
const session = new HarnessSession({
|
|
1552
|
+
core,
|
|
1553
|
+
// The two caps land in different places: maxStepsPerTurn is an Agent property
|
|
1554
|
+
// (the Runner reads the agent's value first), maxTurns is a per-run option.
|
|
1555
|
+
agent,
|
|
1556
|
+
runner: this.runner,
|
|
1557
|
+
events,
|
|
1558
|
+
responder,
|
|
1559
|
+
projection,
|
|
1560
|
+
workDir,
|
|
1561
|
+
context: this.contextFor(opts),
|
|
1562
|
+
...(opts.maxTurns ?? this.options.maxTurns) !== undefined
|
|
1563
|
+
? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
|
|
1564
|
+
: {},
|
|
1565
|
+
interrupted,
|
|
1566
|
+
mountExtension: (definition) => {
|
|
1567
|
+
if (params?.[definition.id] === false) {
|
|
1568
|
+
throw new Error(`extension "${definition.id}" is opted out for this session (params["${definition.id}"] is false)`);
|
|
1569
|
+
}
|
|
1570
|
+
return extensionCapability(definition, { host: this.extensionHost(id), params: params?.[definition.id], startReason: "attach" });
|
|
1571
|
+
},
|
|
1572
|
+
onClosed: (sid) => {
|
|
1573
|
+
// Only THIS instance's registration goes — a later open under the same id (a resume
|
|
1574
|
+
// that waited for this close) must keep its own.
|
|
1575
|
+
if (this.activeSessions.get(sid) === session)
|
|
1576
|
+
this.activeSessions.delete(sid);
|
|
1577
|
+
},
|
|
1578
|
+
});
|
|
1579
|
+
this.activeSessions.set(id, session);
|
|
1580
|
+
// Born gated: a session appearing while a reshape barrier holds (createSession,
|
|
1581
|
+
// resumeSession and openSession all land here) must not start runs mid-swap. Its
|
|
1582
|
+
// quiescent is trivially settled — it has no run — so it never delays the rendezvous.
|
|
1583
|
+
if (this.activeBarrier !== undefined)
|
|
1584
|
+
this.activeBarrier.holds.set(id, session.holdAtBoundary());
|
|
1585
|
+
return session;
|
|
1586
|
+
}
|
|
1587
|
+
catch (error) {
|
|
1588
|
+
await this.rollbackOpen(id, { projection, core }, error);
|
|
1589
|
+
throw error;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Undo a failed `openFromStore`, newest artifact first. Every step runs even if an earlier one
|
|
1594
|
+
* throws (a cleanup failure is logged, never allowed to mask the open's own error).
|
|
1595
|
+
*/
|
|
1596
|
+
async rollbackOpen(id, built, cause) {
|
|
1597
|
+
const logger = this.parts.logger;
|
|
1598
|
+
const attempt = async (step, run) => {
|
|
1599
|
+
try {
|
|
1600
|
+
await run();
|
|
1601
|
+
}
|
|
1602
|
+
catch (error) {
|
|
1603
|
+
logger?.log("warn", `session open rollback: ${step} failed`, {
|
|
1604
|
+
sessionId: id,
|
|
1605
|
+
step,
|
|
1606
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1607
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
await attempt("detach projection", () => built.projection?.detach());
|
|
1612
|
+
if (built.core !== undefined)
|
|
1613
|
+
await attempt("close core", () => built.core.close());
|
|
1614
|
+
}
|
|
1615
|
+
/** Every definition this harness mounts, by value then from files. */
|
|
1616
|
+
allDefinitions() {
|
|
1617
|
+
return [...this.valueDefs.values(), ...(this.extensions?.sessionDefinitions() ?? [])];
|
|
1365
1618
|
}
|
|
1366
1619
|
contextFor(options) {
|
|
1367
1620
|
return hasOwnContext(options) ? options.context : this.options.context;
|
|
@@ -1378,7 +1631,7 @@ export class Harness {
|
|
|
1378
1631
|
*
|
|
1379
1632
|
* A worker that dies mid-turn journals no `turn.ended`; to every reader the turn is still
|
|
1380
1633
|
* running. Opening the session repairs that (the same recovery runs inside `resumeSession`),
|
|
1381
|
-
* but opening boots capabilities and
|
|
1634
|
+
* but opening boots capabilities and an environment just to write two records. This does only the
|
|
1382
1635
|
* repair: fold the log, and for every frame still inside a turn append `turn.ended` with
|
|
1383
1636
|
* `reason: "failed"` (and `agent.ended` for a live sub-agent). Returns the events those records
|
|
1384
1637
|
* project to, so a host that has live subscribers can hand them on — a record appended here
|
|
@@ -1389,7 +1642,7 @@ export class Harness {
|
|
|
1389
1642
|
export async function closeOrphanedTurns(sessionId, store) {
|
|
1390
1643
|
if ((await store.getState(INTERRUPTION_STATE_KEY)) !== null)
|
|
1391
1644
|
return [];
|
|
1392
|
-
const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await
|
|
1645
|
+
const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await readSessionTails(store));
|
|
1393
1646
|
const records = await closeOrphanedProjectionFrames(sessionId, store, projection);
|
|
1394
1647
|
const events = [];
|
|
1395
1648
|
for (const record of records) {
|