operon-agents 0.1.0-alpha.5 → 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/harness.d.ts +77 -10
- package/dist/harness.js +361 -136
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/local.js +4 -2
- package/package.json +2 -2
package/dist/harness.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export type QuestionHandler = (request: QuestionRequest, options?: {
|
|
|
19
19
|
readonly signal?: AbortSignal;
|
|
20
20
|
}) => Promise<QuestionResult> | QuestionResult;
|
|
21
21
|
export type HarnessSessionState = "idle" | "running" | "interrupted" | "closed";
|
|
22
|
+
/** Test-only (exported via `operon-agents/internal`): shrink the close deadlines. */
|
|
23
|
+
export declare function setHarnessCloseTimeoutsForTest(ms: {
|
|
24
|
+
runSettle?: number;
|
|
25
|
+
scopeDispose?: number;
|
|
26
|
+
}): void;
|
|
22
27
|
export type DeliveryMode = "auto" | "steer" | "follow_up";
|
|
23
28
|
export interface DeliveryOptions {
|
|
24
29
|
readonly source: string;
|
|
@@ -402,17 +407,27 @@ export declare class HarnessSession<TContext = unknown> {
|
|
|
402
407
|
private readonly responder;
|
|
403
408
|
private readonly onClosed;
|
|
404
409
|
private runContext;
|
|
405
|
-
private currentRun;
|
|
406
410
|
/** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
|
|
407
411
|
* session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
|
|
408
412
|
* run is never touched. */
|
|
409
413
|
private runGate;
|
|
410
|
-
/**
|
|
411
|
-
*
|
|
412
|
-
|
|
413
|
-
|
|
414
|
+
/**
|
|
415
|
+
* Every run this facade has begun and not yet seen settle, by its controller. More than one
|
|
416
|
+
* entry means runs are queued behind the core's run lock (`prompt` while a turn is in flight):
|
|
417
|
+
* "busy" is this set being non-empty, `cancel()` aborts ALL of them, and the barrier's
|
|
418
|
+
* quiescence is ALL of them settling — tracking only the last-begun run would abort the
|
|
419
|
+
* queued one and leave the running one untouched.
|
|
420
|
+
*/
|
|
421
|
+
private readonly runs;
|
|
414
422
|
private lastRunInterrupted;
|
|
415
|
-
|
|
423
|
+
/** open → closing (runs being drained, resources going down) → closed. Never goes back. */
|
|
424
|
+
private lifecycle;
|
|
425
|
+
/** The one close in flight (or finished): every `close()` call returns THIS promise. */
|
|
426
|
+
private closing;
|
|
427
|
+
/** Resolves the moment close() begins — what wakes a caller parked at the barrier gate so it
|
|
428
|
+
* can fail with the closed error instead of hanging on a gate nobody will release. */
|
|
429
|
+
private notifyClosing;
|
|
430
|
+
private readonly closingNotified;
|
|
416
431
|
private deliveryCounter;
|
|
417
432
|
/** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
|
|
418
433
|
private wakeScheduled;
|
|
@@ -435,11 +450,19 @@ export declare class HarnessSession<TContext = unknown> {
|
|
|
435
450
|
* Start a turn to consume queued follow-ups, once the current synchronous work is done.
|
|
436
451
|
*
|
|
437
452
|
* Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
|
|
438
|
-
* wake, and a settle that lands in a finishing run's own stack sees
|
|
453
|
+
* wake, and a settle that lands in a finishing run's own stack sees its run already
|
|
439
454
|
* cleared. Re-checked at fire time because either can change in between — and re-checked
|
|
440
455
|
* again after every run, since the queue may have filled during one whose drain had passed.
|
|
441
456
|
*/
|
|
442
457
|
private scheduleIdleWake;
|
|
458
|
+
/** Not open: closing or closed. Every run entry point refuses from here on. */
|
|
459
|
+
private get closed();
|
|
460
|
+
/**
|
|
461
|
+
* Park at the barrier gate until released — or until this session starts closing, in which
|
|
462
|
+
* case fail with the closed error rather than wait on a gate no coordinator will release.
|
|
463
|
+
* Re-checked in a loop: a new hold can land between a release and this task resuming.
|
|
464
|
+
*/
|
|
465
|
+
private parkAtGate;
|
|
443
466
|
/** Run options every run on this session carries — currently the session's turn cap. */
|
|
444
467
|
private get runOverrides();
|
|
445
468
|
/** Current runtime-only application context supplied to Agent callbacks. */
|
|
@@ -452,6 +475,10 @@ export declare class HarnessSession<TContext = unknown> {
|
|
|
452
475
|
setContext(context: TContext | undefined): void;
|
|
453
476
|
private beginRun;
|
|
454
477
|
private endRun;
|
|
478
|
+
/** A run is in flight or queued behind the core's run lock. */
|
|
479
|
+
private hasActiveRuns;
|
|
480
|
+
/** Settles when every run begun so far has settled (trivially, when idle). */
|
|
481
|
+
private allRunsSettled;
|
|
455
482
|
/**
|
|
456
483
|
* Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
|
|
457
484
|
* report when this session reaches its run boundary. Installing the gate is synchronous — a
|
|
@@ -538,7 +565,7 @@ export declare class HarnessSession<TContext = unknown> {
|
|
|
538
565
|
* active run — use `prompt` instead in that case.
|
|
539
566
|
*/
|
|
540
567
|
followUp(input: string): string | null;
|
|
541
|
-
/** Abort the
|
|
568
|
+
/** Abort every run this session has accepted: the one in flight AND any queued behind it. */
|
|
542
569
|
cancel(): void;
|
|
543
570
|
/** Subscribe to this session's event stream. Returns an unsubscribe fn. */
|
|
544
571
|
onEvent(listener: AgentEventListener): () => void;
|
|
@@ -621,8 +648,18 @@ export declare class HarnessSession<TContext = unknown> {
|
|
|
621
648
|
listMcpTools(...args: Parameters<Session["listMcpTools"]>): ReturnType<Session["listMcpTools"]>;
|
|
622
649
|
listMcpServers(...args: Parameters<Session["listMcpServers"]>): ReturnType<Session["listMcpServers"]>;
|
|
623
650
|
reconnectMcpServer(...args: Parameters<Session["reconnectMcpServer"]>): ReturnType<Session["reconnectMcpServer"]>;
|
|
624
|
-
/**
|
|
651
|
+
/**
|
|
652
|
+
* Close the session and release its resources. Order: stop accepting runs, abort the ones
|
|
653
|
+
* accepted (in flight and queued) and wait for them to settle — their tools' finally blocks,
|
|
654
|
+
* their journal writes — then detach the projection and take the core down. Every call
|
|
655
|
+
* returns the SAME promise, so nobody returns while the teardown is still running.
|
|
656
|
+
*
|
|
657
|
+
* The wait for runs is bounded (`CLOSE_RUN_WAIT_MS`, 5s): a run that ignores its abort signal
|
|
658
|
+
* is logged and left behind, and the teardown continues — the deadline caps the wait for
|
|
659
|
+
* runs, not the whole close (flushes and disposes carry their own deadlines).
|
|
660
|
+
*/
|
|
625
661
|
close(): Promise<void>;
|
|
662
|
+
private runClose;
|
|
626
663
|
}
|
|
627
664
|
/** Manages a fleet of in-process sessions over a shared engine config. */
|
|
628
665
|
export declare class Harness<TContext = unknown> {
|
|
@@ -631,6 +668,11 @@ export declare class Harness<TContext = unknown> {
|
|
|
631
668
|
/** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
|
|
632
669
|
private readonly toolPalette;
|
|
633
670
|
private readonly activeSessions;
|
|
671
|
+
/** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
|
|
672
|
+
* whole of it, reconcile and failure cleanup included — instead of each building an instance. */
|
|
673
|
+
private readonly opening;
|
|
674
|
+
/** The one close in flight (or finished): every `close()` call returns THIS promise. */
|
|
675
|
+
private closing;
|
|
634
676
|
/** The harness-tier scope: every process-lived object, and the parent of every workspace scope. */
|
|
635
677
|
readonly scope: Scope<"harness">;
|
|
636
678
|
/** Workspace scopes by key, reference-counted by the sessions open under them. */
|
|
@@ -692,6 +734,19 @@ export declare class Harness<TContext = unknown> {
|
|
|
692
734
|
private buildDefaultAgent;
|
|
693
735
|
get sessions(): ReadonlyMap<string, HarnessSession<TContext>>;
|
|
694
736
|
createSession(opts?: CreateSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
|
|
737
|
+
/**
|
|
738
|
+
* Register an open in flight under its id for as long as it runs. Every path that builds a
|
|
739
|
+
* session goes through here — create, resume, fork — so a `resumeSession` for an id that is
|
|
740
|
+
* being created joins that open instead of building a twin over the same store, and
|
|
741
|
+
* `closeSession` / `harness.close()` wait for it instead of closing its scope under it.
|
|
742
|
+
*/
|
|
743
|
+
private trackOpen;
|
|
744
|
+
/**
|
|
745
|
+
* The open session with this id, or reopen it from the store. Concurrent calls for one id share
|
|
746
|
+
* a single open (`opening`): the second caller waits for the first's — reconcile included — and
|
|
747
|
+
* receives the same instance, never a second one that a close would then unregister. An
|
|
748
|
+
* instance that is closing is not handed out: the reopen waits for its close to finish first.
|
|
749
|
+
*/
|
|
695
750
|
resumeSession(id: string, opts?: ResumeSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
|
|
696
751
|
/** Fork an existing session into a new one (copies its log + state) and open it. */
|
|
697
752
|
forkSession(sourceId: string, opts?: ForkSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
|
|
@@ -703,7 +758,8 @@ export declare class Harness<TContext = unknown> {
|
|
|
703
758
|
inspectSession(id: string, options?: OpenSessionOptions): Promise<SessionHandle | undefined>;
|
|
704
759
|
/** Read one catalog row without the O(N) `listSessions()` scan. */
|
|
705
760
|
getSessionSummary(id: string): Promise<SessionSummary | undefined>;
|
|
706
|
-
/** Close one open session by id (no-op if it isn't open).
|
|
761
|
+
/** Close one open session by id (no-op if it isn't open). An open in flight for this id is
|
|
762
|
+
* waited for and then closed, so a close/delete never races a half-built session. */
|
|
707
763
|
closeSession(id: string): Promise<void>;
|
|
708
764
|
/**
|
|
709
765
|
* Delete a session after releasing any open in-process handle. Soft by default — the durable
|
|
@@ -720,6 +776,9 @@ export declare class Harness<TContext = unknown> {
|
|
|
720
776
|
* registrations, then the defaults.
|
|
721
777
|
*/
|
|
722
778
|
close(): Promise<void>;
|
|
779
|
+
private runClose;
|
|
780
|
+
/** Every path that opens a session starts here: a closing harness opens nothing new. */
|
|
781
|
+
private assertAcceptingSessions;
|
|
723
782
|
/**
|
|
724
783
|
* Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
|
|
725
784
|
* `harness.extensions.reload()` (docs/architecture.md §5.5). Every session holding
|
|
@@ -779,6 +838,12 @@ export declare class Harness<TContext = unknown> {
|
|
|
779
838
|
*/
|
|
780
839
|
private extensionHost;
|
|
781
840
|
private openFromStore;
|
|
841
|
+
/**
|
|
842
|
+
* Undo a failed `openFromStore`, newest artifact first. Every step runs even if an earlier one
|
|
843
|
+
* throws (a cleanup failure is logged, never allowed to mask the open's own error), and the
|
|
844
|
+
* workspace hold this open took is always returned.
|
|
845
|
+
*/
|
|
846
|
+
private rollbackOpen;
|
|
782
847
|
/**
|
|
783
848
|
* The workspace scope for `key`, composed on first use — the host's `workspace` hook, then
|
|
784
849
|
* every registered extension's `workspace` half — and ref-counted by the sessions under it.
|
|
@@ -786,6 +851,8 @@ export declare class Harness<TContext = unknown> {
|
|
|
786
851
|
private acquireWorkspace;
|
|
787
852
|
/** Drop one session's hold; the last one out closes the workspace scope. */
|
|
788
853
|
private releaseWorkspace;
|
|
854
|
+
/** Close options for a scope the harness tears down itself: per-service deadline, failures logged. */
|
|
855
|
+
private scopeCloseOptions;
|
|
789
856
|
/**
|
|
790
857
|
* A handle to a workspace-tier extension service in ONE workspace — how a host reaches a
|
|
791
858
|
* `workspace` half's instance (`harness.services.handle` only answers for the harness tier).
|
package/dist/harness.js
CHANGED
|
@@ -19,6 +19,19 @@ import { mkdirSync } from "node:fs";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { assertOneSharedHalf, extensionsCapability, ExtensionRuntime, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
|
|
21
21
|
import { createExtensionCommandRegistry } from "operon-agents-core";
|
|
22
|
+
/** How long `HarnessSession.close()` waits for aborted runs to settle before continuing teardown. */
|
|
23
|
+
let CLOSE_RUN_WAIT_MS = 5_000;
|
|
24
|
+
/** Per-service dispose deadline when the harness tears a scope down itself — a workspace whose
|
|
25
|
+
* last session left, a session scope a failed open rolls back, the harness scope on close.
|
|
26
|
+
* Mirrors the core session's own close deadline: one hung MCP shutdown must not hang close(). */
|
|
27
|
+
let SCOPE_DISPOSE_TIMEOUT_MS = 5_000;
|
|
28
|
+
/** Test-only (exported via `operon-agents/internal`): shrink the close deadlines. */
|
|
29
|
+
export function setHarnessCloseTimeoutsForTest(ms) {
|
|
30
|
+
if (ms.runSettle !== undefined)
|
|
31
|
+
CLOSE_RUN_WAIT_MS = ms.runSettle;
|
|
32
|
+
if (ms.scopeDispose !== undefined)
|
|
33
|
+
SCOPE_DISPOSE_TIMEOUT_MS = ms.scopeDispose;
|
|
34
|
+
}
|
|
22
35
|
/** Rendezvous budget for `replaceExtension` when the caller names none. */
|
|
23
36
|
const DEFAULT_REPLACE_TIMEOUT_MS = 30_000;
|
|
24
37
|
/** Session-store key holding the per-session extension params map (see `createSession({ params })`). */
|
|
@@ -153,17 +166,29 @@ export class HarnessSession {
|
|
|
153
166
|
responder;
|
|
154
167
|
onClosed;
|
|
155
168
|
runContext;
|
|
156
|
-
currentRun;
|
|
157
169
|
/** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
|
|
158
170
|
* session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
|
|
159
171
|
* run is never touched. */
|
|
160
172
|
runGate;
|
|
161
|
-
/**
|
|
162
|
-
*
|
|
163
|
-
|
|
164
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Every run this facade has begun and not yet seen settle, by its controller. More than one
|
|
175
|
+
* entry means runs are queued behind the core's run lock (`prompt` while a turn is in flight):
|
|
176
|
+
* "busy" is this set being non-empty, `cancel()` aborts ALL of them, and the barrier's
|
|
177
|
+
* quiescence is ALL of them settling — tracking only the last-begun run would abort the
|
|
178
|
+
* queued one and leave the running one untouched.
|
|
179
|
+
*/
|
|
180
|
+
runs = new Map();
|
|
165
181
|
lastRunInterrupted = false;
|
|
166
|
-
closed
|
|
182
|
+
/** open → closing (runs being drained, resources going down) → closed. Never goes back. */
|
|
183
|
+
lifecycle = "open";
|
|
184
|
+
/** The one close in flight (or finished): every `close()` call returns THIS promise. */
|
|
185
|
+
closing;
|
|
186
|
+
/** Resolves the moment close() begins — what wakes a caller parked at the barrier gate so it
|
|
187
|
+
* can fail with the closed error instead of hanging on a gate nobody will release. */
|
|
188
|
+
notifyClosing;
|
|
189
|
+
closingNotified = new Promise((resolve) => {
|
|
190
|
+
this.notifyClosing = resolve;
|
|
191
|
+
});
|
|
167
192
|
deliveryCounter = 0;
|
|
168
193
|
/** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
|
|
169
194
|
wakeScheduled = false;
|
|
@@ -193,7 +218,7 @@ export class HarnessSession {
|
|
|
193
218
|
* Start a turn to consume queued follow-ups, once the current synchronous work is done.
|
|
194
219
|
*
|
|
195
220
|
* Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
|
|
196
|
-
* wake, and a settle that lands in a finishing run's own stack sees
|
|
221
|
+
* wake, and a settle that lands in a finishing run's own stack sees its run already
|
|
197
222
|
* cleared. Re-checked at fire time because either can change in between — and re-checked
|
|
198
223
|
* again after every run, since the queue may have filled during one whose drain had passed.
|
|
199
224
|
*/
|
|
@@ -211,7 +236,7 @@ export class HarnessSession {
|
|
|
211
236
|
return;
|
|
212
237
|
// A run in flight drains at its own turn boundary; an interrupted one must be resumed,
|
|
213
238
|
// not talked over.
|
|
214
|
-
if (this.
|
|
239
|
+
if (this.hasActiveRuns())
|
|
215
240
|
return;
|
|
216
241
|
// Either channel counts. A "steering" item is an interruption of work in flight, but with
|
|
217
242
|
// nothing in flight there is nothing to interrupt — it is simply the next input, and the
|
|
@@ -223,6 +248,22 @@ export class HarnessSession {
|
|
|
223
248
|
void this.runPrompt([]).catch(() => undefined);
|
|
224
249
|
});
|
|
225
250
|
}
|
|
251
|
+
/** Not open: closing or closed. Every run entry point refuses from here on. */
|
|
252
|
+
get closed() {
|
|
253
|
+
return this.lifecycle !== "open";
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Park at the barrier gate until released — or until this session starts closing, in which
|
|
257
|
+
* case fail with the closed error rather than wait on a gate no coordinator will release.
|
|
258
|
+
* Re-checked in a loop: a new hold can land between a release and this task resuming.
|
|
259
|
+
*/
|
|
260
|
+
async parkAtGate() {
|
|
261
|
+
while (this.runGate !== undefined) {
|
|
262
|
+
await Promise.race([this.runGate.released, this.closingNotified]);
|
|
263
|
+
if (this.closed)
|
|
264
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
226
267
|
/** Run options every run on this session carries — currently the session's turn cap. */
|
|
227
268
|
get runOverrides() {
|
|
228
269
|
return this.maxTurns !== undefined ? { maxTurns: this.maxTurns } : {};
|
|
@@ -235,7 +276,7 @@ export class HarnessSession {
|
|
|
235
276
|
return {
|
|
236
277
|
state: this.closed
|
|
237
278
|
? "closed"
|
|
238
|
-
: this.
|
|
279
|
+
: this.hasActiveRuns()
|
|
239
280
|
? "running"
|
|
240
281
|
: this.lastRunInterrupted
|
|
241
282
|
? "interrupted"
|
|
@@ -258,16 +299,23 @@ export class HarnessSession {
|
|
|
258
299
|
}
|
|
259
300
|
// ── Run driving (the methods core Session does not have) ──
|
|
260
301
|
beginRun(controller) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
302
|
+
let resolve;
|
|
303
|
+
const settled = new Promise((r) => {
|
|
304
|
+
resolve = r;
|
|
264
305
|
});
|
|
306
|
+
this.runs.set(controller, { settled, resolve });
|
|
265
307
|
}
|
|
266
308
|
endRun(controller) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
309
|
+
this.runs.get(controller)?.resolve();
|
|
310
|
+
this.runs.delete(controller);
|
|
311
|
+
}
|
|
312
|
+
/** A run is in flight or queued behind the core's run lock. */
|
|
313
|
+
hasActiveRuns() {
|
|
314
|
+
return this.runs.size > 0;
|
|
315
|
+
}
|
|
316
|
+
/** Settles when every run begun so far has settled (trivially, when idle). */
|
|
317
|
+
allRunsSettled() {
|
|
318
|
+
return Promise.all([...this.runs.values()].map((run) => run.settled)).then(() => undefined);
|
|
271
319
|
}
|
|
272
320
|
/**
|
|
273
321
|
* Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
|
|
@@ -284,7 +332,7 @@ export class HarnessSession {
|
|
|
284
332
|
});
|
|
285
333
|
const gate = { released, release: releaseGate };
|
|
286
334
|
this.runGate = gate;
|
|
287
|
-
const quiescent = this.
|
|
335
|
+
const quiescent = this.allRunsSettled();
|
|
288
336
|
return {
|
|
289
337
|
quiescent,
|
|
290
338
|
release: () => {
|
|
@@ -303,10 +351,11 @@ export class HarnessSession {
|
|
|
303
351
|
async runPrompt(input, inputOrigin) {
|
|
304
352
|
if (this.closed)
|
|
305
353
|
throw new Error(`session "${this.id}" is closed`);
|
|
306
|
-
// Barrier gate: park
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
354
|
+
// Barrier gate: park until released (a hold is "wait", not "fail") — unless the session
|
|
355
|
+
// closes meanwhile, which is the one thing that turns the wait into a failure. Ungated, the
|
|
356
|
+
// run is registered SYNCHRONOUSLY: a cancel() or close() on the next line must find it.
|
|
357
|
+
if (this.runGate !== undefined)
|
|
358
|
+
await this.parkAtGate();
|
|
310
359
|
const controller = new AbortController();
|
|
311
360
|
this.beginRun(controller);
|
|
312
361
|
try {
|
|
@@ -330,7 +379,7 @@ export class HarnessSession {
|
|
|
330
379
|
finally {
|
|
331
380
|
this.endRun(controller);
|
|
332
381
|
// A follow-up can land after this run's last drain but before it settles — that enqueue
|
|
333
|
-
// saw
|
|
382
|
+
// saw an active run and stood down, so the run itself has to look once on the way out.
|
|
334
383
|
this.scheduleIdleWake();
|
|
335
384
|
}
|
|
336
385
|
}
|
|
@@ -343,7 +392,7 @@ export class HarnessSession {
|
|
|
343
392
|
if (this.closed || this.lastRunInterrupted)
|
|
344
393
|
return false;
|
|
345
394
|
// A run in flight drains its own queues, so handing the message to the frame is enough.
|
|
346
|
-
if (this.
|
|
395
|
+
if (this.hasActiveRuns())
|
|
347
396
|
return this.core.steerTo(address, content, origin);
|
|
348
397
|
// Idle: only the root frame can be woken. A subagent whose frame has ended is not a teammate
|
|
349
398
|
// waiting for mail — it is a finished delegation, and only the parent that delegated it can
|
|
@@ -375,7 +424,7 @@ export class HarnessSession {
|
|
|
375
424
|
async deliver(input, options) {
|
|
376
425
|
if (this.closed)
|
|
377
426
|
throw new Error(`session "${this.id}" is closed`);
|
|
378
|
-
if (this.lastRunInterrupted && this.
|
|
427
|
+
if (this.lastRunInterrupted && !this.hasActiveRuns()) {
|
|
379
428
|
throw new Error(`session "${this.id}" is interrupted; resume it before delivering new work`);
|
|
380
429
|
}
|
|
381
430
|
if (!options.source.trim())
|
|
@@ -438,7 +487,7 @@ export class HarnessSession {
|
|
|
438
487
|
const channel = origin.kind === "external"
|
|
439
488
|
? origin.channel ?? "steering"
|
|
440
489
|
: origin.kind === "user_follow_up" ? "follow_up" : "steering";
|
|
441
|
-
if (this.
|
|
490
|
+
if (this.hasActiveRuns()) {
|
|
442
491
|
const receipt = this.core.steer.steer(input, origin);
|
|
443
492
|
return {
|
|
444
493
|
deliveryId,
|
|
@@ -491,11 +540,13 @@ export class HarnessSession {
|
|
|
491
540
|
* copy see a "stale interruption state" error; re-fetching the persisted state clears it.
|
|
492
541
|
*/
|
|
493
542
|
async resume(answers) {
|
|
543
|
+
if (this.closed)
|
|
544
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
494
545
|
// Barrier gate first (a coordinator holding the barrier should not be resumed against —
|
|
495
|
-
// the call parks here until release rather than failing).
|
|
496
|
-
|
|
497
|
-
await this.
|
|
498
|
-
if (this.
|
|
546
|
+
// the call parks here until release rather than failing; closing meanwhile does fail it).
|
|
547
|
+
if (this.runGate !== undefined)
|
|
548
|
+
await this.parkAtGate();
|
|
549
|
+
if (this.hasActiveRuns())
|
|
499
550
|
throw new Error(`session "${this.id}" already has an active run`);
|
|
500
551
|
const controller = new AbortController();
|
|
501
552
|
// Set this BEFORE the first await (past the gate). A host returning 202 from resume can
|
|
@@ -535,6 +586,8 @@ export class HarnessSession {
|
|
|
535
586
|
}
|
|
536
587
|
/** Streaming variant: iterate events, await `.completed` for the result. */
|
|
537
588
|
promptStream(input) {
|
|
589
|
+
if (this.closed)
|
|
590
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
538
591
|
const controller = new AbortController();
|
|
539
592
|
// Ungated: start synchronously, byte-identical to the pre-barrier behavior. Gated: the
|
|
540
593
|
// handle returns immediately but the underlying run starts only after release (lazy start —
|
|
@@ -544,8 +597,7 @@ export class HarnessSession {
|
|
|
544
597
|
this.beginRun(controller);
|
|
545
598
|
const start = (async () => {
|
|
546
599
|
if (gated) {
|
|
547
|
-
|
|
548
|
-
await this.runGate.released;
|
|
600
|
+
await this.parkAtGate();
|
|
549
601
|
this.beginRun(controller);
|
|
550
602
|
}
|
|
551
603
|
return this.runner.runStream(this.agent, input, {
|
|
@@ -571,6 +623,9 @@ export class HarnessSession {
|
|
|
571
623
|
})
|
|
572
624
|
.finally(() => {
|
|
573
625
|
this.endRun(controller);
|
|
626
|
+
// Same as runPrompt's exit: input queued after this run's last drain (a background
|
|
627
|
+
// notification landing at agent.ended) needs a wake, or it sits until the next prompt.
|
|
628
|
+
this.scheduleIdleWake();
|
|
574
629
|
});
|
|
575
630
|
// Guard the floating promise so iterate-only callers don't trip an unhandled rejection
|
|
576
631
|
// (mirrors runStream's own internal guard); awaiters still receive a real rejection.
|
|
@@ -619,13 +674,14 @@ export class HarnessSession {
|
|
|
619
674
|
* active run — use `prompt` instead in that case.
|
|
620
675
|
*/
|
|
621
676
|
followUp(input) {
|
|
622
|
-
if (this.
|
|
677
|
+
if (!this.hasActiveRuns())
|
|
623
678
|
return null;
|
|
624
679
|
return this.core.steer.followUp(input).steerId;
|
|
625
680
|
}
|
|
626
|
-
/** Abort the
|
|
681
|
+
/** Abort every run this session has accepted: the one in flight AND any queued behind it. */
|
|
627
682
|
cancel() {
|
|
628
|
-
this.
|
|
683
|
+
for (const controller of [...this.runs.keys()])
|
|
684
|
+
controller.abort();
|
|
629
685
|
}
|
|
630
686
|
/** Subscribe to this session's event stream. Returns an unsubscribe fn. */
|
|
631
687
|
onEvent(listener) {
|
|
@@ -833,15 +889,50 @@ export class HarnessSession {
|
|
|
833
889
|
reconnectMcpServer(...args) {
|
|
834
890
|
return this.core.reconnectMcpServer(...args);
|
|
835
891
|
}
|
|
836
|
-
/**
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
892
|
+
/**
|
|
893
|
+
* Close the session and release its resources. Order: stop accepting runs, abort the ones
|
|
894
|
+
* accepted (in flight and queued) and wait for them to settle — their tools' finally blocks,
|
|
895
|
+
* their journal writes — then detach the projection and take the core down. Every call
|
|
896
|
+
* returns the SAME promise, so nobody returns while the teardown is still running.
|
|
897
|
+
*
|
|
898
|
+
* The wait for runs is bounded (`CLOSE_RUN_WAIT_MS`, 5s): a run that ignores its abort signal
|
|
899
|
+
* is logged and left behind, and the teardown continues — the deadline caps the wait for
|
|
900
|
+
* runs, not the whole close (flushes and disposes carry their own deadlines).
|
|
901
|
+
*/
|
|
902
|
+
close() {
|
|
903
|
+
if (this.closing !== undefined)
|
|
904
|
+
return this.closing;
|
|
905
|
+
this.lifecycle = "closing";
|
|
906
|
+
this.notifyClosing();
|
|
907
|
+
this.closing = this.runClose();
|
|
908
|
+
return this.closing;
|
|
909
|
+
}
|
|
910
|
+
async runClose() {
|
|
911
|
+
try {
|
|
912
|
+
const settled = this.allRunsSettled();
|
|
913
|
+
this.cancel();
|
|
914
|
+
let timer;
|
|
915
|
+
const timedOut = await Promise.race([
|
|
916
|
+
settled.then(() => false),
|
|
917
|
+
new Promise((resolve) => {
|
|
918
|
+
timer = setTimeout(() => resolve(true), CLOSE_RUN_WAIT_MS);
|
|
919
|
+
}),
|
|
920
|
+
]);
|
|
921
|
+
clearTimeout(timer);
|
|
922
|
+
if (timedOut) {
|
|
923
|
+
this.core.logger.log("warn", "session close: runs still settling after the deadline; continuing teardown", {
|
|
924
|
+
sessionId: this.id,
|
|
925
|
+
runs: this.runs.size,
|
|
926
|
+
deadlineMs: CLOSE_RUN_WAIT_MS,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
this.projection.detach();
|
|
930
|
+
await this.core.close();
|
|
931
|
+
await this.onClosed(this.id);
|
|
932
|
+
}
|
|
933
|
+
finally {
|
|
934
|
+
this.lifecycle = "closed";
|
|
935
|
+
}
|
|
845
936
|
}
|
|
846
937
|
}
|
|
847
938
|
/** Manages a fleet of in-process sessions over a shared engine config. */
|
|
@@ -851,6 +942,11 @@ export class Harness {
|
|
|
851
942
|
/** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
|
|
852
943
|
toolPalette;
|
|
853
944
|
activeSessions = new Map();
|
|
945
|
+
/** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
|
|
946
|
+
* whole of it, reconcile and failure cleanup included — instead of each building an instance. */
|
|
947
|
+
opening = new Map();
|
|
948
|
+
/** The one close in flight (or finished): every `close()` call returns THIS promise. */
|
|
949
|
+
closing;
|
|
854
950
|
/** The harness-tier scope: every process-lived object, and the parent of every workspace scope. */
|
|
855
951
|
scope;
|
|
856
952
|
/** Workspace scopes by key, reference-counted by the sessions open under them. */
|
|
@@ -1070,36 +1166,91 @@ export class Harness {
|
|
|
1070
1166
|
return this.activeSessions;
|
|
1071
1167
|
}
|
|
1072
1168
|
async createSession(opts = {}) {
|
|
1169
|
+
this.assertAcceptingSessions();
|
|
1073
1170
|
const workDir = opts.workDir ?? this.options.workDir ?? process.cwd();
|
|
1074
1171
|
const handle = await (await this.repository()).create({ id: opts.id, workDir, ownerKey: opts.ownerKey, title: opts.title });
|
|
1075
|
-
return this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1172
|
+
return this.trackOpen(handle.id, () => this.openFromStore(handle.id, handle.workDir, handle.store, opts));
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Register an open in flight under its id for as long as it runs. Every path that builds a
|
|
1176
|
+
* session goes through here — create, resume, fork — so a `resumeSession` for an id that is
|
|
1177
|
+
* being created joins that open instead of building a twin over the same store, and
|
|
1178
|
+
* `closeSession` / `harness.close()` wait for it instead of closing its scope under it.
|
|
1179
|
+
*/
|
|
1180
|
+
trackOpen(id, open) {
|
|
1181
|
+
const promise = open().finally(() => {
|
|
1182
|
+
if (this.opening.get(id) === promise)
|
|
1183
|
+
this.opening.delete(id);
|
|
1184
|
+
});
|
|
1185
|
+
this.opening.set(id, promise);
|
|
1186
|
+
return promise;
|
|
1076
1187
|
}
|
|
1077
|
-
|
|
1188
|
+
/**
|
|
1189
|
+
* The open session with this id, or reopen it from the store. Concurrent calls for one id share
|
|
1190
|
+
* a single open (`opening`): the second caller waits for the first's — reconcile included — and
|
|
1191
|
+
* receives the same instance, never a second one that a close would then unregister. An
|
|
1192
|
+
* instance that is closing is not handed out: the reopen waits for its close to finish first.
|
|
1193
|
+
*/
|
|
1194
|
+
resumeSession(id, opts = {}) {
|
|
1195
|
+
// Not async (the in-flight/active fast paths return existing promises), so refuse by rejecting.
|
|
1196
|
+
if (this.closing !== undefined)
|
|
1197
|
+
return Promise.reject(new Error("harness is closed"));
|
|
1198
|
+
const inflight = this.opening.get(id);
|
|
1199
|
+
if (inflight !== undefined) {
|
|
1200
|
+
return hasOwnContext(opts)
|
|
1201
|
+
? inflight.then((session) => {
|
|
1202
|
+
session.setContext(opts.context);
|
|
1203
|
+
return session;
|
|
1204
|
+
})
|
|
1205
|
+
: inflight;
|
|
1206
|
+
}
|
|
1078
1207
|
const existing = this.activeSessions.get(id);
|
|
1079
|
-
if (existing) {
|
|
1208
|
+
if (existing !== undefined && existing.status.state !== "closed") {
|
|
1080
1209
|
if (hasOwnContext(opts))
|
|
1081
1210
|
existing.setContext(opts.context);
|
|
1082
|
-
return existing;
|
|
1211
|
+
return Promise.resolve(existing);
|
|
1083
1212
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1213
|
+
return this.trackOpen(id, async () => {
|
|
1214
|
+
// `existing` here is one that is closing: wait for that close (its registration and
|
|
1215
|
+
// workspace hold go with it) rather than open a twin beside it.
|
|
1216
|
+
if (existing !== undefined)
|
|
1217
|
+
await existing.close();
|
|
1218
|
+
const handle = await (await this.repository()).open(id);
|
|
1219
|
+
if (handle === undefined)
|
|
1220
|
+
throw new SessionRepositoryNotFoundError(id);
|
|
1221
|
+
const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1222
|
+
// Reopening: orphaned background subagents (running from a dead process) → lost. A failure
|
|
1223
|
+
// here is an open failure: the session is torn down, not left registered and unreturned.
|
|
1224
|
+
try {
|
|
1225
|
+
await session.reconcileSubagents();
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1228
|
+
await session.close();
|
|
1229
|
+
throw error;
|
|
1230
|
+
}
|
|
1231
|
+
return session;
|
|
1232
|
+
});
|
|
1091
1233
|
}
|
|
1092
1234
|
/** Fork an existing session into a new one (copies its log + state) and open it. */
|
|
1093
1235
|
async forkSession(sourceId, opts = {}) {
|
|
1236
|
+
this.assertAcceptingSessions();
|
|
1094
1237
|
const handle = await (await this.repository()).fork(sourceId, {
|
|
1095
1238
|
...(opts.id !== undefined ? { id: opts.id } : {}),
|
|
1096
1239
|
...(opts.ownerKey !== undefined ? { ownerKey: opts.ownerKey } : {}),
|
|
1097
1240
|
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
1098
1241
|
});
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1242
|
+
return this.trackOpen(handle.id, async () => {
|
|
1243
|
+
const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1244
|
+
// A fork copies the subagent ledger; its background "running" rows have no live task → lost.
|
|
1245
|
+
try {
|
|
1246
|
+
await session.reconcileSubagents();
|
|
1247
|
+
}
|
|
1248
|
+
catch (error) {
|
|
1249
|
+
await session.close();
|
|
1250
|
+
throw error;
|
|
1251
|
+
}
|
|
1252
|
+
return session;
|
|
1253
|
+
});
|
|
1103
1254
|
}
|
|
1104
1255
|
/** The currently-open session with this id, or undefined (does not reopen — use resumeSession). */
|
|
1105
1256
|
getSession(id) {
|
|
@@ -1115,8 +1266,15 @@ export class Harness {
|
|
|
1115
1266
|
async getSessionSummary(id) {
|
|
1116
1267
|
return (await this.repository()).get(id);
|
|
1117
1268
|
}
|
|
1118
|
-
/** Close one open session by id (no-op if it isn't open).
|
|
1269
|
+
/** Close one open session by id (no-op if it isn't open). An open in flight for this id is
|
|
1270
|
+
* waited for and then closed, so a close/delete never races a half-built session. */
|
|
1119
1271
|
async closeSession(id) {
|
|
1272
|
+
const inflight = this.opening.get(id);
|
|
1273
|
+
if (inflight !== undefined) {
|
|
1274
|
+
const session = await inflight.catch(() => undefined);
|
|
1275
|
+
await session?.close();
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1120
1278
|
await this.activeSessions.get(id)?.close();
|
|
1121
1279
|
}
|
|
1122
1280
|
/**
|
|
@@ -1140,12 +1298,27 @@ export class Harness {
|
|
|
1140
1298
|
* drained, then its dispose hook — default the instance's close()), then the host's own
|
|
1141
1299
|
* registrations, then the defaults.
|
|
1142
1300
|
*/
|
|
1143
|
-
|
|
1301
|
+
close() {
|
|
1302
|
+
if (this.closing !== undefined)
|
|
1303
|
+
return this.closing;
|
|
1304
|
+
this.closing = this.runClose();
|
|
1305
|
+
return this.closing;
|
|
1306
|
+
}
|
|
1307
|
+
async runClose() {
|
|
1308
|
+
// Opens in flight finish (or fail) first: closing the scope tree under a half-built session
|
|
1309
|
+
// would tear its scope out from under `Session.open`. New opens are refused from here on
|
|
1310
|
+
// (`assertAcceptingSessions`), so the set only shrinks.
|
|
1311
|
+
await Promise.allSettled([...this.opening.values()]);
|
|
1144
1312
|
for (const session of [...this.activeSessions.values()])
|
|
1145
1313
|
await session.close();
|
|
1146
1314
|
await this.sharedReady.catch(() => undefined);
|
|
1147
1315
|
this.createdServices.length = 0;
|
|
1148
|
-
await this.scope.close();
|
|
1316
|
+
await this.scope.close(this.scopeCloseOptions());
|
|
1317
|
+
}
|
|
1318
|
+
/** Every path that opens a session starts here: a closing harness opens nothing new. */
|
|
1319
|
+
assertAcceptingSessions() {
|
|
1320
|
+
if (this.closing !== undefined)
|
|
1321
|
+
throw new Error("harness is closed");
|
|
1149
1322
|
}
|
|
1150
1323
|
/**
|
|
1151
1324
|
* Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
|
|
@@ -1401,88 +1574,126 @@ export class Harness {
|
|
|
1401
1574
|
// The workspace scope (one per key, shared) and under it the session scope: what the harness
|
|
1402
1575
|
// decides for this session goes in here; Session.open provides the defaults for the rest,
|
|
1403
1576
|
// and from then on the session owns the scope.
|
|
1577
|
+
// From here the open is a transaction: the session is registered as active only once every
|
|
1578
|
+
// step has succeeded, and any failure tears down exactly what was built — projection, the
|
|
1579
|
+
// session scope (via the core when it got that far), the workspace hold — before rethrowing.
|
|
1580
|
+
// Half-open leftovers are what kept a workspace alive after its last real session closed.
|
|
1404
1581
|
const workspace = await this.acquireWorkspace(workspaceKey, workDir);
|
|
1405
1582
|
let scope;
|
|
1583
|
+
let projection;
|
|
1584
|
+
let core;
|
|
1406
1585
|
try {
|
|
1407
1586
|
scope = workspace.child("session");
|
|
1587
|
+
scope.register(T.SessionId, id);
|
|
1588
|
+
scope.register(T.StoreBackend, store, { owned: false }); // the repository owns the store's lifetime
|
|
1589
|
+
const events = new ListenerSink();
|
|
1590
|
+
scope.register(T.Events, events);
|
|
1591
|
+
const responder = new MutableResponder();
|
|
1592
|
+
scope.register(T.Responder, responder);
|
|
1593
|
+
// Machine: this call's override → the harness-level factory (resolved by Session.open) →
|
|
1594
|
+
// a LocalMachine at the session's own workDir. The session only OPERATES a caller-supplied
|
|
1595
|
+
// machine; the default one is its own.
|
|
1596
|
+
if (opts.machine !== undefined) {
|
|
1597
|
+
if (typeof opts.machine === "function")
|
|
1598
|
+
scope.register(T.SessionMachineFactory, opts.machine);
|
|
1599
|
+
else
|
|
1600
|
+
scope.register(T.Machine, opts.machine, { owned: false });
|
|
1601
|
+
}
|
|
1602
|
+
else if (!workspace.has(T.WorkspaceMachineFactory) && !this.scope.has(T.MachineFactory)) {
|
|
1603
|
+
scope.provide(T.Machine, () => new LocalMachine(workDir));
|
|
1604
|
+
}
|
|
1605
|
+
scope.register(T.PermissionOptions, opts.permission ?? this.options.permission ?? { mode: "yolo" });
|
|
1606
|
+
if (opts.eventPublication !== undefined)
|
|
1607
|
+
scope.register(T.SessionEventPublication, opts.eventPublication);
|
|
1608
|
+
// ONE open-time log read, shared: the projection seeds from it, and Session.open
|
|
1609
|
+
// receives it as `preloadedLog` so its capability restore + context pre-build fold the
|
|
1610
|
+
// same records — same IO, and the same `Message` objects (no second parsed copy).
|
|
1611
|
+
// Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
|
|
1612
|
+
// race the subscription, and anything capabilities emit during open is already folded.
|
|
1613
|
+
let preloadedLog = await readLog(store);
|
|
1614
|
+
// A fresh session has an empty log; anything else is a reopen. Telemetry is the only reader.
|
|
1615
|
+
const resumed = preloadedLog.length > 0;
|
|
1616
|
+
projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
|
|
1617
|
+
const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
|
|
1618
|
+
if (!interrupted) {
|
|
1619
|
+
const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
|
|
1620
|
+
if (recoveryRecords.length > 0)
|
|
1621
|
+
preloadedLog = [...preloadedLog, ...recoveryRecords];
|
|
1622
|
+
}
|
|
1623
|
+
const capabilities = await this.resolveCapabilities(scope, {
|
|
1624
|
+
sessionId: id,
|
|
1625
|
+
workDir,
|
|
1626
|
+
ownMachine: opts.machine !== undefined,
|
|
1627
|
+
...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
|
|
1628
|
+
}, params);
|
|
1629
|
+
core = await Session.open(scope, { capabilities, preloadedLog, resumed });
|
|
1630
|
+
const agent = opts.agent ??
|
|
1631
|
+
this.options.agent ??
|
|
1632
|
+
(await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
|
|
1633
|
+
const session = new HarnessSession({
|
|
1634
|
+
core,
|
|
1635
|
+
// The two caps land in different places: maxStepsPerTurn is an Agent property
|
|
1636
|
+
// (the Runner reads the agent's value first), maxTurns is a per-run option.
|
|
1637
|
+
agent,
|
|
1638
|
+
runner: this.runner,
|
|
1639
|
+
events,
|
|
1640
|
+
responder,
|
|
1641
|
+
projection,
|
|
1642
|
+
workDir,
|
|
1643
|
+
context: this.contextFor(opts),
|
|
1644
|
+
...(opts.maxTurns ?? this.options.maxTurns) !== undefined
|
|
1645
|
+
? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
|
|
1646
|
+
: {},
|
|
1647
|
+
interrupted,
|
|
1648
|
+
onClosed: async (sid) => {
|
|
1649
|
+
// Only THIS instance's registration goes — a later open under the same id (a resume
|
|
1650
|
+
// that waited for this close) must keep its own. The workspace hold is this
|
|
1651
|
+
// instance's regardless and is always returned.
|
|
1652
|
+
if (this.activeSessions.get(sid) === session)
|
|
1653
|
+
this.activeSessions.delete(sid);
|
|
1654
|
+
await this.releaseWorkspace(workspaceKey);
|
|
1655
|
+
},
|
|
1656
|
+
});
|
|
1657
|
+
this.activeSessions.set(id, session);
|
|
1658
|
+
// Born gated: a session appearing while a reshape barrier holds (createSession,
|
|
1659
|
+
// resumeSession and openSession all land here) must not start runs mid-swap. Its
|
|
1660
|
+
// quiescent is trivially settled — it has no run — so it never delays the rendezvous.
|
|
1661
|
+
if (this.activeBarrier !== undefined)
|
|
1662
|
+
this.activeBarrier.holds.set(id, session.holdAtBoundary());
|
|
1663
|
+
return session;
|
|
1408
1664
|
}
|
|
1409
1665
|
catch (error) {
|
|
1410
|
-
await this.
|
|
1666
|
+
await this.rollbackOpen(id, workspaceKey, { projection, core, scope }, error);
|
|
1411
1667
|
throw error;
|
|
1412
1668
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
//
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
const resumed = preloadedLog.length > 0;
|
|
1442
|
-
const projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
|
|
1443
|
-
const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
|
|
1444
|
-
if (!interrupted) {
|
|
1445
|
-
const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
|
|
1446
|
-
if (recoveryRecords.length > 0)
|
|
1447
|
-
preloadedLog = [...preloadedLog, ...recoveryRecords];
|
|
1448
|
-
}
|
|
1449
|
-
const capabilities = await this.resolveCapabilities(scope, {
|
|
1450
|
-
sessionId: id,
|
|
1451
|
-
workDir,
|
|
1452
|
-
ownMachine: opts.machine !== undefined,
|
|
1453
|
-
...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
|
|
1454
|
-
}, params);
|
|
1455
|
-
const core = await Session.open(scope, { capabilities, preloadedLog, resumed });
|
|
1456
|
-
const agent = opts.agent ??
|
|
1457
|
-
this.options.agent ??
|
|
1458
|
-
(await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
|
|
1459
|
-
const session = new HarnessSession({
|
|
1460
|
-
core,
|
|
1461
|
-
// The two caps land in different places: maxStepsPerTurn is an Agent property
|
|
1462
|
-
// (the Runner reads the agent's value first), maxTurns is a per-run option.
|
|
1463
|
-
agent,
|
|
1464
|
-
runner: this.runner,
|
|
1465
|
-
events,
|
|
1466
|
-
responder,
|
|
1467
|
-
projection,
|
|
1468
|
-
workDir,
|
|
1469
|
-
context: this.contextFor(opts),
|
|
1470
|
-
...(opts.maxTurns ?? this.options.maxTurns) !== undefined
|
|
1471
|
-
? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
|
|
1472
|
-
: {},
|
|
1473
|
-
interrupted,
|
|
1474
|
-
onClosed: async (sid) => {
|
|
1475
|
-
this.activeSessions.delete(sid);
|
|
1476
|
-
await this.releaseWorkspace(workspaceKey);
|
|
1477
|
-
},
|
|
1478
|
-
});
|
|
1479
|
-
this.activeSessions.set(id, session);
|
|
1480
|
-
// Born gated: a session appearing while a reshape barrier holds (createSession,
|
|
1481
|
-
// resumeSession and openSession all land here) must not start runs mid-swap. Its
|
|
1482
|
-
// quiescent is trivially settled — it has no run — so it never delays the rendezvous.
|
|
1483
|
-
if (this.activeBarrier !== undefined)
|
|
1484
|
-
this.activeBarrier.holds.set(id, session.holdAtBoundary());
|
|
1485
|
-
return session;
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Undo a failed `openFromStore`, newest artifact first. Every step runs even if an earlier one
|
|
1672
|
+
* throws (a cleanup failure is logged, never allowed to mask the open's own error), and the
|
|
1673
|
+
* workspace hold this open took is always returned.
|
|
1674
|
+
*/
|
|
1675
|
+
async rollbackOpen(id, workspaceKey, built, cause) {
|
|
1676
|
+
const logger = this.scope.get(T.Logger);
|
|
1677
|
+
const attempt = async (step, run) => {
|
|
1678
|
+
try {
|
|
1679
|
+
await run();
|
|
1680
|
+
}
|
|
1681
|
+
catch (error) {
|
|
1682
|
+
logger?.log("warn", `session open rollback: ${step} failed`, {
|
|
1683
|
+
sessionId: id,
|
|
1684
|
+
step,
|
|
1685
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1686
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
await attempt("detach projection", () => built.projection?.detach());
|
|
1691
|
+
// The core owns the scope once it opened; before that the scope is ours to close.
|
|
1692
|
+
if (built.core !== undefined)
|
|
1693
|
+
await attempt("close core", () => built.core.close());
|
|
1694
|
+
else if (built.scope !== undefined)
|
|
1695
|
+
await attempt("close scope", () => built.scope.close(this.scopeCloseOptions(id)));
|
|
1696
|
+
await attempt("release workspace", () => this.releaseWorkspace(workspaceKey));
|
|
1486
1697
|
}
|
|
1487
1698
|
/**
|
|
1488
1699
|
* The workspace scope for `key`, composed on first use — the host's `workspace` hook, then
|
|
@@ -1520,7 +1731,21 @@ export class Harness {
|
|
|
1520
1731
|
if (entry.refs > 0)
|
|
1521
1732
|
return;
|
|
1522
1733
|
this.workspaces.delete(key);
|
|
1523
|
-
await entry.scope.close();
|
|
1734
|
+
await entry.scope.close(this.scopeCloseOptions(undefined, key));
|
|
1735
|
+
}
|
|
1736
|
+
/** Close options for a scope the harness tears down itself: per-service deadline, failures logged. */
|
|
1737
|
+
scopeCloseOptions(sessionId, workspaceKey) {
|
|
1738
|
+
return {
|
|
1739
|
+
disposeTimeoutMs: SCOPE_DISPOSE_TIMEOUT_MS,
|
|
1740
|
+
onDisposeError: (name, error) => {
|
|
1741
|
+
this.scope.get(T.Logger)?.log("warn", `service "${name}" dispose failed/timed out`, {
|
|
1742
|
+
service: name,
|
|
1743
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1744
|
+
...(workspaceKey !== undefined ? { workspaceKey } : {}),
|
|
1745
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1746
|
+
});
|
|
1747
|
+
},
|
|
1748
|
+
};
|
|
1524
1749
|
}
|
|
1525
1750
|
/**
|
|
1526
1751
|
* A handle to a workspace-tier extension service in ONE workspace — how a host reaches a
|
package/dist/internal.d.ts
CHANGED
package/dist/internal.js
CHANGED
package/dist/local.js
CHANGED
|
@@ -56,11 +56,13 @@ export async function localHarnessOptions(options) {
|
|
|
56
56
|
await workspace?.(scope, ctx);
|
|
57
57
|
// Skills follow the workspace's EXECUTION machine, not the host's disk: the catalog the
|
|
58
58
|
// model sees must be the one whose scripts its Bash can reach. A remote workspace
|
|
59
|
-
// registers its machine above and the scan runs through it
|
|
59
|
+
// registers its machine above and the scan runs through it; absent that, the harness's
|
|
60
|
+
// default (`T.MachineFactory`, read through the parent chain) is what sessions here will
|
|
61
|
+
// execute on — the same precedence `Session.open` resolves. A machine FACTORY (one
|
|
60
62
|
// machine per session) has no single filesystem to scan — no shared registry then; each
|
|
61
63
|
// session scans through its own `T.Machine` (`defaultCapabilities` without `T.SkillRegistry`).
|
|
62
64
|
if (!scope.hasLocal(T.SkillRegistry)) {
|
|
63
|
-
const workspaceMachine = scope.get(T.WorkspaceMachineFactory) ?? new LocalMachine(ctx.workDir);
|
|
65
|
+
const workspaceMachine = scope.get(T.WorkspaceMachineFactory) ?? scope.get(T.MachineFactory) ?? new LocalMachine(ctx.workDir);
|
|
64
66
|
if (typeof workspaceMachine !== "function") {
|
|
65
67
|
const registry = new SkillRegistry();
|
|
66
68
|
await loadSkillRoots(workspaceMachine, registry, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "operon-agents",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.6",
|
|
4
4
|
"description": "Public SDK entrypoint for building agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"posthog-node": "^5.51.6",
|
|
74
74
|
"zod": "^4.4.3",
|
|
75
|
-
"operon-agents-core": "0.1.0-alpha.
|
|
75
|
+
"operon-agents-core": "0.1.0-alpha.6"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"@earendil-works/pi-ai": "0.84.1"
|