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 +37 -0
- package/dist/harness.d.ts +84 -11
- package/dist/harness.js +363 -134
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/local.js +4 -2
- package/dist/telemetry-posthog.d.ts +88 -0
- package/dist/telemetry-posthog.js +142 -0
- package/dist/telemetry.d.ts +4 -0
- package/dist/telemetry.js +3 -0
- package/package.json +7 -2
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
|
-
/**
|
|
405
|
-
*
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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. */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
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.
|
|
1666
|
+
await this.rollbackOpen(id, workspaceKey, { projection, core, scope }, error);
|
|
1409
1667
|
throw error;
|
|
1410
1668
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
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
|
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, {
|
|
@@ -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
|
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": {
|
|
@@ -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.
|
|
75
|
+
"operon-agents-core": "0.1.0-alpha.6"
|
|
71
76
|
},
|
|
72
77
|
"devDependencies": {
|
|
73
78
|
"@earendil-works/pi-ai": "0.84.1"
|