fix(scope): harden lifecycle ownership foundation
Make Cordis construction and teardown ownership reentrancy-safe, then carry caller and provider ownership through reservation, setup, publication, quiescence, and sentinel retirement. Stabilize registry carriers and factory/workflow boundaries, add adversarial lifecycle regressions, and align the rewritten RFC plus generated contracts with the enforced behavior.
This commit is contained in:
@@ -105,7 +105,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
|
||||
|
||||
### Agent Handles
|
||||
|
||||
`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the API other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`.
|
||||
`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the API other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. The caller fiber and concrete factory provider structurally co-own programmatic lifecycles; a consumer handle is the only non-structural teardown capability, and every owner reaches the same awaited disposer.
|
||||
|
||||
### Agent Scope
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:44`](../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:119`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-bash-local`
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n
|
||||
|
||||
### `agent/created` — emit
|
||||
|
||||
An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary.
|
||||
An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
@@ -23,11 +23,11 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:307`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
An agent was removed from the registry after its driver and any in-flight turn reached quiescence. Ordered teardown may still be detaching the session and unwinding the agent's scoped registrations when this notification runs.
|
||||
An agent was removed from the registry. The concrete AgentLoop lifecycle emits this only after its driver and any in-flight turn reach quiescence; a custom agent registered through the public registry owns its own driver contract, which the registry cannot infer. Ordered teardown may still be detaching the session and unwinding scoped registrations when this runs.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
@@ -35,7 +35,7 @@ An agent was removed from the registry after its driver and any in-flight turn r
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
@@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:590`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:596`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/pre-step` — serial
|
||||
|
||||
@@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:422`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
@@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:446`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/queued` — emit
|
||||
|
||||
@@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
@@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:469`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:475`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-prefix` — waterfall
|
||||
|
||||
@@ -113,19 +113,19 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:521`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:527`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup).
|
||||
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): a listener cannot veto by returning a decision or throwing. A listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees). A lifecycle owner can still dispose its structural ownership edge during this notification; publication rechecks liveness and then aborts before the driver starts.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:371`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
@@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step-result` — waterfall
|
||||
|
||||
@@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:536`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:542`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
@@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:554`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:560`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stop` — serial
|
||||
|
||||
@@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:573`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:579`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `approval/*`
|
||||
|
||||
@@ -245,13 +245,13 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts
|
||||
|
||||
### `session/created` — emit
|
||||
|
||||
A session was created in the store. A synchronous listener throw vetoes publication and rollback emits the matching `session/disposed` edge; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
A session was created in the store. A synchronous listener throw vetoes publication and rollback emits the matching `session/disposed` edge; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced detach does not remove the entry immediately: removal and the paired `session/disposed` edge wait until the creation dispatch unwinds. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:50`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/disposed` — emit
|
||||
|
||||
@@ -261,7 +261,7 @@ A previously announced session left the store. Emitted exactly once on normal de
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:62`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
@@ -273,7 +273,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per-
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:76`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
@@ -283,7 +283,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:94`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `skill/*`
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ The loop itself is deliberately thin — every behavior beyond "call the model,
|
||||
|
||||
```ts cordis-catalog
|
||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
|
||||
async createAgent(options: CreateAgentOptions): Promise<AgentHandle>
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:78`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:153`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
@@ -39,9 +39,9 @@ get(id: AgentId): Agent | undefined
|
||||
list(): Agent[]
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentRegistrationReservation](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/index.ts:202`](../../packages/core/agent/src/index.ts)
|
||||
Source: [`packages/core/agent/src/index.ts:250`](../../packages/core/agent/src/index.ts)
|
||||
|
||||
## `ctx.approval` — `ApprovalService`
|
||||
|
||||
@@ -222,7 +222,9 @@ list(): Session[]
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:663`](../../packages/core/session/src/index.ts)
|
||||
Types: [SessionRegistrationReservation](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:667`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ The mode is part of the event's public contract. New harness events document it
|
||||
|
||||
`ctx.waterfall` is around-middleware. A listener receives `(...args, next)`. Call `next()` to delegate the possibly wrapped result to the next service; return without `next()` to short-circuit. Values propagate through `next()`'s return value.
|
||||
|
||||
Cooperative listeners usually mutate a shared request or decision object and then delegate. A listener can also choose to repalce the result entirely and downstream listeners will only see the result after replacement. Use `prepend: true` only when the listener must run before ordinary registrations.
|
||||
Cooperative listeners usually mutate a shared request or decision object and then delegate. A listener can also choose to replace the result entirely and downstream listeners will only see the result after replacement. Use `prepend: true` only when the listener must run before ordinary registrations.
|
||||
|
||||
For single-decision events, short-circuiting is the design. A policy listener can return without `next()` when it owns the decision, while a listener that only annotates or observes must delegate.
|
||||
|
||||
|
||||
@@ -346,6 +346,19 @@ interface Agent {
|
||||
|
||||
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
|
||||
|
||||
### `AgentRegistrationReservation` — unpublished identity ownership
|
||||
|
||||
An agent factory reserves its public `AgentId` before awaiting setup, so setup code cannot publish either the intended agent or a replacement under that id ahead of the transaction. The opaque capability authorizes exactly the later `enter()` call. Its `release` function is the exact Cordis owner effect disposer, letting the lifecycle adopt it by identity and place release after scope quiescence while owner disposal remains the abandoned-transaction backstop. Ordinary plugins use `register()` and never hold this type.
|
||||
|
||||
Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts)
|
||||
|
||||
```ts type-equiv
|
||||
interface AgentRegistrationReservation {
|
||||
readonly id: AgentId
|
||||
release(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Interception decisions
|
||||
|
||||
Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. They share one envelope for model-facing context, `HookContext`, which is `inject()`ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt).
|
||||
|
||||
@@ -196,6 +196,20 @@ export interface SurfaceNode {
|
||||
}
|
||||
```
|
||||
|
||||
## `SessionRegistrationReservation` — unpublished identity and construction ownership
|
||||
|
||||
A session factory reserves its public `SessionId` before awaiting persistence load or scoped setup, so concurrent code cannot create, prepare, or enter a session under that id ahead of the transaction. The opaque capability may construct exactly one unpublished `Session` and authorizes exactly that object at `enter()`. Its `release` function is the exact Cordis owner effect disposer, letting the lifecycle adopt it by identity and place release after scope quiescence while owner disposal remains the abandoned-transaction backstop. Ordinary session consumers use `create()` and never hold this type.
|
||||
|
||||
Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts)
|
||||
|
||||
```ts type-equiv
|
||||
interface SessionRegistrationReservation {
|
||||
readonly id: SessionId
|
||||
prepare(options?: CreateSessionOptions): Session
|
||||
release(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Derived history: `deriveMessages()` and `deriveEventMessage()`
|
||||
|
||||
`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules:
|
||||
|
||||
@@ -7,28 +7,28 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:590`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:422`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:469`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:521`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:536`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:554`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:573`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:307`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:596`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:428`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:446`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:527`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:371`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:542`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:560`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:579`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
|
||||
@@ -16,7 +16,7 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit
|
||||
|
||||
### 2. `AgentHandle` async disposer
|
||||
|
||||
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear down exactly this agent: stop its loop, `await` the loop's exit (true quiescence, not just the `disposed` status flip), unregister it, and remove its session from the store. `ctx.agents.get(id)` still returns a bare `Agent`. Config-created agents stay owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
|
||||
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, `await` its exit (true quiescence, not just the `disposed` status flip), unregister it, remove its session from the store, unwind its scope, and only then release both public IDs. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
|
||||
|
||||
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race detaching the session store's private append observer against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
|
||||
|
||||
|
||||
@@ -27,18 +27,19 @@ The subagent API makes these requirements concrete. Two concurrent children can
|
||||
|
||||
Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned.
|
||||
|
||||
The design has four parts:
|
||||
The design has five parts:
|
||||
|
||||
| Part | Rule | Purpose |
|
||||
|---|---|---|
|
||||
| Registration scope | A registration through a plain plugin context is global; the same registration through `agent.ctx` belongs to that agent | Reuse existing APIs for per-agent tools, prompt state, and listeners |
|
||||
| Lifecycle transaction | Create and resume await scoped setup while the agent and session are unpublished, then publish them in an ordered rollback-covered sequence | No observer sees a partially composed agent, and every failure path owns cleanup |
|
||||
| Lifecycle transaction | Caller and factory ownership cover create and resume from reservation or load through scoped setup, ordered publication, and teardown | No observer sees a partially composed agent, and caller or provider loss cannot orphan work |
|
||||
| Lifecycle foundation | Effects become owner-visible before setup, child fibers become parent-owned before publication, and unloading fibers reject late effects | Reentrant HMR cannot strand a half-built or cleanup-time registration outside the unload snapshot |
|
||||
| Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order |
|
||||
| Boundary ownership | Services capture fixed fields once, materialize lossless-JSON data once, and publish owner-controlled views | Validation, execution, persistence, and telemetry cannot observe different values from one call |
|
||||
|
||||
Three domain terms recur below. A **Session** is one agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** means JSON primitives plus dense arrays and plain objects that can be copied without changing meaning; the boundary rejects sparse arrays, cycles, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols instead of coercing or erasing them. **Code Mode** presents the model with a generated software-development-kit interface and a reserved `run_code` transport, rather than advertising every end-capability as a native tool.
|
||||
|
||||
Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the agent factory owns identity reservation, setup, and publication; the session owns accepted history; the tool and subagent services own their pipeline records; and the workflow host owns cancellation of the runs it started. A caller never validates a value that another component later rereads from the caller's mutable object.
|
||||
Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the caller owns the programmatic agent lifetime it requested; the concrete agent factory owns identity reservation, setup, publication, and structural invalidation of agents that still depend on it; the session owns accepted history; the tool and subagent services own their pipeline records; and each workflow run captures its holder-bound dependencies and owns its cancellation after the engine returns it. A caller never validates a value that another component later rereads from the caller's mutable object.
|
||||
|
||||
The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority.
|
||||
|
||||
@@ -54,10 +55,14 @@ A Cordis `Context` is the object through which a plugin reaches services such as
|
||||
|
||||
A context also carries a capability view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context.
|
||||
|
||||
Factory delegation uses two contexts whose jobs must remain separate. The registry derives a caller-bound context carrying the fiber and scope from which `ctx.agents.create()` or `resume()` was called and passes it explicitly as `ownerCtx`; those facts identify the fiber and optional parent agent that own the requested lifetime. When the registered factory is itself a Cordis service, the registry also invokes it through a traced receiver, which preserves the factory's own injected dependency origin. A plain object that merely implements the factory methods receives the same explicit `ownerCtx` without depending on Cordis tracing. Conflating these roles would either attach the agent to the factory registrant instead of the caller or make the concrete loop resolve dependencies from the wrong service view.
|
||||
|
||||
### Effects give registrations an owner
|
||||
|
||||
A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload.
|
||||
|
||||
Ownership must exist before effect setup can call arbitrary code. The vendored Fiber implementation therefore places an effect's cleanup wrapper in the owner list before running its setup body; a reentrant unload sees that in-construction effect and waits for setup plus every cleanup it collected. A child fiber likewise receives its parent-owned disposer before `internal/plugin` announces the child. Teardown delivers that notification with per-observer failure containment so one callback cannot starve peers or interrupt cleanup. Effects remain legal while a fiber is pending or loading, because setup needs them, but a fiber already unloading rejects new effects: its cleanup snapshot has been taken, so accepting another registration would strand it in the old epoch.
|
||||
|
||||
`dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context.
|
||||
|
||||
### A waterfall is ordered around-middleware
|
||||
@@ -125,7 +130,7 @@ The property is deliberately not treated as the authoritative scope tag. A neste
|
||||
| `Scope.dispose()` | Give ordinary callers an idempotent promise shared by repeat and racing calls until quiescence |
|
||||
| `Scope.rawDispose` | Expose the exact Cordis disposer so a larger generator lifecycle can nest it at a precise teardown position |
|
||||
|
||||
The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope.
|
||||
The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope. Pre-registration of an effect wrapper solves a different race: it makes the first owner unload see construction in progress without changing this single-shot raw-disposer contract.
|
||||
|
||||
The primitive itself is small. Its essential implementation shape is:
|
||||
|
||||
@@ -245,72 +250,92 @@ The real helpers fuse values that must agree. `agentEvents(context, agent)` uses
|
||||
|
||||
Function-style listeners receive the carrier as `this`, and agent event APIs allow them to call subject methods. The carrier is therefore a JavaScript proxy that reads and writes through to the real subject and binds methods to it.
|
||||
|
||||
Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the Proxy-safe way for an extensible surrogate to expose a property it does not itself own. A filter property pinned on the subject before, during, or after construction cannot trigger the proxy invariant that would otherwise force delivery to use the subject's raw filter and silently drop scope isolation. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters.
|
||||
Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable.
|
||||
|
||||
The composed filter is an authorization boundary, not an ordinary exposed callback. It invokes a subject's pre-existing filter with stable references to the built-in `Reflect.apply` and `Function.prototype.call` operations, pins its own `.call` to that captured built-in, and freezes the callable. Code holding the subject or carrier therefore cannot replace either `.call` property to turn a scoped predicate into an always-allow predicate. Keeping the filter on the surrogate also means a filter property pinned on the subject before, during, or after carrier construction cannot trigger a Proxy invariant that silently replaces scope isolation with the subject's raw filter.
|
||||
|
||||
The surrogate must remain extensible so its reported own-key view can follow the subject. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the only Proxy-safe description of a property the extensible surrogate does not itself own. For the same reason, defining a property through the carrier is supported only when the descriptor explicitly says `configurable: true`; an omitted or false flag is rejected before the subject is touched. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters.
|
||||
|
||||
`Scoped<T>` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches.
|
||||
|
||||
## Agent creation and teardown
|
||||
|
||||
An agent's scope, session, registry entry, and driver form one owned transaction. Setup finishes before publication, publication is synchronous and rollback-covered rather than magically atomic, and teardown reaches one ordered quiescent boundary.
|
||||
An agent's scope, session, registry entry, and driver form one transaction with two ownership edges. The caller context owns the work it requested and receives the only consumer-facing teardown capability; the concrete `AgentLoop` provider is a structural co-owner because the live agent continues to use the provider's injected services. Either edge deactivates the transaction and converges on the same ordered, memoized quiescence boundary. Setup finishes before publication, and publication is synchronous and rollback-covered rather than magically atomic.
|
||||
|
||||
### Create and resume reserve identities before asynchronous work
|
||||
|
||||
Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup.
|
||||
|
||||
The registry treats the factory seam as an untrusted runtime boundary. A TypeScript interface checks source code but does not constrain the JavaScript object received at runtime, which may expose stateful getters. `setFactory()` therefore claims the single factory slot before reading method accessors, canonicalizes an already traced Cordis service to its concrete target, then captures that target plus the `createAgent` and `resume` callback identities once. A getter cannot reenter `setFactory()` and replace the outer factory while it is being accepted, later method replacement cannot redirect calls, and a service proxy cannot accumulate a second trace layer that breaks raw-identity state. On each call, the registry passes a caller-bound context carrying the accessing fiber and scope as `ownerCtx`, retraces the concrete service target exactly once through that context, and invokes the captured callback with both pieces. The explicit argument binds ownership; the traced receiver preserves the factory's dependency origin.
|
||||
|
||||
The factory first captures the requested IDs, setup callback, and caller-owned agent options. Seed events and session metadata take a stricter route than a preliminary clone: cloning can erase an exotic prototype before validation sees it, so the factory reads each reference once and hands it synchronously to the session store's reservation-bound prepare operation. That boundary rejects exotic shells, reads accepted metadata fields once, and recursively materializes each seed record in one pass. Resume applies the same rule to persistence output by capturing the loaded header fields once before reconstruction. The transaction therefore cannot move to different identities, storage routing, or lineage after an asynchronous boundary.
|
||||
|
||||
Before setup can observe the new objects, their ownership-bearing public properties become stable runtime data slots rather than TypeScript-only `readonly` promises. The concrete agent pins its ID, accepted options, and session; the factory binds its scope context exactly once. The session pins its ID and detached, deep-frozen header. Registry detach closures likewise close over their accepted map keys instead of rereading public properties during teardown. A JavaScript assignment or stateful accessor therefore cannot split registry lookup, dispatch, persistence, and the driver into different identities.
|
||||
|
||||
The session owns the accepted log as described in [the session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md). Seed and append paths materialize lossless JSON once, validate both the event envelope and the metadata that places message-producing events into derived model history, and deep-freeze the exact accepted event. `session.events` returns a frozen snapshot that never grows later. The store keeps append notification and scope-carrier state in store-owned private tables instead of caller-writable `Session` fields, so outside JavaScript cannot suppress or redirect `session/event` dispatch.
|
||||
|
||||
Reservations prevent two concurrent factory transactions from composing different unpublished objects under the same public identities. Each reservation belongs both to the factory transaction and to the Cordis fiber that requested it: explicit release covers every success or failure path, while owner-fiber disposal is the backstop for an abandoned handle during plugin unload or HMR. The agent registry and session store recognize their own reserved keys: setup code that calls public reserve, prepare, create, register, or bare enter APIs with the same IDs fails. The session capability can prepare exactly one object, and publication succeeds only when both stores receive the factory-held exact capabilities; the session store additionally checks that the capability owns that exact prepared session. This closes the otherwise possible path in which setup publishes a substitute object under an ID that the factory merely tracked in a separate pending set, without letting a vanished owner wedge the ID forever.
|
||||
Reservations prevent two concurrent factory transactions from composing different unpublished objects under the same public identities. Each capability's `release` is its exact Cordis effect disposer. Before asynchronous work, the owning sentinel adopts those functions by identity, removing them from the caller fiber's concurrent sibling list; teardown reaches them only after the transaction's driver, registry entries, session, and scope have quiesced. Explicit release covers pre-lifecycle failure and the ordered final step, while the owning fiber remains the backstop for an abandoned transaction. The concrete factory also tracks the whole create transaction before reservation and session preparation begin, and keeps that structural edge through reservation release. Provider unload first stops the factory from accepting work, then aborts or drains every tracked transaction before its dependency surface disappears.
|
||||
|
||||
Resume installs an owner-liveness sentinel before reserving IDs or starting persistence I/O, then races loading against owner disposal. If disposal wins, resume rejects and releases both reservations immediately; a backend promise that settles later cannot publish. After a successful load, the factory synchronously installs the full agent lifecycle before removing the sentinel, so ownership passes from load to setup without an unobserved disposal gap.
|
||||
The agent registry and session store recognize their own reserved keys: setup code that calls public reserve, prepare, create, register, or bare enter APIs with the same IDs fails. The session capability can prepare exactly one object, and publication succeeds only when both stores receive the factory-held exact capabilities; the session store additionally checks that the capability owns that exact prepared session. This closes the otherwise possible path in which setup publishes a substitute object under an ID that the factory merely tracked in a separate pending set, without letting a vanished owner wedge the ID forever.
|
||||
|
||||
The sentinel exists only for the interval in which no agent lifecycle can exist yet:
|
||||
Resume needs an ownership edge before an agent object exists. It reserves the identities, then installs a caller-liveness sentinel that adopts both exact reservation disposers before persistence I/O; a factory-tracked load transaction supplies the provider edge. If either owner wins, resume rejects, waits for the load transaction to settle, and only then releases both reservations; a backend promise that settles later cannot publish. After a successful load, `startOwned` synchronously returns both the complete lifecycle disposer and the asynchronous setup/publication result. Even a preparation failure is represented by a disposer-backed result, so the load sentinel can hand off to a real quiescence boundary instead of mistaking an async function's rejected promise for successful installation. The load tracker remains until the surrounding transaction settles, while the load and caller sentinels remain lifecycle-long followers, so no ownership or ID-release gap opens. Once the shared lifecycle quiesces, each sentinel first disarms its follower and then removes its owner-fiber effect; long-lived callers therefore do not retain completed agents, scopes, or reservation closures.
|
||||
|
||||
The load sentinel changes what it follows at handoff but remains an owner-visible boundary:
|
||||
|
||||
```text
|
||||
resume(request):
|
||||
resume(ownerCtx, request):
|
||||
snapshot request ids, options, and setup callback
|
||||
sentinel = owner.effect(onDispose => signal ownerDisposed)
|
||||
reservations = reserve agentId in AgentRegistry and sessionId in SessionStore
|
||||
sentinel = ownerCtx.effect(
|
||||
onDispose => abort and await load settlement before reservation release,
|
||||
adopt exact reservation disposers)
|
||||
loadTransaction = factory.track(onDispose => signal deactivated and await settlement)
|
||||
|
||||
try:
|
||||
persisted = await firstOf(persistence.load(sessionId), ownerDisposed)
|
||||
persisted = await firstOf(persistence.load(sessionId), deactivated)
|
||||
session = reservations.session.prepare(reconstruct persisted data)
|
||||
|
||||
# This call installs the full lifecycle before its first await.
|
||||
starting = startOwned(agentId, session, options, reservations, setup)
|
||||
disarm and dispose sentinel
|
||||
return await starting
|
||||
# This synchronous call returns a lifecycle boundary even when preparation fails.
|
||||
starting = startOwned(ownerCtx, agentId, session, options, reservations, setup)
|
||||
sentinel.follow(starting.dispose)
|
||||
return await starting.result
|
||||
finally:
|
||||
release both reservation capabilities
|
||||
settle the sentinel transaction
|
||||
release directly only if no lifecycle boundary was established
|
||||
settle and untrack the load transaction
|
||||
```
|
||||
|
||||
If `ownerDisposed` wins, the load promise may continue inside the backend, but it has no path back to publication.
|
||||
If deactivation wins, the load promise may continue inside the backend, but it has no path back to publication.
|
||||
|
||||
### Setup composes an unpublished world
|
||||
|
||||
The optional `setup(agentCtx)` callback receives the new agent context and may synchronously register contributions or await child-plugin activation. During setup, neither the session nor agent is visible through its global registry, but `agentCtx.agent` exposes the unpublished agent to the code composing it.
|
||||
|
||||
Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If the owner unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish.
|
||||
Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If either the caller owner or concrete factory unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish.
|
||||
|
||||
After setup settles, the factory yields one microtask checkpoint and rechecks the lifecycle flag, owner-fiber state, and owning agent's disposed state. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit owner checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent.
|
||||
Both structural edges exist before driver preparation or scope minting. The provider uses a tracked placeholder, while the caller gets a lifecycle-long sentinel that adopts the reservation effects and resolves to the same memoized lifecycle disposer. If `internal/plugin` reentrantly unloads either owner while the scope fiber is being constructed, Cordis has already attached the child disposer to its parent and the sentinel waits until preparation publishes either the complete lifecycle or a rollback disposer. A failure halfway through preparation therefore leaves both owners with a quiescence boundary for the prepared driver, minted scope, and reservations.
|
||||
|
||||
The factory checks liveness before invoking arbitrary setup. After setup settles, it yields one microtask checkpoint and checks the lifecycle flag, factory state, caller-fiber state, and the owner context's associated agent state again. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent.
|
||||
|
||||
Setup composes but does not drive. The concrete agent rejects `send`, `steer`, `inject`, and `cancel` until publication reaches the session-start boundary, keeps its inbox in a JavaScript native-private field, and allows only one concrete driver to claim a session. Driver startup is absent from the package surface: the package exports neither its loop/inbox internals nor source subpaths, and only instance-bound controls held by the factory can enable and start the driver. JavaScript or a type cast therefore cannot bypass the lock by calling a public `start()` or writing directly into the queue. These boundaries prevent a turn from opening before lifecycle listeners know the session exists.
|
||||
|
||||
The common create/resume tail makes the unpublished boundary explicit:
|
||||
|
||||
```text
|
||||
startOwned(snapshot, preparedSession):
|
||||
world = prepareLifecycle(snapshot, preparedSession)
|
||||
# world now owns agent.ctx and the complete rollback/teardown skeleton
|
||||
|
||||
startOwned(ownerCtx, snapshot, preparedSession):
|
||||
try:
|
||||
world = prepareLifecycle(ownerCtx, snapshot, preparedSession)
|
||||
# Factory placeholder, lifecycle-long caller sentinel, reservation adoption,
|
||||
# and complete rollback/teardown skeleton all exist before the first await.
|
||||
catch preparationError with rollbackBoundary:
|
||||
return { dispose: rollbackBoundary,
|
||||
result: await rollbackBoundary then reject original error }
|
||||
|
||||
result = async:
|
||||
require world.lifecycleActive
|
||||
await firstOf(snapshot.setup(world.agent.ctx), world.deactivated)
|
||||
await oneMicrotask()
|
||||
require world.lifecycleActive
|
||||
require world.factoryActive
|
||||
require world.ownerFiberActive
|
||||
require world.ownerAgentNotDisposed
|
||||
|
||||
@@ -319,60 +344,82 @@ startOwned(snapshot, preparedSession):
|
||||
catch error:
|
||||
await world.dispose()
|
||||
throw error
|
||||
|
||||
return { dispose: world.dispose, result }
|
||||
```
|
||||
|
||||
`setup` can await arbitrary plugin activation, but every exit still passes through the already-installed disposer.
|
||||
|
||||
### Publication is ordered and rollback-covered
|
||||
|
||||
After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps:
|
||||
After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps. Each registry has already claimed its ID across every caller-code boundary needed to construct a stable entry: the agent registry pins the accepted ID and captures one lifecycle carrier while its claim is held, and the session store holds the same kind of claim while evaluating its filter and carrier. A Proxy trap or filter getter can therefore neither overwrite a reentrant same-ID entry nor create a stale detach capability that later deletes another object. Liveness checkpoints then divide publication into three notification phases, and an outer publication barrier keeps teardown from revoking either registry entry or the scope while one of those phases is on the stack:
|
||||
|
||||
1. Enter the session store and capture its scope carrier.
|
||||
2. Enter the agent registry without announcing it.
|
||||
3. Emit `session/created`.
|
||||
4. Emit `agent/created`.
|
||||
5. Enable driving.
|
||||
6. Emit `agent/session-start`.
|
||||
7. Start the driver loop.
|
||||
3. Recheck caller and factory liveness; entering either registry may have evaluated a caller-owned getter that began teardown.
|
||||
4. Emit `session/created`.
|
||||
5. Recheck liveness; if teardown began, skip the agent announcement and roll back.
|
||||
6. Emit `agent/created`.
|
||||
7. Recheck liveness; if teardown began, keep driving locked and roll back.
|
||||
8. Enable driving.
|
||||
9. Emit `agent/session-start`.
|
||||
10. Recheck liveness; if teardown began, roll back without starting the driver.
|
||||
11. Start the driver loop.
|
||||
|
||||
The implementation keeps publication synchronous and leaves rollback to the surrounding owned transaction:
|
||||
|
||||
```text
|
||||
publish(world):
|
||||
world.detachSession = world.agent.ctx.sessions.enter(world.session, world.sessionReservation)
|
||||
world.detachAgent = app.agents.enter(world.agent, world.agentReservation)
|
||||
app.sessions.announce(world.session)
|
||||
app.agents.announce(world.agent)
|
||||
world.driver.enableDrivingVerbs()
|
||||
emitNonVetoing(agent/session-start)
|
||||
world.stopDriver = world.driver.start()
|
||||
world.beginSynchronousPublication()
|
||||
try:
|
||||
world.detachSession = world.agent.ctx.sessions.enter(world.session, world.sessionReservation)
|
||||
world.detachAgent = app.agents.enter(world.agent, world.agentReservation)
|
||||
require world.callerAndFactoryActive
|
||||
app.sessions.announce(world.session)
|
||||
require world.callerAndFactoryActive
|
||||
app.agents.announce(world.agent)
|
||||
require world.callerAndFactoryActive
|
||||
world.driver.enableDrivingVerbs()
|
||||
emitNonVetoing(agent/session-start)
|
||||
require world.callerAndFactoryActive
|
||||
world.driver.start()
|
||||
finally:
|
||||
world.endSynchronousPublication()
|
||||
```
|
||||
|
||||
Both registry entries exist before the first creation listener runs, and setup-installed listeners receive both announcements. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work.
|
||||
Both registry entries exist before the first creation listener runs, and setup-installed listeners receive every announcement that publication reaches. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work. A synchronous teardown request from any notification marks the lifecycle inactive immediately, which makes the next checkpoint abort, but actual loop, registry, session, and scope cleanup waits until the current synchronous notification phase and publication call stack unwind. Teardown itself therefore cannot make a later listener that still runs observe a different world; teardown from `session/created` prevents `agent/created`, teardown from `agent/created` prevents session start, and teardown from `agent/session-start` prevents the driver from starting.
|
||||
|
||||
The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws synchronously, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. Each store therefore marks its announcement as begun before invoking creation listeners and rejects a repeat or reentrant announcement before dispatch. Rollback emits `session/disposed` or `agent/disposed` exactly once for every corresponding creation announcement that began, including a partial emit in which an early listener observed creation before a later listener threw. An object entered but never announced has no disposal notification because no observer was told it existed.
|
||||
|
||||
Each registry also protects ordering inside its own creation phase. If a listener uses an advanced detach capability while `session/created` or `agent/created` is dispatching, removal and the paired disposal edge are deferred until that dispatch unwinds. The agent's creation and disposal edges reuse the carrier captured before commit instead of rebuilding it from a mutable filter getter. A detach request therefore cannot make a later listener observe `created` after `disposed`, find the just-created entry missing, or trigger disposal while creation is still constructing its receiver. Exact-object guards on both detach paths are the final defense against a stale capability deleting a later same-ID entry. The factory's outer publication barrier is the cross-registry complement: caller or provider teardown cannot remove the other entry or unwind `agent.ctx` while the current phase is still running.
|
||||
|
||||
Creation notification preserves that synchronous veto while also defending against JavaScript's asynchronous callback shape. A listener may return a promise even though the event type returns `void`; the dispatcher does not await it because publication has no asynchronous gap, but it observes and logs a later rejection. Such a rejection is too late to roll back, does not become unhandled, and does not starve the listeners invoked after that callback.
|
||||
|
||||
The disposal notifications and `agent/session-start` are deliberately non-vetoing. Their dispatchers invoke every listener synchronously and independently; they log and contain both a synchronous throw and a rejection from a returned promise. Returned promises are observed for failure but not awaited, so an asynchronous notification listener cannot delay rollback or teardown, veto driver startup, or starve a later listener.
|
||||
The disposal notifications and `agent/session-start` do not treat return values or listener failures as vetoes. Their dispatchers invoke every listener synchronously and independently; they log and contain both a synchronous throw and a rejection from a returned promise. Completion or rejection of a returned promise is observed but not awaited, so it cannot delay rollback or teardown, veto driver startup, or starve a later listener. The callback's synchronous prefix remains ordinary code: if it holds and disposes a structural ownership edge, the next publication liveness check deliberately aborts startup.
|
||||
|
||||
### Teardown stops work before revoking its world
|
||||
|
||||
Every owner path uses the same reverse order: stop the loop and await its actual exit plus every agent-started durability checkpoint, remove the agent from the registry, detach the session, then unwind the scope. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live.
|
||||
Every owner path reaches the same memoized reverse order: the consumer handle, caller-fiber disposal, and structural factory-provider unload first deactivate the lifecycle; wait for an in-progress synchronous publication phase; stop the loop and await its actual exit plus every agent-started durability checkpoint; remove the agent from the registry; detach the session; unwind the scope; and only then release both IDs. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live, and a replacement cannot reuse either identity while old scoped cleanup remains in flight.
|
||||
|
||||
```text
|
||||
disposeOwnedAgent(world):
|
||||
mark world inactive
|
||||
await world.synchronousPublicationIfRunning()
|
||||
await world.stopDriver() # waits for loop exit and all agent-started flushes
|
||||
world.detachAgent() # leaves registry; emits agent/disposed if announced
|
||||
world.detachSession() # stops event feed, leaves store; emits session/disposed if announced
|
||||
await world.scope.dispose()
|
||||
world.releaseSessionReservation()
|
||||
world.releaseAgentReservation()
|
||||
```
|
||||
|
||||
The actual Cordis generator yields these disposers in reverse so its last-in-first-out teardown executes in the order shown.
|
||||
|
||||
`agent/disposed` means the driver is quiescent and the agent has left the registry; the session is still live during that notification. `session/disposed` follows after append notification has been detached and the session has left its store. The scope is still live when each disposal listener is selected and invoked, although returned asynchronous work is observed rather than awaited. Both notifications use the same scope key and delivery rule as their creation partners and occur exactly once only when those creation announcements began.
|
||||
For the concrete AgentLoop transaction, `agent/disposed` runs after the driver is quiescent and the agent has left the registry; the session is still live during that notification. The public AgentRegistry alone promises only exact removal, because a custom registered `Agent` owns any stronger driver contract itself. `session/disposed` follows after append notification has been detached and the session has left its store. The scope is still live when each disposal listener is selected and invoked, although returned asynchronous work is observed rather than awaited. Both notifications use the stable scope key and delivery rule captured for their creation partners and occur exactly once only when those creation announcements began.
|
||||
|
||||
`AgentHandle.dispose()` is memoized so concurrent owners await the same full transaction, and `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races.
|
||||
`AgentHandle.dispose()` is memoized so repeated consumer calls await the same full transaction. The lifecycle-long caller sentinel independently follows that memoized promise, so handle-first teardown cannot make a racing caller-fiber unload observe Cordis's inert second raw-disposer call and return early. Once the transaction reaches its final quiescent stage, retirement disarms and removes the sentinel before settling that shared promise. `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races. The provider's ownership ledger is internal rather than another public handle: it stops accepting new transactions, invokes every tracked disposer independently, and waits for all of them before the AgentLoop service surface disappears.
|
||||
|
||||
Provider co-ownership is specific to resources that remain structurally dependent on their provider. An AgentLoop-created agent continues to resolve the loop's injected services, so loop unload must stop it. A worker workflow run instead captures its holder-bound `SubagentService` handle synchronously at `start()` and stores that independent dependency on the run; unloading `WorkerWorkflowEngine` removes the ability to start new runs but does not revoke an already returned run or prevent its later worker message from starting a child. The two lifetimes differ by dependency shape, not by a blanket rule that every service must own every value it creates.
|
||||
|
||||
Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities.
|
||||
|
||||
@@ -564,7 +611,7 @@ Provider registration first freezes an acceptance snapshot of the provider name,
|
||||
|
||||
Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent.
|
||||
|
||||
The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view.
|
||||
The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view.
|
||||
|
||||
The provider's run separates acceptance from publication with `started: Promise<void>`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle.
|
||||
|
||||
@@ -624,7 +671,9 @@ Before publishing the workflow's own result:
|
||||
|
||||
Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects.
|
||||
|
||||
Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber, so the agent factory's liveness check fails, `started` rejects, and neither the child session nor agent can publish. The run's result still settles as `aborted`. Before the workflow's own result becomes observable, its host likewise drives both permitted cancellation channels: it aborts the shared request signal and calls each registered run's `cancel()`, including runs still waiting on readiness. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result.
|
||||
Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber. If cancellation lands before publication, the factory's liveness check prevents either creation edge. If it begins synchronously inside `session/created`, `agent/created`, or `agent/session-start`, the publication barrier lets the current notification phase unwind without revoking its world, the next liveness check prevents every later phase and driver start, and rollback pairs every creation edge that already began. In either case `started` rejects, no `subagent/start` or `subagent/end` is emitted, and the run result settles as `aborted`.
|
||||
|
||||
Before the workflow's own result becomes observable, its host likewise drives both permitted cancellation channels: it aborts the shared request signal and calls each registered run's `cancel()`, including runs still waiting on readiness. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result.
|
||||
|
||||
Together these rules prevent an early result rejection from going unhandled, ensure `workflow/agent-start` never names an unpublished child, and prevent a child from publishing after its workflow has ended.
|
||||
|
||||
@@ -758,9 +807,9 @@ The owner-final APIs express the actual strength required by each rule: restore
|
||||
|
||||
Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Persona, tool filtering, and concurrent structured schemas would still require global mutation.
|
||||
|
||||
### Add scope semantics to vendored Cordis
|
||||
### Put agent-scope policy inside vendored Cordis
|
||||
|
||||
Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering. The harness-level primitive combines those mechanisms without adding a framework fork whose synchronization cost would outlive this feature.
|
||||
Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering, so the harness-level primitive composes those mechanisms instead of teaching the framework about agents, tools, prompts, or global-plus-scope resolution. The implementation does harden Cordis's domain-neutral lifecycle substrate: effects are owner-visible before setup callbacks, child fibers are parent-owned before publication, and an unloading fiber rejects registrations that missed its cleanup snapshot. Those rules are required by every plugin under reentrant HMR, not scope-specific policy pushed into the framework.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -772,8 +821,8 @@ The main benefit is one composition model across data, behavior, and lifetime: r
|
||||
|
||||
- Plugin authors use the same registration APIs globally and per agent; only the context changes.
|
||||
- Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view.
|
||||
- Create and resume expose no partially configured registry entry during awaited setup.
|
||||
- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled.
|
||||
- Create and resume expose no partially configured registry entry during awaited setup, and overlapping caller/factory ownership leaves no gap between resume load, preparation failure, and the live lifecycle.
|
||||
- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled, and retains both public IDs until scope cleanup is quiescent.
|
||||
- Structured output composes per child without global mutation or listener-order assumptions.
|
||||
- Existing unscoped plugins remain deployment-wide contributors and observers.
|
||||
|
||||
@@ -784,13 +833,14 @@ The costs are concentrated in dispatch discipline, per-scope registry state, and
|
||||
- Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners.
|
||||
- `agent.ctx` is capability-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate service surface.
|
||||
- Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime.
|
||||
- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject.
|
||||
- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject. Its composed filter is frozen, and defining a property through the carrier requires an explicitly configurable descriptor because the extensible surrogate cannot truthfully expose a new non-configurable subject property.
|
||||
- Flat scopes do not inherit parent capabilities; a desired child capability must be global or explicitly registered for the child.
|
||||
- `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt.
|
||||
- Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing.
|
||||
- Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy.
|
||||
- Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The direct no-setup `ctx.agentLoop.create()` path, used by configuration and programmatic callers that already have complete options, remains synchronous.
|
||||
- Ordered composition requires both an exact raw scope disposer and a shared public quiescence promise; the dual surface reflects two distinct Cordis lifecycle requirements.
|
||||
- A programmatic agent is caller-owned but also structurally owned by its concrete AgentLoop provider. Reloading that provider tears the agent down even if a consumer still holds its handle, because the handle cannot keep the provider's dependency surface valid.
|
||||
- Ordered composition requires exact raw effect identities plus shared public quiescence promises; the dual surfaces and lifecycle-long owner sentinels reflect distinct Cordis nesting and repeated-caller requirements.
|
||||
|
||||
### Deliberate boundaries
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
|
||||
methods: [
|
||||
'create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
|
||||
'async createAgent(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
|
||||
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -251,7 +251,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'agent/disposed',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
|
||||
summary: 'An agent was removed from the registry after its driver and any in-flight turn reached quiescence.',
|
||||
summary: 'An agent was removed from the registry.',
|
||||
},
|
||||
{
|
||||
name: 'agent/error',
|
||||
@@ -497,7 +497,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AgentFactory',
|
||||
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise<AgentHandle>;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
|
||||
declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentHandle',
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { Context, CordisError, FiberState, type Fiber } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Direct regressions for the vendored Cordis ownership substrate used by
|
||||
* tool-cordis's dynamic plugin tree and every other harness plugin.
|
||||
*/
|
||||
|
||||
describe('Cordis effect ownership', () => {
|
||||
it('makes an effect visible to a reentrant owner restart and awaits setup plus cleanup', async () => {
|
||||
const ctx = new Context()
|
||||
const setupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
let setupFinished = false
|
||||
let cleanupFinished = false
|
||||
|
||||
ctx.effect(async () => {
|
||||
restarted = ctx.fiber.restart()
|
||||
await setupGate.promise
|
||||
setupFinished = true
|
||||
return async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}
|
||||
}, 'reentrant-restart')
|
||||
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
setupGate.resolve(undefined)
|
||||
await cleanupStarted.promise
|
||||
expect(setupFinished).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back collected cleanup and its owner-list entry when setup throws synchronously', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield () => { cleanups += 1 }
|
||||
throw new Error('setup failed')
|
||||
}, 'throwing-setup')).toThrow('setup failed')
|
||||
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('makes a reentrant owner restart await asynchronous rollback after synchronous setup failure', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}
|
||||
restarted = ctx.fiber.restart()
|
||||
throw new Error('setup failed after restart')
|
||||
}, 'reentrant-throw')).toThrow('setup failed after restart')
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps ordinary teardown synchronous and the public disposer single-shot', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
const dispose = ctx.effect(() => () => { cleanups += 1 }, 'sync-effect')
|
||||
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects cleanup-time registration while a restart is unloading', async () => {
|
||||
const ctx = new Context()
|
||||
let registrationError: unknown
|
||||
|
||||
ctx.effect(() => () => {
|
||||
try {
|
||||
ctx.effect(() => () => {}, 'too-late')
|
||||
} catch (error) {
|
||||
registrationError = error
|
||||
}
|
||||
}, 'restart-cleanup')
|
||||
|
||||
await ctx.fiber.restart()
|
||||
expect(registrationError).toBeInstanceOf(CordisError)
|
||||
expect((registrationError as CordisError).code).toBe('INACTIVE_EFFECT')
|
||||
expect(ctx.fiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps effect registration legal while child fibers are PENDING and LOADING', async () => {
|
||||
const ctx = new Context()
|
||||
let pendingCleanup = false
|
||||
let loadingCleanup = false
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'state-probe' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => () => { pendingCleanup = true }, 'pending-effect')
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'state-probe',
|
||||
apply(inner) {
|
||||
expect(inner.fiber.state).toBe(FiberState.LOADING)
|
||||
inner.effect(() => () => { loadingCleanup = true }, 'loading-effect')
|
||||
},
|
||||
})
|
||||
await fiber.dispose()
|
||||
|
||||
expect(pendingCleanup).toBe(true)
|
||||
expect(loadingCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves dependencies that internal/plugin adds before child activation', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('late-inject', {})
|
||||
let applyCalls = 0
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loader-shaped' || fiber.uid === null) return
|
||||
fiber.inject['late-inject'] = {}
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'loader-shaped',
|
||||
apply() {
|
||||
applyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(applyCalls).toBe(1)
|
||||
expect(fiber.state).toBe(FiberState.ACTIVE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis child publication ownership', () => {
|
||||
it('rolls back parent and runtime ownership when internal/plugin publication throws', () => {
|
||||
const ctx = new Context()
|
||||
const plugin = { name: 'publication-failure', apply() {} }
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === plugin.name) throw new Error('publication failed')
|
||||
})
|
||||
|
||||
expect(() => ctx.plugin(plugin)).toThrow('publication failed')
|
||||
expect(ctx.registry.has(plugin)).toBe(false)
|
||||
})
|
||||
|
||||
it('contains teardown notification failures so ownership cleanup and peers complete', async () => {
|
||||
const ctx = new Context()
|
||||
const errors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
|
||||
const observed: string[] = []
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) {
|
||||
throw new Error('broken teardown observer')
|
||||
}
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) observed.push('disposed')
|
||||
})
|
||||
const child = await ctx.plugin({ name: 'contained-teardown', apply() {} })
|
||||
|
||||
await expect(child.dispose()).resolves.toBeUndefined()
|
||||
expect(observed).toEqual(['disposed'])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toEqual(expect.objectContaining({ message: 'broken teardown observer' }))
|
||||
expect(child.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('makes a LOADING parent join child cleanup started before its unload snapshot', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let ownerFiber!: Fiber
|
||||
let ownerDisposal!: Promise<void>
|
||||
let childDisposal!: Promise<void>
|
||||
let childFiber!: Fiber
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loading-child' || fiber.uid === null) return
|
||||
childFiber = fiber
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}, 'loading-child-cleanup')
|
||||
ownerDisposal = ownerFiber.dispose()
|
||||
childDisposal = Promise.resolve(fiber.dispose())
|
||||
})
|
||||
|
||||
const ownerMount = ctx.plugin({
|
||||
name: 'loading-owner',
|
||||
apply(inner) {
|
||||
ownerFiber = inner.fiber
|
||||
inner.plugin({ name: 'loading-child', apply() {} })
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
void ownerDisposal.then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await Promise.all([ownerDisposal, childDisposal, ownerMount])
|
||||
expect(childFiber.uid).toBeNull()
|
||||
expect(ownerFiber.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('lets parent disposal during internal/plugin await the unpublished child to quiescence', async () => {
|
||||
const ctx = new Context()
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin({
|
||||
name: 'owner',
|
||||
apply(inner) {
|
||||
ownerCtx = inner
|
||||
},
|
||||
})
|
||||
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
let childApplyCalls = 0
|
||||
let parentDisposal!: Promise<void>
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}, 'pending-child-cleanup')
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
parentDisposal = owner.dispose()
|
||||
})
|
||||
|
||||
const child = ownerCtx.plugin({
|
||||
name: 'child',
|
||||
apply() {
|
||||
childApplyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void parentDisposal.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await parentDisposal
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(childApplyCalls).toBe(0)
|
||||
expect(child.uid).toBeNull()
|
||||
expect(child.state).toBe(FiberState.DISPOSED)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,9 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. The capabilities reject competing `register`/`enter`/`prepare`/`create` calls, so setup cannot publish the factory objects or same-id replacements. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. The concrete agent owns runtime-pinned `id`, frozen detached `options`, `session`, and `ctx` bindings. Load/setup rejection or owner unload publishes nothing; partial creation announcements are paired during rollback. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, which contains sync/async listener failures per observer; per-step assembly goes through `assembleContextFor(agent)`; the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`.
|
||||
Lifecycle (scoped and dual-owned): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly; the trace-bound `AgentLoop` receiver still supplies the dependency origin, so a caller that injects only `agents` can create an agent whose scope reaches the loop's `sessions`/`llm`/`tools`/`systemPrompt` surface. The caller owns cancellation and the returned handle, while AgentLoop remains a structural second owner because the live driver depends on that service surface: unloading the provider aborts pending load/setup, tears down live programmatic agents, and awaits the same quiescence and ID-release boundary before its dependencies disappear.
|
||||
|
||||
The complete create transaction is factory-tracked before ID reservation/session validation, and both a factory placeholder and lifecycle-long caller sentinel exist before scope minting can reenter plugin lifecycle notifications. The caller sentinel adopts the exact reservation effects and always follows the memoized lifecycle boundary, including handle-first teardown followed by caller unload. Resume adds a load sentinel before persistence I/O; it waits for load settlement until `startOwned` synchronously returns a lifecycle/rollback disposer, then follows that disposer without a handoff gap. The ID capabilities reject competing `register`/`enter`/`prepare`/`create` calls and remain held through scope quiescence. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume captures each loaded metadata field once. After setup resolves, the factory checks caller and provider liveness after constructing both registry entries but before the first announcement, after `session/created`, after `agent/created`, and again after `agent/session-start` before starting the driver, so synchronous getter- or listener-triggered teardown wins. A publication-wide barrier flips lifecycle liveness immediately but keeps both entries and `agent.ctx` intact until the current synchronous notification phase unwinds; only then does rollback revoke them. Registry/store entries claim IDs across caller-code commit windows, detach exact objects only, and reuse stable carriers for paired edges. Load/setup rejection or owner unload before announcement emits no creation edge; if teardown begins inside a creation or session-start listener, the already-started notifications are paired during rollback and no live or drivable publication survives. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope → release reservations. After quiescence, the caller sentinel and any resume-load sentinel disarm and remove their owner-fiber effects so a long-lived caller does not retain the completed agent and scope. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`; the registry's paired disposal edge applies the same failure containment through its captured carrier. Per-step assembly goes through `assembleContextFor(agent)`, and the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
|
||||
@@ -17,7 +19,7 @@ Lifecycle (scoped): programmatic creation and resume snapshot caller-owned ident
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and detaches each raw value in one pass. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
|
||||
|
||||
### Injected services
|
||||
|
||||
|
||||
@@ -25,18 +25,23 @@ const claimedDriverSessions = new WeakSet<Session>()
|
||||
/** Module-private driver entry: its symbol is absent from the package surface. */
|
||||
const startDriver = Symbol('dsh.agent-loop.start-driver')
|
||||
|
||||
/** Module-private quiescent stop, valid both before and after driver start. */
|
||||
const stopDriver = Symbol('dsh.agent-loop.stop-driver')
|
||||
|
||||
/** Factory-owned controls that can operate only on the agent created with them. */
|
||||
export interface PreparedReactLoopAgent {
|
||||
/** The unpublished concrete agent. */
|
||||
agent: ReactLoopAgent
|
||||
/** Open its driving verbs at the rollback-covered publication boundary. */
|
||||
enableDrive(): void
|
||||
/** Stop the prepared instance even when publication has not started its loop. */
|
||||
dispose(): Promise<void> | void
|
||||
/**
|
||||
* Start its driver after publication and session-start notification.
|
||||
* The returned disposer reaches quiescence for both the loop and every
|
||||
* fire-and-forget idle-injection flush the agent started.
|
||||
*/
|
||||
startDriver(): () => Promise<void>
|
||||
startDriver(): () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,12 +61,20 @@ export function prepareReactLoopAgent(
|
||||
if (claimedDriverSessions.has(session)) {
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
}
|
||||
claimedDriverSessions.add(session)
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session)
|
||||
// Construction snapshots caller options and can throw. Claim only the fully
|
||||
// initialized driver so the same prepared session remains retryable after a
|
||||
// rejected caller value.
|
||||
claimedDriverSessions.add(session)
|
||||
const dispose = () => agent[stopDriver]()
|
||||
return {
|
||||
agent,
|
||||
enableDrive: () => { driveEnabledAgents.add(agent) },
|
||||
startDriver: () => agent[startDriver](),
|
||||
dispose,
|
||||
startDriver: () => {
|
||||
agent[startDriver]()
|
||||
return dispose
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +121,8 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
private currentAbort: AbortController | undefined
|
||||
/** Whether runLoop has been installed into {@link done}. */
|
||||
private driverStarted = false
|
||||
/**
|
||||
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
|
||||
* driver loop (via the LoopHandle) at every point a turn could start or
|
||||
@@ -361,17 +376,13 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the driver loop. Returns a disposer: calling it sets status to
|
||||
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
|
||||
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
|
||||
* aborts the current request if any. Its returned promise resolves only after
|
||||
* the loop exits and every idle-injection flush started by this agent settles.
|
||||
* @returns the disposer — idempotent, synchronously marks the agent disposed,
|
||||
* and asynchronously reaches loop + flush quiescence without rejecting (it
|
||||
* runs inside the fiber's LIFO disposal chain, where a rejection would skip
|
||||
* later disposers).
|
||||
* Start the driver loop. The prepared controller already owns its stable
|
||||
* disposer, so teardown can mark the agent disposed even in the narrow
|
||||
* publication window before this method runs.
|
||||
*/
|
||||
[startDriver](): () => Promise<void> {
|
||||
[startDriver](): void {
|
||||
if (this._status === 'disposed') return
|
||||
this.driverStarted = true
|
||||
this.done = runLoop(this.loopCtx, this, {
|
||||
inbox: this.#inbox,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
@@ -389,35 +400,50 @@ export class ReactLoopAgent implements Agent {
|
||||
// that would resolve a freshly-queued prompt as cancelled.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
})
|
||||
// The disposer must be infallible: it runs inside the fiber's LIFO
|
||||
// disposal chain, where a throw would skip later disposers (e.g. the
|
||||
// registry unregistration) and leave `done` pending forever.
|
||||
return async () => {
|
||||
if (this._status !== 'disposed') {
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
this.settleIdleWaiters()
|
||||
this.currentAbort?.abort('disposed')
|
||||
// setStatus refuses transitions out of 'disposed', so emit directly —
|
||||
// 'disposed' is part of the agent/status contract. Guarded: a throwing
|
||||
// listener must not break the disposal chain.
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiescent stop shared by pre-start rollback and live teardown. It marks the
|
||||
* agent disposed synchronously, contains an unexpected loop rejection, and
|
||||
* drains every idle-injection flush before resolving.
|
||||
*/
|
||||
private [stopDriver](): Promise<void> | void {
|
||||
if (this._status !== 'disposed') {
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
this.settleIdleWaiters()
|
||||
this.currentAbort?.abort('disposed')
|
||||
// An unpublished rollback has no public status lifecycle to announce.
|
||||
// Once driving is enabled, disposed is part of the agent/status contract.
|
||||
if (driveEnabledAgents.has(this)) {
|
||||
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
|
||||
}
|
||||
// An unexpected driver rejection must not skip registry/session/scope
|
||||
// cleanup. The normal loop contains turn failures itself; allSettled is the
|
||||
// final lifecycle backstop for anything outside those boundaries.
|
||||
await Promise.allSettled([this.done])
|
||||
// No new inject() can start after the synchronous disposed transition.
|
||||
// Loop because settled tasks retire themselves in promise reactions that
|
||||
// may run beside this continuation; either the set is empty or this waits
|
||||
// the exact remaining quiescence boundary. allSettled keeps a failure in
|
||||
// error reporting from skipping the registry/session/scope disposers.
|
||||
while (this.pendingIdleFlushes.size > 0) {
|
||||
await Promise.allSettled([...this.pendingIdleFlushes])
|
||||
}
|
||||
}
|
||||
// Before runLoop starts there is normally nothing asynchronous to drain;
|
||||
// keep publication rollback synchronous so create() cannot throw while its
|
||||
// session/agent entries are still briefly live. A session-start listener
|
||||
// may have used the newly enabled inject() surface, however, so preserve
|
||||
// its durability checkpoint as a real quiescence boundary.
|
||||
if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return
|
||||
return this.drainDriver()
|
||||
}
|
||||
|
||||
/** Await the loop (when started) and every outstanding idle flush. */
|
||||
private async drainDriver(): Promise<void> {
|
||||
// An unexpected driver rejection must not skip registry/session/scope
|
||||
// cleanup. The normal loop contains turn failures itself; allSettled is the
|
||||
// final lifecycle backstop for anything outside those boundaries.
|
||||
await Promise.allSettled([this.done])
|
||||
// No new inject() can start after the synchronous disposed transition.
|
||||
// Loop because settled tasks retire themselves in promise reactions that
|
||||
// may run beside this continuation; either the set is empty or this waits
|
||||
// the exact remaining quiescence boundary. allSettled keeps a failure in
|
||||
// error reporting from skipping registry/session/scope disposers.
|
||||
while (this.pendingIdleFlushes.size > 0) {
|
||||
await Promise.allSettled([...this.pendingIdleFlushes])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @module @deepseek-ai/dsh-agent-loop
|
||||
*/
|
||||
|
||||
import { Context, FiberState, Service } from 'cordis'
|
||||
import { Context, CordisError, FiberState, Service, symbols } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
@@ -31,6 +31,81 @@ interface RegistrationReservations {
|
||||
release(): void
|
||||
}
|
||||
|
||||
/** A synchronously established ownership handoff plus its async publication result. */
|
||||
interface OwnedAgentStart {
|
||||
result: Promise<AgentHandle>
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Internal carrier for a preparation error whose rollback still has to quiesce. */
|
||||
class LifecyclePreparationFailure extends Error {
|
||||
constructor(
|
||||
readonly reason: unknown,
|
||||
readonly dispose: () => Promise<void>,
|
||||
) {
|
||||
super('agent lifecycle preparation failed', { cause: reason })
|
||||
this.name = 'LifecyclePreparationFailure'
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable construction-time state shared by every traceable AgentLoop receiver. */
|
||||
interface FactoryOwnership {
|
||||
isActive(): boolean
|
||||
track(dispose: () => Promise<void>): () => void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Fiber states in which a concrete factory cannot safely serve dependencies. */
|
||||
const INACTIVE_FACTORY_STATES: ReadonlySet<FiberState> = new Set([
|
||||
FiberState.UNLOADING,
|
||||
FiberState.DISPOSED,
|
||||
FiberState.FAILED,
|
||||
])
|
||||
|
||||
/** Build a tamper-resistant controller around one factory's private ledger. */
|
||||
function createFactoryOwnership(fiber: Context['fiber']): FactoryOwnership {
|
||||
let accepting = true
|
||||
const transactions = new Set<() => Promise<void>>()
|
||||
const isActive = (): boolean => accepting && !INACTIVE_FACTORY_STATES.has(fiber.state)
|
||||
return Object.freeze({
|
||||
isActive,
|
||||
track(dispose: () => Promise<void>): () => void {
|
||||
/* v8 ignore next -- every call site checks the same controller immediately
|
||||
* before this synchronous, non-reentrant insertion; retain the guard as an invariant */
|
||||
if (!isActive()) throw new Error('agent loop is not active')
|
||||
transactions.add(dispose)
|
||||
return () => { transactions.delete(dispose) }
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
accepting = false
|
||||
const disposers = [...transactions]
|
||||
transactions.clear()
|
||||
const results = await Promise.allSettled(disposers.map(dispose => Promise.resolve().then(dispose)))
|
||||
/* v8 ignore next -- tracked lifecycle/load boundaries are deliberately
|
||||
* infallible; keep reasons if that lower-level contract ever breaks */
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
|
||||
/* v8 ignore next -- every tracked boundary is deliberately infallible;
|
||||
* preserve an exact unexpected single failure as a defensive backstop */
|
||||
if (errors.length === 1) throw errors[0]
|
||||
/* v8 ignore next -- multiple failures require multiple contract-breaking
|
||||
* lifecycle disposers, but teardown must still retain every cause */
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'agent loop transaction disposal failed')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Private ownership controllers keyed by the concrete, unproxied service. */
|
||||
const factoryOwnerships = new WeakMap<AgentLoop, FactoryOwnership>()
|
||||
|
||||
/** Recover the stable controller when a Cordis trace proxy is the receiver. */
|
||||
function factoryOwnershipFor(loop: AgentLoop): FactoryOwnership {
|
||||
const original = (loop as AgentLoop & { [symbols.original]?: AgentLoop })[symbols.original] ?? loop
|
||||
// Installed immediately after Service construction, before AgentLoop starts
|
||||
// any effect or config-driven transaction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return factoryOwnerships.get(original)!
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
@@ -94,6 +169,13 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentLoop')
|
||||
const factoryOwnership = createFactoryOwnership(ctx.fiber)
|
||||
factoryOwnerships.set(this, factoryOwnership)
|
||||
// Programmatic agents are caller-owned, but this implementation is their
|
||||
// dependency provider too. Retain a second ownership edge so unloading the
|
||||
// loop aborts unpublished work and drains every live lifecycle before its
|
||||
// service surface disappears.
|
||||
ctx.effect(() => () => factoryOwnership.dispose(), 'agentLoop.factoryTransactions()')
|
||||
// Provide the agent-creation factory to the registry (effect-scoped: the
|
||||
// slot is cleared on dispose).
|
||||
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
||||
@@ -118,7 +200,11 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// failed resume is contained + logged: startup must not crash.
|
||||
ctx.effect(() => {
|
||||
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
agentId: id,
|
||||
resumeSessionId,
|
||||
agentOptions: options,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
})
|
||||
@@ -135,6 +221,22 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this concrete factory may begin or publish more work. */
|
||||
private factoryIsActive(): boolean {
|
||||
return factoryOwnershipFor(this).isActive()
|
||||
}
|
||||
|
||||
/** Reject a call that raced the concrete loop's unload boundary. */
|
||||
private assertFactoryActive(): void {
|
||||
if (this.factoryIsActive()) return
|
||||
throw new Error('agent loop is not active')
|
||||
}
|
||||
|
||||
/** Add one memoized quiescence boundary to the factory's ownership set. */
|
||||
private trackFactoryTransaction(dispose: () => Promise<void>): () => void {
|
||||
return factoryOwnershipFor(this).track(dispose)
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven create: an agent on a FRESH, non-colliding session id per run
|
||||
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
|
||||
@@ -162,13 +264,17 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// lifecycle into the agent's composite effect (so a fiber unload tears the
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
let session: Session
|
||||
try {
|
||||
const session = reservations.session.prepare({ meta })
|
||||
const { agent } = this.start(id, options, session, 'startup', reservations)
|
||||
return agent
|
||||
} finally {
|
||||
session = reservations.session.prepare({ meta })
|
||||
} catch (error: unknown) {
|
||||
reservations.release()
|
||||
throw error
|
||||
}
|
||||
// start() accepts ownership of both reservation capabilities even when
|
||||
// synchronous preparation fails; its rollback releases them at quiescence.
|
||||
const { agent } = this.start(id, options, session, 'startup', reservations)
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,11 +286,13 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
|
||||
* starts with the parent's context. Returns an {@link AgentHandle} the owner
|
||||
* disposes to tear down exactly this agent.
|
||||
* @param ownerCtx - the caller context that owns setup and the live lifecycle.
|
||||
* @param options - agent id, caller-supplied session id, optional seed/meta,
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
this.assertFactoryActive()
|
||||
// Snapshot every caller-owned field before the first async setup boundary.
|
||||
// The callback itself is an identity capability. Agent options detach here;
|
||||
// seed and metadata stay raw only until sessions.prepare() synchronously
|
||||
@@ -196,16 +304,32 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const seed = options.seed
|
||||
const meta = options.meta
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
// Snapshot accessors can reenter plugin teardown; do not reserve identities
|
||||
// after the dependency provider has begun unloading.
|
||||
this.assertFactoryActive()
|
||||
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||
const disposeCreateForFactory = (): Promise<void> => transactionSettled
|
||||
const untrackFactoryCreate = this.trackFactoryTransaction(disposeCreateForFactory)
|
||||
try {
|
||||
const session = reservations.session.prepare({
|
||||
...seed !== undefined ? { seed } : {},
|
||||
...meta !== undefined ? { meta } : {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume.
|
||||
return await this.startOwned(agentId, agentOptions, session, 'startup', reservations, setup)
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
let lifecycleStarted = false
|
||||
try {
|
||||
const session = reservations.session.prepare({
|
||||
...seed !== undefined ? { seed } : {},
|
||||
...meta !== undefined ? { meta } : {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume.
|
||||
lifecycleStarted = true
|
||||
return await this.startOwned(ownerCtx, agentId, agentOptions, session, 'startup', reservations, setup).result
|
||||
} finally {
|
||||
// Once startOwned is invoked, even a preparation failure carries its
|
||||
// own quiescent rollback boundary. Only failures before that handoff
|
||||
// release directly here.
|
||||
if (!lifecycleStarted) reservations.release()
|
||||
}
|
||||
} finally {
|
||||
reservations.release()
|
||||
markTransactionSettled()
|
||||
untrackFactoryCreate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,10 +344,12 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* configured. NOT hard-injected (that would make non-persistent demos pend
|
||||
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
||||
* by the time this runs the service exists.
|
||||
* @param ownerCtx - the caller context that owns load, setup, and the live lifecycle.
|
||||
* @param options - the persisted session id to reload, plus agent id/options.
|
||||
* @returns the handle for the agent resumed on the reconstructed session.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
this.assertFactoryActive()
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
// global-store lookup keyed by the isolate symbol — NOT
|
||||
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
|
||||
@@ -243,7 +369,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
if (persistence === undefined) {
|
||||
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
return this.resumeWith(persistence, options)
|
||||
return this.resumeWith(ownerCtx, persistence, options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,7 +381,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* sessions store + registry are still read through `this.ctx` (both are in
|
||||
* AgentLoop's static inject, so they resolve fine).
|
||||
*/
|
||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
private async resumeWith(ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
// Persistence is an async trust boundary. Reserve, load, reconstruct, and
|
||||
// publish only the identities/options accepted at entry—never fields
|
||||
// reread from a caller-owned object after the await.
|
||||
@@ -263,25 +389,65 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const sessionId = options.resumeSessionId
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const setup = options.setup
|
||||
// Caller-owned accessors above are a synchronous reentrancy boundary: one
|
||||
// can begin factory unload while options are snapshotted. Re-check before
|
||||
// installing either ownership sentinel, so a rejected transaction leaves
|
||||
// no orphan effect or unresolved settlement promise.
|
||||
this.assertFactoryActive()
|
||||
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
|
||||
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||
let observingOwner = true
|
||||
// Resume must observe its caller from BEFORE persistence I/O begins. The
|
||||
// full agent lifecycle does not exist until load returns, so without this
|
||||
// sentinel a never-settling backend outlives owner disposal and holds both
|
||||
// public identities forever. `this.ctx.effect` retains the traceable caller
|
||||
// ownership used by startOwned's lifecycle effect. Install it before even
|
||||
// reserving the ids: an inactive owner cannot leak a reservation if effect
|
||||
// registration fails.
|
||||
const disposeLoadSentinel = this.ctx.effect(() => () => {
|
||||
if (!observingOwner) return
|
||||
// public identities forever. The caller-bound effect retains the same owner
|
||||
// later used by startOwned's lifecycle effect and adopts both reservation
|
||||
// disposers before persistence I/O begins.
|
||||
let lifecycleBoundary: (() => Promise<void>) | undefined
|
||||
let disposingForFactory: Promise<void> | undefined
|
||||
const disposeLoadForFactory = (): Promise<void> => (disposingForFactory ??= (async () => {
|
||||
markOwnerDisposed()
|
||||
// Owner-triggered teardown does not reach quiescence until the resume
|
||||
// transaction has observed disposal and released both reservations.
|
||||
return transactionSettled
|
||||
}, `agentLoop.resumeLoad(${agentId})`)
|
||||
await transactionSettled
|
||||
})())
|
||||
let untrackFactoryLoad: (() => void) | undefined
|
||||
let disposeLoadSentinel: (() => Promise<void> | void) | undefined
|
||||
let loadSentinelRetired = false
|
||||
const retireLoadSentinel = (): void => {
|
||||
/* v8 ignore next -- every lifecycle/rollback boundary is memoized and
|
||||
* invokes its after-quiescence hook once; retain idempotence defensively */
|
||||
if (loadSentinelRetired) return
|
||||
// Disarm the follower before invoking its wrapper: retirement can happen
|
||||
// from inside the lifecycle it used to follow, so recursing into that
|
||||
// same boundary here would deadlock final teardown.
|
||||
loadSentinelRetired = true
|
||||
observingOwner = false
|
||||
void disposeLoadSentinel?.()
|
||||
}
|
||||
let reservations: RegistrationReservations | undefined
|
||||
let lifecycleStarted = false
|
||||
try {
|
||||
const reservations = this.reserve(agentId, sessionId)
|
||||
reservations = this.reserve(agentId, sessionId)
|
||||
const ownedReservations = reservations
|
||||
// Move both reservation effects under a sentinel BEFORE persistence I/O.
|
||||
// Its first teardown stage either aborts/waits for the load transaction
|
||||
// or follows the full lifecycle after handoff; only then do the exact
|
||||
// reservation disposers run. They therefore cannot race ahead as owner
|
||||
// siblings and reopen ids while load/setup/scope cleanup is still live.
|
||||
disposeLoadSentinel = ownerCtx.effect(function* () {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield ownedReservations.agent.release
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield ownedReservations.session.release
|
||||
yield () => {
|
||||
if (loadSentinelRetired) return
|
||||
if (observingOwner) {
|
||||
markOwnerDisposed()
|
||||
return transactionSettled
|
||||
}
|
||||
return lifecycleBoundary?.()
|
||||
}
|
||||
}, `agentLoop.resumeLoad(${agentId})`)
|
||||
untrackFactoryLoad = this.trackFactoryTransaction(disposeLoadForFactory)
|
||||
try {
|
||||
const loadTask = persistence.load(sessionId)
|
||||
const { meta, events } = await Promise.race([
|
||||
@@ -309,16 +475,26 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...seedLength !== undefined ? { seedLength } : {},
|
||||
},
|
||||
})
|
||||
// Calling startOwned synchronously installs the complete lifecycle
|
||||
// effect before it reaches its first setup await. Only then disarm the
|
||||
// load sentinel: ownership passes directly from one effect to the other
|
||||
// with no disposal gap.
|
||||
const starting = this.startOwned(agentId, agentOptions, session, 'resume', reservations, setup)
|
||||
// startOwned synchronously returns either the complete lifecycle or a
|
||||
// preparation-rollback boundary before its result reaches the first
|
||||
// setup await. Retarget the lifecycle-long load sentinel to that disposer;
|
||||
// ownership overlaps instead of creating a gap.
|
||||
lifecycleStarted = true
|
||||
const starting = this.startOwned(
|
||||
ownerCtx,
|
||||
agentId,
|
||||
agentOptions,
|
||||
session,
|
||||
'resume',
|
||||
reservations,
|
||||
setup,
|
||||
retireLoadSentinel,
|
||||
)
|
||||
lifecycleBoundary = starting.dispose
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
return await starting
|
||||
return await starting.result
|
||||
} finally {
|
||||
reservations.release()
|
||||
if (!lifecycleStarted) reservations.release()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
@@ -327,10 +503,18 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// owner already triggered cleanup, this idempotent second disposal is a
|
||||
// no-op and the owner's first cleanup remains parked on the shared
|
||||
// settlement promise.
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
if (!lifecycleStarted) {
|
||||
// Covers reserve succeeding but sentinel/factory tracking failing
|
||||
// before the inner load transaction begins.
|
||||
reservations?.release()
|
||||
// A failed pre-lifecycle transaction has already released directly;
|
||||
// retire the sentinel so it cannot remain as a stale owner effect.
|
||||
retireLoadSentinel()
|
||||
await disposeLoadSentinel?.()
|
||||
}
|
||||
} finally {
|
||||
markTransactionSettled()
|
||||
untrackFactoryLoad?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,17 +542,20 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
|
||||
/**
|
||||
* Construct an unpublished agent and synchronously install its complete
|
||||
* teardown skeleton before any setup await. The closures are assigned their
|
||||
* session/registry/loop disposers only at publication, while the exact scope
|
||||
* disposer is nested immediately. Therefore owner unload during setup flips
|
||||
* `active`, unwinds the scope, and wins the race without any late Cordis
|
||||
* effect collection.
|
||||
* teardown skeleton before any setup await. A lifecycle-long caller sentinel and
|
||||
* factory placeholder exist before driver/scope construction; the closures
|
||||
* receive their session/registry/loop disposers only at publication, while
|
||||
* the exact scope disposer is nested as soon as construction returns. Owner
|
||||
* unload during preparation or setup therefore follows a real rollback
|
||||
* boundary, flips liveness, and wins without late Cordis effect collection.
|
||||
*/
|
||||
private prepareLifecycle(
|
||||
ownerCtx: Context,
|
||||
id: AgentId,
|
||||
options: AgentOptions,
|
||||
session: Session,
|
||||
reservations: RegistrationReservations,
|
||||
afterQuiescence?: () => void,
|
||||
): {
|
||||
agent: ReactLoopAgent
|
||||
active: () => boolean
|
||||
@@ -381,77 +568,266 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// than Cordis reaches nested scope effects. Include that signal in the
|
||||
// pre-publication liveness check so a same-turn parent dispose cannot race
|
||||
// an already-fulfilled setup promise into briefly publishing a child.
|
||||
const ownerAgent = this.ctx.agent
|
||||
const ownerFiber = this.ctx.fiber
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
const { agent } = driver
|
||||
const scope: Scope = createScope(this.ctx, agent)
|
||||
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
|
||||
|
||||
let active = true
|
||||
let detachSession: (() => void) | undefined
|
||||
let detachAgent: (() => void) | undefined
|
||||
let stop: (() => Promise<void>) | undefined
|
||||
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
|
||||
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
|
||||
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
// First yielded, disposed last: every preceding teardown stage settled.
|
||||
yield () => { markTorndown() }
|
||||
// Exact identity moves the scope fiber out of the owner's concurrent
|
||||
// sibling list and into this ordered transaction.
|
||||
yield scope.rawDispose
|
||||
yield () => {
|
||||
detachSession?.()
|
||||
detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
detachAgent?.()
|
||||
detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first. Keep the pre-publication path
|
||||
// synchronous: returning a Promise only after the loop actually began
|
||||
// lets a failed announcement roll back registry/store before create's
|
||||
// rejection is observed.
|
||||
yield () => {
|
||||
active = false
|
||||
markDeactivated()
|
||||
if (stop === undefined) return
|
||||
return stop()
|
||||
}
|
||||
}, 'agentLoop.lifecycle()')
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
const disposeAgent = (): Promise<void> => (disposing ??= (async () => {
|
||||
await dispose()
|
||||
await torndown
|
||||
})())
|
||||
|
||||
const publish = (source: SessionStartSource): void => {
|
||||
// Publication is one synchronous, rollback-covered sequence. Setup has
|
||||
// already completed, so its scoped listeners observe both announcements.
|
||||
detachSession = agent.ctx.sessions.enter(session, reservations.session)
|
||||
detachAgent = this.ctx.agents.enter(agent, reservations.agent)
|
||||
this.ctx.sessions.announce(session)
|
||||
this.ctx.agents.announce(agent)
|
||||
// Setup is over and both entries are live. Open the driving surface just
|
||||
// before session-start so its listeners retain their supported ability to
|
||||
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||
driver.enableDrive()
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
stop = driver.startDriver()
|
||||
let ownerAgent: Context['agent']
|
||||
let ownerFiber: Context['fiber']
|
||||
try {
|
||||
this.assertFactoryActive()
|
||||
ownerCtx.fiber.assertActive()
|
||||
ownerAgent = ownerCtx.agent
|
||||
ownerFiber = ownerCtx.fiber
|
||||
} catch (error: unknown) {
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
const dispose = (): Promise<void> => Promise.resolve()
|
||||
throw new LifecyclePreparationFailure(error, dispose)
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
active: () => active
|
||||
// Establish BOTH ownership edges before driver preparation or scope
|
||||
// minting can publish an internal lifecycle notification. The lifecycle-long
|
||||
// caller sentinel also adopts the exact reservation effects: owner unload
|
||||
// first waits for the memoized lifecycle boundary, then reaches those
|
||||
// capabilities, so IDs cannot reopen while scope cleanup is still live.
|
||||
const { promise: lifecycleReady, resolve: markLifecycleReady }
|
||||
= Promise.withResolvers<() => Promise<void>>()
|
||||
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
|
||||
let ownerDisposed = false
|
||||
const ownerIsDisposed = (): boolean => ownerDisposed
|
||||
let ownerSentinelRetired = false
|
||||
let disposeOwnerSentinel: () => Promise<void> | void
|
||||
try {
|
||||
disposeOwnerSentinel = ownerCtx.effect(function* () {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield reservations.agent.release
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract
|
||||
yield reservations.session.release
|
||||
yield () => {
|
||||
if (ownerSentinelRetired) return
|
||||
ownerDisposed = true
|
||||
markDeactivated()
|
||||
return lifecycleReady.then(disposeLifecycle => disposeLifecycle())
|
||||
}
|
||||
}, `agentLoop.ownerLifecycle(${id})`)
|
||||
} catch (error: unknown) {
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
const dispose = (): Promise<void> => Promise.resolve()
|
||||
markLifecycleReady(dispose)
|
||||
// The only callback-free effect-install failure is Cordis's inactive
|
||||
// owner boundary; preserve the original value as cause for diagnostics.
|
||||
const reportedError = new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error })
|
||||
throw new LifecyclePreparationFailure(reportedError, dispose)
|
||||
}
|
||||
let disposingForFactory: Promise<void> | undefined
|
||||
const disposeForFactory = (): Promise<void> => (disposingForFactory ??= (async () => {
|
||||
const disposeLifecycle = await lifecycleReady
|
||||
await disposeLifecycle()
|
||||
})())
|
||||
let untrackFactory: () => void
|
||||
try {
|
||||
untrackFactory = this.trackFactoryTransaction(disposeForFactory)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore start -- no callback boundary exists between the active
|
||||
* factory check, sentinel installation, and this synchronous ledger insert */
|
||||
let cleanupTask: Promise<void> | undefined
|
||||
const cleanup = (): Promise<void> => (cleanupTask ??= Promise.resolve().then(() => {
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
}))
|
||||
markLifecycleReady(cleanup)
|
||||
void disposeOwnerSentinel()
|
||||
throw new LifecyclePreparationFailure(error, cleanup)
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
let scope: Scope | undefined
|
||||
let stopPrepared: (() => Promise<void> | void) | undefined
|
||||
let disposeAgent: (() => Promise<void>) | undefined
|
||||
try {
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
stopPrepared = () => driver.dispose()
|
||||
const { agent } = driver
|
||||
scope = createScope(this.ctx, agent)
|
||||
const lifecycleScope = scope
|
||||
if (ownerIsDisposed() || !this.factoryIsActive()
|
||||
|| ownerFiber.state === FiberState.UNLOADING
|
||||
|| ownerFiber.state === FiberState.DISPOSED
|
||||
|| ownerFiber.state === FiberState.FAILED
|
||||
|| ownerAgent?.status === 'disposed') {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
bindReactLoopAgentContext(agent, lifecycleScope.ctx.extend({ agent }))
|
||||
|
||||
let active = true
|
||||
let detachSession: (() => void) | undefined
|
||||
let detachAgent: (() => void) | undefined
|
||||
const stop = stopPrepared
|
||||
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
|
||||
const { promise: publicationSettled, resolve: markPublicationSettled } = Promise.withResolvers<void>()
|
||||
let publishing = false
|
||||
|
||||
const dispose = ownerCtx.effect(function* () {
|
||||
// First yielded, disposed last: every preceding teardown stage settled.
|
||||
yield () => {
|
||||
// Reservation ownership is part of lifecycle settlement: a factory
|
||||
// unload that awaited this disposer may reuse both ids immediately.
|
||||
reservations.release()
|
||||
// Retire both follower effects only after quiescence reached this final
|
||||
// stage. Their retired branches skip recursively disposing this same
|
||||
// lifecycle while their exact reservation children are already inert.
|
||||
ownerSentinelRetired = true
|
||||
void disposeOwnerSentinel()
|
||||
afterQuiescence?.()
|
||||
untrackFactory()
|
||||
markTorndown()
|
||||
}
|
||||
// Exact identity moves the scope fiber out of the owner's concurrent
|
||||
// sibling list and into this ordered transaction.
|
||||
yield lifecycleScope.rawDispose
|
||||
yield () => {
|
||||
detachSession?.()
|
||||
detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
detachAgent?.()
|
||||
detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first. Keep the pre-publication path
|
||||
// synchronous: returning a Promise only after the loop actually began
|
||||
// lets a failed announcement roll back registry/store before create's
|
||||
// rejection is observed.
|
||||
yield () => {
|
||||
active = false
|
||||
markDeactivated()
|
||||
// A listener can begin owner teardown reentrantly. Flip liveness now
|
||||
// so publish's next checkpoint aborts, but keep both registry entries
|
||||
// and the scope intact until the current synchronous publication
|
||||
// phase has unwound.
|
||||
if (publishing) return publicationSettled.then(stop)
|
||||
return stop()
|
||||
}
|
||||
}, 'agentLoop.lifecycle()')
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
disposeAgent = (): Promise<void> => (disposing ??= (async () => {
|
||||
await dispose()
|
||||
await torndown
|
||||
})())
|
||||
markLifecycleReady(disposeAgent)
|
||||
|
||||
const isActive = (): boolean => active
|
||||
&& !ownerIsDisposed()
|
||||
&& this.factoryIsActive()
|
||||
&& ownerFiber.state !== FiberState.UNLOADING
|
||||
&& ownerFiber.state !== FiberState.DISPOSED
|
||||
&& ownerFiber.state !== FiberState.FAILED
|
||||
&& ownerAgent?.status !== 'disposed',
|
||||
deactivated,
|
||||
publish,
|
||||
disposeAgent,
|
||||
&& ownerAgent?.status !== 'disposed'
|
||||
|
||||
const publish = (source: SessionStartSource): void => {
|
||||
publishing = true
|
||||
try {
|
||||
/* v8 ignore next 3 -- both callers check active immediately before
|
||||
* this callback-free synchronous publish entry */
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
// Publication is one synchronous, rollback-covered sequence. Setup has
|
||||
// already completed, so its scoped listeners observe both announcements.
|
||||
detachSession = agent.ctx.sessions.enter(session, reservations.session)
|
||||
detachAgent = this.ctx.agents.enter(agent, reservations.agent)
|
||||
// Both enter() calls capture stable dispatch carriers and therefore
|
||||
// evaluate a caller-owned Context.filter. A getter can begin teardown;
|
||||
// entries exist for rollback, but no creation edge may escape afterward.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
this.ctx.sessions.announce(session)
|
||||
// Session listeners can dispose an owner. Finish that dispatch while
|
||||
// both entries/scope remain live, then skip the agent edge entirely.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
this.ctx.agents.announce(agent)
|
||||
// Creation listeners may synchronously dispose either owner. Cordis
|
||||
// flips the relevant fiber state before it invokes nested effects, so
|
||||
// re-check here and never unlock a driver after teardown began.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
// Setup is over and both entries are live. Open the driving surface just
|
||||
// before session-start so its listeners retain their supported ability to
|
||||
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||
driver.enableDrive()
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
// session-start is the final synchronous listener boundary before the
|
||||
// loop begins. Teardown there must win just like teardown from either
|
||||
// creation announcement; the prebuilt driver disposer makes rollback
|
||||
// quiescent even though the loop never started.
|
||||
if (!isActive()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
driver.startDriver()
|
||||
} finally {
|
||||
publishing = false
|
||||
markPublicationSettled()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
active: isActive,
|
||||
deactivated,
|
||||
publish,
|
||||
disposeAgent,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Preparation failed before startOwned received a lifecycle object. Give
|
||||
// a factory unload that already captured the placeholder a real boundary,
|
||||
// and retire the entry only after the minted scope (if any) is quiescent.
|
||||
const failedScope = scope
|
||||
const ownershipInactive = ownerIsDisposed() || ownerFiber.uid === null || !this.factoryIsActive()
|
||||
|| ownerFiber.state === FiberState.UNLOADING
|
||||
|| ownerFiber.state === FiberState.DISPOSED
|
||||
|| ownerFiber.state === FiberState.FAILED
|
||||
|| ownerAgent?.status === 'disposed'
|
||||
const reportedError = ownershipInactive && error instanceof CordisError
|
||||
? new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error })
|
||||
: error
|
||||
let fallbackTask: Promise<void> | undefined
|
||||
const cleanup = disposeAgent ?? (() => (fallbackTask ??= (async () => {
|
||||
try {
|
||||
await stopPrepared?.()
|
||||
} finally {
|
||||
try {
|
||||
await failedScope?.dispose()
|
||||
} finally {
|
||||
// Factory and caller quiescence include the prepared driver,
|
||||
// minted scope, and both unpublished identities even when the
|
||||
// complete lifecycle effect could not be installed.
|
||||
reservations.release()
|
||||
afterQuiescence?.()
|
||||
}
|
||||
}
|
||||
})()))
|
||||
markLifecycleReady(cleanup)
|
||||
const cleanupTask = cleanup()
|
||||
// Retire the provisional owner edge. If owner unload already claimed it,
|
||||
// this is an inert repeat and that first caller is following cleanupTask.
|
||||
void disposeOwnerSentinel()
|
||||
void cleanupTask.then(
|
||||
untrackFactory,
|
||||
/* v8 ignore next -- Scope.dispose is specified to contain child
|
||||
* failures; preserve diagnostics if that lower-level contract breaks */
|
||||
(cleanupError: unknown) => {
|
||||
untrackFactory()
|
||||
try {
|
||||
this.ctx.logger.error(new AggregateError([reportedError, cleanupError], 'agent lifecycle preparation and rollback failed'))
|
||||
} catch {
|
||||
// Only a logger-export failure is swallowed: the original
|
||||
// preparation error is already propagating to the caller.
|
||||
}
|
||||
},
|
||||
)
|
||||
throw new LifecyclePreparationFailure(reportedError, cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,7 +839,16 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
source: SessionStartSource,
|
||||
reservations: RegistrationReservations,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
|
||||
let lifecycle: ReturnType<AgentLoop['prepareLifecycle']>
|
||||
try {
|
||||
lifecycle = this.prepareLifecycle(this.ctx, id, options, session, reservations)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- prepareLifecycle converts every failure into its
|
||||
* rollback-bearing internal error before crossing this boundary */
|
||||
if (!(error instanceof LifecyclePreparationFailure)) throw error
|
||||
void error.dispose()
|
||||
throw error.reason
|
||||
}
|
||||
try {
|
||||
lifecycle.publish(source)
|
||||
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
|
||||
@@ -477,10 +862,10 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
|
||||
* handle's `dispose()` runs the composite effect's disposer (see
|
||||
* {@link start}) — which stops the loop, awaits its exit and outstanding
|
||||
* idle-injection flushes, unregisters the agent, and detaches the session, in
|
||||
* that order.
|
||||
* The same composite effect is what a fiber unload disposes, so both teardown
|
||||
* triggers honor the ordering identically.
|
||||
* idle-injection flushes, unregisters the agent, detaches the session,
|
||||
* unwinds the scope, and releases both ids, in that order. Caller-fiber unload
|
||||
* also invokes an independent sentinel that follows this memoized boundary,
|
||||
* so handle-first and owner-first races honor the same ordering.
|
||||
*
|
||||
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
|
||||
* single-shot (a second call returns immediately because the effect's epoch is
|
||||
@@ -491,13 +876,49 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
*/
|
||||
private async startOwned(
|
||||
private startOwned(
|
||||
ownerCtx: Context,
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
reservations: RegistrationReservations,
|
||||
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||
): Promise<AgentHandle> {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session, reservations)
|
||||
afterQuiescence?: () => void,
|
||||
): OwnedAgentStart {
|
||||
let lifecycle: ReturnType<AgentLoop['prepareLifecycle']>
|
||||
try {
|
||||
lifecycle = this.prepareLifecycle(ownerCtx, id, options, session, reservations, afterQuiescence)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 1 -- prepareLifecycle wraps every synchronous failure */
|
||||
if (!(error instanceof LifecyclePreparationFailure)) throw error
|
||||
return {
|
||||
dispose: error.dispose,
|
||||
result: (async () => {
|
||||
await error.dispose()
|
||||
throw error.reason
|
||||
})(),
|
||||
}
|
||||
}
|
||||
return {
|
||||
dispose: lifecycle.disposeAgent,
|
||||
result: this.finishOwnedStart(lifecycle, id, source, setup),
|
||||
}
|
||||
}
|
||||
|
||||
/** Await setup and publish after {@link startOwned} established ownership synchronously. */
|
||||
private async finishOwnedStart(
|
||||
lifecycle: ReturnType<AgentLoop['prepareLifecycle']>,
|
||||
id: AgentId,
|
||||
source: SessionStartSource,
|
||||
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||
): Promise<AgentHandle> {
|
||||
try {
|
||||
// Scope minting emits Cordis's synchronous internal/plugin notification.
|
||||
// A listener can unload either owner there; never run arbitrary setup in
|
||||
// the already-doomed scope while the tracked disposer is catching up.
|
||||
/* v8 ignore next 3 -- prepareLifecycle returns success only after its
|
||||
* final synchronous liveness check; no callback runs before this line */
|
||||
if (!lifecycle.active()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
// The owner-disposal branch makes a never-settling setup unable to hold
|
||||
// the transaction or its ID reservations forever. Promise.race installs
|
||||
// rejection observation on setup even if owner disposal wins first.
|
||||
|
||||
@@ -296,6 +296,39 @@ describe('ReactLoopAgent', () => {
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
|
||||
it('a pre-start disposal makes a later driver-start attempt inert', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
|
||||
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
const dispose = prepared.startDriver()
|
||||
await dispose()
|
||||
await expect(prepared.agent.done).resolves.toBeUndefined()
|
||||
expect(prepared.agent.session.events).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not claim a session when concrete-agent construction rejects options', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('constructor-retry'))
|
||||
const badOptions = {
|
||||
get model(): string {
|
||||
throw new Error('bad model getter')
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => prepareReactLoopAgent(ctx, AgentId('bad-constructor'), badOptions, session))
|
||||
.toThrow('bad model getter')
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('constructor-retry'), { model: 'mock' }, session)
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('setting the same status does not emit agent/status again', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -228,6 +228,27 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('successful resume disposal retires both caller ownership sentinels', async () => {
|
||||
const sessionId = SessionId('resume-retired-sentinels-s')
|
||||
const agentId = AgentId('resume-retired-sentinels')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const handle = await ctx.agents.resume({
|
||||
agentId,
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const sentinelLabels = [
|
||||
`agentLoop.resumeLoad(${agentId})`,
|
||||
`agentLoop.ownerLifecycle(${agentId})`,
|
||||
]
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(sentinelLabels))
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => sentinelLabels.includes(effect.label))).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
|
||||
const sessionId = SessionId('resume-setup-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
@@ -351,6 +372,93 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits reservation release', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const agentId = AgentId('resume-load-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
}
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
|
||||
await loadStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
|
||||
await promptly(loopFiber.dispose())
|
||||
await rejection
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(sessionId)
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('option snapshot reentrancy cannot install a resume sentinel after factory unload begins', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-factory-unload')
|
||||
const agentId = AgentId('resume-snapshot-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
let loads = 0
|
||||
const load = ctx.sessionPersistence.load.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
loads += 1
|
||||
return load(id)
|
||||
}
|
||||
const options = {
|
||||
agentId,
|
||||
resumeSessionId: sessionId,
|
||||
get agentOptions() {
|
||||
void loopFiber.dispose()
|
||||
return { model: 'mock' }
|
||||
},
|
||||
}
|
||||
|
||||
await expect(ctx.agents.resume(options)).rejects.toThrow('agent loop is not active')
|
||||
await loopFiber.dispose()
|
||||
expect(loads).toBe(0)
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.resumeLoad(${agentId})`)).toEqual([])
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(sessionId)
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('snapshots resume identities and agent options before persistence load', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-source')
|
||||
const root = await persistSession(sessionId)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -12,16 +12,20 @@ import * as concreteAgentModule from '../src/agent.ts'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) {
|
||||
async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
return { ctx, loopFiber }
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<Context> {
|
||||
return (await harnessWithLoop(adapter)).ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
@@ -37,6 +41,17 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
|
||||
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
|
||||
/** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */
|
||||
function disposeCurrentLifecycle(ownerCtx: Context): void {
|
||||
const lifecycle = [...ownerCtx.fiber._disposables]
|
||||
.find((dispose) => {
|
||||
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
|
||||
return effect?.label === 'agentLoop.lifecycle()'
|
||||
})
|
||||
if (lifecycle === undefined) throw new Error('agent lifecycle effect not found')
|
||||
void lifecycle()
|
||||
}
|
||||
|
||||
describe('agent scope lifecycle', () => {
|
||||
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
|
||||
const ctx = await harness()
|
||||
@@ -312,6 +327,508 @@ describe('agent scope lifecycle', () => {
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an AgentLoop unload aborts pending setup, awaits cleanup, and releases both ids', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-setup-race'),
|
||||
sessionId: SessionId('factory-setup-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
|
||||
await loopFiber.dispose()
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
||||
|
||||
// Factory unload itself reached the reservation-release boundary.
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-setup-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-setup-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
gate.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let unloaded = false
|
||||
let setupCalls = 0
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (unloaded || fiber.name !== 'scope') return
|
||||
unloaded = true
|
||||
void loopFiber.dispose()
|
||||
})
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-scope-race'),
|
||||
sessionId: SessionId('factory-scope-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => { setupCalls += 1 },
|
||||
})
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await loopFiber.dispose()
|
||||
expect(setupCalls).toBe(0)
|
||||
expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('caller unload during scope minting owns and drains the half-built child', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let ownerFiber!: Fiber
|
||||
let ownerDisposal!: Promise<void>
|
||||
let scopeFiber: Fiber | undefined
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'scope' || scopeFiber !== undefined) return
|
||||
scopeFiber = fiber
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
})
|
||||
ownerDisposal = ownerFiber.dispose()
|
||||
})
|
||||
|
||||
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerFiber = inner.fiber
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('caller-scope-race'),
|
||||
sessionId: SessionId('caller-scope-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
void ownerDisposal.then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
gate.resolve(undefined)
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await ownerDisposal
|
||||
await owner
|
||||
expect(scopeFiber?.uid).toBeNull()
|
||||
expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
|
||||
await owner.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('synchronous create rechecks provider liveness before its first publication edge', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
let unloaded = false
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (unloaded || fiber.name !== 'scope') return
|
||||
unloaded = true
|
||||
void loopFiber.dispose()
|
||||
})
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' }))
|
||||
.toThrow(/owner disposed during setup/)
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('synchronous create releases both reservations when session preparation fails', async () => {
|
||||
const ctx = await harness()
|
||||
const id = AgentId('config-prepare-failure')
|
||||
|
||||
expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' }))
|
||||
.toThrow(/absolute path/)
|
||||
const replacement = ctx.agentLoop.create(id, { model: 'mock' }, { cwd: '/recovered' })
|
||||
expect(ctx.agents.get(id)).toBe(replacement)
|
||||
await replacement.whenIdle()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('turns owner disposal from the caller association getter into a rollback boundary', async () => {
|
||||
const ctx = await harness()
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
let getterCalls = 0
|
||||
const creationStarted = Promise.withResolvers<undefined>()
|
||||
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
||||
Object.defineProperty(inner, 'agent', {
|
||||
configurable: true,
|
||||
get() {
|
||||
getterCalls += 1
|
||||
void inner.fiber.dispose()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('association-dispose'),
|
||||
sessionId: SessionId('association-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
creationStarted.resolve(undefined)
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await creationStarted.promise
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner
|
||||
expect(getterCalls).toBe(1)
|
||||
expect(ctx.agents.get(AgentId('association-dispose'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('association-dispose-s'))).toBeUndefined()
|
||||
const replacement = await ctx.agents.create({
|
||||
agentId: AgentId('association-dispose'),
|
||||
sessionId: SessionId('association-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await replacement.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload awaits reservations when reentrant scope preparation throws', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let triggered = false
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (triggered || fiber.name !== 'scope') return
|
||||
triggered = true
|
||||
void loopFiber.dispose()
|
||||
throw new Error('scope preparation failed')
|
||||
})
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('factory-scope-throw'),
|
||||
sessionId: SessionId('factory-scope-throw-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow('scope preparation failed')
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-scope-throw'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-throw-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory unload during session preparation awaits create reservation release', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
let unloading!: Promise<void>
|
||||
const meta = {
|
||||
get cwd() {
|
||||
unloading = loopFiber.dispose()
|
||||
return '/factory-unload'
|
||||
},
|
||||
}
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-prepare-race'),
|
||||
sessionId: SessionId('factory-prepare-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
meta,
|
||||
})
|
||||
await unloading
|
||||
await expect(creating).rejects.toThrow('agent loop is not active')
|
||||
|
||||
const agentReservation = ctx.agents.reserve(AgentId('factory-prepare-race'))
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-prepare-race-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
const loop = ctx.agentLoop
|
||||
const agentId = AgentId('factory-live')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('factory-live-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
await loopFiber.dispose()
|
||||
expect(handle.agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
|
||||
// The consumer handle shares the provider's completed quiescence boundary.
|
||||
await handle.dispose()
|
||||
|
||||
const agentReservation = ctx.agents.reserve(agentId)
|
||||
const sessionReservation = ctx.sessions.reserve(SessionId('factory-live-s'))
|
||||
sessionReservation.release()
|
||||
agentReservation.release()
|
||||
await expect(loop.createAgent(ctx, {
|
||||
agentId: AgentId('factory-inactive'),
|
||||
sessionId: SessionId('factory-inactive-s'),
|
||||
})).rejects.toThrow('agent loop is not active')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps AgentLoop dependencies available when the caller injects only agents', async () => {
|
||||
const ctx = await harness()
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('dependency-origin'),
|
||||
sessionId: SessionId('dependency-origin-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
agentCtx.tools.register({
|
||||
name: 'dependency-origin-tool',
|
||||
description: 'proves AgentLoop dependency origin',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve(text('ok')),
|
||||
})
|
||||
agentCtx.systemPrompt.section({
|
||||
name: 'dependency-origin-section',
|
||||
order: 1,
|
||||
text: 'factory dependency surface',
|
||||
})
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
const handle = await creating
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent))
|
||||
expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool')
|
||||
expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section')
|
||||
await handle.dispose()
|
||||
await owner.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps both entries and the scope live through a reentrant session/created teardown', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id !== SessionId('session-created-barrier-s')) return
|
||||
lifecycle.push('session-created:dispose')
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id !== SessionId('session-created-barrier-s')) return
|
||||
const agent = ctx.agents.get(AgentId('session-created-barrier'))!
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(agent.session).toBe(session)
|
||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||
lifecycle.push('session-created:observer')
|
||||
})
|
||||
ctx.on('agent/created', () => void lifecycle.push('agent-created'))
|
||||
ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed'))
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed')
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('session-created-barrier'),
|
||||
sessionId: SessionId('session-created-barrier-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created:dispose',
|
||||
'session-created:observer',
|
||||
'session-disposed',
|
||||
'scope-disposed',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id !== AgentId('agent-created-barrier')) return
|
||||
lifecycle.push('agent-created:dispose')
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id !== AgentId('agent-created-barrier')) return
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||
lifecycle.push('agent-created:observer')
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('agent-created-barrier'),
|
||||
sessionId: SessionId('agent-created-barrier-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created',
|
||||
'agent-created:dispose',
|
||||
'agent-created:observer',
|
||||
'agent-disposed',
|
||||
'session-disposed',
|
||||
'scope-disposed',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks owner liveness after carrier capture before the first creation edge', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
|
||||
const agentId = AgentId('carrier-owner-race')
|
||||
const sessionId = SessionId('carrier-owner-race-s')
|
||||
const lifecycle: string[] = []
|
||||
let filterReads = 0
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id === sessionId) lifecycle.push('session-created')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === sessionId) lifecycle.push('session-disposed')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id === agentId) lifecycle.push('agent-created')
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
if (agent.id === agentId) lifecycle.push('agent-disposed')
|
||||
})
|
||||
|
||||
const creating = ownerCtx.agents.create({
|
||||
agentId,
|
||||
sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
Object.defineProperty(agentCtx.agent!.session, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
filterReads += 1
|
||||
void owner.dispose()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(filterReads).toBe(1)
|
||||
expect(lifecycle).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
|
||||
const ctx = await harness()
|
||||
const starts: string[] = []
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
ctx.on('agent/session-start', agent => void starts.push(agent.id))
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('listener-dispose'),
|
||||
sessionId: SessionId('listener-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(starts).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rechecks caller liveness after session-start before starting the driver', async () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
let announced!: ReactLoopAgent
|
||||
const statuses: string[] = []
|
||||
let scopeDisposed = false
|
||||
let observerSawLive = false
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
if (agent.id === AgentId('session-start-dispose')) statuses.push(status)
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
if (agent.id !== AgentId('session-start-dispose')) return
|
||||
announced = agent as ReactLoopAgent
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
if (agent.id !== AgentId('session-start-dispose')) return
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||
agent.ctx.effect(() => () => { scopeDisposed = true })
|
||||
observerSawLive = true
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('session-start-dispose'),
|
||||
sessionId: SessionId('session-start-dispose-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(announced.status).toBe('disposed')
|
||||
expect(statuses).toEqual(['disposed'])
|
||||
expect(observerSawLive).toBe(true)
|
||||
expect(scopeDisposed).toBe(true)
|
||||
expect(announced.session.events).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
|
||||
const ctx = await harness()
|
||||
const published: string[] = []
|
||||
@@ -527,6 +1044,89 @@ describe('agent scope lifecycle', () => {
|
||||
await unload
|
||||
})
|
||||
|
||||
it('successful handle disposal retires its caller ownership sentinel', async () => {
|
||||
const ctx = await harness()
|
||||
const agentId = AgentId('retired-owner-sentinel')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('retired-owner-sentinel-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.ownerLifecycle(${agentId})`)
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload after handle-first teardown follows the same in-flight boundary', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({
|
||||
agentId: AgentId('manual-first'),
|
||||
sessionId: SessionId('manual-first-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
agentCtx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
})
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
const disposing = handle.dispose()
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
const unloading = owner.dispose().then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([disposing, unloading])
|
||||
expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('retains both identity reservations until scope teardown reaches quiescence', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
const sessionDisposed = Promise.withResolvers<undefined>()
|
||||
const agentId = AgentId('quiescent-reservation')
|
||||
const sessionId = SessionId('quiescent-reservation-s')
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === sessionId) sessionDisposed.resolve(undefined)
|
||||
})
|
||||
const first = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
agentCtx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const disposing = first.dispose()
|
||||
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await expect(ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }))
|
||||
.rejects.toThrow(/reserved/)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await disposing
|
||||
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })
|
||||
await replacement.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
|
||||
@@ -8,28 +8,28 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
|
||||
|
||||
### Public API
|
||||
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary.
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair deliberately reuses the stable carrier captured before entry commit and applies the same per-listener containment directly. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary.
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => Promise<void> | void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||
- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability owned by the calling fiber (owner unload releases an abandoned reservation); `enter(agent, reservation?): () => void` inserts under one captured, runtime-pinned id without announcing; and `announce(agent)` emits `agent/created` exactly once for that exact live entry, rejecting repeat or reentrant announcement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`.
|
||||
- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability whose `release` is the exact owner effect disposer, allowing the factory to place ID release after scope quiescence instead of racing owner unload as a sibling. `enter(agent, reservation?): () => void` claims the ID across runtime pinning and stable lifecycle-carrier construction, then inserts without announcing; a Proxy trap or filter getter cannot reentrantly overwrite the commit. `announce(agent)` reuses that carrier and emits `agent/created` exactly once for the exact live entry, rejecting repeat or reentrant announcement. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach is exact-object guarded, so a later listener cannot observe inverted lifecycle edges and a stale capability cannot delete a replacement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`.
|
||||
- `ctx.agents.get(id: AgentId): Agent | undefined`
|
||||
- `ctx.agents.list(): Agent[]`
|
||||
|
||||
#### Factory seam (creation)
|
||||
|
||||
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package.
|
||||
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target, captures and validates the factory's `createAgent` and `resume` callbacks once at registration, retains that target as their intentional receiver, and passes each call an explicit caller-bound `ownerCtx`; later method replacement cannot redirect a transaction, double tracing cannot break raw-identity state, and a plain non-Cordis factory receives enough context to implement caller ownership.
|
||||
|
||||
- `ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
|
||||
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured.
|
||||
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert both session and agent, then recheck caller and factory liveness before the first creation announcement and after each later notification boundary. Only a still-live transaction opens `agent/session-start` and starts a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, caller unload, factory unload, or cancellation from a creation listener publishes no drivable agent. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert both → pre-announcement liveness check → session announcement → liveness check → agent announcement → liveness check → session-start → final liveness check → loop-start boundary. The IDs are reserved across persistence load, setup, and teardown quiescence; load/setup rejection, caller unload, or factory unload leaves no drivable or live publication, while any creation edge that already began is paired during rollback. Rejects if no factory is registered or session persistence is unconfigured.
|
||||
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
|
||||
|
||||
### Live events
|
||||
|
||||
`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events.
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` runs after the driver is quiescent and the agent leaves the registry, while ordered teardown may still be detaching its session and unwinding its scope.
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#owner-final-policy-boundaries).
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Fused scope-carrier dispatch for agent-subject events, plus the assembly
|
||||
* context builder. The ONE sanctioned spelling for dispatching `agent/*`
|
||||
* events: `agentEvents(ctx, agent).waterfall('agent/request', …)` builds the
|
||||
* scope carrier ({@link scopeTarget} keyed by the agent) AND injects the
|
||||
* subject as the first event argument in one move, so the correct dispatch is
|
||||
* also the shortest — a dispatch site cannot pass a carrier keyed to one
|
||||
* agent while naming another as the subject, which is the invariant the
|
||||
* dev-mode scoped-dispatch check asserts at runtime.
|
||||
* Fused scope-carrier dispatch for agent-subject operations, plus the assembly
|
||||
* context builder. The sanctioned ordinary spelling is
|
||||
* `agentEvents(ctx, agent).waterfall('agent/request', …)`: it builds the scope
|
||||
* carrier ({@link scopeTarget} keyed by the agent) AND injects the subject as
|
||||
* the first argument in one move, so a site cannot name a different subject.
|
||||
* The registry lifecycle pair is the deliberate exception: `enter()` captures
|
||||
* one stable carrier before commit and `announce()`/detach dispatch through it
|
||||
* directly, preventing a mutable filter getter from changing or reentering the
|
||||
* paired edges. The dev scoped-dispatch invariant checks both shapes.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/dispatch
|
||||
*/
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* @module @deepseek-ai/dsh-agent
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, getTraceable, Service, symbols } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, AgentId, AgentOptions } from './types.ts'
|
||||
import { agentEvents } from './dispatch.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
||||
@@ -112,17 +112,21 @@ export interface ResumeAgentOptions {
|
||||
|
||||
/**
|
||||
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
|
||||
* can tear this agent down. `dispose()` stops the loop, awaits its exit and
|
||||
* every outstanding idle-injection flush (quiescence — NOT just the `disposed`
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers,
|
||||
* only the holder can tear this agent down. The registered factory provider is
|
||||
* also a structural owner because the scoped agent depends on that provider's
|
||||
* service surface; provider unload stops and drains every live handle it made.
|
||||
* `dispose()` stops the loop, awaits its exit and every outstanding
|
||||
* idle-injection flush (quiescence — NOT just the `disposed`
|
||||
* status flip), unregisters the agent, removes its session from the store, and
|
||||
* finally unwinds its scoped world. This order captures every agent-started
|
||||
* `session/flush` before the session is detached and keeps scoped listeners
|
||||
* alive through those checkpoints.
|
||||
*
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
|
||||
* for the OWNER that created it. Config-created agents (the loop's own startup)
|
||||
* are owned by the loop fiber and never need a handle.
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is
|
||||
* exposed only to the consumer owner that created it; the structural provider
|
||||
* reaches the same teardown internally. Config-created agents (the loop's own
|
||||
* startup) are owned by the loop fiber and never need a handle.
|
||||
*/
|
||||
export interface AgentHandle {
|
||||
agent: Agent
|
||||
@@ -146,20 +150,62 @@ export interface AgentFactory {
|
||||
* that began is paired by `agent/disposed` or `session/disposed` during
|
||||
* rollback. The owner disposes the resolved handle to stop/drain,
|
||||
* unregister, remove the session, and unwind the scope.
|
||||
* The registry passes a context carrying the `create()` caller's fiber and
|
||||
* scope as `ownerCtx`. The implementation attaches the unpublished
|
||||
* transaction and resulting lifecycle to that owner; it must not infer
|
||||
* ownership from the factory object's registration context.
|
||||
* @param ownerCtx - caller-bound context that owns the transaction and live handle.
|
||||
* @param options - agent/session identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): Promise<AgentHandle>
|
||||
createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||
* both `ctx.sessionPersistence.load` and the optional unpublished setup
|
||||
* transaction; must be called after that service exists (consumers inject
|
||||
* `sessionPersistence`). Publication and drive unlocking follow the same
|
||||
* ordered boundary as {@link createAgent}.
|
||||
* @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
|
||||
* @param options - persisted identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
}
|
||||
|
||||
/** One accepted factory target plus the callback identities captured at registration. */
|
||||
interface AcceptedAgentFactory {
|
||||
target: AgentFactory
|
||||
createAgent: AgentFactory['createAgent']
|
||||
resume: AgentFactory['resume']
|
||||
}
|
||||
|
||||
/** Slot reservation while callback accessors are being captured. */
|
||||
const ACCEPTING_FACTORY = Symbol('accepting agent factory')
|
||||
|
||||
/** Capture and validate the complete factory contract exactly once. */
|
||||
function acceptAgentFactory(factory: unknown): AcceptedAgentFactory {
|
||||
if ((typeof factory !== 'object' && typeof factory !== 'function') || factory === null) {
|
||||
throw new TypeError('agent factory must be a non-null object or function')
|
||||
}
|
||||
// A service read through ctx is already a Cordis trace proxy. Retaining that
|
||||
// proxy and tracing it again for each create() caller produces two shadow
|
||||
// layers; raw-identity state (AgentLoop's private ownership controller is
|
||||
// one example) then unwraps only to the inner proxy instead of its service.
|
||||
// Canonicalize the one framework-produced layer at acceptance and capture
|
||||
// callbacks from the concrete target. Plain objects expose no original.
|
||||
const original: unknown = Reflect.get(factory, symbols.original)
|
||||
const target = ((typeof original === 'object' || typeof original === 'function') && original !== null)
|
||||
? original
|
||||
: factory
|
||||
const createAgent: unknown = Reflect.get(target, 'createAgent')
|
||||
const resume: unknown = Reflect.get(target, 'resume')
|
||||
if (typeof createAgent !== 'function') throw new TypeError('agent factory createAgent must be a function')
|
||||
if (typeof resume !== 'function') throw new TypeError('agent factory resume must be a function')
|
||||
return Object.freeze({
|
||||
target: target as AgentFactory,
|
||||
createAgent: createAgent as AgentFactory['createAgent'],
|
||||
resume: resume as AgentFactory['resume'],
|
||||
})
|
||||
}
|
||||
|
||||
/** Thrown when create/resume is called before an agent factory is registered. */
|
||||
@@ -187,6 +233,8 @@ export interface AgentRegistrationReservation {
|
||||
/**
|
||||
* Release the unpublished reservation; idempotent. The registry also
|
||||
* releases it automatically when the fiber that called `reserve` disposes.
|
||||
* This function is that exact Cordis effect disposer, so an ordered
|
||||
* lifecycle may yield it by identity and place release after quiescence.
|
||||
* @returns nothing.
|
||||
*/
|
||||
release(): void
|
||||
@@ -201,13 +249,21 @@ export interface AgentRegistrationReservation {
|
||||
*/
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<AgentId, Agent>()
|
||||
/** Ids claimed across caller-code boundaries before their exact entry commits. */
|
||||
private enteringIds = new Set<AgentId>()
|
||||
/** The one accepted registry key for each live agent; never reread caller state. */
|
||||
private acceptedIds = new WeakMap<Agent, AgentId>()
|
||||
/** Unpublished identities held across factory setup/load transactions. */
|
||||
private reservations = new Map<AgentId, AgentRegistrationReservation>()
|
||||
/** Entries whose `agent/created` announcement phase began. */
|
||||
private announced = new WeakSet<Agent>()
|
||||
private factory: AgentFactory | undefined
|
||||
/** Entries currently dispatching `agent/created`; detach waits for that dispatch to unwind. */
|
||||
private announcing = new WeakSet<Agent>()
|
||||
/** A detach requested reentrantly from `agent/created`. */
|
||||
private pendingDetach = new WeakSet<Agent>()
|
||||
/** Stable lifecycle dispatch carrier captured before an entry commits. */
|
||||
private carriers = new WeakMap<Agent, Scoped<Agent>>()
|
||||
private factory: AcceptedAgentFactory | typeof ACCEPTING_FACTORY | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
@@ -234,39 +290,27 @@ export class AgentRegistry extends Service {
|
||||
*/
|
||||
reserve(id: AgentId): AgentRegistrationReservation {
|
||||
if (typeof id !== 'string') throw new TypeError('agent id must be a string')
|
||||
if (this.store.has(id) || this.reservations.has(id)) {
|
||||
if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) {
|
||||
throw new Error(`agent "${id}" is already registered or reserved`)
|
||||
}
|
||||
let active = true
|
||||
const rawRelease = (): void => {
|
||||
if (!active) return
|
||||
active = false
|
||||
this.reservations.delete(id)
|
||||
}
|
||||
let disposeEffect!: () => Promise<void> | void
|
||||
const reservation: AgentRegistrationReservation = Object.freeze({
|
||||
id,
|
||||
release: () => {
|
||||
rawRelease()
|
||||
// Remove the now-inert ownership effect on manual transaction settle;
|
||||
// its cleanup is the exact idempotent raw release above.
|
||||
void disposeEffect()
|
||||
},
|
||||
})
|
||||
// `release` is the exact effect disposer. A composite lifecycle can yield
|
||||
// it by identity, moving automatic owner cleanup from a racing sibling to
|
||||
// the transaction's final ordered position.
|
||||
const release = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`)
|
||||
const reservation: AgentRegistrationReservation = Object.freeze({ id, release })
|
||||
this.reservations.set(id, reservation)
|
||||
try {
|
||||
disposeEffect = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`)
|
||||
} catch (error: unknown) {
|
||||
rawRelease()
|
||||
throw error
|
||||
}
|
||||
return reservation
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the agent-creation factory (the loop calls this on construction,
|
||||
* effect-scoped). Throws if a factory is already registered. Returns the
|
||||
* disposer; on dispose the factory slot is cleared.
|
||||
* effect-scoped). The registry captures both callback identities once and
|
||||
* later invokes them against the retained target receiver. Throws if a
|
||||
* factory is already registered. Returns the disposer; on dispose the
|
||||
* factory slot is cleared.
|
||||
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
|
||||
* @returns the disposer that clears the factory slot. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
@@ -275,7 +319,17 @@ export class AgentRegistry extends Service {
|
||||
setFactory(factory: AgentFactory): () => Promise<void> | void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
|
||||
this.factory = factory
|
||||
// Claim the slot before reading caller-controlled method accessors. A
|
||||
// getter may synchronously re-enter setFactory(); it must observe the
|
||||
// registration in progress instead of installing a nested factory that
|
||||
// the outer call would silently overwrite.
|
||||
this.factory = ACCEPTING_FACTORY
|
||||
try {
|
||||
this.factory = acceptAgentFactory(factory)
|
||||
} catch (error: unknown) {
|
||||
this.factory = undefined
|
||||
throw error
|
||||
}
|
||||
return () => { this.factory = undefined }
|
||||
}, 'agents.setFactory()')
|
||||
// The exact cordis effect disposer (the agents.register() convention): a
|
||||
@@ -285,6 +339,13 @@ export class AgentRegistry extends Service {
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** Return the accepted factory, excluding absence and reentrant acceptance. */
|
||||
private requireFactory(): AcceptedAgentFactory {
|
||||
const accepted = this.factory
|
||||
if (accepted === undefined || accepted === ACCEPTING_FACTORY) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and publish a new agent through the registered factory.
|
||||
* Distinct from {@link register} (which records an already-constructed
|
||||
@@ -295,8 +356,14 @@ export class AgentRegistry extends Service {
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
async create(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.createAgent(options)
|
||||
const accepted = this.requireFactory()
|
||||
const ownerCtx = this.ctx
|
||||
// Re-trace a Service-backed factory through the accessing context
|
||||
// explicitly. This preserves AgentLoop's dependency origin while binding
|
||||
// its effects to ownerCtx; plain factories receive ownerCtx as an explicit
|
||||
// capability and need no Cordis tracker magic.
|
||||
const receiver = getTraceable(ownerCtx, accepted.target)
|
||||
return Reflect.apply(accepted.createAgent, receiver, [ownerCtx, options])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,8 +374,10 @@ export class AgentRegistry extends Service {
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.resume(options)
|
||||
const accepted = this.requireFactory()
|
||||
const ownerCtx = this.ctx
|
||||
const receiver = getTraceable(ownerCtx, accepted.target)
|
||||
return Reflect.apply(accepted.resume, receiver, [ownerCtx, options])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,7 +416,9 @@ export class AgentRegistry extends Service {
|
||||
* @param reservation - the exact unpublished-id capability, when a factory
|
||||
* reserved this id across setup.
|
||||
* @returns an idempotent closure that removes this exact entry and emits
|
||||
* `agent/disposed` with listener failures contained.
|
||||
* `agent/disposed` with listener failures contained. When called from a
|
||||
* synchronous `agent/created` listener, removal and disposal wait until
|
||||
* that creation dispatch unwinds.
|
||||
*/
|
||||
enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void {
|
||||
const id = agent.id
|
||||
@@ -361,39 +432,108 @@ export class AgentRegistry extends Service {
|
||||
if (this.acceptedIds.has(agent)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
if (this.store.has(id)) {
|
||||
if (this.store.has(id) || this.enteringIds.has(id)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
this.enteringIds.add(id)
|
||||
let carrier: Scoped<Agent>
|
||||
try {
|
||||
// Registration accepts ownership of the public identity contract. Pin an
|
||||
// own data slot from the one captured value so a custom JavaScript Agent
|
||||
// with a getter or writable field cannot later present a different id to
|
||||
// event listeners while the registry still owns the accepted key.
|
||||
Object.defineProperty(agent, 'id', {
|
||||
value: id,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
} catch {
|
||||
// Only the engine's property-definition failure is swallowed; the stable
|
||||
// public error below is the registration contract exposed to callers.
|
||||
throw new TypeError('agent id must be installable as a stable own property')
|
||||
try {
|
||||
Object.defineProperty(agent, 'id', {
|
||||
value: id,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
} catch {
|
||||
// Only the engine's property-definition failure is normalized; filter
|
||||
// construction below retains its own precise failure.
|
||||
throw new TypeError('agent id must be installable as a stable own property')
|
||||
}
|
||||
// Capture one carrier for the paired lifecycle edges. Constructing it
|
||||
// reads a custom Agent's Context.filter and is therefore caller code;
|
||||
// the id claim above makes a same-id reentrant enter lose deterministically.
|
||||
carrier = scopeTarget(agent, agent)
|
||||
} finally {
|
||||
// Kept through the entire caller-code window; the final commit below is
|
||||
// synchronous and callback-free.
|
||||
this.enteringIds.delete(id)
|
||||
}
|
||||
const currentReservation = this.reservations.get(id)
|
||||
if (reservation === undefined) {
|
||||
/* v8 ignore next 2 -- reserve() rejects enteringIds, so no callback in
|
||||
* carrier construction can install a new same-id reservation */
|
||||
if (currentReservation !== undefined) {
|
||||
throw new Error(`agent "${id}" is reserved for unpublished creation`)
|
||||
}
|
||||
} else if (currentReservation !== reservation) {
|
||||
throw new Error(`agent "${id}" registration reservation is not active for this id`)
|
||||
}
|
||||
/* v8 ignore next 2 -- the enteringIds claim blocks every public same-id
|
||||
* commit until this callback-free final check has completed */
|
||||
if (this.acceptedIds.has(agent) || this.store.has(id)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
this.store.set(id, agent)
|
||||
this.acceptedIds.set(agent, id)
|
||||
this.carriers.set(agent, carrier)
|
||||
let entered = true
|
||||
return () => {
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
this.store.delete(id)
|
||||
this.acceptedIds.delete(agent)
|
||||
// An insertion rolled back before announce was never externally created,
|
||||
// so emitting disposed would invent an impossible lifecycle edge. Marking
|
||||
// happens before the created emit: if a later created listener throws,
|
||||
// earlier listeners may already have observed it and must see disposal.
|
||||
if (!this.announced.delete(agent)) return
|
||||
agentEvents(this.ctx, agent).emit('agent/disposed')
|
||||
// Every callback reached by this creation dispatch must observe the same
|
||||
// live entry, and disposal must follow creation. A listener may own
|
||||
// the advanced detach capability, so make that ordering structural:
|
||||
// visibility and the paired disposal are deferred until announce()'s
|
||||
// synchronous dispatch has unwound.
|
||||
if (this.announcing.has(agent)) {
|
||||
this.pendingDetach.add(agent)
|
||||
return
|
||||
}
|
||||
this.detachEntered(agent, id)
|
||||
}
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Remove one exact entered agent and emit its paired disposal when announced. */
|
||||
private detachEntered(agent: Agent, id: AgentId): void {
|
||||
this.pendingDetach.delete(agent)
|
||||
// A stale capability can never delete a later same-id lifecycle. The
|
||||
// commit claim prevents this mismatch in normal operation; retain the
|
||||
// exact-object guard as the final identity boundary.
|
||||
/* v8 ignore next 1 -- the commit claim makes replacement impossible; this
|
||||
* remains the exact-identity backstop against future mutation paths */
|
||||
if (this.store.get(id) !== agent || this.acceptedIds.get(agent) !== id) return
|
||||
this.store.delete(id)
|
||||
this.acceptedIds.delete(agent)
|
||||
const carrier = this.carriers.get(agent)
|
||||
this.carriers.delete(agent)
|
||||
// An insertion rolled back before announce was never externally created,
|
||||
// so emitting disposed would invent an impossible lifecycle edge. Marking
|
||||
// happens before the created emit: if a later created listener throws,
|
||||
// earlier listeners may already have observed it and must see disposal.
|
||||
if (!this.announced.delete(agent)) return
|
||||
/* v8 ignore next -- enter commits the carrier with the exact store entry */
|
||||
if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`)
|
||||
this.emitDisposed(agent, carrier, id)
|
||||
}
|
||||
|
||||
/** Emit the paired disposal edge through the entry's stable carrier. */
|
||||
private emitDisposed(agent: Agent, carrier: Scoped<Agent>, id: AgentId): void {
|
||||
const args: unknown[] = [carrier, 'agent/disposed', agent]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/disposed listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/disposed listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,21 +549,30 @@ export class AgentRegistry extends Service {
|
||||
if (id === undefined || this.store.get(id) !== agent) {
|
||||
throw new Error(`agent "${id ?? '<unknown>'}" is not live in this registry`)
|
||||
}
|
||||
if (this.announced.has(agent)) {
|
||||
if (this.announced.has(agent) || this.announcing.has(agent)) {
|
||||
throw new Error(`agent "${id}" was already announced`)
|
||||
}
|
||||
const carrier = this.carriers.get(agent)
|
||||
/* v8 ignore next -- enter commits the carrier with the exact store entry */
|
||||
if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`)
|
||||
// Mark before dispatch so a listener cannot recursively create a second
|
||||
// lifecycle edge; detach still pairs a partially delivered first edge.
|
||||
this.announcing.add(agent)
|
||||
this.announced.add(agent)
|
||||
const args: unknown[] = [scopeTarget(agent, agent), 'agent/created', agent]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
// A synchronous creation failure vetoes publication and rolls back.
|
||||
// Returned-promise rejection happens after this synchronous boundary, so
|
||||
// observe and report it instead of leaking an unhandled rejection.
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
const args: unknown[] = [carrier, 'agent/created', agent]
|
||||
try {
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
// A synchronous creation failure vetoes publication and rolls back.
|
||||
// Returned-promise rejection happens after this synchronous boundary, so
|
||||
// observe and report it instead of leaking an unhandled rejection.
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.announcing.delete(agent)
|
||||
if (this.pendingDetach.has(agent)) this.detachEntered(agent, id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -291,7 +291,11 @@ declare module 'cordis' {
|
||||
* to inject or queue work during startup. A synchronous listener throw
|
||||
* vetoes publication and rollback emits the matching disposal edges;
|
||||
* returned-promise rejection is observed and logged but cannot
|
||||
* retroactively veto this synchronous boundary.
|
||||
* retroactively veto this synchronous boundary. A synchronous listener
|
||||
* that requests the advanced registry detach does not remove the entry
|
||||
* immediately: removal and the paired `agent/disposed` edge wait until the
|
||||
* creation dispatch unwinds, so no later creation listener observes a
|
||||
* disposal that preceded its own creation callback.
|
||||
* @param agent - the newly registered agent with its live session and completed setup.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
@@ -302,11 +306,12 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent was removed from the registry after its driver and any in-flight
|
||||
* turn reached quiescence. Ordered teardown may still be detaching the
|
||||
* session and unwinding the agent's scoped registrations when this
|
||||
* notification runs.
|
||||
* @param agent - the deregistered agent; its driving handle is now inert.
|
||||
* An agent was removed from the registry. The concrete AgentLoop lifecycle
|
||||
* emits this only after its driver and any in-flight turn reach quiescence;
|
||||
* a custom agent registered through the public registry owns its own driver
|
||||
* contract, which the registry cannot infer. Ordered teardown may still be
|
||||
* detaching the session and unwinding scoped registrations when this runs.
|
||||
* @param agent - the exact agent removed from the registry.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
@@ -348,11 +353,12 @@ declare module 'cordis' {
|
||||
/**
|
||||
* The agent's session lifecycle began, fired once before its first turn.
|
||||
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
|
||||
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): it
|
||||
* carries no veto — a session-start listener that wants to seed context does
|
||||
* so via `agent.inject()` (a `context/message` the first request sees), not
|
||||
* by returning a decision. Cannot block the session from starting; that gap
|
||||
* is deliberate (a bridge logs/injects, it does not gate startup).
|
||||
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): a
|
||||
* listener cannot veto by returning a decision or throwing. A listener that
|
||||
* wants to seed context does so via `agent.inject()` (a `context/message` the
|
||||
* first request sees). A lifecycle owner can still dispose its structural
|
||||
* ownership edge during this notification; publication rechecks liveness and
|
||||
* then aborts before the driver starts.
|
||||
* @param agent - the agent whose session lifecycle began.
|
||||
* @param source - why the session started (fresh startup, resume, …).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Agent, AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
const id = AgentId(rawId)
|
||||
@@ -178,6 +179,96 @@ describe('AgentRegistry', () => {
|
||||
expect(() => ctx.agents.enter(pinnedAccessor)).toThrow(/installable as a stable own property/)
|
||||
})
|
||||
|
||||
it('claims an id across a Proxy defineProperty trap before committing the exact entry', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const id = AgentId('reentrant-enter')
|
||||
const nested = stubAgent(id)
|
||||
let nestedError = ''
|
||||
let attempted = false
|
||||
const target = stubAgent(id)
|
||||
const outer = new Proxy(target, {
|
||||
defineProperty(inner, property, descriptor) {
|
||||
if (property === 'id' && !attempted) {
|
||||
attempted = true
|
||||
try {
|
||||
ctx.agents.enter(nested)
|
||||
} catch (error: unknown) {
|
||||
nestedError = String(error)
|
||||
}
|
||||
}
|
||||
return Reflect.defineProperty(inner, property, descriptor)
|
||||
},
|
||||
})
|
||||
|
||||
const detach = ctx.agents.enter(outer)
|
||||
expect(nestedError).toMatch(/already registered/)
|
||||
expect(ctx.agents.get(id)).toBe(outer)
|
||||
detach()
|
||||
expect(ctx.agents.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures one lifecycle carrier before commit so a filter getter cannot invert edges', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const events: string[] = []
|
||||
const agent = stubAgent('reentrant-carrier')
|
||||
let detach = (): void => {}
|
||||
Object.defineProperty(agent, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
events.push('filter-getter')
|
||||
detach()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
detach = ctx.agents.enter(agent)
|
||||
ctx.on('agent/created', () => { events.push('created') })
|
||||
ctx.on('agent/disposed', () => { events.push('disposed') })
|
||||
|
||||
ctx.agents.announce(agent)
|
||||
expect(events).toEqual(['filter-getter', 'created'])
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
detach()
|
||||
expect(events).toEqual(['filter-getter', 'created', 'disposed'])
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('revalidates an exact reservation after carrier construction runs caller code', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const reservation = ctx.agents.reserve(AgentId('released-during-enter'))
|
||||
const agent = stubAgent('released-during-enter')
|
||||
Object.defineProperty(agent, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
reservation.release()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => ctx.agents.enter(agent, reservation)).toThrow(/reservation is not active/)
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('observes an async agent/disposed rejection through the stable carrier', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
ctx.on('agent/disposed', () => Promise.reject(new Error('late disposal failure')) as never)
|
||||
const agent = stubAgent('async-disposed')
|
||||
const detach = ctx.agents.enter(agent)
|
||||
ctx.agents.announce(agent)
|
||||
|
||||
detach()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(warnings).toEqual([
|
||||
'agent "async-disposed": agent/disposed listener rejected: Error: late disposal failure',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses an opaque one-id reservation to gate unpublished factory insertion', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -251,6 +342,34 @@ describe('AgentRegistry', () => {
|
||||
detach()
|
||||
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
|
||||
})
|
||||
|
||||
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const order: string[] = []
|
||||
const agent = stubAgent('reentrant-detach')
|
||||
const detach = ctx.agents.enter(agent)
|
||||
|
||||
ctx.on('agent/created', (created) => {
|
||||
order.push('created:first')
|
||||
detach()
|
||||
expect(ctx.agents.get(created.id)).toBe(created)
|
||||
})
|
||||
ctx.on('agent/created', (created) => {
|
||||
order.push('created:second')
|
||||
expect(ctx.agents.get(created.id)).toBe(created)
|
||||
})
|
||||
ctx.on('agent/disposed', (disposed) => {
|
||||
order.push('disposed')
|
||||
expect(ctx.agents.get(disposed.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
ctx.agents.announce(agent)
|
||||
|
||||
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
detach()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentEvents()', () => {
|
||||
@@ -281,14 +400,17 @@ describe('agentEvents()', () => {
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
/** A stub AgentFactory that records calls and returns a stub agent. */
|
||||
function stubFactory() {
|
||||
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
|
||||
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
|
||||
async createAgent(options) {
|
||||
calls.create.push(options)
|
||||
const calls: {
|
||||
create: Array<{ ownerCtx: Context; options: CreateAgentOptions }>
|
||||
resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }>
|
||||
} = { create: [], resume: [] }
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(ownerCtx, options) {
|
||||
calls.create.push({ ownerCtx, options })
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
},
|
||||
resume(options) {
|
||||
calls.resume.push(options)
|
||||
resume(ownerCtx, options) {
|
||||
calls.resume.push({ ownerCtx, options })
|
||||
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
||||
},
|
||||
}
|
||||
@@ -310,11 +432,151 @@ describe('AgentRegistry factory seam', () => {
|
||||
|
||||
const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
|
||||
expect(created.agent.id).toBe('c1')
|
||||
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
|
||||
expect(calls.create).toHaveLength(1)
|
||||
expect(calls.create[0]!.ownerCtx.fiber).toBe(ctx.fiber)
|
||||
expect(calls.create[0]!.options)
|
||||
.toEqual({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
|
||||
|
||||
const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
|
||||
expect(resumed.agent.id).toBe('r1')
|
||||
expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
|
||||
expect(calls.resume).toHaveLength(1)
|
||||
expect(calls.resume[0]!.ownerCtx.fiber).toBe(ctx.fiber)
|
||||
expect(calls.resume[0]!.options).toEqual({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
|
||||
})
|
||||
|
||||
it('passes the calling fiber to a plain factory for create and resume ownership', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const { factory, calls } = stubFactory()
|
||||
ctx.agents.setFactory(factory)
|
||||
let callerFiber: Context['fiber'] | undefined
|
||||
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
callerFiber = inner.fiber
|
||||
await inner.agents.create({ agentId: AgentId('owned-create'), sessionId: SessionId('owned-session') })
|
||||
await inner.agents.resume({ agentId: AgentId('owned-resume'), resumeSessionId: SessionId('persisted') })
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
expect(calls.create[0]!.ownerCtx.fiber).toBe(callerFiber)
|
||||
expect(calls.resume[0]!.ownerCtx.fiber).toBe(callerFiber)
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('captures factory callbacks once while retaining the intentional target receiver', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const reads = { create: 0, resume: 0 }
|
||||
const receivers: unknown[] = []
|
||||
const replacements: string[] = []
|
||||
const target = { label: 'accepted-target' } as { label: string } & AgentFactory
|
||||
|
||||
Object.defineProperties(target, {
|
||||
createAgent: {
|
||||
configurable: true,
|
||||
get() {
|
||||
reads.create += 1
|
||||
return function (this: typeof target, _ownerCtx: Context, options: CreateAgentOptions) {
|
||||
receivers.push(this)
|
||||
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
||||
}
|
||||
},
|
||||
},
|
||||
resume: {
|
||||
configurable: true,
|
||||
get() {
|
||||
reads.resume += 1
|
||||
return function (this: typeof target, _ownerCtx: Context, options: ResumeAgentOptions) {
|
||||
receivers.push(this)
|
||||
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx.agents.setFactory(target)
|
||||
Object.defineProperties(target, {
|
||||
createAgent: {
|
||||
value: () => {
|
||||
replacements.push('create')
|
||||
return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() })
|
||||
},
|
||||
},
|
||||
resume: {
|
||||
value: () => {
|
||||
replacements.push('resume')
|
||||
return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() })
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.agents.create({ agentId: AgentId('captured-create'), sessionId: SessionId('captured-session') })
|
||||
await ctx.agents.resume({ agentId: AgentId('captured-resume'), resumeSessionId: SessionId('captured-persisted') })
|
||||
|
||||
expect(reads).toEqual({ create: 1, resume: 1 })
|
||||
expect(receivers).toEqual([target, target])
|
||||
expect(replacements).toEqual([])
|
||||
})
|
||||
|
||||
it('reserves the factory slot before reading reentrant callback accessors', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const nested = stubFactory().factory
|
||||
const reads: string[] = []
|
||||
const reentrantCreate: Promise<unknown>[] = []
|
||||
const target = {} as AgentFactory
|
||||
|
||||
Object.defineProperties(target, {
|
||||
createAgent: {
|
||||
get() {
|
||||
reads.push('createAgent')
|
||||
expect(() => ctx.agents.setFactory(nested)).toThrow(/already registered/)
|
||||
reentrantCreate.push(ctx.agents.create({
|
||||
agentId: AgentId('during-acceptance'),
|
||||
sessionId: SessionId('during-acceptance-session'),
|
||||
}))
|
||||
return (_ownerCtx: Context, options: CreateAgentOptions) => Promise.resolve({
|
||||
agent: stubAgent(options.agentId),
|
||||
dispose: () => Promise.resolve(),
|
||||
})
|
||||
},
|
||||
},
|
||||
resume: {
|
||||
get() {
|
||||
reads.push('resume')
|
||||
return (_ownerCtx: Context, options: ResumeAgentOptions) => Promise.resolve({
|
||||
agent: stubAgent(options.agentId),
|
||||
dispose: () => Promise.resolve(),
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx.agents.setFactory(target)
|
||||
expect(reentrantCreate).toHaveLength(1)
|
||||
await expect(Promise.all(reentrantCreate)).rejects.toThrow(/no agent factory/)
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('after-acceptance'),
|
||||
sessionId: SessionId('after-acceptance-session'),
|
||||
})).resolves.toMatchObject({ agent: { id: 'after-acceptance' } })
|
||||
expect(reads).toEqual(['createAgent', 'resume'])
|
||||
})
|
||||
|
||||
it('validates the complete factory shape when accepting it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
expect(() => ctx.agents.setFactory(null as unknown as AgentFactory)).toThrow(/non-null object or function/)
|
||||
expect(() => ctx.agents.setFactory(42 as unknown as AgentFactory)).toThrow(/non-null object or function/)
|
||||
expect(() => ctx.agents.setFactory({ resume() { return Promise.resolve() } } as unknown as AgentFactory))
|
||||
.toThrow(/createAgent must be a function/)
|
||||
expect(() => ctx.agents.setFactory({ createAgent() { return Promise.resolve() } } as unknown as AgentFactory))
|
||||
.toThrow(/resume must be a function/)
|
||||
|
||||
const callable = Object.assign(() => undefined, stubFactory().factory)
|
||||
const dispose = ctx.agents.setFactory(callable)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('callable'), sessionId: SessionId('callable-session') }))
|
||||
.resolves.toBeDefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('setFactory rejects a second factory', async () => {
|
||||
@@ -337,4 +599,81 @@ describe('AgentRegistry factory seam', () => {
|
||||
// factory slot cleared → create throws again
|
||||
await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/)
|
||||
})
|
||||
|
||||
it('canonicalizes an already traced Service factory before caller retracing', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const states = new WeakMap<object, string[]>()
|
||||
class TracedFactory extends Service implements AgentFactory {
|
||||
constructor(inner: Context) {
|
||||
super(inner, 'tracedFactory')
|
||||
states.set(this, [])
|
||||
}
|
||||
|
||||
private calls(): string[] {
|
||||
const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this
|
||||
const calls = states.get(original)
|
||||
if (calls === undefined) throw new Error('factory receiver did not canonicalize to the raw service')
|
||||
return calls
|
||||
}
|
||||
|
||||
createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
|
||||
this.calls().push('create')
|
||||
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
||||
}
|
||||
|
||||
resume(_ownerCtx: Context, options: ResumeAgentOptions) {
|
||||
this.calls().push('resume')
|
||||
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
||||
}
|
||||
}
|
||||
await ctx.plugin(TracedFactory)
|
||||
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
|
||||
ctx.agents.setFactory(traced)
|
||||
|
||||
await ctx.agents.create({ agentId: AgentId('traced-create'), sessionId: SessionId('traced-session') })
|
||||
await ctx.agents.resume({ agentId: AgentId('traced-resume'), resumeSessionId: SessionId('traced-persisted') })
|
||||
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
|
||||
expect(states.get(raw!)).toEqual(['create', 'resume'])
|
||||
})
|
||||
|
||||
it('rolls back register and factory acceptance when their owner unloads reentrantly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
|
||||
const agent = stubAgent('register-unload-race')
|
||||
ctx.on('agent/created', (created) => {
|
||||
if (created === agent) void owner.dispose()
|
||||
})
|
||||
|
||||
ownerCtx.agents.register(agent)
|
||||
await owner.dispose()
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
|
||||
let factoryOwnerCtx!: Context
|
||||
const factoryOwner = await ctx.plugin(Object.assign((inner: Context) => { factoryOwnerCtx = inner }, { inject: ['agents'] }))
|
||||
const target = {} as AgentFactory
|
||||
Object.defineProperties(target, {
|
||||
createAgent: {
|
||||
get() {
|
||||
void factoryOwner.dispose()
|
||||
return (_inner: Context, options: CreateAgentOptions) => Promise.resolve({
|
||||
agent: stubAgent(options.agentId),
|
||||
dispose: () => Promise.resolve(),
|
||||
})
|
||||
},
|
||||
},
|
||||
resume: {
|
||||
value: (_inner: Context, options: ResumeAgentOptions) => Promise.resolve({
|
||||
agent: stubAgent(options.agentId),
|
||||
dispose: () => Promise.resolve(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
factoryOwnerCtx.agents.setFactory(target)
|
||||
await factoryOwner.dispose()
|
||||
await expect(ctx.agents.create({ agentId: AgentId('after-owner'), sessionId: SessionId('after-owner-s') }))
|
||||
.rejects.toThrow(/no agent factory/)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
|
||||
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
|
||||
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
|
||||
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier uses a dedicated surrogate proxy target whose immutable filter slot cannot be replaced by a base property pinned before, during, or after construction; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The captured base filter and the exposed composed filter are invoked through captured JavaScript primordials, and the composed filter's frozen invocation surface cannot be replaced or tampered with. The carrier uses a dedicated surrogate proxy target; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate; defining through the carrier is therefore supported only with an explicit `configurable: true` descriptor, while an omitted or false flag is rejected before the base is touched. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
// Capture the invocation primordials once. A carrier holder can reach the
|
||||
// composed Context.filter function, so neither that function's mutable
|
||||
// property surface nor a base filter's own `.call` may choose how isolation
|
||||
// predicates are invoked.
|
||||
const reflectApply = Reflect.apply
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const functionCall = Function.prototype.call
|
||||
|
||||
/**
|
||||
* The identity a scope is keyed by. Opaque and compared by object identity —
|
||||
* never inspected. The harness convention: a live `Agent` is the key of its
|
||||
@@ -194,8 +202,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean {
|
||||
* - its tag IS `key` (a scoped listener seeing exactly its own subject),
|
||||
*
|
||||
* AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits
|
||||
* it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a
|
||||
* tool call with no calling agent or a bare (agent-less) session's events —
|
||||
* it. Both the captured base filter and the composed filter are invoked
|
||||
* through captured JavaScript primordials, so mutating either function's
|
||||
* public `.call` property cannot bypass either predicate. Dispatching with
|
||||
* `key === undefined` — a subject-less dispatch, e.g. a tool call with no
|
||||
* calling agent or a bare (agent-less) session's events —
|
||||
* admits only untagged listeners: a scoped listener never fires for someone
|
||||
* else's (or nobody's) subject. Listeners registered `{ global: true }`
|
||||
* bypass all filtering (Cordis semantics).
|
||||
@@ -211,7 +222,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean {
|
||||
* subject always travels in the event's arguments. The returned carrier is
|
||||
* branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} /
|
||||
* {@link carrierKeyOf}) so both the type system and the dev invariants can
|
||||
* tell a carrier from a bare subject.
|
||||
* tell a carrier from a bare subject. Defining an ordinary property through
|
||||
* the carrier is supported only when its descriptor explicitly says
|
||||
* `configurable: true`; an omitted or false flag is rejected before touching
|
||||
* `base`, because the extensible surrogate cannot truthfully report a new
|
||||
* non-configurable base property.
|
||||
* @param base - the object the event is dispatched on behalf of (the owning
|
||||
* service, or the subject agent itself); its own `Context.filter` is
|
||||
* preserved and composed.
|
||||
@@ -225,10 +240,19 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
|
||||
throw new TypeError('scope target Context.filter must be a function when present')
|
||||
}
|
||||
const filter = (ctx: Context): boolean => {
|
||||
if (baseFilter && !baseFilter.call(base, ctx)) return false
|
||||
if (baseFilter && !reflectApply(functionCall, baseFilter, [base, ctx])) return false
|
||||
const tag = scopeOf(ctx)
|
||||
return tag === undefined || tag === key
|
||||
}
|
||||
// Cordis invokes a dispatch filter as `filter.call(thisArg, listenerCtx)`.
|
||||
// Pin that property to the captured primordial, then freeze the callable so
|
||||
// a carrier holder cannot replace it with an always-true scope bypass.
|
||||
Object.defineProperty(filter, 'call', {
|
||||
value: functionCall,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
Object.freeze(filter)
|
||||
const overlay: Record<string | symbol, unknown> = {
|
||||
[CordisContext.filter]: filter,
|
||||
[kCarrier]: Object.freeze({ key }),
|
||||
@@ -317,7 +341,7 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
|
||||
return undefined
|
||||
},
|
||||
defineProperty(_target, prop, attributes) {
|
||||
if (Object.hasOwn(overlay, prop)) return false
|
||||
if (Object.hasOwn(overlay, prop) || attributes.configurable !== true) return false
|
||||
return Reflect.defineProperty(base, prop, attributes)
|
||||
},
|
||||
deleteProperty(_target, prop) {
|
||||
|
||||
@@ -189,10 +189,54 @@ describe('scopeTarget dispatch filtering', () => {
|
||||
ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed')
|
||||
expect(heard).toEqual([])
|
||||
|
||||
// A base whose filter accepts delegates to the scope predicate.
|
||||
const openBase = { [Context.filter]: () => true }
|
||||
// A base whose filter accepts delegates to the scope predicate, with the
|
||||
// real base preserved as its `this` receiver.
|
||||
let baseReceiverWasOpen = false
|
||||
const openBase = {
|
||||
[Context.filter](this: object): boolean {
|
||||
baseReceiverWasOpen = this === openBase
|
||||
return true
|
||||
},
|
||||
}
|
||||
ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open')
|
||||
expect(heard).toEqual(['global:open', 'A:open'])
|
||||
expect(baseReceiverWasOpen).toBe(true)
|
||||
|
||||
// A function's public `.call` property is not its invocation semantics.
|
||||
// An always-true replacement must not override the base predicate's veto.
|
||||
const tamperedVeto = (): boolean => false
|
||||
Object.defineProperty(tamperedVeto, 'call', { value: () => true })
|
||||
ctx.emit(scopeTarget({ [Context.filter]: tamperedVeto }, keyA), 'scope-test/ping', 'tampered-veto')
|
||||
expect(heard).toEqual(['global:open', 'A:open'])
|
||||
})
|
||||
|
||||
it('pins the exposed composed filter invocation so a carrier holder cannot bypass isolation', async () => {
|
||||
const ctx = new Context()
|
||||
const keyA = { name: 'A' }
|
||||
const keyB = { name: 'B' }
|
||||
const scopeA = await mintScope(ctx, keyA)
|
||||
const scopeB = await mintScope(ctx, keyB)
|
||||
const heard: string[] = []
|
||||
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
|
||||
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
|
||||
|
||||
const carrier = scopeTarget(ctx, keyA)
|
||||
const exposedFilter: unknown = Reflect.get(carrier, Context.filter)
|
||||
expect(typeof exposedFilter).toBe('function')
|
||||
const filter = exposedFilter as ((ctx: Context) => boolean) & { call: (...args: unknown[]) => unknown }
|
||||
const primordialCall: unknown = Reflect.get(Function.prototype, 'call')
|
||||
expect(Object.getOwnPropertyDescriptor(filter, 'call')).toMatchObject({
|
||||
value: primordialCall,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
expect(Object.isFrozen(filter)).toBe(true)
|
||||
expect(Reflect.set(filter, 'call', () => true)).toBe(false)
|
||||
expect(Reflect.defineProperty(filter, 'call', { value: () => true })).toBe(false)
|
||||
|
||||
ctx.emit(carrier, 'scope-test/ping', 'still-A-only')
|
||||
expect(heard).toEqual(['global:still-A-only', 'A:still-A-only'])
|
||||
})
|
||||
|
||||
it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => {
|
||||
@@ -256,6 +300,14 @@ describe('scopeTarget dispatch filtering', () => {
|
||||
Object.defineProperty(carrier, 'extra', { value: 1, configurable: true })
|
||||
expect((base as typeof base & { extra?: number }).extra).toBe(1)
|
||||
expect(delete (carrier as typeof carrier & { extra?: number }).extra).toBe(true)
|
||||
|
||||
// A non-configurable property cannot be reflected truthfully through the
|
||||
// extensible surrogate. Reject before mutating the delegated base; an
|
||||
// omitted `configurable` has JavaScript's false default and is rejected too.
|
||||
expect(Reflect.defineProperty(carrier, 'sealed', { value: 1, configurable: false })).toBe(false)
|
||||
expect(Object.hasOwn(base, 'sealed')).toBe(false)
|
||||
expect(Reflect.defineProperty(carrier, 'default-sealed', { value: 2 })).toBe(false)
|
||||
expect(Object.hasOwn(base, 'default-sealed')).toBe(false)
|
||||
expect(Reflect.preventExtensions(carrier)).toBe(false)
|
||||
expect(Reflect.setPrototypeOf(carrier, null)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -19,9 +19,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store-owned append observer detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
|
||||
|
||||
- `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
|
||||
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. Until `release()` or owner unload, bare `prepare`/`create`/`enter` calls for that id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal.
|
||||
- `ctx.sessions.enter(session, reservation?): () => void` — install the module-private `session/event` observer, capture its scope carrier, and add the session under one accepted id; returns the idempotent DETACH disposer, which clears notification, carrier, and accepted-key state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
|
||||
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. Its detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
|
||||
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. `release` is the exact owner effect disposer, so the agent lifecycle can adopt it and keep the ID reserved until scope cleanup quiesces. Until that release, bare `prepare`/`create`/`enter` calls for the id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal.
|
||||
- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private `session/event` observer and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears notification, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
|
||||
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
|
||||
|
||||
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ declare module 'cordis' {
|
||||
* A session was created in the store. A synchronous listener throw vetoes
|
||||
* publication and rollback emits the matching `session/disposed` edge;
|
||||
* returned-promise rejection is observed and logged but cannot retroactively
|
||||
* veto this synchronous boundary.
|
||||
* veto this synchronous boundary. A synchronous listener that requests the
|
||||
* advanced detach does not remove the entry immediately: removal and the
|
||||
* paired `session/disposed` edge wait until the creation dispatch unwinds.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
@@ -648,7 +650,9 @@ export interface SessionRegistrationReservation {
|
||||
prepare(options?: CreateSessionOptions): Session
|
||||
/**
|
||||
* Release the unpublished reservation; idempotent. The store also releases
|
||||
* it automatically when the fiber that called `reserve` disposes.
|
||||
* it automatically when the fiber that called `reserve` disposes. This
|
||||
* function is that exact Cordis effect disposer, so an ordered lifecycle may
|
||||
* yield it by identity and place release after quiescence.
|
||||
* @returns nothing.
|
||||
*/
|
||||
release(): void
|
||||
@@ -662,10 +666,16 @@ export interface SessionRegistrationReservation {
|
||||
*/
|
||||
export class SessionStore extends Service {
|
||||
private store = new Map<SessionId, Session>()
|
||||
/** Ids claimed across caller-code boundaries before their exact entry commits. */
|
||||
private enteringIds = new Set<SessionId>()
|
||||
/** The one accepted map key for each live session; never reread caller state. */
|
||||
private acceptedIds = new WeakMap<Session, SessionId>()
|
||||
/** Sessions whose creation announcement began and therefore require a pair. */
|
||||
private announced = new WeakSet<Session>()
|
||||
/** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */
|
||||
private announcing = new WeakSet<Session>()
|
||||
/** A detach requested reentrantly from `session/created`. */
|
||||
private pendingDetach = new WeakSet<Session>()
|
||||
/** Unpublished identities held across factory load/setup transactions. */
|
||||
private reservations = new Map<SessionId, SessionRegistrationReservation>()
|
||||
/** The exact prepared object owned by each reservation capability. */
|
||||
@@ -696,18 +706,19 @@ export class SessionStore extends Service {
|
||||
*/
|
||||
reserve(id: SessionId): SessionRegistrationReservation {
|
||||
if (typeof id !== 'string') throw new TypeError('session id must be a string')
|
||||
if (this.store.has(id) || this.reservations.has(id)) {
|
||||
if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) {
|
||||
throw new Error(`session "${id}" already exists or is reserved`)
|
||||
}
|
||||
let active = true
|
||||
let prepared = false
|
||||
const rawRelease = (): void => {
|
||||
if (!active) return
|
||||
active = false
|
||||
this.reservedSessions.delete(reservation)
|
||||
this.reservations.delete(id)
|
||||
}
|
||||
let disposeEffect!: () => Promise<void> | void
|
||||
// `release` is the exact effect disposer, so an ordered composite can
|
||||
// adopt the automatic owner cleanup instead of racing it as a sibling.
|
||||
const release = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`)
|
||||
const reservation: SessionRegistrationReservation = Object.freeze({
|
||||
id,
|
||||
prepare: (options?: CreateSessionOptions) => {
|
||||
@@ -720,20 +731,9 @@ export class SessionStore extends Service {
|
||||
this.reservedSessions.set(reservation, session)
|
||||
return session
|
||||
},
|
||||
release: () => {
|
||||
rawRelease()
|
||||
// Remove the now-inert ownership effect on manual transaction settle;
|
||||
// its cleanup is the exact idempotent raw release above.
|
||||
void disposeEffect()
|
||||
},
|
||||
release,
|
||||
})
|
||||
this.reservations.set(id, reservation)
|
||||
try {
|
||||
disposeEffect = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`)
|
||||
} catch (error: unknown) {
|
||||
rawRelease()
|
||||
throw error
|
||||
}
|
||||
return reservation
|
||||
}
|
||||
|
||||
@@ -845,7 +845,9 @@ export class SessionStore extends Service {
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @param reservation - the exact unpublished-id capability when a factory
|
||||
* reserved this session across setup.
|
||||
* @returns the detach disposer (observer + store removal).
|
||||
* @returns the detach disposer (observer + store removal). When called from
|
||||
* a synchronous `session/created` listener, removal and disposal wait until
|
||||
* that creation dispatch unwinds.
|
||||
* @throws if a session with this id is already in the store.
|
||||
*/
|
||||
enter(session: Session, reservation?: SessionRegistrationReservation): () => void {
|
||||
@@ -858,30 +860,71 @@ export class SessionStore extends Service {
|
||||
|| this.reservedSessions.get(reservation) !== session) {
|
||||
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
|
||||
}
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
if (this.store.has(id) || this.enteringIds.has(id)) {
|
||||
throw new Error(`session "${id}" already exists`)
|
||||
}
|
||||
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
this.enteringIds.add(id)
|
||||
// The carrier is decided HERE, once, from the ENTERING context's scope tag
|
||||
// (`this.ctx` is the caller's context — the tracker mechanism): every
|
||||
// session/created|event|flush dispatch for this session uses it, so the
|
||||
// session's whole event feed is scope-filtered consistently. The base is
|
||||
// the session itself (scoped listeners' `this` is the session).
|
||||
const carrier = scopeTarget(session, scopeOf(this.ctx))
|
||||
let carrier: Scoped<Session>
|
||||
try {
|
||||
carrier = scopeTarget(session, scopeOf(this.ctx))
|
||||
} finally {
|
||||
this.enteringIds.delete(id)
|
||||
}
|
||||
const currentReservation = this.reservations.get(id)
|
||||
if (reservation === undefined) {
|
||||
/* v8 ignore next 2 -- reserve() rejects enteringIds, so carrier
|
||||
* construction cannot install a new same-id reservation */
|
||||
if (currentReservation !== undefined) {
|
||||
throw new Error(`session "${id}" is reserved for unpublished creation`)
|
||||
}
|
||||
} else if (currentReservation !== reservation
|
||||
|| this.reservedSessions.get(reservation) !== session) {
|
||||
throw new Error(`session "${id}" registration reservation does not own this prepared session`)
|
||||
}
|
||||
/* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
this.carriers.set(session, carrier)
|
||||
const emitCtx = this.ctx
|
||||
appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) })
|
||||
this.acceptedIds.set(session, id)
|
||||
this.store.set(id, session)
|
||||
let entered = true
|
||||
return () => {
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
const wasAnnounced = this.announced.delete(session)
|
||||
appendObservers.delete(session)
|
||||
this.acceptedIds.delete(session)
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(id)
|
||||
if (wasAnnounced) this.emitDisposed(session, carrier, id)
|
||||
// A creation listener may own the advanced detach capability. Keep the
|
||||
// entry and its event observer live until the synchronous creation
|
||||
// dispatch unwinds, then publish the paired disposal edge.
|
||||
if (this.announcing.has(session)) {
|
||||
this.pendingDetach.add(session)
|
||||
return
|
||||
}
|
||||
this.detachEntered(session, id, carrier)
|
||||
}
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Remove one exact entered session and emit its paired disposal when announced. */
|
||||
private detachEntered(session: Session, id: SessionId, carrier: Scoped<Session>): void {
|
||||
this.pendingDetach.delete(session)
|
||||
// A stale capability cannot remove observers or storage belonging to a
|
||||
// later same-id lifecycle.
|
||||
/* v8 ignore next 1 -- the commit claim makes replacement impossible; this
|
||||
* remains the exact-identity backstop against future mutation paths */
|
||||
if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return
|
||||
const wasAnnounced = this.announced.delete(session)
|
||||
appendObservers.delete(session)
|
||||
this.acceptedIds.delete(session)
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(id)
|
||||
if (wasAnnounced) this.emitDisposed(session, carrier, id)
|
||||
}
|
||||
|
||||
/** Emit `session/created` exactly once for an {@link enter}ed session (with
|
||||
@@ -892,25 +935,31 @@ export class SessionStore extends Service {
|
||||
* @throws if the session is not live or its announcement already began,
|
||||
* including a reentrant call from a creation listener. */
|
||||
announce(session: Session): void {
|
||||
const carrier = this.liveCarrierFor(session)
|
||||
const { carrier, id } = this.liveEntryFor(session)
|
||||
if (this.announced.has(session)) {
|
||||
throw new Error(`session "${session.id}" was already announced`)
|
||||
throw new Error(`session "${id}" was already announced`)
|
||||
}
|
||||
// Mark before emit: Cordis emit may deliver to earlier listeners and then
|
||||
// throw. Rollback must still pair that partial creation with disposal, and
|
||||
// a listener cannot recursively create a second lifecycle edge.
|
||||
this.announced.add(session)
|
||||
const args: unknown[] = [carrier, 'session/created', session]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
// Synchronous throws intentionally propagate and veto publication; the
|
||||
// yielded detach then emits the paired disposal edge. An async function
|
||||
// is nevertheless assignable to a void listener, so observe its returned
|
||||
// promise: rejection is too late to roll back and must be logged instead
|
||||
// of becoming unhandled.
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${session.id}": session/created listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
this.announcing.add(session)
|
||||
try {
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
// Synchronous throws intentionally propagate and veto publication; the
|
||||
// yielded detach then emits the paired disposal edge. An async function
|
||||
// is nevertheless assignable to a void listener, so observe its returned
|
||||
// promise: rejection is too late to roll back and must be logged instead
|
||||
// of becoming unhandled.
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${id}": session/created listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.announcing.delete(session)
|
||||
if (this.pendingDetach.has(session)) this.detachEntered(session, id, carrier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,11 +989,11 @@ export class SessionStore extends Service {
|
||||
* @returns resolves when every flush listener has settled; rejects if one rejects.
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
|
||||
await this.ctx.parallel(this.liveEntryFor(session).carrier, 'session/flush', session)
|
||||
}
|
||||
|
||||
/** Return the exact live session's carrier; detached/prepared objects reject. */
|
||||
private liveCarrierFor(session: Session): Scoped<Session> {
|
||||
/** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */
|
||||
private liveEntryFor(session: Session): { id: SessionId; carrier: Scoped<Session> } {
|
||||
const id = this.acceptedIds.get(session)
|
||||
if (id === undefined || this.store.get(id) !== session) {
|
||||
throw new Error(`session "${id ?? session.id}" is not live in this store`)
|
||||
@@ -957,7 +1006,7 @@ export class SessionStore extends Service {
|
||||
if (carrier === undefined) {
|
||||
throw new Error(`session "${id}" has no dispatch carrier`)
|
||||
}
|
||||
return carrier
|
||||
return { id, carrier }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -740,6 +740,79 @@ describe('SessionStore', () => {
|
||||
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
|
||||
})
|
||||
|
||||
it('claims an id across Context.filter evaluation before committing the exact session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const id = SessionId('reentrant-enter')
|
||||
const nested = new Session(id)
|
||||
const outer = new Session(id)
|
||||
let nestedError = ''
|
||||
let attempted = false
|
||||
Object.defineProperty(outer, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (!attempted) {
|
||||
attempted = true
|
||||
try {
|
||||
ctx.sessions.enter(nested)
|
||||
} catch (error: unknown) {
|
||||
nestedError = String(error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const detach = ctx.sessions.enter(outer)
|
||||
expect(nestedError).toMatch(/already exists/)
|
||||
expect(ctx.sessions.get(id)).toBe(outer)
|
||||
detach()
|
||||
expect(ctx.sessions.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('revalidates reservation ownership after carrier construction runs caller code', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const id = SessionId('released-during-enter')
|
||||
const reservation = ctx.sessions.reserve(id)
|
||||
const session = reservation.prepare()
|
||||
Object.defineProperty(session, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
reservation.release()
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => ctx.sessions.enter(session, reservation)).toThrow(/does not own this prepared session/)
|
||||
expect(ctx.sessions.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects when carrier construction attaches the same session to another store', async () => {
|
||||
const firstCtx = new Context()
|
||||
const secondCtx = new Context()
|
||||
await firstCtx.plugin(SessionStore)
|
||||
await secondCtx.plugin(SessionStore)
|
||||
const session = new Session(SessionId('cross-store-carrier'))
|
||||
let attempted = false
|
||||
let detachSecond = (): void => {}
|
||||
Object.defineProperty(session, Context.filter, {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (!attempted) {
|
||||
attempted = true
|
||||
detachSecond = secondCtx.sessions.enter(session)
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => firstCtx.sessions.enter(session)).toThrow(/already attached to a store/)
|
||||
expect(firstCtx.sessions.get(session.id)).toBeUndefined()
|
||||
expect(secondCtx.sessions.get(session.id)).toBe(session)
|
||||
detachSecond()
|
||||
})
|
||||
|
||||
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -869,6 +942,49 @@ describe('SessionStore', () => {
|
||||
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
|
||||
})
|
||||
|
||||
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const order: string[] = []
|
||||
const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
|
||||
ctx.on('session/created', (created) => {
|
||||
order.push('created:first')
|
||||
detach()
|
||||
expect(ctx.sessions.get(created.id)).toBe(created)
|
||||
})
|
||||
ctx.on('session/created', (created) => {
|
||||
order.push('created:second')
|
||||
expect(ctx.sessions.get(created.id)).toBe(created)
|
||||
})
|
||||
ctx.on('session/disposed', (disposed) => {
|
||||
order.push('disposed')
|
||||
expect(ctx.sessions.get(disposed.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
detach()
|
||||
})
|
||||
|
||||
it('rolls back create when its owner unloads from session/created', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
|
||||
const id = SessionId('create-unload-race')
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id === id) void owner.dispose()
|
||||
})
|
||||
|
||||
ownerCtx.sessions.create(id)
|
||||
await owner.dispose()
|
||||
expect(ctx.sessions.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('synthesizes a minimal current-version header for a bare-created session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -13,7 +13,7 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
|
||||
3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent;
|
||||
4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field).
|
||||
|
||||
`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the unpublished owner so no agent, session, or lifecycle event can escape; after readiness it cancels the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
|
||||
`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the creation owner: before creation notification begins, no agent/session lifecycle edge escapes; if cancellation is triggered synchronously by a creation observer, every begun edge is paired by rollback and the driver never unlocks or starts. After readiness, cancellation reaches the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`.
|
||||
|
||||
### `InProcessRunOptions`
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ What the seam guarantees regardless, because benign scripts hit these constantly
|
||||
|
||||
`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (unbuilt: a JavaScript data-URL bootstrap registers tsx's ESM and CommonJS transforms inside the worker before importing `src/worker.ts`, giving the whole mixed-module source graph full TypeScript and tsconfig-path transformation on every supported Node line; built: the sibling `lib/worker.js` bundle) with the meta, body, `args`, and worker-side limits as `workerData`.
|
||||
|
||||
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child on `ctx.subagents` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through.
|
||||
Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child through the holder-bound `SubagentService` handle captured synchronously by `start()`, with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. This capture is part of the seam's holder-owned lifetime: unloading the engine removes `ctx.workflows` for new calls but does not invalidate an already returned run whose worker starts another child afterward.
|
||||
|
||||
The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. It then replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. A readiness rejection replies `child-start-error`, emits no workflow agent pair, and makes the host dispose the attempt because the worker never received a handle; the worker classifies it as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC.
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* The host half of one worker-engine run: spawn the Worker, bridge its child
|
||||
* RPC onto `ctx.subagents`, fan its observer messages into the engine's
|
||||
* events, and own cancellation, the settle-within-grace guarantee, and child
|
||||
* cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always
|
||||
* ends with `worker.terminate()`, so no thread outlives its run.
|
||||
* RPC onto the holder-bound subagent service, fan its observer messages into
|
||||
* the engine's events, and own cancellation, the settle-within-grace
|
||||
* guarantee, and child cleanup. The worker's lifetime IS the run's lifetime:
|
||||
* `dispose()` always ends with `worker.terminate()`, so no thread outlives its
|
||||
* run.
|
||||
*
|
||||
* The run's `result` promise settles exactly once, from whichever of these
|
||||
* lands first: the worker's `result` message (a host-side cancellation in
|
||||
@@ -47,6 +48,7 @@ import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentRun } from '@deepseek-ai/dsh-subagent'
|
||||
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import { renderThrown } from './realm.ts'
|
||||
@@ -121,7 +123,9 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti
|
||||
* `start()` directly. Owns the Worker, the child registry, and the result
|
||||
* settlement; `result` never rejects. `meta` is this handle's OWN clone
|
||||
* (event payloads carry separate clones), so a consumer mutating it corrupts
|
||||
* nothing.
|
||||
* nothing. The holder-bound SubagentService handle is captured before the
|
||||
* engine returns this run, so unloading the engine removes only the ability to
|
||||
* start another workflow; this run can still start and clean up its children.
|
||||
*/
|
||||
export class WorkerRun implements WorkflowRun {
|
||||
/** Settles exactly once with the run's outcome; never rejects. */
|
||||
@@ -151,6 +155,7 @@ export class WorkerRun implements WorkflowRun {
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly subagents: SubagentService,
|
||||
readonly id: WorkflowRunId,
|
||||
readonly meta: WorkflowMeta,
|
||||
private readonly parent: Agent,
|
||||
@@ -329,7 +334,7 @@ export class WorkerRun implements WorkflowRun {
|
||||
this.hostStarted += 1
|
||||
let run: SubagentRun
|
||||
try {
|
||||
run = this.ctx.subagents.start(this.provider, {
|
||||
run = this.subagents.start(this.provider, {
|
||||
prompt: [{ type: 'text', text: request.prompt }],
|
||||
parent: this.parent,
|
||||
signal: this.controller.signal,
|
||||
|
||||
@@ -168,8 +168,17 @@ export class WorkerWorkflowEngine extends WorkflowService {
|
||||
...request.args !== undefined ? { args: request.args } : {},
|
||||
limits,
|
||||
}
|
||||
// Capture the dependency while this service call is still traced through
|
||||
// the start() holder. Cordis strips the engine-provider shadow when it
|
||||
// returns the SubagentService handle, so an already-returned run can keep
|
||||
// starting children after an engine HMR unload removes ctx.workflows.
|
||||
// Re-resolving `this.ctx.subagents` later from WorkerRun would instead walk
|
||||
// the now-inactive engine fiber and break the seam's holder-owned lifetime.
|
||||
const runCtx = this.ctx
|
||||
const subagents = runCtx.subagents
|
||||
const workerRun = new WorkerRun(
|
||||
this.ctx,
|
||||
runCtx,
|
||||
subagents,
|
||||
id,
|
||||
structuredClone(meta),
|
||||
request.parent,
|
||||
|
||||
@@ -148,8 +148,8 @@ async function setup(options?: SetupOptions) {
|
||||
// A fixed concurrency ceiling: the auto-resolved default is machine-derived
|
||||
// (cores - 2, floored at 1), so tests that expect N children in flight
|
||||
// would wedge on small CI runners.
|
||||
await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config })
|
||||
return { ctx, provider, parent: fakeParent() }
|
||||
const engineFiber = await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config })
|
||||
return { ctx, provider, parent: fakeParent(), engineFiber }
|
||||
}
|
||||
|
||||
/** The standard test meta plus a body, spread into a start request. */
|
||||
@@ -1235,6 +1235,34 @@ describe('dsh-workflow-workerthread', () => {
|
||||
expect(ctx.get('workflows')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a holder-owned run usable when the engine unloads before its child starts', async () => {
|
||||
const { ctx, parent, provider, engineFiber } = await setup({ reply: () => text('survived reload') })
|
||||
let handle!: ReturnType<typeof ctx.workflows.start>
|
||||
const holder = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
handle = inner.workflows.start({ ...scripted("return await agent('after reload')"), parent })
|
||||
}, { inject: ['workflows'] }))
|
||||
|
||||
try {
|
||||
// A real worker cannot deliver child-start in the synchronous start()
|
||||
// slice. Unload the provider before that message arrives: the returned
|
||||
// run belongs to `holder`, not to the engine fiber being reloaded.
|
||||
expect(provider.runs).toHaveLength(0)
|
||||
await engineFiber.dispose()
|
||||
expect(ctx.get('workflows')).toBeUndefined()
|
||||
|
||||
await expect(handle.result).resolves.toEqual({
|
||||
value: 'survived reload',
|
||||
stopReason: 'completed',
|
||||
agentsStarted: 1,
|
||||
})
|
||||
expect(provider.runs).toHaveLength(1)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
await holder.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('has the class-plugin export shape (default = the engine service class)', () => {
|
||||
expect(workerEngineModule.default).toBe(WorkerWorkflowEngine)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
|
||||
@@ -81,12 +81,15 @@ const FENCE = 'ts cordis-catalog'
|
||||
*/
|
||||
export const LINK_MAP: Record<string, string> = {
|
||||
Agent: 'core.md',
|
||||
AgentRegistrationReservation: 'core.md',
|
||||
ContentBlock: 'core.md',
|
||||
Message: 'core.md',
|
||||
MessageSource: 'core.md',
|
||||
GenerateOptions: 'core.md',
|
||||
LlmCallConfig: 'core.md',
|
||||
SessionEvent: 'core.md',
|
||||
SessionStartSource: 'core.md',
|
||||
SessionRegistrationReservation: 'session.md',
|
||||
StreamChunk: 'llm-streaming.md',
|
||||
TurnEndReason: 'session.md',
|
||||
ToolDefinition: 'tools.md',
|
||||
|
||||
@@ -249,6 +249,9 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str
|
||||
// Creation notifications preserve synchronous veto/rollback but observe
|
||||
// returned promises explicitly so async listener rejection is not unhandled.
|
||||
{ event: 'agent/created', pkg: 'agent', method: 'events.dispatch' },
|
||||
// Registry disposal reuses the stable carrier captured before entry commit
|
||||
// and contains each listener directly rather than rebuilding via agentEvents.
|
||||
{ event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' },
|
||||
{ event: 'session/created', pkg: 'session', method: 'events.dispatch' },
|
||||
// Session disposal uses direct callback resolution so teardown contains each
|
||||
// synchronous throw and returned-promise rejection independently.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "AgentRegistrationReservation", "source": "packages/core/agent/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
@@ -40,6 +41,7 @@
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionRegistrationReservation", "source": "packages/core/session/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
|
||||
|
||||
Vendored
+1
@@ -35,6 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
|
||||
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
|
||||
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
|
||||
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
|
||||
Vendored
+165
-24
@@ -80,6 +80,35 @@ interface EffectRunner<T> {
|
||||
getOuterStack: () => string[]
|
||||
}
|
||||
|
||||
// Public effect disposers remain single-shot, but structural owners and outer
|
||||
// effects must still be able to join a cleanup that another caller started.
|
||||
const effectInertia = new WeakMap<Disposable, () => void | Promise<void>>()
|
||||
|
||||
function runDisposable(dispose: Disposable) {
|
||||
const result = dispose()
|
||||
return effectInertia.get(dispose)?.() ?? result
|
||||
}
|
||||
|
||||
/** Notify plugin teardown without allowing one observer to break ownership cleanup. */
|
||||
function emitPluginDisposed(context: Context, fiber: Fiber) {
|
||||
const args: any[] = ['internal/plugin', fiber]
|
||||
let callbacks: Function[]
|
||||
try {
|
||||
callbacks = context.events.dispatch('emit', args)
|
||||
} catch (error) {
|
||||
context.logger.error(error)
|
||||
return
|
||||
}
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned = callback(...args)
|
||||
void Promise.resolve(returned).catch(error => context.logger.error(error))
|
||||
} catch (error) {
|
||||
context.logger.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lifecycle state for one plugin fiber. */
|
||||
export const enum FiberState {
|
||||
PENDING,
|
||||
@@ -175,24 +204,19 @@ export class Fiber {
|
||||
collect,
|
||||
}
|
||||
|
||||
this.context.emit('internal/plugin', this)
|
||||
|
||||
for (const name of Object.keys(this.inject)) {
|
||||
this._checkImpl(name)
|
||||
}
|
||||
|
||||
let shouldRefresh = false
|
||||
this.dispose = parent.fiber.effect(() => {
|
||||
const remove = runtime.fibers.push(this)
|
||||
try {
|
||||
this.config = resolveConfig(runtime, config)
|
||||
this._refresh()
|
||||
shouldRefresh = true
|
||||
} catch (error) {
|
||||
this.ctx.logger.error(error)
|
||||
this._error = error
|
||||
}
|
||||
return async () => {
|
||||
this.uid = null
|
||||
this.context.emit('internal/plugin', this)
|
||||
emitPluginDisposed(this.context, this)
|
||||
if (this.ctx.registry.has(runtime.callback)) {
|
||||
remove()
|
||||
if (!runtime.fibers.length) {
|
||||
@@ -200,6 +224,16 @@ export class Fiber {
|
||||
}
|
||||
}
|
||||
this._setEpoch(INACTIVE)
|
||||
// A PENDING fiber can already own effects registered by an
|
||||
// internal/plugin observer. Its epoch is still INACTIVE, so
|
||||
// _setEpoch() has no transition to drive; explicitly unload that
|
||||
// pre-activation work before reporting disposal complete.
|
||||
if (!this.inertia) {
|
||||
this._updateState(() => {
|
||||
this.inertia = this._unload()
|
||||
return FiberState.UNLOADING
|
||||
})
|
||||
}
|
||||
// `this.inertia` itself should never reject — both `_reload` and
|
||||
// `_unload` swallow their own work errors via `ctx.logger.error`.
|
||||
// If it *does* reject, the only remaining cause is the logger
|
||||
@@ -211,6 +245,28 @@ export class Fiber {
|
||||
}
|
||||
}
|
||||
}, 'ctx.plugin()')
|
||||
|
||||
try {
|
||||
// Publish only after the parent owns a fully assigned disposer. A
|
||||
// synchronous observer may dispose either this fiber or its parent.
|
||||
this.context.emit('internal/plugin', this)
|
||||
} catch (error) {
|
||||
// Publication failed synchronously. The disposer removes the child
|
||||
// from both the parent and runtime before control escapes.
|
||||
void Promise.resolve(this.dispose()).catch(reason => this.ctx.logger.error(reason))
|
||||
throw error
|
||||
}
|
||||
|
||||
// Keep the initial notification's historical PENDING view. The loader
|
||||
// may also extend `inject` in that notification, so resolve dependencies
|
||||
// only after publication. A reentrant parent unload makes the child
|
||||
// disposer responsible for draining any PENDING effects instead.
|
||||
if (this.uid !== null && parent.fiber.state !== FiberState.UNLOADING) {
|
||||
for (const name of Object.keys(this.inject)) {
|
||||
this._checkImpl(name)
|
||||
}
|
||||
if (shouldRefresh) this._refresh()
|
||||
}
|
||||
} else {
|
||||
this.uid = 0
|
||||
this.ctx = this.context = parent
|
||||
@@ -292,21 +348,28 @@ export class Fiber {
|
||||
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
|
||||
effect(execute: () => Effect, label = 'anonymous'): any {
|
||||
this.assertActive()
|
||||
if (this.state === FiberState.UNLOADING) {
|
||||
throw new CordisError('INACTIVE_EFFECT')
|
||||
}
|
||||
|
||||
const disposables: Disposable[] = []
|
||||
let disposing = false
|
||||
let disposalTask: void | Promise<void>
|
||||
const dispose = () => {
|
||||
if (disposing) return disposalTask
|
||||
disposing = true
|
||||
let task!: void | Promise<void>
|
||||
for (const dispose of disposables.splice(0).reverse()) {
|
||||
for (const disposable of disposables.splice(0).reverse()) {
|
||||
if (task) {
|
||||
task = task.then(dispose)
|
||||
task = task.then(() => runDisposable(disposable))
|
||||
} else {
|
||||
const result = dispose()
|
||||
const result = runDisposable(disposable)
|
||||
if (isObject(result) && 'then' in result) {
|
||||
task = result as any
|
||||
}
|
||||
}
|
||||
}
|
||||
return task
|
||||
return disposalTask = task
|
||||
}
|
||||
|
||||
const meta: EffectMeta = { label, children: [] }
|
||||
@@ -324,34 +387,107 @@ export class Fiber {
|
||||
}
|
||||
|
||||
let task: void | Promise<void>
|
||||
let executing = true
|
||||
let resolveSetup: (() => void) | undefined
|
||||
let rejectSetup: ((reason: unknown) => void) | undefined
|
||||
let setupBarrier: Promise<void> | undefined
|
||||
let setupFailed = false
|
||||
let inFlight: void | Promise<void>
|
||||
let removeWrapper = () => false
|
||||
|
||||
const waitForSetup = () => {
|
||||
setupBarrier ??= new Promise<void>((resolve, reject) => {
|
||||
resolveSetup = resolve
|
||||
rejectSetup = reject
|
||||
})
|
||||
return setupBarrier
|
||||
}
|
||||
|
||||
const disposeAfter = (setup: PromiseLike<void>) => {
|
||||
return Promise.resolve(setup).then(
|
||||
() => dispose(),
|
||||
async (reason) => {
|
||||
await dispose()
|
||||
throw reason
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const finalizeDisposal = (callback: () => void | Promise<void>) => {
|
||||
let result: void | Promise<void>
|
||||
try {
|
||||
result = callback()
|
||||
} catch (error) {
|
||||
removeWrapper()
|
||||
throw error
|
||||
}
|
||||
if (isObject(result) && 'then' in result) {
|
||||
const pending = Promise.resolve(result).finally(() => {
|
||||
removeWrapper()
|
||||
if (inFlight === pending) inFlight = undefined
|
||||
})
|
||||
return inFlight = pending
|
||||
}
|
||||
removeWrapper()
|
||||
return result
|
||||
}
|
||||
|
||||
const wrapper = defineProperty(() => {
|
||||
// A synchronous setup failure can race an owner unload that already
|
||||
// captured this wrapper but has not invoked it yet. The failed effect is
|
||||
// never returned publicly, so let that internal caller await rollback.
|
||||
if (!runner.epoch) return setupFailed ? inFlight : undefined
|
||||
runner.epoch = false
|
||||
return finalizeDisposal(() => {
|
||||
if (executing) return disposeAfter(waitForSetup())
|
||||
return task ? disposeAfter(task) : dispose()
|
||||
})
|
||||
}, symbols.effect, meta) as AsyncDisposable
|
||||
effectInertia.set(wrapper, () => inFlight)
|
||||
|
||||
// Make the effect visible to a reentrant owner unload before execute()
|
||||
// runs any plugin code. Async teardown stays owner-visible until it
|
||||
// settles, allowing an outer effect to join cleanup another caller began.
|
||||
removeWrapper = this._disposables.push(wrapper)
|
||||
try {
|
||||
task = this._execute(runner)
|
||||
} catch (reason) {
|
||||
dispose()
|
||||
executing = false
|
||||
setupFailed = true
|
||||
runner.epoch = false
|
||||
let cleanup: void | Promise<void>
|
||||
try {
|
||||
cleanup = finalizeDisposal(dispose)
|
||||
} finally {
|
||||
rejectSetup?.(reason)
|
||||
}
|
||||
if (isObject(cleanup) && 'then' in cleanup) {
|
||||
cleanup.catch(error => this.ctx.logger.error(error))
|
||||
}
|
||||
throw reason
|
||||
}
|
||||
executing = false
|
||||
if (setupBarrier) {
|
||||
Promise.resolve(task).then(resolveSetup, rejectSetup)
|
||||
}
|
||||
|
||||
// prevent unhandled rejection — both from `task` itself and from the
|
||||
// disposer chain if it fails to settle cleanly.
|
||||
task?.catch(dispose).catch((error) => this.ctx.logger.error(error))
|
||||
|
||||
const wrapper = defineProperty(() => {
|
||||
if (!runner.epoch) return
|
||||
runner.epoch = false
|
||||
return task ? task.then(dispose) : dispose()
|
||||
}, symbols.effect, meta) as AsyncDisposable
|
||||
task?.catch(() => {
|
||||
if (!runner.epoch) return dispose()
|
||||
return finalizeDisposal(dispose)
|
||||
}).catch((error) => this.ctx.logger.error(error))
|
||||
|
||||
const disposeAsync = () => {
|
||||
if (!runner.epoch) return
|
||||
runner.epoch = false
|
||||
return dispose()
|
||||
return finalizeDisposal(dispose)
|
||||
}
|
||||
wrapper.then = async (onFulfilled, onRejected) => {
|
||||
return Promise.resolve(task)
|
||||
.then(() => disposeAsync)
|
||||
.then(onFulfilled, onRejected)
|
||||
}
|
||||
disposables.push(this._disposables.push(wrapper))
|
||||
return wrapper
|
||||
}
|
||||
|
||||
@@ -434,7 +570,12 @@ export class Fiber {
|
||||
const oldEpoch = this._runner.epoch
|
||||
try {
|
||||
await Promise.resolve()
|
||||
await this._execute(this._runner)
|
||||
// A disposer queued before this checkpoint may already have invalidated
|
||||
// the load. Do not run plugin code for a stale epoch; the state update
|
||||
// below will drain any effects collected while the fiber was PENDING.
|
||||
if (this._runner.epoch === oldEpoch) {
|
||||
await this._execute(this._runner)
|
||||
}
|
||||
} catch (reason) {
|
||||
// impl guarantees that the error is non-null (?)
|
||||
this.ctx.logger.error(reason)
|
||||
@@ -457,7 +598,7 @@ export class Fiber {
|
||||
await composeError(async (info) => {
|
||||
await Promise.resolve()
|
||||
info.error = new Error()
|
||||
await dispose()
|
||||
await runDisposable(dispose)
|
||||
}, this._runner.getOuterStack)
|
||||
} catch (reason) {
|
||||
this.ctx.logger.error(reason)
|
||||
|
||||
Reference in New Issue
Block a user