fix: bind stdio to its exact fresh identity
This commit is contained in:
@@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA
|
||||
|
||||
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-<uuid>` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity.
|
||||
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary; an app may instead supply an exact fresh `sessionId` when another coupled component must bind to it. `resumeSessionId` loads and registers the exact persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
@@ -33,6 +33,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
|
||||
interface Config {
|
||||
agents: Array<{
|
||||
id: string // required stable label; prefixes fresh combined ids
|
||||
sessionId?: string // optional exact identity for a fresh session
|
||||
model?: string
|
||||
resumeSessionId?: string // load this persisted session instead of creating one
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
@@ -40,7 +41,7 @@ interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
Agents listed in config are auto-created at startup. `cwd` and optional `sessionId` apply only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
|
||||
### Exported concrete class
|
||||
|
||||
|
||||
@@ -326,6 +326,8 @@ export interface Config {
|
||||
agents: (AgentOptions & {
|
||||
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
||||
id: string
|
||||
/** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */
|
||||
sessionId?: SessionId
|
||||
/** Optional workspace for a fresh session. */
|
||||
cwd?: string
|
||||
/** Persisted session to resume instead of creating a fresh session. */
|
||||
@@ -341,6 +343,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
static Config = z.object({
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
sessionId: z.string(),
|
||||
model: z.string(),
|
||||
cwd: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
@@ -360,12 +363,14 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
|
||||
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
if (resumeSessionId === undefined || resumeSessionId === '') {
|
||||
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
|
||||
this.create(sessionId, options, cwd === undefined ? {} : { cwd })
|
||||
this.create(sessionId ?? SessionId(`${id}-session-${randomUUID()}`), options, cwd === undefined ? {} : { cwd })
|
||||
continue
|
||||
}
|
||||
if (sessionId !== undefined) {
|
||||
throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
|
||||
@@ -24,7 +24,37 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
async function makeCoreContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
it('accepts one exact fresh id and rejects it alongside a resume id', async () => {
|
||||
const exact = await makeCoreContext()
|
||||
await exact.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }],
|
||||
})
|
||||
expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact')
|
||||
await exact.fiber.dispose()
|
||||
|
||||
const conflicting = await makeCoreContext()
|
||||
await expect(conflicting.plugin(AgentLoop, {
|
||||
agents: [{
|
||||
id: 'main',
|
||||
sessionId: SessionId('fresh'),
|
||||
resumeSessionId: SessionId('persisted'),
|
||||
model: 'mock',
|
||||
}],
|
||||
})).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive')
|
||||
await conflicting.fiber.dispose()
|
||||
})
|
||||
|
||||
it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
Reference in New Issue
Block a user