feat(execution): add execution observer foundation - #1097
Conversation
ff3ac2f to
a74821d
Compare
Greptile SummaryThis PR introduces the execution observer foundation: a typed lifecycle event stream (
Confidence Score: 5/5The change is additive and opt-out by default; executions with no registered observer pay only a no-op context lookup, and the error isolation design prevents any observer from breaking a live execution. The interrupt-propagation and failure-isolation logic is correct and well-tested. The daemon fiber correctly inherits the observer context through forkDetach. Both execution paths emit a complete and symmetric event sequence. The two findings are narrow edge cases under concurrent interruption that do not affect the common path. No files require special attention for merge safety. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Caller
participant Engine
participant DaemonFiber
participant Observer
Caller->>Engine: executeWithPause(code, options)
Engine->>Observer: ExecutionStarted
Engine->>DaemonFiber: forkDetach (inherits Observer context)
DaemonFiber->>Observer: ToolCallStarted
DaemonFiber->>Observer: ToolCallFinished
DaemonFiber->>Observer: InteractionStarted
DaemonFiber-->>Engine: paused (Deferred)
Engine-->>Caller: PausedExecution
Caller->>Engine: resume(executionId, response)
Engine->>DaemonFiber: Deferred.succeed(response)
DaemonFiber->>Observer: InteractionResolved
DaemonFiber->>Observer: ExecutionFinished
Engine-->>Caller: ExecutionResult
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Caller
participant Engine
participant DaemonFiber
participant Observer
Caller->>Engine: executeWithPause(code, options)
Engine->>Observer: ExecutionStarted
Engine->>DaemonFiber: forkDetach (inherits Observer context)
DaemonFiber->>Observer: ToolCallStarted
DaemonFiber->>Observer: ToolCallFinished
DaemonFiber->>Observer: InteractionStarted
DaemonFiber-->>Engine: paused (Deferred)
Engine-->>Caller: PausedExecution
Caller->>Engine: resume(executionId, response)
Engine->>DaemonFiber: Deferred.succeed(response)
DaemonFiber->>Observer: InteractionResolved
DaemonFiber->>Observer: ExecutionFinished
Engine-->>Caller: ExecutionResult
Reviews (5): Last reviewed commit: "refactor(execution): scope observer disp..." | Re-trigger Greptile |
a5352a8 to
df4389d
Compare
## Summary - Mirror the upstream execution observer foundation and hardening from UsefulSoftwareCo#1097. - Keep the dev branch aligned with the scoped observer API: `withExecutionObserver`, `emitExecutionEvent`, and composed plugin observers. - Preserve fork-only execution actor fields while matching upstream observer failure handling and deterministic dispatch behavior. - Update dev-only execution observer plugins to use exhaustive Effect `Match` dispatch for `ExecutionEvent` handling. ## Type Safety Note Plugin observers handle `ExecutionEvent` as an exhaustive Effect tagged-union match rather than a raw `switch (event._tag)` or predicate chain. `execution-history` now uses `Match.exhaustive` for the full lifecycle stream, so a future event variant becomes a compile-time update point. The metrics observers also use exhaustive matching and explicitly ignore interaction events with no-op cases. ```ts import { Effect, Match } from "effect"; import { type ExecutionEvent } from "@executor-js/sdk"; const handleExecutionEvent = (history: ExecutionHistoryExtension) => Match.type<ExecutionEvent>().pipe( Match.withReturnType<Effect.Effect<void, unknown>>(), Match.tag("ExecutionStarted", (event) => history.store.createRun(event)), Match.tag("ToolCallStarted", (event) => history.store.createToolCall(event)), Match.tag("ToolCallFinished", (event) => history.store.finishToolCall(event)), Match.tag("InteractionStarted", (event) => history.store.createInteraction(event)), Match.tag("InteractionResolved", (event) => history.store.resolveInteraction(event)), Match.tag("ExecutionFinished", (event) => history.store.finishRun(event)), Match.exhaustive, ); ``` ## Validation - `bun run --cwd packages/core/sdk test -- execution-observer.test.ts` - `bun run --cwd packages/core/execution test -- engine-observer.test.ts` - `bun run --cwd packages/core/sdk typecheck` - `bun run --cwd packages/core/execution typecheck` - `bun run --cwd packages/plugins/execution-history test` - `bun run --cwd packages/plugins/execution-metrics test` - `bun run --cwd packages/plugins/execution-history typecheck` - `bun run --cwd packages/plugins/execution-metrics typecheck` - touched-file `oxfmt --check` - touched-file `oxlint -c .oxlintrc.jsonc --deny-warnings` - `git diff --check`
3fb7bab to
9f93734
Compare
2a809f1 to
e8406ff
Compare
e8406ff to
a3be7ac
Compare
|
Rebased onto current main. The conflicts were with #1919's pause/resume rework in |
…s to observers The observer contract lagged the engine: `ExecuteResult.output` (everything the code sent through `emit()`) never reached `ExecutionFinished`, so an emit-only run recorded no result; expected tool failures ride the success channel as `ToolResult.fail` envelopes since UsefulSoftwareCo#826, so every upstream 4xx was observed as a completed call; and the finish event was emitted from inside the execution fiber, so an interrupt (client abort, host backstop, sandbox shutdown) tore the run down before any observer learned it had ended. - `ExecutionFinished` gains `output` and the `interrupted` status; the new `ExecutionOutputItem` mirrors the sandbox shape structurally so the sdk stays free of the kernel package. - The engine emits the finish event from `Effect.onExit`, which runs as an uninterruptible finalizer; an interrupt-only cause maps to `interrupted`. - `ToolCallFinished` reports `failed` with `code: message` when the result is a failure envelope, keeping the envelope attached for inspection. Claude-Session: https://claude.ai/code/session_016tQXmEycQ2gmmW7LJt6Nhz
|
Head moved It extends the observer contract with Verified on the new head: |
Summary
Add an Effect-native execution lifecycle observer that plugins can use for durable history, metrics, indexing, or cache maintenance without coupling those products to the execution engine. The hook complements existing spans and OpenTelemetry rather than replacing them.
Contract
Contract update
The observer contract now carries what the engine already knew:
ExecutionFinished.output— everything the code sent throughemit(), inorder, so an emit-only run records a result instead of nothing. The new
ExecutionOutputItemmirrors the sandbox shape structurally, keeping the sdkfree of the kernel package.
ExecutionStatusgainsinterrupted, emitted from anEffect.onExitfinalizer on both execute paths. The finalizer runs uninterruptibly, so a run
torn down from outside (client abort, host backstop, sandbox shutdown) still
closes in every observer instead of dangling as "running"; an interrupt-only
cause maps to
interruptedrather thanfailed.ToolResultenvelopes are reported as failed tool calls. Expected toolfailures ride the success channel as
ToolResult.fail, soToolCallFinishednow reports
failedwithcode: messageand keeps the envelope attached forinspection, instead of observing every upstream 4xx as a completed call.
Validation
9f9373469.Execution history delivery map
Prerequisites:
Follow-up PR-sized diffs:
The fork also uses this observer for execution metrics, which remains a separate consumer. View the complete fork comparison.
https://claude.ai/code/session_016tQXmEycQ2gmmW7LJt6Nhz