From b5e13d8984d5b81196bc3659112dac567f885a58 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 20:34:10 -0700 Subject: [PATCH 1/3] fix(supervise): preserve cloud director sessions across turns and recovery --- api-surface.json | 2 +- docs/agent-managed-compute/reliability.md | 18 +- docs/api/primitive-catalog.md | 2 +- docs/api/runtime.md | 18 +- docs/canonical-api.md | 2 +- package.json | 2 +- src/runtime/environment-provider.ts | 143 ++++---- src/runtime/retained-run-start.ts | 45 ++- src/runtime/retained-run-types.ts | 2 + src/runtime/retained-run.test.ts | 210 ++++++++++++ src/runtime/supervise/coordination-mcp.ts | 29 +- src/runtime/supervise/driver-executor.ts | 9 +- src/runtime/supervise/retained-executor.ts | 10 +- src/runtime/supervise/retained-scope-owner.ts | 112 ++++++- src/runtime/supervise/scope.ts | 9 +- src/runtime/supervise/supervise.ts | 11 +- src/runtime/supervise/supervisor.ts | 3 +- .../fixtures/agent-improvement-proposal.json | 10 +- .../agent-profile-improvement-proposal.json | 6 +- tests/helpers/durable-retained-provider.ts | 61 +++- tests/kernel/coordination-mcp.test.ts | 187 +++++++++-- tests/kernel/retained-scope-owner.test.ts | 85 +++++ .../supervise-retained-owner-recovery.test.ts | 308 +++++++++++++++++- 23 files changed, 1143 insertions(+), 141 deletions(-) diff --git a/api-surface.json b/api-surface.json index df1aaf0f..28f0730b 100644 --- a/api-surface.json +++ b/api-surface.json @@ -1153,7 +1153,7 @@ "RetainedRunIntentAdmission": "type b66fa18d06a4", "RetainedRunReplayPoint": "type 51964fc96901", "RetainedRunSnapshot": "type 2415f4255673", - "RetainedRunStartMaterial": "type 4065e18ce1c2", + "RetainedRunStartMaterial": "type 02c0c2f4cb72", "RetainedRunTurnInput": "type 18b243b8b913", "RollingDispatchOptions": "type 94f6dce5b352", "RootHandle": "type 9eaa838c3456", diff --git a/docs/agent-managed-compute/reliability.md b/docs/agent-managed-compute/reliability.md index 46b001e1..a05246ac 100644 --- a/docs/agent-managed-compute/reliability.md +++ b/docs/agent-managed-compute/reliability.md @@ -203,7 +203,7 @@ Provider conformance tests belong in the shared provider test package. Remote coordination MCP requires: -- an expiring bearer token scoped to one run and actor, +- a bearer token scoped to one live run and actor, - an audience bound to the MCP endpoint, - expiration and key rotation, - a maximum request size, @@ -215,11 +215,13 @@ Remote coordination MCP requires: The default remains loopback-only. Set `coordination.authentication` to `true` to mint an ephemeral credential. -Credentials expire after 15 minutes by default. A caller may set `authentication.ttlMs` to a longer finite lifetime with a safely representable expiry. -There is no independent 24-hour cutoff. Long-lived bearer credentials increase exposure; use narrow grants, protected storage, and revocable signing keys. +By default, credentials remain valid while their scope is live, within its original absolute deadline. +Closing the listener or aborting the scope rejects its credentials immediately. +Set `authentication.ttlMs` to require an additional finite expiry. +Long-lived bearer credentials increase exposure; use narrow grants, protected storage, and revocable signing keys. Runtime does not renew credentials automatically or refresh credentials inside a retained environment. -Configure `ttlMs` to cover the manager invocation and expected coordinator downtime. -Run deadlines do not extend credential lifetime. +If specified, `ttlMs` must cover the manager invocation and expected coordinator downtime. +Neither key rotation nor coordinator restart extends the original scope deadline. Set `coordination.publicUrl` to the caller-owned reachable endpoint or an actor-aware endpoint resolver. The resolver can return a promise and receives the bound port, run identity, actor identity, and manager signal. Runtime awaits resolution before admitting the manager, while the listener refuses requests. @@ -231,7 +233,7 @@ Remote public endpoints require HTTPS. For same-host coordinator restart, configure `authentication.signingKeys` with an active key ID and a secret key map. Keep the public URL, run ID, actor ID, tool grants, and verification key stable until the retained credential expires. -Stable keys support resumed coordination only before the original credential expires. +Stable keys support resumed coordination within the original scope deadline and any explicit credential expiry. After expiry, the retained session can reattach, but its coordination requests receive HTTP 401. A new active key can mint credentials while previous keys verify existing credentials. Remove a verification key to revoke its credentials across coordinator restarts. @@ -242,6 +244,10 @@ Provider managers require `capabilities.create.runtimeAttachments.mcp` and an au Runtime passes the MCP server through `CreateAgentEnvironmentInput.runtimeAttachments`, preserving the canonical profile. Credential headers remain runtime-only and must not appear in receipts or journals. Retained manager recovery uses the original admitted backend input and validates its intent before reconnecting. +Deliberate re-prompts retain the manager's environment and conversation, with a fresh execution and turn identity. +Retrying an interrupted turn retains its original identities and does not create replacement work. +The scope's existing `retainedAtSettlement` policy releases or preserves the environment after all manager turns. +An unconfirmed release remains explicit in the journal and final result. The HTTP adapter bounds request bytes, body and action deadlines, concurrent work, and request rates. A timed-out action keeps its admission slot until execution settles. diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index ba38c34a..578373f7 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.228.0` and `@tangle-network/agent-eval@0.182.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.230.0` and `@tangle-network/agent-eval@0.182.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 36502658..202e78b8 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -8081,6 +8081,12 @@ Environment, turn, and optional identity needed to replay one retained start. > **idempotencyKey**: `string` +##### existingEnvironmentId? + +> `readonly` `optional` **existingEnvironmentId?**: `string` + +Reuse an environment whose retained ownership key is verified instead of creating one. + ##### turn > `readonly` **turn**: [`RetainedRunTurnInput`](#retainedrunturninput) @@ -8129,6 +8135,16 @@ A retained start is retry-safe only when environment and turn keys are explicit. [`RetainedRunStartMaterial`](#retainedrunstartmaterial).[`environment`](#environment-2) +##### existingEnvironmentId? + +> `readonly` `optional` **existingEnvironmentId?**: `string` + +Reuse an environment whose retained ownership key is verified instead of creating one. + +###### Inherited from + +[`RetainedRunStartMaterial`](#retainedrunstartmaterial).[`existingEnvironmentId`](#existingenvironmentid) + ##### turn > `readonly` **turn**: [`RetainedRunTurnInput`](#retainedrunturninput) @@ -13360,7 +13376,7 @@ End one peer exchange: every further mail on the thread is refused `thread-stopp > `readonly` `optional` **ttlMs?**: `number` -Explicit finite credential lifetime; defaults to 15 minutes. Longer runs must configure it. +Explicit finite lifetime. Omission binds the credential to the live scope and its deadline. ##### signingKeys? diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 4d23a80f..faf0d593 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -4,7 +4,7 @@ Generated signatures and the complete export list live in docs/api/. Run pnpm docs:freshness after editing this file. --> -> **Version 0.228.0.** +> **Version 0.230.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.182.0 <0.183.0`. > `sandbox` must satisfy `>=0.36.4 <0.40.0`. diff --git a/package.json b/package.json index a0933cab..9360a58e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.228.0", + "version": "0.230.0", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/runtime/environment-provider.ts b/src/runtime/environment-provider.ts index b1e9760a..c6f447d3 100644 --- a/src/runtime/environment-provider.ts +++ b/src/runtime/environment-provider.ts @@ -73,6 +73,7 @@ import { } from './retained-run-binding' import { assertRetainedRunReplayMaterial, + mintRetainedIdentity, reconnectRetainedRun, recoverRetainedRun, startRetainedRun, @@ -629,7 +630,7 @@ function createProviderExecutor( options: ProviderExecutorOptions, placement?: { id: string; digest: string }, ): Executor { - const controller = linkAbort(ctx.signal) + const controller = new AbortController() let environment: AgentEnvironment | undefined let artifact: ExecutorResult | undefined @@ -679,7 +680,7 @@ function createProviderExecutor( plan: { kind: 'agent-environment', provider: provider.name, - destroyOnSettle: options.destroyOnSettle ?? true, + destroyOnSettle: retention?.preserveEnvironment ? false : (options.destroyOnSettle ?? true), requireTerminalEvent: options.requireTerminalEvent ?? true, tokenLimits, ...(placement ? { placement } : {}), @@ -702,55 +703,59 @@ function createProviderExecutor( } let executor!: Executor - const run = ( + const run = async function* ( task: unknown, signal: AbortSignal, recovering = false, - ): AsyncIterable => { - return streamProviderExecutor({ - provider, - profile, - createProfile, - task, - signal, - controller, - options, - retention, - executionId, - trace, - recovering, - onRetained: (handle) => { - retained = handle - }, - onPending: (value) => { - pending = value - }, - onEnvironment: (env) => { - environment = env - // `create` resolved, so the environment identity the provider issued is now evidence. It - // goes in `execution`, which the mid-run guard treats as per-attempt routing, and NOT in - // `plan`, which the guard holds fixed across attempts. It used to be written to both, so a - // re-prompted attempt in a new environment changed `materializationPlanDigest` and was - // refused as a changed materialization even after #1230 excused `execution.id`. Measured - // on mech-interp-foundations-pi-20260915i under 0.225.5, which was the first guard able to - // name the field. The admission events already record which environment served each - // attempt; nothing reads the id from the plan. - finalizeRuntimeOwnedPendingExecutor( - executor, - { - ...plannedDeclaration, - execution: { kind: 'environment', id: env.id }, - }, - plannedBinding, - ) - }, - onArtifact: (next) => { - artifact = next - }, - onDestroyed: () => { - destroyed = true - }, - }) + ): AsyncIterable { + const linked = linkAbort(ctx.signal, signal, controller.signal) + try { + yield* streamProviderExecutor({ + provider, + profile, + createProfile, + task, + signal: linked.signal, + options, + retention, + executionId, + trace, + recovering, + onRetained: (handle) => { + retained = handle + }, + onPending: (value) => { + pending = value + }, + onEnvironment: (env) => { + environment = env + // `create` resolved, so the environment identity the provider issued is now evidence. It + // goes in `execution`, which the mid-run guard treats as per-attempt routing, and NOT in + // `plan`, which the guard holds fixed across attempts. It used to be written to both, so a + // re-prompted attempt in a new environment changed `materializationPlanDigest` and was + // refused as a changed materialization even after #1230 excused `execution.id`. Measured + // on mech-interp-foundations-pi-20260915i under 0.225.5, which was the first guard able to + // name the field. The admission events already record which environment served each + // attempt; nothing reads the id from the plan. + finalizeRuntimeOwnedPendingExecutor( + executor, + { + ...plannedDeclaration, + execution: { kind: 'environment', id: env.id }, + }, + plannedBinding, + ) + }, + onArtifact: (next) => { + artifact = next + }, + onDestroyed: () => { + destroyed = true + }, + }) + } finally { + linked.release() + } } executor = { runtime, @@ -892,7 +897,6 @@ interface StreamProviderExecutorArgs { createProfile: AgentProfile task: unknown signal: AbortSignal - controller: AbortController options: ProviderExecutorOptions retention?: RetainedExecutorContext executionId: string @@ -911,7 +915,7 @@ async function* streamProviderExecutor( args: StreamProviderExecutorArgs, ): AsyncIterable { const started = Date.now() - const linked = linkAbort(args.signal, args.controller.signal).signal + const linked = args.signal // READINESS IS THE PROVIDER'S CONTRACT. `create` resolves with an environment that can take a // turn, so this streams straight into it and adds no wait of its own. The sandbox seam's // `acquireSandbox` exists because a raw `SandboxClient.create` returns before the box is ready; @@ -1083,6 +1087,7 @@ async function* streamProviderExecutor( } finally { if ( (!source.retained || (settled !== undefined && !failed)) && + !(source.retained && args.retention?.preserveEnvironment) && (args.options.destroyOnSettle ?? true) ) { try { @@ -1212,14 +1217,26 @@ async function providerExecutionSource( admitted = true args.onPending(true) } + const priorSession = retention.priorSession + const environmentKey = priorSession?.idempotencyKey ?? `runtime:${args.executionId}` + const turnId = `${args.executionId}:turn:0` const material = { environment: { ...args.options.defaults, profile: args.createProfile, - idempotencyKey: `runtime:${args.executionId}`, + idempotencyKey: environmentKey, signal, }, - turn: { ...turn, turnId: `${args.executionId}:turn:0` }, + turn: { ...turn, turnId }, + ...(priorSession === undefined + ? {} + : { + existingEnvironmentId: priorSession.environmentId, + identity: { + sessionId: priorSession.sessionId, + executionId: mintRetainedIdentity(environmentKey, turnId).executionId, + }, + }), } try { const admissions = retention.admissions @@ -1241,12 +1258,22 @@ async function providerExecutionSource( if (!reconnected) throw new Error('retained provider environment is unavailable') handle = reconnected } else if (args.recovering && environmentAdmission?.phase === 'environment') { - let recovered = await recoverRetainedRun({ - provider: args.provider, - environmentId: environmentAdmission.environmentId, - sessionId: environmentAdmission.sessionId, - executionId: environmentAdmission.executionId, - }) + // Before a new turn dispatches, a reused session still identifies its previous turn. + // Replay the committed intent rather than mistaking that prior result for this execution. + let recovered = + material.existingEnvironmentId !== undefined && intent?.phase === 'intent' + ? await recoverRetainedRun({ + provider: args.provider, + admission: intent, + replay: material, + onAdmission, + }) + : await recoverRetainedRun({ + provider: args.provider, + environmentId: environmentAdmission.environmentId, + sessionId: environmentAdmission.sessionId, + executionId: environmentAdmission.executionId, + }) if (recovered.outcome === 'unverifiable' && intent?.phase === 'intent') { // The environment admission precedes dispatch. Replay the validated original keys // when the provider cannot yet identify a session, including a lost dispatch reply. diff --git a/src/runtime/retained-run-start.ts b/src/runtime/retained-run-start.ts index b7878826..b8fc6c8b 100644 --- a/src/runtime/retained-run-start.ts +++ b/src/runtime/retained-run-start.ts @@ -113,13 +113,32 @@ export async function startRetainedRun( ) options.environment.signal?.throwIfAborted() options.turn.signal?.throwIfAborted() - const environment = await options.provider.create({ - ...options.environment, - metadata: retainedEnvironmentMetadata( - options.environment.metadata, + let environment: AgentEnvironment + if (options.existingEnvironmentId !== undefined) { + assertStableText(options.existingEnvironmentId, 'retained environment id') + const existing = await options.provider.get(options.existingEnvironmentId) + if ( + !existing || + existing.id !== options.existingEnvironmentId || + existing.provider !== options.provider.name + ) { + throw new Error('retained provider environment is unavailable or has another identity') + } + await assertRetainedEnvironmentOwnership( + options.provider, + existing.id, options.environment.idempotencyKey, - ), - }) + ) + environment = existing + } else { + environment = await options.provider.create({ + ...options.environment, + metadata: retainedEnvironmentMetadata( + options.environment.metadata, + options.environment.idempotencyKey, + ), + }) + } let capabilities: AgentEnvironmentCapabilities try { capabilities = retainedCapabilitiesForEnvironment( @@ -133,6 +152,7 @@ export async function startRetainedRun( capabilities, ) } catch (error) { + if (options.existingEnvironmentId !== undefined) throw error try { await environment.destroy?.() } catch (cleanupError) { @@ -144,6 +164,11 @@ export async function startRetainedRun( throw error } if (!environment.dispatch || !environment.session) { + if (options.existingEnvironmentId !== undefined) { + throw new Error( + `provider "${options.provider.name}" does not expose detached session control`, + ) + } try { await environment.destroy?.() } catch (cleanupError) { @@ -169,6 +194,7 @@ export async function startRetainedRun( onAdmission: options.onAdmission, capabilities, now: options.now, + preserveEnvironment: options.existingEnvironmentId !== undefined, }) } @@ -240,6 +266,7 @@ export async function startRetainedRunInEnvironment( onAdmission: options.onAdmission, capabilities, now: options.now, + preserveEnvironment: true, }) } @@ -267,6 +294,7 @@ async function assertRetainedEnvironmentOwnership( } interface DispatchRetainedRunOptions { + readonly preserveEnvironment?: boolean readonly signal?: AbortSignal readonly provider: AgentEnvironmentProvider readonly environment: AgentEnvironment @@ -304,7 +332,7 @@ async function dispatchRetainedRun( // only `created` is destroyed, and `replayed` or an absent receipt is kept, because an // environment whose creation cannot be proven may be held by someone else. const destroyIfCreated = async (cause: unknown): Promise => { - if (environment.creation !== 'created') throw cause + if (options.preserveEnvironment || environment.creation !== 'created') throw cause try { await environment.destroy?.() } catch (cleanupError) { @@ -536,6 +564,9 @@ function retainedRunIntent( kind: 'retained-run-intent.v1', provider: options.provider.name, idempotencyKey: options.environment.idempotencyKey, + ...(options.existingEnvironmentId === undefined + ? {} + : { existingEnvironmentId: options.existingEnvironmentId }), turnId: options.turn.turnId, sessionId: identity.sessionId, executionId: identity.executionId, diff --git a/src/runtime/retained-run-types.ts b/src/runtime/retained-run-types.ts index 43c4bf11..342e091d 100644 --- a/src/runtime/retained-run-types.ts +++ b/src/runtime/retained-run-types.ts @@ -222,6 +222,8 @@ export type RetainedRunAdmissionHook = (admission: RetainedRunAdmission) => Prom /** Environment, turn, and optional identity needed to replay one retained start. @stable */ export interface RetainedRunStartMaterial { readonly environment: CreateAgentEnvironmentInput & { idempotencyKey: string } + /** Reuse an environment whose retained ownership key is verified instead of creating one. */ + readonly existingEnvironmentId?: string readonly turn: RetainedRunTurnInput /** * Explicit dispatch coordinates. When omitted, the runtime mints diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index f0f38bb7..5433976f 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -869,6 +869,216 @@ describe('retained runtime run control', () => { }) }) + it('reuses an existing environment through startRetainedRun with a fresh explicit turn identity', async () => { + const identity = { sessionId: 'shared-session', executionId: 'reprompt-execution-2' } + const controlRef = { + runId: 'reprompt-run-2', + provider: 'test-provider', + environmentId: 'environment-1', + ...identity, + requestDigest: retainedRequestDigest, + } + const session: AgentSession = { + id: identity.sessionId, + controlRef, + status: async () => 'running', + async *events() { + yield* [] + }, + result: async () => ({ + text: 'reused', + success: true, + sessionId: identity.sessionId, + metadata: { + runId: controlRef.runId, + executionId: controlRef.executionId, + requestDigest: controlRef.requestDigest, + }, + }), + prompt: async () => ({ text: 'continued', success: true }), + cancel: async () => {}, + } + let creates = 0 + let destroys = 0 + let dispatched: AgentTurnInput | undefined + const provider = providerWithEnvironment({ + async dispatch(input) { + dispatched = input + return { id: identity.sessionId, provider: 'test-provider', controlRef } + }, + session: () => session, + async destroy() { + destroys++ + }, + }) + provider.create = async () => { + creates++ + throw new Error('existing-environment start must not create') + } + provider.list = async () => [ + { + id: 'environment-1', + provider: 'test-provider', + metadata: { retainedIdempotencyKey: 'shared-environment' }, + }, + ] + const recorder = recordedAdmissions() + const run = await startRetainedRun({ + provider, + environment: { profile: { name: 'worker' }, idempotencyKey: 'shared-environment' }, + existingEnvironmentId: 'environment-1', + turn: { prompt: 'reprompt', turnId: 'reprompt-turn-2' }, + identity, + onAdmission: recorder.onAdmission, + }) + + expect(creates).toBe(0) + expect(destroys).toBe(0) + expect(dispatched).toEqual({ + prompt: 'reprompt', + turnId: 'reprompt-turn-2', + detach: true, + ...identity, + }) + expect(recorder.admissions).toMatchObject([ + { phase: 'intent', idempotencyKey: 'shared-environment', ...identity }, + { phase: 'environment', environmentId: 'environment-1', ...identity }, + { phase: 'dispatched', controlRef }, + ]) + await expect(run.result()).resolves.toMatchObject({ + text: 'reused', + sessionId: identity.sessionId, + }) + }) + + it('replays an existing-environment intent exactly and rejects changed reuse material', async () => { + const identity = { sessionId: 'replay-session', executionId: 'replay-execution' } + const controlRef = { + runId: 'replay-run', + provider: 'test-provider', + environmentId: 'environment-1', + ...identity, + requestDigest: retainedRequestDigest, + } + const session: AgentSession = { + id: identity.sessionId, + controlRef, + status: async () => 'running', + async *events() { + yield* [] + }, + result: async () => ({ + text: 'replayed', + success: true, + sessionId: identity.sessionId, + metadata: { runId: controlRef.runId, ...identity, requestDigest: controlRef.requestDigest }, + }), + prompt: async () => ({ text: 'continued', success: true }), + cancel: async () => {}, + } + let dispatches = 0 + const provider = providerWithEnvironment({ + async dispatch() { + dispatches++ + return { id: identity.sessionId, provider: 'test-provider', controlRef } + }, + session: () => session, + }) + provider.list = async () => [ + { + id: 'environment-1', + provider: 'test-provider', + metadata: { retainedIdempotencyKey: 'replay-environment' }, + }, + ] + const environment = { profile: { name: 'worker' }, idempotencyKey: 'replay-environment' } + const turn = { prompt: 'replay this', turnId: 'replay-turn' } + const firstAdmissions: RetainedRunAdmission[] = [] + const failed = await startRetainedRun({ + provider, + environment, + existingEnvironmentId: 'environment-1', + turn, + identity, + onAdmission: async (admission) => { + firstAdmissions.push(admission) + if (admission.phase === 'intent') throw new Error('coordinator crashed') + }, + }).catch((error: unknown) => error) + expect(failed).toBeInstanceOf(RetainedRunAdmissionError) + const intent = firstAdmissions[0] + if (intent?.phase !== 'intent') throw new Error('expected existing-environment intent') + + await expect( + startRetainedRun({ + provider, + environment, + existingEnvironmentId: 'changed-environment', + turn, + identity, + intent, + onAdmission: async () => {}, + }), + ).rejects.toThrow('retained run intent conflicts with replay material') + expect(dispatches).toBe(0) + + const recovered = await startRetainedRun({ + provider, + environment, + existingEnvironmentId: 'environment-1', + turn, + identity, + intent, + onAdmission: async () => {}, + }) + await expect(recovered.result()).resolves.toMatchObject({ text: 'replayed' }) + expect(dispatches).toBe(1) + }) + + it('does not destroy an existing environment when its reused dispatch returns another identity', async () => { + let destroys = 0 + const rogueRef = { + runId: 'rogue-run', + provider: 'test-provider', + environmentId: 'environment-1', + sessionId: 'rogue-session', + executionId: 'rogue-execution', + requestDigest: retainedRequestDigest, + } + const provider = providerWithEnvironment({ + creation: 'created', + async dispatch() { + return { id: rogueRef.sessionId, provider: 'test-provider', controlRef: rogueRef } + }, + async destroy() { + destroys++ + }, + }) + provider.list = async () => [ + { + id: 'environment-1', + provider: 'test-provider', + metadata: { retainedIdempotencyKey: 'owned-environment' }, + }, + ] + const recorder = recordedAdmissions() + await expect( + startRetainedRun({ + provider, + environment: { profile: { name: 'worker' }, idempotencyKey: 'owned-environment' }, + existingEnvironmentId: 'environment-1', + turn: { prompt: 'go', turnId: 'reuse-mismatch' }, + identity: { sessionId: 'honest-session', executionId: 'honest-execution' }, + onAdmission: recorder.onAdmission, + }), + ).rejects.toBeInstanceOf(RetainedRunDispatchBindingError) + expect(destroys).toBe(0) + expect(recorder.admissions.map((admission) => admission.phase)).toEqual([ + 'intent', + 'environment', + ]) + }) + it('fails before admission when an existing retained environment is unavailable or unusable', async () => { const missing = providerWithEnvironment({}) missing.get = async () => null diff --git a/src/runtime/supervise/coordination-mcp.ts b/src/runtime/supervise/coordination-mcp.ts index 2a849e08..c4a28f4c 100644 --- a/src/runtime/supervise/coordination-mcp.ts +++ b/src/runtime/supervise/coordination-mcp.ts @@ -104,7 +104,7 @@ export function isLoopbackHost(host: string): boolean { } export interface CoordinationAuthentication { - /** Explicit finite credential lifetime; defaults to 15 minutes. Longer runs must configure it. */ + /** Explicit finite lifetime. Omission binds the credential to the live scope and its deadline. */ readonly ttlMs?: number /** Caller-owned secret keys. Keep prior keys to verify unexpired credentials after restart. */ readonly signingKeys?: { @@ -327,14 +327,16 @@ export async function serveCoordinationMcp( const rotateCredential = () => { if (!opts.authentication) throw new ConfigError('coordination authentication is not configured') if (closed) throw new ConfigError('coordination server is closed') - const expiresAt = Date.now() + (auth?.ttlMs ?? 900_000) - if (!Number.isSafeInteger(expiresAt)) { + const deadline = opts.scope.budget.deadlineMs + const expiresAt = + auth?.ttlMs === undefined ? (deadline > 0 ? deadline : undefined) : Date.now() + auth.ttlMs + if (expiresAt !== undefined && !Number.isSafeInteger(expiresAt)) { throw new ConfigError('coordination credential expiry must remain a safe integer') } for (const [credential, expiry] of revoked) { if (expiry <= Date.now()) revoked.delete(credential) } - if (token) revoked.set(token.toString(), credentialExpiresAt!) + if (token) revoked.set(token.toString(), credentialExpiresAt ?? Number.POSITIVE_INFINITY) credentialExpiresAt = expiresAt const nonce = randomBytes(32).toString('base64url') let text = nonce @@ -346,7 +348,7 @@ export async function serveCoordinationMcp( actor: identity.actorId, audience: credentialAudience, grants: grantDigest, - expires: credentialExpiresAt, + expires: credentialExpiresAt ?? null, nonce, }), ).toString('base64url') @@ -359,11 +361,18 @@ export async function serveCoordinationMcp( headers = Object.freeze({ Authorization: `Bearer ${text}` }) } const validCredential = (supplied: Buffer): boolean => { - if (closed || revoked.has(supplied.toString())) return false + const deadline = opts.scope.budget.deadlineMs + if ( + closed || + opts.scope.signal.aborted || + (deadline > 0 && Date.now() >= deadline) || + revoked.has(supplied.toString()) + ) + return false if (!signingKeys) return ( token !== undefined && - Date.now() < credentialExpiresAt! && + (credentialExpiresAt === undefined || Date.now() < credentialExpiresAt) && supplied.length === token.length && timingSafeEqual(supplied, token) ) @@ -378,14 +387,16 @@ export async function serveCoordinationMcp( const expected = createHmac('sha256', signingKeys.keys[claims.key]!).update(payload).digest() const signature = Buffer.from(parts[1]!, 'base64url') return ( + // Revocation keys use the wire token; alternate encodings must not alias it. + signature.toString('base64url') === parts[1] && signature.length === expected.length && timingSafeEqual(signature, expected) && claims.run === identity.runId && claims.actor === identity.actorId && claims.audience === credentialAudience && claims.grants === grantDigest && - Number.isSafeInteger(claims.expires) && - claims.expires > Date.now() + ((claims.expires === null && auth?.ttlMs === undefined) || + (Number.isSafeInteger(claims.expires) && claims.expires > Date.now())) ) } catch { return false diff --git a/src/runtime/supervise/driver-executor.ts b/src/runtime/supervise/driver-executor.ts index 7ce321b1..930876fa 100644 --- a/src/runtime/supervise/driver-executor.ts +++ b/src/runtime/supervise/driver-executor.ts @@ -401,12 +401,11 @@ export const driverExecutorFactory: ExecutorFactory = (rawSpec, ctx) => : { destroyed: true } }, async releaseRetained(): Promise> { - // A manager holds no environment of its own. Its retained children live in the nested scope, - // which journals their receipts to the nested tree; what changes here is the answer this - // executor's `teardown` gives next, read from the nested scope after the release. + // The nested scope releases its retained owner and children and journals their receipts. + // The executor's next teardown reports any release that remains unconfirmed. if (nestedScopeHeld === undefined) return [] - await releaseRetainedEnvironments(nestedScopeHeld) - unconfirmedDescendants = nestedScopeHeld.workerCapacity.unconfirmed.map((node) => node.id) + const unconfirmed = await releaseRetainedEnvironments(nestedScopeHeld) + unconfirmedDescendants = unconfirmed.map((node) => node.id) return [] }, resultArtifact(): ExecutorResult { diff --git a/src/runtime/supervise/retained-executor.ts b/src/runtime/supervise/retained-executor.ts index f7914182..5c33cdd4 100644 --- a/src/runtime/supervise/retained-executor.ts +++ b/src/runtime/supervise/retained-executor.ts @@ -1,5 +1,9 @@ import type { AgentProfile } from '@tangle-network/agent-interface' -import type { RetainedRunAdmission, RetainedRunAdmissionHook } from '../retained-run-types' +import type { + RetainedRunAdmission, + RetainedRunAdmissionHook, + RetainedRunEnvironmentAdmission, +} from '../retained-run-types' import type { AgentSpec, ExecutorContext, @@ -15,6 +19,10 @@ export const retainedExecutorSeamKey = 'runtime.retainedExecutor' export interface RetainedExecutorContext { /** Stable across recovery; distinct for deliberate later invocations of one owner. */ readonly executionId?: string + /** Prior committed environment admission to reuse for a deliberate later invocation. */ + readonly priorSession?: RetainedRunEnvironmentAdmission + /** Keep the retained provider environment alive across deliberate invocations. */ + readonly preserveEnvironment?: boolean readonly admissions: readonly RetainedRunAdmission[] readonly onReady?: () => void | Promise readonly onAdmission: RetainedRunAdmissionHook diff --git a/src/runtime/supervise/retained-scope-owner.ts b/src/runtime/supervise/retained-scope-owner.ts index 955a2e23..3c6db277 100644 --- a/src/runtime/supervise/retained-scope-owner.ts +++ b/src/runtime/supervise/retained-scope-owner.ts @@ -1,7 +1,9 @@ +import type { AgentEnvironmentProvider } from '@tangle-network/agent-interface/environment-provider' import { contentAddress } from '../../durable/spawn-journal' import { ValidationError } from '../../errors' -import type { RetainedRunAdmission } from '../retained-run-types' +import type { RetainedRunAdmission, RetainedRunEnvironmentAdmission } from '../retained-run-types' import { addSpend, zeroSpend } from '../util' +import { runAbortable } from './abortable' import { assertValidSpend } from './budget' import { executorFailureReason } from './executor-outcome' import type { RetainedExecutorContext } from './retained-executor' @@ -13,6 +15,7 @@ import type { Scope, SpawnEvent, SpawnJournal, + UnconfirmedTeardown, } from './types' interface OwnerState { @@ -22,6 +25,8 @@ interface OwnerState { inputSequence?: number prepared?: boolean acceptedConsumed?: boolean + priorSession?: RetainedRunEnvironmentAdmission + provider?: AgentEnvironmentProvider nextSequence: () => number taskRef?: string accepted?: ExecutorResult @@ -43,10 +48,26 @@ export function registerScopeRetainedOwner(scope: Scope, args: OwnerReg const taskEvent = [...events].reverse().find((event) => event.kind === 'execution-input') const start = taskEvent === undefined ? 0 : events.indexOf(taskEvent) const attempt = events.slice(start) + const beforeInput = taskEvent === undefined ? [] : events.slice(0, start) + const priorInput = [...beforeInput].reverse().find((event) => event.kind === 'execution-input') + const priorAttempt = + priorInput === undefined ? [] : beforeInput.slice(beforeInput.indexOf(priorInput)) const admissions = attempt.flatMap((event) => event.kind === 'execution-admitted' ? [event.admission] : [], ) const acceptedRef = [...attempt].reverse().find((event) => event.kind === 'execution-result') + const priorResult = [...priorAttempt].reverse().find((event) => event.kind === 'execution-result') + const priorSession = + priorResult?.kind === 'execution-result' + ? priorAttempt + .slice(0, priorAttempt.indexOf(priorResult)) + .reverse() + .flatMap((event) => + event.kind === 'execution-admitted' && event.admission.phase === 'environment' + ? [event.admission] + : [], + )[0] + : undefined let sequence = Math.max(0, ...events.map((event) => ('seq' in event ? event.seq : 0))) const state: OwnerState = { args, @@ -56,12 +77,19 @@ export function registerScopeRetainedOwner(scope: Scope, args: OwnerReg ? { taskRef: taskEvent.taskRef, inputSequence: taskEvent.seq } : {}), ...(acceptedRef?.kind === 'execution-result' ? { acceptedRef } : {}), + ...(priorSession ? { priorSession } : {}), context: { get executionId() { if (state.inputSequence === undefined) throw new ValidationError('retained owner input was not committed') return `${args.nodeId}:input:${state.inputSequence}` }, + get priorSession() { + return state.priorSession + }, + // The first retained turn must stay alive so a later deliberate invocation can reuse it. + // The owning scope releases it once the complete manager scope finishes. + preserveEnvironment: true, admissions, onAdmission: async (admission) => { scope.signal.throwIfAborted() @@ -115,6 +143,84 @@ export function scopeRetainedOwnerContext( return owners.get(scope)?.context } +/** Bind cleanup before replay can return an already accepted owner result. */ +export function bindScopeRetainedOwnerProvider( + scope: Scope, + provider: AgentEnvironmentProvider, +): void { + const state = owners.get(scope) + if (state) state.provider = provider +} + +/** The existing scope settlement barrier releases the owner's environment after all its turns. */ +export async function releaseScopeRetainedOwnerEnvironment( + scope: Scope, +): Promise { + const state = owners.get(scope) + if (!state?.provider) return [] + const { provider, args } = state + const events = (await args.journal.loadTree(args.rootId)) ?? [] + const released = new Set( + events.flatMap((event) => + event.kind === 'environment-teardown' && + event.id === args.nodeId && + event.provider === provider.name && + event.destroyed + ? [event.environmentId] + : [], + ), + ) + const environments = new Set( + events.flatMap((event) => + event.id === args.nodeId && + event.kind === 'execution-admitted' && + event.admission.phase === 'environment' + ? [event.admission.environmentId] + : [], + ), + ) + let unconfirmed = false + for (const environmentId of environments) { + if (released.has(environmentId)) continue + let destroyed = false + let detail: string | undefined + try { + await runAbortable( + async () => { + if (!provider.get) throw new Error('provider cannot reconstruct the retained environment') + const environment = await provider.get(environmentId) + if (environment === null) return + if (environment.id !== environmentId || environment.provider !== provider.name) { + throw new Error('provider returned another retained environment') + } + if (!environment.destroy) + throw new Error('provider cannot destroy the retained environment') + await environment.destroy() + }, + AbortSignal.timeout(30_000), + 'retained owner cleanup timed out', + ) + destroyed = true + } catch { + detail = 'retained owner environment cleanup was not confirmed' + unconfirmed = true + } + await args.journal.appendEvent(args.rootId, { + kind: 'environment-teardown', + id: args.nodeId, + provider: provider.name, + environmentId, + destroyed, + ...(detail === undefined ? {} : { detail }), + seq: state.nextSequence(), + at: new Date(args.now()).toISOString(), + }) + } + return unconfirmed + ? [{ id: args.nodeId, label: 'scope owner', runtime: provider.name, status: 'done' }] + : [] +} + /** Resume the original backend prompt; rebuilt coordination observations cannot replace it. */ export async function prepareScopeRetainedOwnerTask( scope: Scope, @@ -140,6 +246,10 @@ export async function prepareScopeRetainedOwnerTask( if (accepted?.kind === 'execution-result') state.acceptedRef = accepted } if (state.prepared && state.acceptedRef && state.acceptedConsumed) { + const currentEnvironment = [...state.admissions] + .reverse() + .find((admission) => admission.phase === 'environment') + if (currentEnvironment?.phase === 'environment') state.priorSession = currentEnvironment delete state.inputSequence delete state.taskRef delete state.acceptedRef diff --git a/src/runtime/supervise/scope.ts b/src/runtime/supervise/scope.ts index 756e12f2..b8247449 100644 --- a/src/runtime/supervise/scope.ts +++ b/src/runtime/supervise/scope.ts @@ -89,7 +89,10 @@ import { type RetainedExecutorContext, retainedExecutorSeamKey, } from './retained-executor' -import { registerScopeRetainedOwner } from './retained-scope-owner' +import { + registerScopeRetainedOwner, + releaseScopeRetainedOwnerEnvironment, +} from './retained-scope-owner' import { detachedSnapshot } from './snapshot' import { captureWorkerTraceEvidence } from './trace-evidence' import type { TraceSource } from './trace-source' @@ -288,9 +291,11 @@ export async function startScopeRecoveries(scope: Scope): Promise * per environment, on this scope's own tree; a nested manager reaches its children through * `Executor.releaseRetained`, and its nested scope journals theirs. */ -export async function releaseRetainedEnvironments(scope: Scope): Promise { +export async function releaseRetainedEnvironments(scope: Scope) { const release = retainedReleasers.get(scope) if (release) await release() + const ownerUnconfirmed = await releaseScopeRetainedOwnerEnvironment(scope) + return [...scope.workerCapacity.unconfirmed, ...ownerUnconfirmed] } /** diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index d069a26c..93051ab2 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -96,6 +96,7 @@ import type { PeerMailLimits } from './peer-mail' import { addResourceSpend, resourceTelemetry, withBudgetResources } from './resources' import { registerRetainedExecutorPreparation, retainedExecutorSeamKey } from './retained-executor' import { + bindScopeRetainedOwnerProvider, consumeScopeRetainedOwnerResult, prepareScopeRetainedOwnerTask, scopeRetainedOwnerContext, @@ -792,6 +793,12 @@ function driveHarnessFromBackend( }) => { const retainedOwner = boundBackend.backend === 'provider' ? scopeRetainedOwnerContext(scope) : undefined + if (retainedOwner && boundBackend.backend === 'provider') { + bindScopeRetainedOwnerProvider( + scope, + resolveAgentEnvironmentProvider(boundBackend.provider, boundBackend.registry), + ) + } const originalTask = retainedOwner ? await prepareScopeRetainedOwnerTask(scope, task) : task const acceptedOwner = retainedOwner ? await scopeRetainedOwnerResult(scope) : undefined if (acceptedOwner) { @@ -1366,7 +1373,9 @@ function driveHarnessFromBackend( } } try { - await teardownOnce(completed ? DEFAULT_SUCCESSFUL_SHUTDOWN_MS : 'brutalKill') + if (!retainedOwner?.preserveEnvironment || retainedOwner.admissions.length === 0) { + await teardownOnce(completed ? DEFAULT_SUCCESSFUL_SHUTDOWN_MS : 'brutalKill') + } } catch (error) { if (!failed) { failed = true diff --git a/src/runtime/supervise/supervisor.ts b/src/runtime/supervise/supervisor.ts index 16bc6bb9..62e62cc5 100644 --- a/src/runtime/supervise/supervisor.ts +++ b/src/runtime/supervise/supervisor.ts @@ -725,8 +725,7 @@ export function createSupervisor(): Supervisor { // Whether a later process resumes is the caller's knowledge, not the outcome's, so the // policy decides; the unconfirmed set is re-read so a released node is no longer named. if (retainedAtSettlement === 'release') { - await releaseRetainedEnvironments(openScope) - teardownUnconfirmed = openScope.workerCapacity.unconfirmed + teardownUnconfirmed = await releaseRetainedEnvironments(openScope) } // The leak is real and must surface, so it is journaled per node — durable evidence a // fleet autopsy reads without the run's outcome being voided by cleanup bookkeeping. diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index ca58d482..83b53941 100644 --- a/src/testing/fixtures/agent-improvement-proposal.json +++ b/src/testing/fixtures/agent-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt"], - "digest": "sha256:7a5aed9de832f8872109fd32d35378ba16f044243377e073236891e289486dd3", + "digest": "sha256:8a6be0dffd2acd2002525433806319410ab64dfbd34b1bee9379b70624367cea", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.228.0" + "runtimeVersion": "0.230.0" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:3a47704454b044814c2e6c0457bdc33d8dcc2ee87923ff9e90c5a5c76797b4d9", - "runId": "agent-runtime-0.228.0-proposal-fixture", + "recordDigest": "sha256:cabdbcdaff1e9ce4cfab7e2b17dc38bb6e342a5f27c5981ef99a4c956c388f5f", + "runId": "agent-runtime-0.230.0-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -5021,5 +5021,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.228.0-proposal-fixture" + "runId": "agent-runtime-0.230.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index 1f8a72b8..ebb76657 100644 --- a/src/testing/fixtures/agent-profile-improvement-proposal.json +++ b/src/testing/fixtures/agent-profile-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt", "skills"], - "digest": "sha256:c7f195c9e7fbd6bed8fe787dc66b8c63467fb2408190b1db902273f78864bd15", + "digest": "sha256:84097d923139e2b6787636e3c834206b2de90fd7d9ec3228728435c6da757e48", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.228.0" + "runtimeVersion": "0.230.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:dc0db499ec659d245d6198250a7b991e11e7f47758fe433934111729a1dcde0e", + "recordDigest": "sha256:0595d437053c831277ba2a92ac88716ba863913dfe1f07a6d4a31a85398a93a0", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } diff --git a/tests/helpers/durable-retained-provider.ts b/tests/helpers/durable-retained-provider.ts index a530fbf1..ea6f8755 100644 --- a/tests/helpers/durable-retained-provider.ts +++ b/tests/helpers/durable-retained-provider.ts @@ -18,6 +18,8 @@ import type { AgentEnvironmentCapabilities, AgentEnvironmentEvent, AgentEnvironmentProvider, + AgentEnvironmentQuery, + AgentEnvironmentSummary, AgentNativeContextContinuationOptions, AgentSession, AgentSessionStatus, @@ -27,6 +29,7 @@ import type { interface StoredSession { readonly id: string controlRef: AgentExactRunControlRef + readonly controls: Record status: AgentSessionStatus readonly events: AgentEnvironmentEvent[] readonly dispatches: Array> @@ -51,6 +54,7 @@ interface StoredSession { interface StoredEnvironment { readonly id: string + readonly metadata?: Record readonly sessions: Record } @@ -68,7 +72,11 @@ export function durableRetainedProvider(stateFile: string): AgentEnvironmentProv if (!input.idempotencyKey) throw new Error('durable test provider requires a create key') const state = readState(stateFile) const id = `environment-${input.idempotencyKey}` - state.environments[id] ??= { id, sessions: {} } + state.environments[id] ??= { + id, + sessions: {}, + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + } writeState(stateFile, state) return environmentFor(stateFile, providerName, id) }, @@ -77,6 +85,18 @@ export function durableRetainedProvider(stateFile: string): AgentEnvironmentProv ? environmentFor(stateFile, providerName, id) : null }, + async list(query?: AgentEnvironmentQuery): Promise { + return Object.values(readState(stateFile).environments) + .filter((environment) => { + const expected = query?.metadata?.retainedIdempotencyKey + return expected === undefined || environment.metadata?.retainedIdempotencyKey === expected + }) + .map((environment) => ({ + id: environment.id, + provider: providerName, + ...(environment.metadata === undefined ? {} : { metadata: environment.metadata }), + })) + }, } } @@ -116,6 +136,7 @@ function environmentFor(stateFile: string, provider: string, id: string): AgentE session = { id: sessionId, controlRef, + controls: { [executionId]: controlRef }, status: 'running', events: retainedEvents(controlRef), dispatches: [], @@ -126,6 +147,35 @@ function environmentFor(stateFile: string, provider: string, id: string): AgentE nativeResponseLosses: {}, } environment.sessions[sessionId] = session + } else { + const prior = session.controls[executionId] + if (prior) { + const digest = canonicalCandidateDigest({ + environmentId: id, + sessionId, + turnId: input.turnId, + prompt: input.prompt ?? null, + parts: input.parts ?? null, + }) + if (prior.requestDigest !== digest) throw new Error('durable test turn key conflict') + } else { + const controlRef: AgentExactRunControlRef = { + runId: `run-${input.turnId}`, + provider, + environmentId: id, + sessionId, + executionId, + requestDigest: canonicalCandidateDigest({ + environmentId: id, + sessionId, + turnId: input.turnId, + prompt: input.prompt ?? null, + parts: input.parts ?? null, + }), + } + session.controls[executionId] = controlRef + session.controlRef = controlRef + } } session.dispatches.push(serializableTurn(input)) writeState(stateFile, state) @@ -133,13 +183,18 @@ function environmentFor(stateFile: string, provider: string, id: string): AgentE }, session(sessionId, options) { const stored = sessionState(stateFile, id, sessionId) + const requested = + options?.controlRef?.executionId === undefined + ? undefined + : stored.controls?.[options.controlRef.executionId] + const selected = requested ?? stored.controlRef if ( options?.controlRef && - canonicalCandidateDigest(options.controlRef) !== canonicalCandidateDigest(stored.controlRef) + canonicalCandidateDigest(options.controlRef) !== canonicalCandidateDigest(selected) ) { throw new Error('durable test provider received the wrong control reference') } - return sessionFor(stateFile, id, stored) + return sessionFor(stateFile, id, { ...stored, controlRef: selected }) }, async destroy() { const state = readState(stateFile) diff --git a/tests/kernel/coordination-mcp.test.ts b/tests/kernel/coordination-mcp.test.ts index fb572e9f..f3bfcd1b 100644 --- a/tests/kernel/coordination-mcp.test.ts +++ b/tests/kernel/coordination-mcp.test.ts @@ -272,6 +272,7 @@ describe('coordination MCP over a live Scope — the real keystone (HTTP → MCP async function withLiveScope( body: (scope: Scope) => Promise, signal?: AbortSignal, + deadlineMs?: number, ): Promise { const blobs = new InMemoryResultBlobStore() let captured: { ok: true; value: T } | { ok: false; error: unknown } | undefined @@ -295,13 +296,17 @@ async function withLiveScope( }, } await createSupervisor().run(root, 'bind', { - budget: { maxIterations: 10, maxTokens: 1000 }, + budget: { + maxIterations: 10, + maxTokens: 1000, + ...(deadlineMs === undefined ? {} : { deadlineMs }), + }, runId: 'bind-gate', journal: new InMemorySpawnJournal(), blobs, executors: createExecutorRegistry(), maxDepth: 2, - now: () => 0, + now: deadlineMs === undefined ? () => 0 : Date.now, signal, }) if (!started) throw new Error('the root agent never ran') @@ -470,32 +475,37 @@ async function withBoundHttp( extra: Partial[0]>, body: (mcp: Awaited>) => Promise, signal?: AbortSignal, + deadlineMs?: number, ): Promise { - return withLiveScope(async (scope) => { - const mcp = await serveCoordinationMcp({ - scope, - blobs: new InMemoryResultBlobStore(), - makeWorkerAgent: () => deliveringLeaf('unused', {}), - perWorker: { maxIterations: 1, maxTokens: 10 }, - authentication: true, - identity: { runId: 'run-a', actorId: 'actor-a' }, - toolNames: ['probe'], - nodeTools: [ - { - name: 'probe', - description: 'Exercise the real HTTP boundary', - inputSchema: { type: 'object' }, - handler: async () => ({ ok: true }), - }, - ], - ...extra, - }) - try { - return await body(mcp) - } finally { - await mcp.close() - } - }, signal) + return withLiveScope( + async (scope) => { + const mcp = await serveCoordinationMcp({ + scope, + blobs: new InMemoryResultBlobStore(), + makeWorkerAgent: () => deliveringLeaf('unused', {}), + perWorker: { maxIterations: 1, maxTokens: 10 }, + authentication: true, + identity: { runId: 'run-a', actorId: 'actor-a' }, + toolNames: ['probe'], + nodeTools: [ + { + name: 'probe', + description: 'Exercise the real HTTP boundary', + inputSchema: { type: 'object' }, + handler: async () => ({ ok: true }), + }, + ], + ...extra, + }) + try { + return await body(mcp) + } finally { + await mcp.close() + } + }, + signal, + deadlineMs, + ) } function postHttp( @@ -627,6 +637,7 @@ describe('authenticated and bounded coordination HTTP', () => { const events: unknown[] = [] await withBoundHttp( { + authentication: { ttlMs: 900_000 }, onAudit: (event) => { events.push(event) }, @@ -970,13 +981,65 @@ describe('authenticated and bounded coordination HTTP', () => { }) describe('coordination credential continuity', () => { - it('accepts an original credential only for its exact restarted authority and retained verification key', async () => { - const signingKeys = { activeKeyId: 'original', keys: { original: 'a'.repeat(48) } } - const publicUrl = 'https://coordination.example/manager' + it('does not revive a revoked signed credential through signature padding', async () => { + await withBoundHttp( + { + authentication: { signingKeys: { activeKeyId: 'run', keys: { run: 'k'.repeat(48) } } }, + publicUrl: ({ port }) => `http://127.0.0.1:${port}/mcp`, + }, + async (mcp) => { + const original = mcp.headers + expect((await postHttp(mcp, original)).status).toBe(200) + mcp.rotateCredential() + expect((await postHttp(mcp, original)).status).toBe(401) + expect( + ( + await postHttp(mcp, { + Authorization: `${original.Authorization}=`, + }) + ).status, + ).toBe(401) + expect((await postHttp(mcp, mcp.headers)).status).toBe(200) + }, + ) + }) + + it('resumes scope-bound signed credentials only when the receiver permits that lifetime', async () => { + const signingKeys = { activeKeyId: 'run', keys: { run: 'k'.repeat(48) } } + const publicUrl = 'https://coordination.example/scope-bound' let original: Readonly> = {} await withBoundHttp({ authentication: { signingKeys }, publicUrl }, async (mcp) => { + expect(mcp.credentialExpiresAt).toBeUndefined() original = mcp.headers }) + for (const ttlMs of [undefined, 900_000]) { + await withBoundHttp( + { + authentication: { signingKeys, ...(ttlMs === undefined ? {} : { ttlMs }) }, + publicUrl, + }, + async (mcp) => { + const response = await fetch(`http://127.0.0.1:${mcp.port}/mcp`, { + method: 'POST', + headers: { ...original, 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }) + expect(response.status).toBe(ttlMs === undefined ? 200 : 401) + }, + ) + } + }) + + it('accepts an original credential only for its exact restarted authority and retained verification key', async () => { + const signingKeys = { activeKeyId: 'original', keys: { original: 'a'.repeat(48) } } + const publicUrl = 'https://coordination.example/manager' + let original: Readonly> = {} + await withBoundHttp( + { authentication: { signingKeys, ttlMs: 900_000 }, publicUrl }, + async (mcp) => { + original = mcp.headers + }, + ) const request = (mcp: Awaited>) => fetch(`http://127.0.0.1:${mcp.port}/mcp`, { method: 'POST', @@ -1293,6 +1356,70 @@ describe('the coordination HTTP boundary keeps its own deadline', () => { }) describe('caller-owned long-lived coordination credentials', () => { + it('binds default credentials to the original scope deadline without extending it on rotation', async () => { + await withBoundHttp( + {}, + async (mcp) => { + const expiry = mcp.credentialExpiresAt! + expect(expiry).toBeGreaterThan(Date.now() + 3 * 60 * 60 * 1_000) + const clock = vi.spyOn(Date, 'now').mockReturnValue(expiry - 1) + try { + expect((await postHttp(mcp, mcp.headers)).status).toBe(200) + mcp.rotateCredential() + expect(mcp.credentialExpiresAt).toBe(expiry) + clock.mockReturnValue(expiry) + expect((await postHttp(mcp, mcp.headers)).status).toBe(401) + } finally { + clock.mockRestore() + } + }, + undefined, + 4 * 60 * 60 * 1_000, + ) + }) + + it('revokes scope-bound credentials immediately when the scope is cancelled', async () => { + const controller = new AbortController() + await withBoundHttp( + {}, + async (mcp) => { + expect((await postHttp(mcp, mcp.headers)).status).toBe(200) + controller.abort('owner cancelled') + expect((await postHttp(mcp, mcp.headers)).status).toBe(401) + }, + controller.signal, + ) + }) + + it.each([false, true])( + 'keeps a live manager authenticated beyond three hours (signed=%s)', + async (signed) => { + await withBoundHttp( + signed + ? { + authentication: { + signingKeys: { activeKeyId: 'run', keys: { run: 'k'.repeat(48) } }, + }, + publicUrl: ({ port }) => `http://127.0.0.1:${port}/mcp`, + } + : {}, + async (mcp) => { + const headers = mcp.headers + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 3 * 60 * 60 * 1_000) + try { + expect((await postHttp(mcp, headers)).status).toBe(200) + expect((await postHttp(mcp)).status).toBe(401) + mcp.rotateCredential() + expect((await postHttp(mcp, headers)).status).toBe(401) + expect((await postHttp(mcp, mcp.headers)).status).toBe(200) + } finally { + clock.mockRestore() + } + }, + ) + }, + ) + it('honors a finite lifetime beyond one day and still enforces its exact expiry', async () => { const ttlMs = 30 * 24 * 60 * 60 * 1_000 await withBoundHttp({ authentication: { ttlMs } }, async (mcp) => { diff --git a/tests/kernel/retained-scope-owner.test.ts b/tests/kernel/retained-scope-owner.test.ts index 85f1dfcc..6ac19a5f 100644 --- a/tests/kernel/retained-scope-owner.test.ts +++ b/tests/kernel/retained-scope-owner.test.ts @@ -1,9 +1,12 @@ +import type { AgentEnvironmentProvider } from '@tangle-network/agent-interface/environment-provider' import { describe, expect, it } from 'vitest' import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable/spawn-journal' import { + bindScopeRetainedOwnerProvider, consumeScopeRetainedOwnerResult, prepareScopeRetainedOwnerTask, registerScopeRetainedOwner, + releaseScopeRetainedOwnerEnvironment, scopeRetainedOwnerContext, scopeRetainedOwnerResult, } from '../../src/runtime/supervise/retained-scope-owner' @@ -37,6 +40,88 @@ async function inScope(body: (scope: Scope) => Promise) { } describe('retained scope owner input and result', () => { + it.each([ + { id: 'another-owner', provider: 'owner-provider' }, + { id: 'owner-test', provider: 'another-provider' }, + { id: 'another-owner', provider: 'another-provider' }, + ])('does not reuse the teardown receipt for $id on $provider', async (foreign) => { + const environmentId = 'shared-environment-id' + const events: SpawnEvent[] = [ + { + kind: 'environment-teardown', + ...foreign, + environmentId, + destroyed: true, + seq: 1, + at: new Date(0).toISOString(), + }, + ] + let gets = 0 + let destroys = 0 + const provider: AgentEnvironmentProvider = { + name: 'owner-provider', + capabilities() { + throw new Error('cleanup must not request execution capabilities') + }, + async create() { + throw new Error('cleanup must not create an environment') + }, + async get(id) { + expect(id).toBe(environmentId) + gets++ + return { + id, + provider: 'owner-provider', + status: async () => 'running', + async *stream() { + yield* [] + }, + async destroy() { + destroys++ + }, + } + }, + } + await inScope(async (scope) => { + registerScopeRetainedOwner(scope, { + rootId: 'owner-test', + nodeId: 'owner-test', + blobs: new InMemoryResultBlobStore(), + priorEvents: events, + now: () => 0, + journal: { + loadTree: async () => [...events], + beginTree: async () => {}, + appendEvent: async (_root, event) => { + events.push(event) + }, + }, + }) + bindScopeRetainedOwnerProvider(scope, provider) + await scopeRetainedOwnerContext(scope)!.onAdmission({ + phase: 'environment', + provider: provider.name, + environmentId, + idempotencyKey: 'owner-environment-key', + turnId: 'owner-turn', + sessionId: 'owner-session', + executionId: 'owner-execution', + }) + + expect(await releaseScopeRetainedOwnerEnvironment(scope)).toEqual([]) + expect(gets).toBe(1) + expect(destroys).toBe(1) + expect(await releaseScopeRetainedOwnerEnvironment(scope)).toEqual([]) + }) + + expect(gets).toBe(1) + expect(destroys).toBe(1) + expect(events.filter((event) => event.kind === 'environment-teardown')).toMatchObject([ + { ...foreign, environmentId, destroyed: true }, + { id: 'owner-test', provider: provider.name, environmentId, destroyed: true }, + ]) + }) + it('restores original backend input and identity, then allocates a distinct identity for a later drive', async () => { const blobs = new InMemoryResultBlobStore() const priorEvents: SpawnEvent[] = [] diff --git a/tests/kernel/supervise-retained-owner-recovery.test.ts b/tests/kernel/supervise-retained-owner-recovery.test.ts index 7301ceba..8344162c 100644 --- a/tests/kernel/supervise-retained-owner-recovery.test.ts +++ b/tests/kernel/supervise-retained-owner-recovery.test.ts @@ -20,6 +20,304 @@ afterEach(async () => { }) describe('retained external supervisor recovery', () => { + it('reconstructs a reprompt interrupted after environment admission without a third dispatch', async () => { + const directory = await mkdtemp(join(tmpdir(), 'retained-owner-reprompt-crash-')) + directories.push(directory) + const stateFile = join(directory, 'provider.json') + const runDirectory = join(directory, 'run') + const context = createFileRunContext(runDirectory) + let creates = 0 + let dispatches = 0 + let destroys = 0 + let failed = false + let port = 0 + let token = '' + const turns: AgentTurnInput[] = [] + const environmentIds: string[] = [] + const provider: AgentEnvironmentProvider = { + ...durableRetainedProvider(stateFile), + capabilities: async () => ({ + ...(await durableRetainedProvider(stateFile).capabilities()), + create: { runtimeAttachments: { mcp: true } }, + }), + create: async (input) => { + creates++ + token ||= input.env?.AGENT_RUNTIME_COORDINATION_TOKEN ?? '' + const environment = await durableRetainedProvider(stateFile).create(input) + return wrap(environment) + }, + get: async (id) => { + const environment = await durableRetainedProvider(stateFile).get!(id) + return environment ? wrap(environment) : null + }, + } + const wrap = (environment: AgentEnvironment): AgentEnvironment => ({ + ...environment, + session: (id, sessionOptions) => { + const session = environment.session!(id, sessionOptions) + return { + ...session, + result: async () => ({ + ...(await session.result()), + usage: { inputTokens: 5, outputTokens: 5 }, + }), + } + }, + dispatch: async (turn) => { + dispatches++ + turns.push(turn) + environmentIds.push(environment.id) + const result = await environment.dispatch(turn) + if (dispatches === 2) { + const response = await fetch(`http://127.0.0.1:${port}/manager`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'resumed-final', + method: 'tools/call', + params: { name: 'submit_result', arguments: { result: { answer: 'resumed' } } }, + }), + }) + if (!response.ok) throw new Error(`submit_result returned ${response.status}`) + } + return result + }, + destroy: async () => { + destroys++ + await environment.destroy?.() + }, + }) + const profile = testAgentProfile('root', { + harness: 'codex', + tools: runtimeToolDeclarations('submit_result'), + }) + const options = { + runDir: runDirectory, + runId: 'reprompt-crash-root', + backend: { backend: 'provider' as const, provider }, + driverBackend: { backend: 'provider' as const, provider }, + budget: { maxIterations: 8, maxTokens: 100 }, + driverRetry: { enabled: false }, + repromptOnUnmet: 1, + deliverable: { + describe: 'resumed answer', + check: (v: unknown) => (v as { answer?: string }).answer === 'resumed', + }, + coordination: { + authentication: { + signingKeys: { activeKeyId: 'test', keys: { test: 'test-secret-'.repeat(4) } }, + }, + publicUrl: (address: { port: number }) => { + port = address.port + return 'https://coordination.example/manager' + }, + }, + journal: { + beginTree: context.journal.beginTree.bind(context.journal), + loadTree: context.journal.loadTree.bind(context.journal), + appendEvent: async (root: string, event: SpawnEvent) => { + await context.journal.appendEvent(root, event) + if ( + !failed && + event.kind === 'execution-admitted' && + event.admission.phase === 'environment' + ) { + const prior = (await context.journal.loadTree(root)) ?? [] + if ( + prior.filter( + (e) => e.kind === 'execution-admitted' && e.admission.phase === 'environment', + ).length === 2 + ) { + failed = true + throw new Error('test crash after second environment admission') + } + } + }, + }, + blobs: context.blobs, + } + const interrupted = await supervise(profile, 'answer', options) + expect(interrupted).toMatchObject({ kind: 'no-winner', reason: 'driver-failed' }) + const resumeAbort = new AbortController() + const resumeTimer = setTimeout(() => resumeAbort.abort(new Error('resume timeout')), 5_000) + const result = await supervise(profile, 'answer', { + ...options, + retainedAtSettlement: 'release', + signal: resumeAbort.signal, + }) + clearTimeout(resumeTimer) + expect(result).toMatchObject({ kind: 'winner', out: { answer: 'resumed' } }) + expect(creates).toBe(1) + expect(dispatches).toBe(2) + expect(new Set(turns.map((turn) => turn.sessionId)).size).toBe(1) + expect(new Set(turns.map((turn) => turn.executionId)).size).toBe(2) + expect(new Set(turns.map((turn) => turn.turnId)).size).toBe(2) + expect(destroys).toBe(1) + const events = (await context.journal.loadTree('reprompt-crash-root')) ?? [] + const environments = events.filter( + (event) => event.kind === 'execution-admitted' && event.admission.phase === 'environment', + ) + expect(environments).toHaveLength(2) + const resumed = environments[1] + expect(resumed?.kind === 'execution-admitted' ? resumed.admission : undefined).toMatchObject({ + environmentId: environmentIds[1], + sessionId: turns[1]?.sessionId, + executionId: turns[1]?.executionId, + }) + expect( + events.reduce( + (total, event) => + event.kind === 'metered' + ? total + event.spend.tokens.input + event.spend.tokens.output + : total, + 0, + ), + ).toBe(10) + }) + + it.each(['release', 'keep', 'release-failed'] as const)( + 're-prompts one retained provider conversation with %s cleanup', + async (cleanup) => { + const directory = await mkdtemp(join(tmpdir(), 'retained-owner-reprompt-')) + directories.push(directory) + const stateFile = join(directory, 'provider.json') + const runDirectory = join(directory, 'run') + const context = createFileRunContext(runDirectory) + let creates = 0 + let destroys = 0 + let coordinationPort = 0 + let coordinationToken = '' + const turns: Array<{ + environmentId: string + sessionId?: string + executionId?: string + turnId?: string + }> = [] + let reprompt = 0 + const wrapEnvironment = (environment: AgentEnvironment): AgentEnvironment => ({ + ...environment, + dispatch: async (turn) => { + turns.push({ + environmentId: environment.id, + sessionId: turn.sessionId, + executionId: turn.executionId, + turnId: turn.turnId, + }) + const dispatched = await environment.dispatch(turn) + reprompt++ + if (reprompt === 3) { + const response = await fetch(`http://127.0.0.1:${coordinationPort}/manager`, { + method: 'POST', + headers: { + Authorization: `Bearer ${coordinationToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'final-submission', + method: 'tools/call', + params: { + name: 'submit_result', + arguments: { result: { answer: 'final reprompt' } }, + }, + }), + }) + if (!response.ok) throw new Error(`submit_result returned ${response.status}`) + } + return dispatched + }, + destroy: async () => { + destroys++ + if (cleanup === 'release-failed') throw new Error('provider cleanup unavailable') + await environment.destroy?.() + }, + }) + + const provider: AgentEnvironmentProvider = { + ...durableRetainedProvider(stateFile), + capabilities: async () => ({ + ...(await durableRetainedProvider(stateFile).capabilities()), + create: { runtimeAttachments: { mcp: true } }, + }), + create: async (input) => { + creates++ + coordinationToken ||= input.env?.AGENT_RUNTIME_COORDINATION_TOKEN ?? '' + const base = durableRetainedProvider(stateFile) + const environment = await base.create(input) + return wrapEnvironment(environment) + }, + get: async (id) => { + const base = durableRetainedProvider(stateFile) + const environment = await base.get!(id) + if (!environment) return null + return wrapEnvironment(environment) + }, + } + + const result = await supervise( + testAgentProfile('root', { + harness: 'codex', + tools: runtimeToolDeclarations('submit_result'), + }), + 'Produce the answer.', + { + runDir: runDirectory, + journal: context.journal, + blobs: context.blobs, + runId: 'reprompt-root', + backend: { backend: 'provider', provider }, + driverBackend: { backend: 'provider', provider }, + budget: { maxIterations: 8, maxTokens: 100 }, + driverRetry: { enabled: false }, + repromptOnUnmet: 2, + retainedAtSettlement: cleanup === 'keep' ? 'keep' : 'release', + deliverable: { + describe: 'an answer from the final reprompt', + check: (value) => (value as { answer?: unknown }).answer === 'final reprompt', + }, + coordination: { + authentication: { + signingKeys: { activeKeyId: 'test', keys: { test: 'test-secret-'.repeat(4) } }, + }, + publicUrl: (address) => { + coordinationPort = address.port + return `https://coordination.example:${address.port}/manager` + }, + }, + }, + ) + + expect(creates).toBe(1) + expect(result.kind).toBe('winner') + expect(result).toMatchObject({ out: { answer: 'final reprompt' } }) + expect(turns).toHaveLength(3) + expect(new Set(turns.map((turn) => turn.environmentId)).size).toBe(1) + expect(turns.every((turn) => turn.sessionId !== undefined)).toBe(true) + expect(new Set(turns.map((turn) => turn.sessionId)).size).toBe(1) + expect(turns.every((turn) => turn.executionId !== undefined)).toBe(true) + expect(new Set(turns.map((turn) => turn.executionId)).size).toBe(3) + expect(turns.every((turn) => turn.turnId !== undefined)).toBe(true) + expect(new Set(turns.map((turn) => turn.turnId)).size).toBe(3) + expect(destroys).toBe(cleanup === 'keep' ? 0 : 1) + const events = (await context.journal.loadTree('reprompt-root')) ?? [] + expect(events.filter((event) => event.kind === 'execution-result')).toHaveLength(3) + expect(events.filter((event) => event.kind === 'environment-teardown')).toMatchObject( + cleanup === 'keep' + ? [] + : [{ destroyed: cleanup === 'release', environmentId: turns[0]?.environmentId }], + ) + if (cleanup === 'release-failed') { + expect(result.teardownUnconfirmed).toEqual([ + { id: 'reprompt-root', label: 'scope owner', runtime: provider.name, status: 'done' }, + ]) + expect(events.filter((event) => event.kind === 'teardown-unconfirmed')).toHaveLength(1) + } else { + expect(result.teardownUnconfirmed).toBeUndefined() + } + }, + ) + it('keeps historical unclassified root usage incomplete when replay supplies a cache split', async () => { const fixture = await setup('metered', false, false, true) await fixture.first() @@ -85,6 +383,10 @@ describe('retained external supervisor recovery', () => { await fixture.first() const before = await fixture.events() expect(before.some((event) => event.kind === 'execution-result')).toBe(true) + const provider = durableRetainedProvider(fixture.stateFile) + for (const environment of await provider.list!()) { + await (await provider.get!(environment.id))?.destroy?.() + } expect(JSON.parse(await readFile(fixture.stateFile, 'utf8')).environments).toEqual({}) const bindings = before.filter((event) => event.kind === 'execution-bound') await fixture.resume() @@ -107,10 +409,10 @@ describe('retained external supervisor recovery', () => { ) expect(inputs).toHaveLength(2) expect(new Set(inputs.map((event) => event.seq)).size).toBe(2) - expect(new Set(intents.map((intent) => intent.idempotencyKey)).size).toBe(2) - expect(fixture.creates()).toBe(2) + expect(new Set(intents.map((intent) => intent.idempotencyKey)).size).toBe(1) + expect(fixture.creates()).toBe(1) expect(tokenTotal(events)).toBe(10) - // The second drive ran in a NEW environment. Its report must bind as known to the one + // The second drive ran in the SAME environment. Its report must bind as known to the one // committed materialization. Before the provider stopped writing the environment id into the // plan, this second binding was unknown with reason invalid-executor-report: the id moved // materializationPlanDigest, the guard refused the receipt, and on the fleet every retry hit From 35d41a695cb05ca6d99ea01c9ec256fc010d6ba6 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 20:41:52 -0700 Subject: [PATCH 2/3] fix(knowledge): adopt validated memory write admission --- pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f313eb4e..b108591e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ catalogs: specifier: ^2.6.0 version: 2.6.0 '@tangle-network/agent-knowledge': - specifier: ^17.0.1 - version: 17.0.1 + specifier: ^17.0.2 + version: 17.0.2 '@tangle-network/agent-profile-materialize': specifier: '>=0.19.0 <0.20.0' version: 0.19.0 @@ -58,7 +58,7 @@ importers: version: 0.9.6(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)) '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 17.0.1(@tangle-network/agent-eval@0.182.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)))(@tangle-network/agent-interface@2.6.0) + version: 17.0.2(@tangle-network/agent-eval@0.182.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)))(@tangle-network/agent-interface@2.6.0) '@tangle-network/agent-profile-materialize': specifier: 'catalog:' version: 0.19.0(@tangle-network/agent-interface@2.6.0) @@ -140,7 +140,7 @@ importers: version: 2.6.0 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 17.0.1(@tangle-network/agent-eval@0.182.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)))(@tangle-network/agent-interface@2.6.0) + version: 17.0.2(@tangle-network/agent-eval@0.182.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)))(@tangle-network/agent-interface@2.6.0) '@tangle-network/agent-runtime': specifier: workspace:^ version: link:.. @@ -1308,8 +1308,8 @@ packages: '@tangle-network/agent-interface@2.8.0': resolution: {integrity: sha512-Li55RKjjB6VvkW5Ryba0QQLhd07zhD0ENYpacAHqYscyifJSeoh0xqJ8ws93qQ2Btp46P05Q42W6ej6wqX8Vjg==} - '@tangle-network/agent-knowledge@17.0.1': - resolution: {integrity: sha512-B85focFu+dCfdMSQ8NQQXQZAtJIUra7lW4K0o24IkRbOM0DVTkRH2DJjzSibnVKavlT6oHilfBUGHWd+fVZbBg==} + '@tangle-network/agent-knowledge@17.0.2': + resolution: {integrity: sha512-xPXGeDT8e7ag4Fz/lzZLLaNE29sWi9qyCr33KFP9kQ8MBBS2nw9R82KejJFDkvaKNm3zxI7iF4bms13PfJH3LA==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -3596,7 +3596,7 @@ snapshots: spdx-expression-parse: 5.0.0 zod: 4.5.4 - '@tangle-network/agent-knowledge@17.0.1(@tangle-network/agent-eval@0.182.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)))(@tangle-network/agent-interface@2.6.0)': + '@tangle-network/agent-knowledge@17.0.2(@tangle-network/agent-eval@0.182.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)))(@tangle-network/agent-interface@2.6.0)': dependencies: '@tangle-network/agent-eval': 0.182.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.5.4)) '@tangle-network/agent-interface': 2.6.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 012f54e2..7d9b4ca5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,7 +25,7 @@ catalog: '@types/node': 26.4.0 '@tangle-network/agent-eval': '>=0.182.0 <0.183.0' '@tangle-network/agent-interface': ^2.6.0 - '@tangle-network/agent-knowledge': ^17.0.1 + '@tangle-network/agent-knowledge': ^17.0.2 '@tangle-network/agent-profile-materialize': '>=0.19.0 <0.20.0' '@tangle-network/agent-trace-contract': ^1.0.2 '@tangle-network/sandbox': '>=0.36.4 <0.40.0' From 0ab53b4c80bcf39523502d2f827e517e39541041 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 20:45:15 -0700 Subject: [PATCH 3/3] chore(bench): version the validated Knowledge dependency cohort --- bench/CHANGELOG.md | 4 ++++ bench/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bench/CHANGELOG.md b/bench/CHANGELOG.md index a23276ee..e163b1b4 100644 --- a/bench/CHANGELOG.md +++ b/bench/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.13.1 + +Require Knowledge 17.0.2 for validated memory writes, matching the Runtime workspace cohort. + ## 0.13.0 Remove the SWE and quant research campaign drivers, their historical fixtures, and the campaign-specific rollout ledger bridge from the source package. diff --git a/bench/package.json b/bench/package.json index 474aecd5..d29f8383 100644 --- a/bench/package.json +++ b/bench/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-bench", - "version": "0.13.0", + "version": "0.13.1", "type": "module", "description": "Benchmark adapters and execution for agent-runtime across coding, tool-use, RAG, memory, browser, and terminal tasks.", "repository": {