operon-agents 0.1.0-alpha.4 → 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/README.md CHANGED
@@ -16,3 +16,40 @@ import { TransportMCPServer } from "operon-agents/mcp";
16
16
  import { bashTool, readTool } from "operon-agents/tools";
17
17
  import { OTelTracingProcessor } from "operon-agents/tracing";
18
18
  ```
19
+
20
+ ## Tracing
21
+
22
+ Every session drives a `TracingProcessor` registered on the harness scope as `T.Tracing`: one
23
+ trace per run (a prompt and everything it triggers), spans for agents, turns, model generations,
24
+ tool calls and sub-agents, the session id on every span as `gen_ai.conversation.id`. By default
25
+ spans are metadata only. Pass `content: "delta"` to also record the system prompt, the messages
26
+ the model was asked to answer, its output, tool arguments and results — enough to replay a
27
+ conversation in a trace viewer. Local Jaeger:
28
+
29
+ ```sh
30
+ docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:2.9.0
31
+ ```
32
+
33
+ ```ts
34
+ import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
35
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
36
+ import { resourceFromAttributes } from "@opentelemetry/resources";
37
+ import { OTelTracingProcessor } from "operon-agents/tracing";
38
+ import { T } from "operon-agents/core";
39
+
40
+ const provider = new NodeTracerProvider({
41
+ resource: resourceFromAttributes({ "service.name": "my-agent" }),
42
+ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: "http://localhost:4318/v1/traces" }))],
43
+ });
44
+ const tracing = new OTelTracingProcessor({ tracer: provider.getTracer("my-agent"), tracerProvider: provider, content: "delta", shutdownProvider: true });
45
+
46
+ const harness = await createLocalHarness({
47
+ model,
48
+ harness: (scope) => scope.register(T.Tracing, tracing),
49
+ });
50
+ ```
51
+
52
+ `content: "full"` repeats the entire context on every generation (trace size grows with steps ×
53
+ context); `contentMaxChars` caps each attribute; `redact: true` masks tokens, emails and keys
54
+ before export when the collector is not on the same machine.
55
+
package/dist/harness.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * low-level "assemble it yourself / stateless server" path, import from
12
12
  * `operon-agents/core` instead.
13
13
  */
14
- import { type AgentEvent, type EventPublicationMode, type AgentEventListener, type AgentInput, SessionProjection, type SessionSnapshot, type SnapshotOptions, type DirectoryEntry, type ProjectionObservation, type ProjectionListener, type ApprovalRequest, type ApprovalResponse, type Capability, type InterruptAnswer, type ChatModel, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionStore, type SessionHandle, type SessionSummary, type ListSessionsFilter, type OpenSessionOptions, type DeleteSessionOptions, type ThinkingLevel, type Tool, type ExternalOriginMetadataValue, type SteerChannel, type SteerOrigin, Agent, Runner, Session, McpOAuthService, type HookDef, type McpServerConfig, type PluginManager, type ApprovalRequestOptions, type Machine, type MachineFactory, type ResolvedAgentProfile, type SubagentProvider, Scope } from "operon-agents-core";
14
+ import { type AgentEvent, type EventPublicationMode, type TelemetryService, type AgentEventListener, type AgentInput, SessionProjection, type SessionSnapshot, type SnapshotOptions, type DirectoryEntry, type ProjectionObservation, type ProjectionListener, type ApprovalRequest, type ApprovalResponse, type Capability, type InterruptAnswer, type ChatModel, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionStore, type SessionHandle, type SessionSummary, type ListSessionsFilter, type OpenSessionOptions, type DeleteSessionOptions, type ThinkingLevel, type Tool, type ExternalOriginMetadataValue, type SteerChannel, type SteerOrigin, Agent, Runner, Session, McpOAuthService, type HookDef, type McpServerConfig, type PluginManager, type ApprovalRequestOptions, type Machine, type MachineFactory, type ResolvedAgentProfile, type SubagentProvider, Scope } from "operon-agents-core";
15
15
  import { HarnessExtensionManager, ServiceRegistry, type ExtensionDefinition, type ServiceOptions } from "./extensions/index.ts";
16
16
  import { type CommandResult } from "operon-agents-core";
17
17
  export type ApprovalHandler = (request: ApprovalRequest, options?: ApprovalRequestOptions) => Promise<ApprovalResponse> | ApprovalResponse;
@@ -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;
@@ -160,6 +165,12 @@ export interface HarnessOptions<TContext = unknown> {
160
165
  /** Event durability policy. Defaults to `immediate` for low-latency local applications.
161
166
  * Managed servers override each opened session to `committed`. */
162
167
  readonly eventPublication?: EventPublicationMode;
168
+ /**
169
+ * Product telemetry (docs/telemetry.md). Process-lifetime: create ONE service, attach the
170
+ * appenders once, pass the same instance to every harness. Absent = nothing is counted.
171
+ * The harness never shuts it down; the product does, before exit.
172
+ */
173
+ readonly telemetry?: TelemetryService;
163
174
  /**
164
175
  * Extra subagent profiles merged into the builtin `coder`/`explore`/`plan` fleet. The SOURCE is
165
176
  * the composition root's concern — disk (`loadAgentProfiles`) locally, external on a server —
@@ -396,17 +407,27 @@ export declare class HarnessSession<TContext = unknown> {
396
407
  private readonly responder;
397
408
  private readonly onClosed;
398
409
  private runContext;
399
- private currentRun;
400
410
  /** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
401
411
  * session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
402
412
  * run is never touched. */
403
413
  private runGate;
404
- /** Resolves when the latest run settles — the "arrived at the boundary" signal a barrier
405
- * coordinator awaits. Undefined while idle. */
406
- private runSettled;
407
- private readonly runSettles;
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;
408
422
  private lastRunInterrupted;
409
- private closed;
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;
410
431
  private deliveryCounter;
411
432
  /** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
412
433
  private wakeScheduled;
@@ -429,11 +450,19 @@ export declare class HarnessSession<TContext = unknown> {
429
450
  * Start a turn to consume queued follow-ups, once the current synchronous work is done.
430
451
  *
431
452
  * Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
432
- * wake, and a settle that lands in a finishing run's own stack sees `currentRun` already
453
+ * wake, and a settle that lands in a finishing run's own stack sees its run already
433
454
  * cleared. Re-checked at fire time because either can change in between — and re-checked
434
455
  * again after every run, since the queue may have filled during one whose drain had passed.
435
456
  */
436
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;
437
466
  /** Run options every run on this session carries — currently the session's turn cap. */
438
467
  private get runOverrides();
439
468
  /** Current runtime-only application context supplied to Agent callbacks. */
@@ -446,6 +475,10 @@ export declare class HarnessSession<TContext = unknown> {
446
475
  setContext(context: TContext | undefined): void;
447
476
  private beginRun;
448
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;
449
482
  /**
450
483
  * Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
451
484
  * report when this session reaches its run boundary. Installing the gate is synchronous — a
@@ -532,7 +565,7 @@ export declare class HarnessSession<TContext = unknown> {
532
565
  * active run — use `prompt` instead in that case.
533
566
  */
534
567
  followUp(input: string): string | null;
535
- /** Abort the current run. */
568
+ /** Abort every run this session has accepted: the one in flight AND any queued behind it. */
536
569
  cancel(): void;
537
570
  /** Subscribe to this session's event stream. Returns an unsubscribe fn. */
538
571
  onEvent(listener: AgentEventListener): () => void;
@@ -615,8 +648,18 @@ export declare class HarnessSession<TContext = unknown> {
615
648
  listMcpTools(...args: Parameters<Session["listMcpTools"]>): ReturnType<Session["listMcpTools"]>;
616
649
  listMcpServers(...args: Parameters<Session["listMcpServers"]>): ReturnType<Session["listMcpServers"]>;
617
650
  reconnectMcpServer(...args: Parameters<Session["reconnectMcpServer"]>): ReturnType<Session["reconnectMcpServer"]>;
618
- /** Close the session and release its resources. */
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
+ */
619
661
  close(): Promise<void>;
662
+ private runClose;
620
663
  }
621
664
  /** Manages a fleet of in-process sessions over a shared engine config. */
622
665
  export declare class Harness<TContext = unknown> {
@@ -625,6 +668,11 @@ export declare class Harness<TContext = unknown> {
625
668
  /** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
626
669
  private readonly toolPalette;
627
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;
628
676
  /** The harness-tier scope: every process-lived object, and the parent of every workspace scope. */
629
677
  readonly scope: Scope<"harness">;
630
678
  /** Workspace scopes by key, reference-counted by the sessions open under them. */
@@ -686,6 +734,19 @@ export declare class Harness<TContext = unknown> {
686
734
  private buildDefaultAgent;
687
735
  get sessions(): ReadonlyMap<string, HarnessSession<TContext>>;
688
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
+ */
689
750
  resumeSession(id: string, opts?: ResumeSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
690
751
  /** Fork an existing session into a new one (copies its log + state) and open it. */
691
752
  forkSession(sourceId: string, opts?: ForkSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
@@ -697,7 +758,8 @@ export declare class Harness<TContext = unknown> {
697
758
  inspectSession(id: string, options?: OpenSessionOptions): Promise<SessionHandle | undefined>;
698
759
  /** Read one catalog row without the O(N) `listSessions()` scan. */
699
760
  getSessionSummary(id: string): Promise<SessionSummary | undefined>;
700
- /** 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. */
701
763
  closeSession(id: string): Promise<void>;
702
764
  /**
703
765
  * Delete a session after releasing any open in-process handle. Soft by default — the durable
@@ -714,6 +776,9 @@ export declare class Harness<TContext = unknown> {
714
776
  * registrations, then the defaults.
715
777
  */
716
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;
717
782
  /**
718
783
  * Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
719
784
  * `harness.extensions.reload()` (docs/architecture.md §5.5). Every session holding
@@ -773,6 +838,12 @@ export declare class Harness<TContext = unknown> {
773
838
  */
774
839
  private extensionHost;
775
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;
776
847
  /**
777
848
  * The workspace scope for `key`, composed on first use — the host's `workspace` hook, then
778
849
  * every registered extension's `workspace` half — and ref-counted by the sessions under it.
@@ -780,6 +851,8 @@ export declare class Harness<TContext = unknown> {
780
851
  private acquireWorkspace;
781
852
  /** Drop one session's hold; the last one out closes the workspace scope. */
782
853
  private releaseWorkspace;
854
+ /** Close options for a scope the harness tears down itself: per-service deadline, failures logged. */
855
+ private scopeCloseOptions;
783
856
  /**
784
857
  * A handle to a workspace-tier extension service in ONE workspace — how a host reaches a
785
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
- /** Resolves when the latest run settles — the "arrived at the boundary" signal a barrier
162
- * coordinator awaits. Undefined while idle. */
163
- runSettled;
164
- runSettles = new Map();
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 = false;
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 `currentRun` already
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.currentRun !== undefined)
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.currentRun !== undefined
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
- this.currentRun = controller;
262
- this.runSettled = new Promise((resolve) => {
263
- this.runSettles.set(controller, resolve);
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
- if (this.currentRun === controller)
268
- this.currentRun = undefined;
269
- this.runSettles.get(controller)?.();
270
- this.runSettles.delete(controller);
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.currentRun !== undefined ? (this.runSettled ?? Promise.resolve()) : Promise.resolve();
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 (never fail) until released. A loop, not an if — a new hold can land
307
- // between the release and this task resuming.
308
- while (this.runGate !== undefined)
309
- await this.runGate.released;
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 `currentRun` set and stood down, so the run itself has to look once on the way out.
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.currentRun !== undefined)
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.currentRun === undefined) {
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.currentRun !== undefined) {
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
- while (this.runGate !== undefined)
497
- await this.runGate.released;
498
- if (this.currentRun !== undefined)
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
- while (this.runGate !== undefined)
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.currentRun === undefined)
677
+ if (!this.hasActiveRuns())
623
678
  return null;
624
679
  return this.core.steer.followUp(input).steerId;
625
680
  }
626
- /** Abort the current run. */
681
+ /** Abort every run this session has accepted: the one in flight AND any queued behind it. */
627
682
  cancel() {
628
- this.currentRun?.abort();
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
- /** Close the session and release its resources. */
837
- async close() {
838
- if (this.closed)
839
- return;
840
- this.closed = true;
841
- this.cancel();
842
- this.projection.detach();
843
- await this.core.close();
844
- await this.onClosed(this.id);
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. */
@@ -888,6 +984,8 @@ export class Harness {
888
984
  this.scope.provide(T.SessionRepository, () => new MemorySessionRepository());
889
985
  if (options.eventPublication !== undefined)
890
986
  this.scope.register(T.EventPublication, options.eventPublication);
987
+ if (options.telemetry !== undefined)
988
+ this.scope.register(T.Telemetry, options.telemetry, { owned: false });
891
989
  const agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
892
990
  this.toolPalette = Object.fromEntries(agentTools.map((tool) => [tool.schema.name, tool]));
893
991
  // The Agent/Workflow tools only appear when the run has subagents to spawn. Default the fleet
@@ -1068,36 +1166,91 @@ export class Harness {
1068
1166
  return this.activeSessions;
1069
1167
  }
1070
1168
  async createSession(opts = {}) {
1169
+ this.assertAcceptingSessions();
1071
1170
  const workDir = opts.workDir ?? this.options.workDir ?? process.cwd();
1072
1171
  const handle = await (await this.repository()).create({ id: opts.id, workDir, ownerKey: opts.ownerKey, title: opts.title });
1073
- 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;
1074
1187
  }
1075
- async resumeSession(id, opts = {}) {
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
+ }
1076
1207
  const existing = this.activeSessions.get(id);
1077
- if (existing) {
1208
+ if (existing !== undefined && existing.status.state !== "closed") {
1078
1209
  if (hasOwnContext(opts))
1079
1210
  existing.setContext(opts.context);
1080
- return existing;
1211
+ return Promise.resolve(existing);
1081
1212
  }
1082
- const handle = await (await this.repository()).open(id);
1083
- if (handle === undefined)
1084
- throw new SessionRepositoryNotFoundError(id);
1085
- const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1086
- // Reopening: orphaned background subagents (running from a dead process) → lost.
1087
- await session.reconcileSubagents();
1088
- return session;
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
+ });
1089
1233
  }
1090
1234
  /** Fork an existing session into a new one (copies its log + state) and open it. */
1091
1235
  async forkSession(sourceId, opts = {}) {
1236
+ this.assertAcceptingSessions();
1092
1237
  const handle = await (await this.repository()).fork(sourceId, {
1093
1238
  ...(opts.id !== undefined ? { id: opts.id } : {}),
1094
1239
  ...(opts.ownerKey !== undefined ? { ownerKey: opts.ownerKey } : {}),
1095
1240
  ...(opts.title !== undefined ? { title: opts.title } : {}),
1096
1241
  });
1097
- const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
1098
- // A fork copies the subagent ledger; its background "running" rows have no live task → lost.
1099
- await session.reconcileSubagents();
1100
- return session;
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
+ });
1101
1254
  }
1102
1255
  /** The currently-open session with this id, or undefined (does not reopen — use resumeSession). */
1103
1256
  getSession(id) {
@@ -1113,8 +1266,15 @@ export class Harness {
1113
1266
  async getSessionSummary(id) {
1114
1267
  return (await this.repository()).get(id);
1115
1268
  }
1116
- /** 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. */
1117
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
+ }
1118
1278
  await this.activeSessions.get(id)?.close();
1119
1279
  }
1120
1280
  /**
@@ -1138,12 +1298,27 @@ export class Harness {
1138
1298
  * drained, then its dispose hook — default the instance's close()), then the host's own
1139
1299
  * registrations, then the defaults.
1140
1300
  */
1141
- async close() {
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()]);
1142
1312
  for (const session of [...this.activeSessions.values()])
1143
1313
  await session.close();
1144
1314
  await this.sharedReady.catch(() => undefined);
1145
1315
  this.createdServices.length = 0;
1146
- 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");
1147
1322
  }
1148
1323
  /**
1149
1324
  * Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
@@ -1399,86 +1574,126 @@ export class Harness {
1399
1574
  // The workspace scope (one per key, shared) and under it the session scope: what the harness
1400
1575
  // decides for this session goes in here; Session.open provides the defaults for the rest,
1401
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.
1402
1581
  const workspace = await this.acquireWorkspace(workspaceKey, workDir);
1403
1582
  let scope;
1583
+ let projection;
1584
+ let core;
1404
1585
  try {
1405
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;
1406
1664
  }
1407
1665
  catch (error) {
1408
- await this.releaseWorkspace(workspaceKey);
1666
+ await this.rollbackOpen(id, workspaceKey, { projection, core, scope }, error);
1409
1667
  throw error;
1410
1668
  }
1411
- scope.register(T.SessionId, id);
1412
- scope.register(T.StoreBackend, store, { owned: false }); // the repository owns the store's lifetime
1413
- const events = new ListenerSink();
1414
- scope.register(T.Events, events);
1415
- const responder = new MutableResponder();
1416
- scope.register(T.Responder, responder);
1417
- // Machine: this call's override → the harness-level factory (resolved by Session.open) →
1418
- // a LocalMachine at the session's own workDir. The session only OPERATES a caller-supplied
1419
- // machine; the default one is its own.
1420
- if (opts.machine !== undefined) {
1421
- if (typeof opts.machine === "function")
1422
- scope.register(T.SessionMachineFactory, opts.machine);
1423
- else
1424
- scope.register(T.Machine, opts.machine, { owned: false });
1425
- }
1426
- else if (!workspace.has(T.WorkspaceMachineFactory) && !this.scope.has(T.MachineFactory)) {
1427
- scope.provide(T.Machine, () => new LocalMachine(workDir));
1428
- }
1429
- scope.register(T.PermissionOptions, opts.permission ?? this.options.permission ?? { mode: "yolo" });
1430
- if (opts.eventPublication !== undefined)
1431
- scope.register(T.SessionEventPublication, opts.eventPublication);
1432
- // ONE open-time log read, shared: the projection seeds from it, and Session.open
1433
- // receives it as `preloadedLog` so its capability restore + context pre-build fold the
1434
- // same records — same IO, and the same `Message` objects (no second parsed copy).
1435
- // Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
1436
- // race the subscription, and anything capabilities emit during open is already folded.
1437
- let preloadedLog = await readLog(store);
1438
- const projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
1439
- const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
1440
- if (!interrupted) {
1441
- const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
1442
- if (recoveryRecords.length > 0)
1443
- preloadedLog = [...preloadedLog, ...recoveryRecords];
1444
- }
1445
- const capabilities = await this.resolveCapabilities(scope, {
1446
- sessionId: id,
1447
- workDir,
1448
- ownMachine: opts.machine !== undefined,
1449
- ...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
1450
- }, params);
1451
- const core = await Session.open(scope, { capabilities, preloadedLog });
1452
- const agent = opts.agent ??
1453
- this.options.agent ??
1454
- (await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
1455
- const session = new HarnessSession({
1456
- core,
1457
- // The two caps land in different places: maxStepsPerTurn is an Agent property
1458
- // (the Runner reads the agent's value first), maxTurns is a per-run option.
1459
- agent,
1460
- runner: this.runner,
1461
- events,
1462
- responder,
1463
- projection,
1464
- workDir,
1465
- context: this.contextFor(opts),
1466
- ...(opts.maxTurns ?? this.options.maxTurns) !== undefined
1467
- ? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
1468
- : {},
1469
- interrupted,
1470
- onClosed: async (sid) => {
1471
- this.activeSessions.delete(sid);
1472
- await this.releaseWorkspace(workspaceKey);
1473
- },
1474
- });
1475
- this.activeSessions.set(id, session);
1476
- // Born gated: a session appearing while a reshape barrier holds (createSession,
1477
- // resumeSession and openSession all land here) must not start runs mid-swap. Its
1478
- // quiescent is trivially settled — it has no run — so it never delays the rendezvous.
1479
- if (this.activeBarrier !== undefined)
1480
- this.activeBarrier.holds.set(id, session.holdAtBoundary());
1481
- 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));
1482
1697
  }
1483
1698
  /**
1484
1699
  * The workspace scope for `key`, composed on first use — the host's `workspace` hook, then
@@ -1516,7 +1731,21 @@ export class Harness {
1516
1731
  if (entry.refs > 0)
1517
1732
  return;
1518
1733
  this.workspaces.delete(key);
1519
- 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
+ };
1520
1749
  }
1521
1750
  /**
1522
1751
  * A handle to a workspace-tier extension service in ONE workspace — how a host reaches a
package/dist/index.d.ts CHANGED
@@ -22,4 +22,5 @@ export * as mcp from "operon-agents-core/mcp";
22
22
  export * as protocol from "operon-agents-core/protocol";
23
23
  export * as tools from "operon-agents-core/tools";
24
24
  export * as tracing from "operon-agents-core/tracing";
25
+ export * as telemetry from "./telemetry.ts";
25
26
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -27,4 +27,5 @@ export * as mcp from "operon-agents-core/mcp";
27
27
  export * as protocol from "operon-agents-core/protocol";
28
28
  export * as tools from "operon-agents-core/tools";
29
29
  export * as tracing from "operon-agents-core/tracing";
30
+ export * as telemetry from "./telemetry.js";
30
31
  //# sourceMappingURL=index.js.map
@@ -6,4 +6,5 @@
6
6
  * No stability guarantees — prefer the main `operon-agents` surface.
7
7
  */
8
8
  export * from "operon-agents-core/internal";
9
+ export { setHarnessCloseTimeoutsForTest } from "./harness.ts";
9
10
  //# sourceMappingURL=internal.d.ts.map
package/dist/internal.js CHANGED
@@ -6,4 +6,5 @@
6
6
  * No stability guarantees — prefer the main `operon-agents` surface.
7
7
  */
8
8
  export * from "operon-agents-core/internal";
9
+ export { setHarnessCloseTimeoutsForTest } from "./harness.js";
9
10
  //# sourceMappingURL=internal.js.map
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. A machine FACTORY (one
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, {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * PostHogAppender — the framework's default telemetry sink (docs/telemetry.md §7).
3
+ *
4
+ * Two ways in, one path after:
5
+ *
6
+ * (a) `{ apiKey, host, getDistinctId }` — the appender builds a `posthog-node` client. For
7
+ * products without their own analytics plumbing. `getDistinctId` is REQUIRED here and is a
8
+ * callback, not a value: sign-in / sign-out change it mid-process. Returning `undefined`
9
+ * DROPS the event — the framework never substitutes an id of its own.
10
+ *
11
+ * (b) `{ client }` — the product injects anything with a `capture()`; identity, consent and
12
+ * buffering stay wherever the product already keeps them (operon desktop wraps its
13
+ * consent-gated main-process sink this way, so there is exactly one PostHog channel).
14
+ *
15
+ * Per event: redact → drop non-primitives (types forbid them; a cast can still sneak one in) →
16
+ * enrich with app/framework identity → capture. Batching, retry and backoff are the client's.
17
+ */
18
+ import { type TelemetryAppender, type TelemetryEvent, type TelemetryPrimitive } from "operon-agents-core/telemetry";
19
+ /** The minimum a sink has to look like. `posthog-node`'s `PostHog` satisfies it as-is. */
20
+ export interface PostHogClientLike {
21
+ capture(message: {
22
+ readonly distinctId?: string;
23
+ readonly event: string;
24
+ readonly properties: Record<string, TelemetryPrimitive>;
25
+ readonly timestamp?: Date;
26
+ }): void;
27
+ flush?(): Promise<void> | void;
28
+ shutdown?(timeoutMs?: number): Promise<void> | void;
29
+ }
30
+ export interface PostHogAppIdentity {
31
+ /** Product name stamped on every event as `app_name` (e.g. `operon`). */
32
+ readonly name: string;
33
+ /** Product version stamped as `app_version`. */
34
+ readonly version?: string;
35
+ }
36
+ interface PostHogAppenderBaseOptions {
37
+ readonly app: PostHogAppIdentity;
38
+ /** Who the event is about. Required in mode (a); optional in mode (b) where the client decides. */
39
+ readonly getDistinctId?: () => string | undefined;
40
+ /** Extra properties stamped on every event (a channel tag, say). Same red lines apply. */
41
+ readonly commonProperties?: Readonly<Record<string, TelemetryPrimitive>>;
42
+ /** Where dropped-event and client errors go. Default: `console.warn`, once per reason. */
43
+ readonly warn?: (message: string) => void;
44
+ }
45
+ /** Mode (a): the appender owns a `posthog-node` client. */
46
+ export interface PostHogAppenderClientOptions extends PostHogAppenderBaseOptions {
47
+ readonly apiKey: string;
48
+ /** PostHog ingestion host, e.g. `https://us.i.posthog.com`. */
49
+ readonly host: string;
50
+ readonly getDistinctId: () => string | undefined;
51
+ /** Client batching knobs; passed through. */
52
+ readonly flushAt?: number;
53
+ readonly flushIntervalMs?: number;
54
+ /** Test seam: build the client from resolved options instead of `new PostHog(...)`. */
55
+ readonly createClient?: (apiKey: string, options: {
56
+ host: string;
57
+ flushAt?: number;
58
+ flushInterval?: number;
59
+ }) => PostHogClientLike;
60
+ }
61
+ /** Mode (b): the product injects the sink. */
62
+ export interface PostHogAppenderInjectedOptions extends PostHogAppenderBaseOptions {
63
+ readonly client: PostHogClientLike;
64
+ }
65
+ export type PostHogAppenderOptions = PostHogAppenderClientOptions | PostHogAppenderInjectedOptions;
66
+ export declare class PostHogAppender implements TelemetryAppender {
67
+ private client;
68
+ private readonly ready;
69
+ private readonly getDistinctId;
70
+ private readonly requireDistinctId;
71
+ private readonly common;
72
+ private readonly warn;
73
+ private readonly warned;
74
+ /** Events that arrived before the lazy client resolved (mode (a) only). Bounded. */
75
+ private pending;
76
+ private closed;
77
+ private droppedCount;
78
+ constructor(options: PostHogAppenderOptions);
79
+ /** Events dropped for lack of a distinct id, a closed appender, or a failed client. */
80
+ get dropped(): number;
81
+ track(event: TelemetryEvent): void;
82
+ private send;
83
+ private warnOnce;
84
+ flush(): Promise<void>;
85
+ shutdown(): Promise<void>;
86
+ }
87
+ export {};
88
+ //# sourceMappingURL=telemetry-posthog.d.ts.map
@@ -0,0 +1,142 @@
1
+ /**
2
+ * PostHogAppender — the framework's default telemetry sink (docs/telemetry.md §7).
3
+ *
4
+ * Two ways in, one path after:
5
+ *
6
+ * (a) `{ apiKey, host, getDistinctId }` — the appender builds a `posthog-node` client. For
7
+ * products without their own analytics plumbing. `getDistinctId` is REQUIRED here and is a
8
+ * callback, not a value: sign-in / sign-out change it mid-process. Returning `undefined`
9
+ * DROPS the event — the framework never substitutes an id of its own.
10
+ *
11
+ * (b) `{ client }` — the product injects anything with a `capture()`; identity, consent and
12
+ * buffering stay wherever the product already keeps them (operon desktop wraps its
13
+ * consent-gated main-process sink this way, so there is exactly one PostHog channel).
14
+ *
15
+ * Per event: redact → drop non-primitives (types forbid them; a cast can still sneak one in) →
16
+ * enrich with app/framework identity → capture. Batching, retry and backoff are the client's.
17
+ */
18
+ import { createRequire } from "node:module";
19
+ import { redactTelemetryProperties } from "operon-agents-core/telemetry";
20
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
21
+ function frameworkVersion() {
22
+ try {
23
+ const require = createRequire(import.meta.url);
24
+ const pkg = require("../package.json");
25
+ return pkg.version ?? "unknown";
26
+ }
27
+ catch {
28
+ return "unknown";
29
+ }
30
+ }
31
+ function isInjected(options) {
32
+ return "client" in options;
33
+ }
34
+ async function createNodeClient(apiKey, options) {
35
+ // Loaded lazily so a product in mode (b) never pays for the module, and so the import lives
36
+ // in one place if the client ever changes.
37
+ const mod = await import("posthog-node");
38
+ return new mod.PostHog(apiKey, { host: options.host, flushAt: options.flushAt, flushInterval: options.flushInterval, disableGeoip: true });
39
+ }
40
+ export class PostHogAppender {
41
+ client;
42
+ ready;
43
+ getDistinctId;
44
+ requireDistinctId;
45
+ common;
46
+ warn;
47
+ warned = new Set();
48
+ /** Events that arrived before the lazy client resolved (mode (a) only). Bounded. */
49
+ pending = [];
50
+ closed = false;
51
+ droppedCount = 0;
52
+ constructor(options) {
53
+ this.getDistinctId = options.getDistinctId;
54
+ this.requireDistinctId = !isInjected(options);
55
+ this.common = Object.freeze({
56
+ app_name: options.app.name,
57
+ app_version: options.app.version ?? null,
58
+ framework_version: frameworkVersion(),
59
+ platform: process.platform,
60
+ ...(options.commonProperties ?? {}),
61
+ });
62
+ this.warn = options.warn ?? ((message) => console.warn(`[telemetry/posthog] ${message}`));
63
+ if (isInjected(options)) {
64
+ this.client = options.client;
65
+ this.ready = Promise.resolve();
66
+ }
67
+ else {
68
+ const create = options.createClient;
69
+ const resolved = { host: options.host, flushAt: options.flushAt, flushInterval: options.flushIntervalMs };
70
+ this.ready = (create === undefined ? createNodeClient(options.apiKey, resolved) : Promise.resolve(create(options.apiKey, resolved)))
71
+ .then((client) => {
72
+ this.client = client;
73
+ const queued = this.pending;
74
+ this.pending = [];
75
+ for (const event of queued)
76
+ this.send(event);
77
+ })
78
+ .catch((error) => {
79
+ this.warnOnce("client-init", `client failed to initialise; events will be dropped: ${String(error)}`);
80
+ this.pending = [];
81
+ });
82
+ }
83
+ }
84
+ /** Events dropped for lack of a distinct id, a closed appender, or a failed client. */
85
+ get dropped() {
86
+ return this.droppedCount;
87
+ }
88
+ track(event) {
89
+ if (this.closed) {
90
+ this.droppedCount += 1;
91
+ return;
92
+ }
93
+ if (this.client === undefined) {
94
+ if (this.pending.length >= 200) {
95
+ this.droppedCount += 1;
96
+ return;
97
+ }
98
+ this.pending.push(event);
99
+ return;
100
+ }
101
+ this.send(event);
102
+ }
103
+ send(event) {
104
+ const client = this.client;
105
+ if (client === undefined)
106
+ return;
107
+ const distinctId = this.getDistinctId?.();
108
+ if (this.requireDistinctId && distinctId === undefined) {
109
+ this.droppedCount += 1;
110
+ this.warnOnce("no-distinct-id", "getDistinctId() returned undefined; event dropped (the framework never mints an id)");
111
+ return;
112
+ }
113
+ const properties = { ...this.common, ...redactTelemetryProperties(event.properties) };
114
+ try {
115
+ client.capture({
116
+ ...(distinctId === undefined ? {} : { distinctId }),
117
+ event: event.name,
118
+ properties,
119
+ timestamp: new Date(event.timestamp),
120
+ });
121
+ }
122
+ catch (error) {
123
+ this.warnOnce("capture", `client.capture threw: ${String(error)}`);
124
+ }
125
+ }
126
+ warnOnce(key, message) {
127
+ if (this.warned.has(key))
128
+ return;
129
+ this.warned.add(key);
130
+ this.warn(message);
131
+ }
132
+ async flush() {
133
+ await this.ready;
134
+ await Promise.resolve(this.client?.flush?.());
135
+ }
136
+ async shutdown() {
137
+ await this.ready;
138
+ this.closed = true;
139
+ await Promise.resolve(this.client?.shutdown?.(SHUTDOWN_TIMEOUT_MS));
140
+ }
141
+ }
142
+ //# sourceMappingURL=telemetry-posthog.js.map
@@ -0,0 +1,4 @@
1
+ export * from "operon-agents-core/telemetry";
2
+ export { PostHogAppender } from "./telemetry-posthog.ts";
3
+ export type { PostHogAppenderOptions, PostHogAppenderClientOptions, PostHogAppenderInjectedOptions, PostHogClientLike, PostHogAppIdentity } from "./telemetry-posthog.ts";
4
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1,3 @@
1
+ export * from "operon-agents-core/telemetry";
2
+ export { PostHogAppender } from "./telemetry-posthog.js";
3
+ //# sourceMappingURL=telemetry.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "operon-agents",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "Public SDK entrypoint for building agents.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -58,6 +58,10 @@
58
58
  "types": "./dist/tracing.d.ts",
59
59
  "import": "./dist/tracing.js"
60
60
  },
61
+ "./telemetry": {
62
+ "types": "./dist/telemetry.d.ts",
63
+ "import": "./dist/telemetry.js"
64
+ },
61
65
  "./package.json": "./package.json"
62
66
  },
63
67
  "files": [
@@ -66,8 +70,9 @@
66
70
  "README.md"
67
71
  ],
68
72
  "dependencies": {
73
+ "posthog-node": "^5.51.6",
69
74
  "zod": "^4.4.3",
70
- "operon-agents-core": "0.1.0-alpha.4"
75
+ "operon-agents-core": "0.1.0-alpha.6"
71
76
  },
72
77
  "devDependencies": {
73
78
  "@earendil-works/pi-ai": "0.84.1"