From 5bf981da1ee3f05091256bd463b21bdb8fcb4c84 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 9 Sep 2026 14:13:46 -0700 Subject: [PATCH] fix(runtime): mint storage-safe retained session identities --- docs/agent-managed-compute/README.md | 2 + docs/api/primitive-catalog.md | 2 +- docs/canonical-api.md | 2 +- package.json | 2 +- ...nment-provider-sandbox-interactive.test.ts | 6 +- src/runtime/retained-interactive.test.ts | 176 +++++++- src/runtime/retained-interactive.ts | 41 +- src/runtime/retained-run-start.ts | 35 +- src/runtime/retained-run.test.ts | 381 +++++++++++------- .../fixtures/agent-improvement-proposal.json | 10 +- .../agent-profile-improvement-proposal.json | 6 +- 11 files changed, 482 insertions(+), 181 deletions(-) diff --git a/docs/agent-managed-compute/README.md b/docs/agent-managed-compute/README.md index feefdce9..e0dfe34d 100644 --- a/docs/agent-managed-compute/README.md +++ b/docs/agent-managed-compute/README.md @@ -17,6 +17,8 @@ The core execution model is sound: Retained provider children and managers can reconcile original invocations after a local coordinator restart. Durable inputs, ordered admissions, content-addressed results, and usage records support that recovery. +New retained sessions use deterministic, bounded identifiers that are safe for provider workspace paths. +Recovery preserves the recorded session and execution identifiers and validates the original request material. Recovery restores live descendants and their reservations before the manager resumes. Managers and children then continue together through the existing Scope lifecycle. Unresolved provider work remains uncertain until exact identity and completion can be established. diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 69359d4e..f47b0990 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.208.0` and `@tangle-network/agent-eval@0.179.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.208.1` and `@tangle-network/agent-eval@0.179.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/canonical-api.md b/docs/canonical-api.md index 285e10db..7e9d7763 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.208.0.** +> **Version 0.208.1.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.179.0 <0.180.0`. > `sandbox` must satisfy `>=0.36.4 <0.39.0`. diff --git a/package.json b/package.json index cbcf68a7..42503da7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.208.0", + "version": "0.208.1", "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-sandbox-interactive.test.ts b/src/runtime/environment-provider-sandbox-interactive.test.ts index 354e9663..2972c89b 100644 --- a/src/runtime/environment-provider-sandbox-interactive.test.ts +++ b/src/runtime/environment-provider-sandbox-interactive.test.ts @@ -22,6 +22,7 @@ import { startRetainedInteractiveRun, } from './retained-interactive' import { claimRetainedInteractiveControl } from './retained-interactive-control' +import { mintRetainedIdentity } from './retained-run-start' import type { SandboxClient } from './types' const profile: AgentProfile = { @@ -423,7 +424,10 @@ function controlFor( function terminalReady(attachCount: number) { return { connectionId: `connection-${attachCount}`, - sessionId: 'retained-session:sandbox-interactive-environment:sandbox-interactive-process', + sessionId: mintRetainedIdentity( + 'sandbox-interactive-environment', + 'sandbox-interactive-process', + ).sessionId, restored: attachCount > 1, detachTimeoutMs: 300_000, attachCount, diff --git a/src/runtime/retained-interactive.test.ts b/src/runtime/retained-interactive.test.ts index 4cf37e0d..24c9d31d 100644 --- a/src/runtime/retained-interactive.test.ts +++ b/src/runtime/retained-interactive.test.ts @@ -11,7 +11,9 @@ import { type AgentProfile, agentInteractiveSessionControlClaimRequestDigest, agentInteractiveSessionPromptRequestDigest, + agentInteractiveSessionRunRef, agentInteractiveSessionStopRequestDigest, + canonicalAgentProfileDigest, canonicalCandidateDigest, } from '@tangle-network/agent-interface' import type { @@ -28,7 +30,12 @@ import { startRetainedInteractiveRun, } from './retained-interactive' import { claimRetainedInteractiveControl } from './retained-interactive-control' -import type { RetainedInteractiveAdmission } from './retained-run-types' +import { mintRetainedIdentity } from './retained-run-start' +import type { + RetainedInteractiveAdmission, + RetainedInteractiveEnvironmentAdmission, + RetainedInteractiveIntentAdmission, +} from './retained-run-types' const profile: AgentProfile = { name: 'Braid product engineer', @@ -72,8 +79,7 @@ describe('retained interactive runs', () => { provider: 'test-provider', idempotencyKey: 'workspace-1', interactiveIdempotencyKey: 'native-turn-1', - sessionId: 'retained-session:workspace-1:native-turn-1', - executionId: 'retained-execution:workspace-1:native-turn-1', + ...mintRetainedIdentity('workspace-1', 'native-turn-1'), runId: expect.stringMatching(/^interactive-intent-run:/u), requestedProfileDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), requestDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), @@ -82,7 +88,9 @@ describe('retained interactive runs', () => { phase: 'interactive_environment', request: { initialPrompt: 'Inspect this workspace.', cols: 120, rows: 40 }, }) - expect(handle.ref.run.sessionId).toBe('retained-session:workspace-1:native-turn-1') + expect(handle.ref.run.sessionId).toBe( + mintRetainedIdentity('workspace-1', 'native-turn-1').sessionId, + ) expect((await handle.status()).state).toBe('running') const control = await claimRetainedInteractiveControl({ handle, holderId: 'braid-ui' }) const promptAcknowledgement = await handle.sendPrompt( @@ -276,7 +284,7 @@ describe('retained interactive runs', () => { }) expect(recovered?.ref.run.sessionId).toBe( - 'retained-session:workspace-intent-crash:native-intent-crash', + mintRetainedIdentity('workspace-intent-crash', 'native-intent-crash').sessionId, ) expect(fixture.createCalls).toBe(1) expect(fixture.environmentCreations).toBe(1) @@ -371,6 +379,164 @@ describe('retained interactive runs', () => { expect(fixture.processStarts).toBe(processStartsBeforeReplay) }) + it('replays a historical intent with its recorded identities and rejects changed coordinates', async () => { + const fixture = interactiveProvider() + const intent: RetainedInteractiveIntentAdmission = { + phase: 'interactive_intent', + provider: 'test-provider', + idempotencyKey: 'workspace-history', + interactiveIdempotencyKey: 'native-history', + sessionId: 'retained-session:workspace-history:native-history', + executionId: 'retained-execution:workspace-history:native-history', + runId: + 'interactive-intent-run:6c516c44b5b285523175da7526453e3cad0096d4980013bd021c7364adbea0d2', + requestedProfileDigest: + 'sha256:f1ed1f46786b31a3ba0038b55d40bdbd3edec5b99a86d8962d017f1253111283', + requestDigest: 'sha256:6c516c44b5b285523175da7526453e3cad0096d4980013bd021c7364adbea0d2', + } + const replay = { + environment: { profile, idempotencyKey: intent.idempotencyKey }, + interactiveIdempotencyKey: intent.interactiveIdempotencyKey, + } + for (const changed of [ + { ...intent, sessionId: 'session-other' }, + { ...intent, executionId: 'execution-other' }, + ]) { + await expect( + recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: changed, + replay, + onAdmission: async () => {}, + }), + ).rejects.toThrow('interactive intent conflicts with replay material') + } + expect(fixture.createCalls).toBe(0) + + const recovered = await recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: intent, + replay, + onAdmission: async () => {}, + }) + + expect(recovered?.ref.run).toMatchObject({ + sessionId: intent.sessionId, + executionId: intent.executionId, + }) + expect(fixture.createCalls).toBe(1) + expect(fixture.processStarts).toBe(1) + }) + + it.each(['sessionId', 'executionId'] as const)( + 'rejects a digest-consistent malformed intent %s before provider creation', + async (field) => { + const fixture = interactiveProvider() + const identity = { + ...mintRetainedIdentity('workspace-malformed', 'native-malformed'), + [field]: ' invalid-coordinate ', + } + const intentMaterial = { + provider: fixture.provider.name, + idempotencyKey: 'workspace-malformed', + interactiveIdempotencyKey: 'native-malformed', + ...identity, + requestedProfileDigest: canonicalAgentProfileDigest(profile), + } + const requestDigest = canonicalCandidateDigest({ + kind: 'retained-interactive-intent.v1', + ...intentMaterial, + create: {}, + start: {}, + }) + + await expect( + recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: { + phase: 'interactive_intent', + ...intentMaterial, + requestDigest, + runId: `interactive-intent-run:${requestDigest.slice('sha256:'.length)}`, + }, + replay: { + environment: { profile, idempotencyKey: intentMaterial.idempotencyKey }, + interactiveIdempotencyKey: intentMaterial.interactiveIdempotencyKey, + }, + onAdmission: async () => {}, + }), + ).rejects.toThrow('outer whitespace') + expect(fixture.createCalls).toBe(0) + expect(fixture.startCalls).toBe(0) + }, + ) + + it.each([ + { + format: 'readable', + idempotencyKey: 'workspace-history', + sessionId: 'retained-session:workspace-history:native-history', + executionId: 'retained-execution:workspace-history:native-history', + }, + { + format: 'digest', + idempotencyKey: `workspace-${'x'.repeat(128)}`, + sessionId: + 'retained-session:c9828a31d8ff9eefd907b22f99861d6130a6998d33cc1691b2d1678e7b871a1e', + executionId: + 'retained-execution:c9828a31d8ff9eefd907b22f99861d6130a6998d33cc1691b2d1678e7b871a1e', + }, + ])( + 'recovers historical $format environment coordinates without starting another process', + async ({ idempotencyKey, sessionId, executionId }) => { + const fixture = interactiveProvider() + const start = { profile, requestedProfileDigest: canonicalAgentProfileDigest(profile) } + const request: AgentInteractiveSessionStart = { + ...start, + run: agentInteractiveSessionRunRef( + { provider: 'test-provider', environmentId: 'sandbox-1', sessionId, executionId }, + start, + ), + } + const admission: RetainedInteractiveEnvironmentAdmission = { + phase: 'interactive_environment', + provider: 'test-provider', + environmentId: 'sandbox-1', + idempotencyKey, + interactiveIdempotencyKey: 'native-history', + request, + } + const environment = await fixture.provider.get!('sandbox-1') + await environment!.startInteractive!(request) + + for (const changed of [ + { ...admission, idempotencyKey: 'workspace-other' }, + { ...admission, interactiveIdempotencyKey: 'native-other' }, + ]) { + await expect( + recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission: changed, + onAdmission: async () => {}, + }), + ).rejects.toThrow('does not match its recovery coordinates') + } + expect(fixture.startCalls).toBe(1) + + const recovered = await recoverRetainedInteractiveRun({ + provider: fixture.provider, + admission, + onAdmission: async () => {}, + }) + + expect(recovered?.ref.run).toEqual(request.run) + expect(fixture.startRequests).toEqual([request, request]) + expect(fixture.startCalls).toBe(2) + expect(fixture.processStarts).toBe(1) + expect(fixture.createCalls).toBe(0) + }, + ) + it('binds the derived workspace cwd to replay identity', async () => { const fixture = interactiveProvider() const admissions: RetainedInteractiveAdmission[] = [] diff --git a/src/runtime/retained-interactive.ts b/src/runtime/retained-interactive.ts index 6d275849..d280f875 100644 --- a/src/runtime/retained-interactive.ts +++ b/src/runtime/retained-interactive.ts @@ -69,10 +69,18 @@ export async function startRetainedInteractiveRun( throw new Error('retained interactive runs require AgentProfile.harness') } const requestedProfileDigest = canonicalAgentProfileDigest(profile) - const identity = mintRetainedIdentity( - startOptions.environment.idempotencyKey, - startOptions.interactiveIdempotencyKey, - ) + const identity = + startOptions.intent === undefined + ? mintRetainedIdentity( + startOptions.environment.idempotencyKey, + startOptions.interactiveIdempotencyKey, + ) + : { + sessionId: startOptions.intent.sessionId, + executionId: startOptions.intent.executionId, + } + assertStableText(identity.sessionId, 'retained session id') + assertStableText(identity.executionId, 'retained execution id') const intent = interactiveIntent(startOptions, profile, identity) if (startOptions.intent === undefined) { // This is the only admission that can be written before provider.create. @@ -220,14 +228,35 @@ function exactRecoveryRequest( if ( request.run.provider !== admission.provider || request.run.environmentId !== admission.environmentId || - request.run.sessionId !== identity.sessionId || - request.run.executionId !== identity.executionId + ((request.run.sessionId !== identity.sessionId || + request.run.executionId !== identity.executionId) && + !matchesHistoricalInteractiveIdentity(admission, request)) ) { throw new Error('interactive admission does not match its recovery coordinates') } return request } +function matchesHistoricalInteractiveIdentity( + admission: RetainedInteractiveEnvironmentAdmission, + request: AgentInteractiveSessionStart, +): boolean { + // These admissions predate storage-safe IDs and still bind both idempotency keys. + // Reconstruct their coordinates only to validate the stored request, never to replace it. + const base = `${encodeURIComponent(admission.idempotencyKey)}:${encodeURIComponent(admission.interactiveIdempotencyKey)}` + const digest = canonicalCandidateDigest({ kind: 'retained-identity.v1', base }).slice( + 'sha256:'.length, + ) + const historicalId = (prefix: string): string => { + const readable = `${prefix}:${base}` + return readable.length <= 128 ? readable : `${prefix}:${digest}` + } + return ( + request.run.sessionId === historicalId('retained-session') && + request.run.executionId === historicalId('retained-execution') + ) +} + /** Rebuild controls for one exact provider-owned coding-agent process. @stable */ export async function reconnectRetainedInteractiveRun( options: ReconnectRetainedInteractiveRunOptions, diff --git a/src/runtime/retained-run-start.ts b/src/runtime/retained-run-start.ts index 9d2d8417..b7878826 100644 --- a/src/runtime/retained-run-start.ts +++ b/src/runtime/retained-run-start.ts @@ -47,15 +47,12 @@ import type { import { detachedSnapshot } from './supervise/snapshot' import { freshTurnInput } from './turn-input' -const MAX_RETAINED_IDENTITY_BYTES = 128 - /** * Mint deterministic dispatch coordinates from the two caller-supplied keys. * The same `(idempotencyKey, turnId)` pair yields the same coordinates in * every process, so a pre-dispatch admission record always names the exact - * session and execution the dispatch will request. Short inputs keep a - * readable URL-encoded identity. Long inputs use a full SHA-256 digest so - * provider storage layers never receive an overlong composite identifier. + * session and execution the dispatch will request. A full SHA-256 digest + * keeps provider session identifiers bounded and safe for workspace paths. */ export function mintRetainedIdentity( idempotencyKey: string, @@ -65,14 +62,10 @@ export function mintRetainedIdentity( const digest = canonicalCandidateDigest({ kind: 'retained-identity.v1', base }).slice( 'sha256:'.length, ) - const sessionId = boundedRetainedIdentity('retained-session', base, digest) - const executionId = boundedRetainedIdentity('retained-execution', base, digest) - return { sessionId, executionId } -} - -function boundedRetainedIdentity(prefix: string, base: string, digest: string): string { - const readable = `${prefix}:${base}` - return readable.length <= MAX_RETAINED_IDENTITY_BYTES ? readable : `${prefix}:${digest}` + return { + sessionId: `retained-session-${digest}`, + executionId: `retained-execution-${digest}`, + } } /** @@ -91,16 +84,17 @@ export async function startRetainedRun( ): Promise { assertStableText(options.environment.idempotencyKey, 'environment idempotency key') assertStableText(options.turn.turnId, 'turn idempotency key') - if (options.identity !== undefined) { - assertStableText(options.identity.sessionId, 'retained session id') - assertStableText(options.identity.executionId, 'retained execution id') - } if (typeof options.onAdmission !== 'function') { throw new Error('startRetainedRun requires an awaited onAdmission durability hook') } - const identity = + const { sessionId, executionId } = options.identity ?? + // Admission coordinates remain authoritative across generator changes. + options.intent ?? mintRetainedIdentity(options.environment.idempotencyKey, options.turn.turnId) + const identity = { sessionId, executionId } + assertStableText(identity.sessionId, 'retained session id') + assertStableText(identity.executionId, 'retained execution id') const contextTransfer = retainedContextTransfer(options.turn.contextTransfer) if (!options.provider.get) { throw new Error(`provider "${options.provider.name}" cannot reconstruct an environment by id`) @@ -519,8 +513,9 @@ export function assertRetainedRunReplayMaterial( replay: RetainedRunStartMaterial, admission: RetainedRunIntentAdmission, ): void { - const identity = - replay.identity ?? mintRetainedIdentity(replay.environment.idempotencyKey, replay.turn.turnId) + const identity = replay.identity ?? admission + assertStableText(identity.sessionId, 'retained session id') + assertStableText(identity.executionId, 'retained execution id') assertExactRetainedRunIntent( admission, retainedRunIntent( diff --git a/src/runtime/retained-run.test.ts b/src/runtime/retained-run.test.ts index 9171abda..f0f38bb7 100644 --- a/src/runtime/retained-run.test.ts +++ b/src/runtime/retained-run.test.ts @@ -11,6 +11,7 @@ import { AgentRunCancellationRequestSchema, type ContextTransferRequest, ContextTransferRequestSchema, + canonicalCandidateDigest, contextTransferRequestDigest, type InteractionCapabilities, interactionRequestDigest, @@ -43,7 +44,7 @@ import { startRetainedRunInEnvironment, } from './retained-run' import { createRetainedRunHandle } from './retained-run-handle' -import { mintRetainedIdentity } from './retained-run-start' +import { assertRetainedRunReplayMaterial, mintRetainedIdentity } from './retained-run-start' const childScript = new URL('../../tests/helpers/retained-run-child.ts', import.meta.url).pathname const retainedRequestDigest = `sha256:${'a'.repeat(64)}` as const @@ -314,143 +315,240 @@ describe('retained runtime run control', () => { ).toEqual(['restart-native-operation']) }) - it('persists public headless intent before create and replays private values after a crash', async () => { - const identity = mintRetainedIdentity('headless-intent-environment', 'headless-intent-turn') - const controlRef = { - runId: 'headless-intent-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: 'recovered', success: true, sessionId: identity.sessionId }), - prompt: async () => ({ text: 'continued', success: true }), - cancel: async () => {}, - } - const provider = providerWithEnvironment({ - async dispatch() { - return { id: session.id, provider: 'test-provider', controlRef } - }, - session: () => session, - }) - const environment = { - profile: { name: 'worker' }, - idempotencyKey: 'headless-intent-environment', - workspace: { - repoUrl: 'https://github.com/tangle-network/braid.git', - gitRef: 'main', - cwd: { base: 'repository' as const, path: 'packages/braid' }, - }, - secrets: { TANGLE_TOKEN: 'headless-secret-value' }, - providerOptions: { credential: 'headless-provider-secret' }, - } - const turn = { prompt: 'replay this exact turn', turnId: 'headless-intent-turn' } - let creates = 0 - let created: CreateAgentEnvironmentInput | undefined - const originalCreate = provider.create - provider.create = async (input) => { - creates += 1 - created = input - return originalCreate(input) - } - const firstAdmissions: RetainedRunAdmission[] = [] - const failed = await startRetainedRun({ - provider, - environment, - turn, - onAdmission: async (admission) => { - firstAdmissions.push(admission) - if (admission.phase === 'intent') throw new Error('coordinator crashed') - }, - }).catch((error: unknown) => error) - - expect(failed).toBeInstanceOf(RetainedRunAdmissionError) - expect((failed as RetainedRunAdmissionError).phase).toBe('intent') - expect(creates).toBe(0) - const intent = firstAdmissions[0] - if (intent?.phase !== 'intent') throw new Error('expected the headless intent admission') - expect(JSON.stringify(intent)).not.toContain('headless-secret-value') - expect(JSON.stringify(intent)).not.toContain('headless-provider-secret') - - await expect( - startRetainedRun({ + it.each(['current', 'legacy-readable', 'legacy-hashed'])( + 'persists public headless intent and replays %s coordinates after a crash', + async (format) => { + const identity = + format === 'current' + ? mintRetainedIdentity('headless-intent-environment', 'headless-intent-turn') + : { + sessionId: + format === 'legacy-readable' + ? 'retained-session:headless-intent-environment:headless-intent-turn' + : `retained-session:${'b'.repeat(64)}`, + executionId: + format === 'legacy-readable' + ? 'retained-execution:headless-intent-environment:headless-intent-turn' + : `retained-execution:${'b'.repeat(64)}`, + } + const controlRef = { + runId: 'headless-intent-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: 'recovered', success: true, sessionId: identity.sessionId }), + prompt: async () => ({ text: 'continued', success: true }), + cancel: async () => {}, + } + let dispatched: AgentTurnInput | undefined + const provider = providerWithEnvironment({ + async dispatch(input) { + dispatched = input + return { id: session.id, provider: 'test-provider', controlRef } + }, + session: () => session, + }) + const environment = { + profile: { name: 'worker' }, + idempotencyKey: 'headless-intent-environment', + workspace: { + repoUrl: 'https://github.com/tangle-network/braid.git', + gitRef: 'main', + cwd: { base: 'repository' as const, path: 'packages/braid' }, + }, + secrets: { TANGLE_TOKEN: 'headless-secret-value' }, + providerOptions: { credential: 'headless-provider-secret' }, + } + const turn = { prompt: 'replay this exact turn', turnId: 'headless-intent-turn' } + let creates = 0 + let created: CreateAgentEnvironmentInput | undefined + const originalCreate = provider.create + provider.create = async (input) => { + creates += 1 + created = input + return originalCreate(input) + } + const firstAdmissions: RetainedRunAdmission[] = [] + const failed = await startRetainedRun({ provider, environment, - turn: { ...turn, prompt: 'changed replay material' }, - intent, - onAdmission: async () => {}, - }), - ).rejects.toThrow('retained run intent conflicts with replay material') - expect(creates).toBe(0) - - await expect( - startRetainedRun({ - provider, - environment: { - ...environment, - workspace: { - ...environment.workspace, - cwd: { base: 'repository', path: 'packages/other' }, - }, - }, turn, - intent, - onAdmission: async () => {}, - }), - ).rejects.toThrow('retained run intent conflicts with replay material') - expect(creates).toBe(0) + ...(format === 'current' ? {} : { identity }), + onAdmission: async (admission) => { + firstAdmissions.push(admission) + if (admission.phase === 'intent') throw new Error('coordinator crashed') + }, + }).catch((error: unknown) => error) - await expect( - startRetainedRun({ + expect(failed).toBeInstanceOf(RetainedRunAdmissionError) + expect((failed as RetainedRunAdmissionError).phase).toBe('intent') + expect(creates).toBe(0) + const intent = firstAdmissions[0] + if (intent?.phase !== 'intent') throw new Error('expected the headless intent admission') + expect(JSON.stringify(intent)).not.toContain('headless-secret-value') + expect(JSON.stringify(intent)).not.toContain('headless-provider-secret') + const immutableIntent = JSON.stringify(intent) + expect(() => + assertRetainedRunReplayMaterial(provider, { environment, turn }, intent), + ).not.toThrow() + expect(() => + assertRetainedRunReplayMaterial( + provider, + { + environment, + turn, + identity: { ...identity, sessionId: 'changed-session' }, + }, + intent, + ), + ).toThrow('retained run intent conflicts with replay material') + expect(() => + assertRetainedRunReplayMaterial( + provider, + { environment, turn }, + { + ...intent, + sessionId: 'changed-session', + }, + ), + ).toThrow('retained run intent conflicts with replay material') + + await expect( + startRetainedRun({ + provider, + environment, + turn: { ...turn, prompt: 'changed replay material' }, + intent, + onAdmission: async () => {}, + }), + ).rejects.toThrow('retained run intent conflicts with replay material') + expect(creates).toBe(0) + + await expect( + startRetainedRun({ + provider, + environment: { + ...environment, + workspace: { + ...environment.workspace, + cwd: { base: 'repository', path: 'packages/other' }, + }, + }, + turn, + intent, + onAdmission: async () => {}, + }), + ).rejects.toThrow('retained run intent conflicts with replay material') + expect(creates).toBe(0) + + await expect( + startRetainedRun({ + provider, + environment: { + ...environment, + secrets: { OTHER_TOKEN: 'headless-secret-value' }, + }, + turn, + intent, + onAdmission: async () => {}, + }), + ).rejects.toThrow('retained run intent conflicts with replay material') + expect(creates).toBe(0) + + const recoveryAdmissions = recordedAdmissions() + const recovered = await recoverRetainedRun({ provider, - environment: { - ...environment, - secrets: { OTHER_TOKEN: 'headless-secret-value' }, + admission: intent, + replay: { + environment: { + ...environment, + secrets: { TANGLE_TOKEN: 'changed-low-entropy' }, + }, + turn, }, - turn, - intent, - onAdmission: async () => {}, - }), - ).rejects.toThrow('retained run intent conflicts with replay material') - expect(creates).toBe(0) + onAdmission: recoveryAdmissions.onAdmission, + }) + expect(recovered.outcome).toBe('recovered') + expect(recovered.outcome === 'recovered' && recovered.handle.controlRef).toMatchObject( + identity, + ) + expect(dispatched).toEqual({ ...turn, ...identity, detach: true }) + expect(JSON.stringify(intent)).toBe(immutableIntent) + expect(creates).toBe(1) + expect(created?.metadata).toEqual({ + retainedIdempotencyKey: environment.idempotencyKey, + }) + expect(created?.workspace).toEqual({ + repoUrl: 'https://github.com/tangle-network/braid.git', + gitRef: 'main', + cwd: { base: 'repository', path: 'packages/braid' }, + }) + expect(created?.secrets).toEqual({ TANGLE_TOKEN: 'changed-low-entropy' }) + expect(created?.providerOptions).toEqual({ credential: 'headless-provider-secret' }) + expect(recoveryAdmissions.admissions.map((admission) => admission.phase)).toEqual([ + 'environment', + 'dispatched', + ]) + }, + ) - const recoveryAdmissions = recordedAdmissions() - const recovered = await recoverRetainedRun({ - provider, - admission: intent, - replay: { - environment: { - ...environment, - secrets: { TANGLE_TOKEN: 'changed-low-entropy' }, - }, - turn, - }, - onAdmission: recoveryAdmissions.onAdmission, - }) - expect(recovered.outcome).toBe('recovered') - expect(creates).toBe(1) - expect(created?.metadata).toEqual({ - retainedIdempotencyKey: environment.idempotencyKey, - }) - expect(created?.workspace).toEqual({ - repoUrl: 'https://github.com/tangle-network/braid.git', - gitRef: 'main', - cwd: { base: 'repository', path: 'packages/braid' }, - }) - expect(created?.secrets).toEqual({ TANGLE_TOKEN: 'changed-low-entropy' }) - expect(created?.providerOptions).toEqual({ credential: 'headless-provider-secret' }) - expect(recoveryAdmissions.admissions.map((admission) => admission.phase)).toEqual([ - 'environment', - 'dispatched', - ]) - }) + it.each([ + { sessionId: ' session', executionId: 'execution', label: 'retained session id' }, + { sessionId: 'session', executionId: 'execution ', label: 'retained execution id' }, + ])( + 'rejects a self-consistent intent with an invalid $label before provider work', + async ({ sessionId, executionId, label }) => { + const replay = { + environment: { profile: { name: 'worker' }, idempotencyKey: 'workspace' }, + turn: { prompt: 'check', turnId: 'turn' }, + } + const coordinates = { + provider: 'test-provider', + idempotencyKey: 'workspace', + turnId: 'turn', + sessionId, + executionId, + requestedProfileDigest: canonicalCandidateDigest(replay.environment.profile), + } + const requestDigest = canonicalCandidateDigest({ + kind: 'retained-run-intent.v1', + ...coordinates, + create: {}, + turn: { prompt: 'check' }, + }) + const intent = { + phase: 'intent' as const, + ...coordinates, + requestDigest, + runId: `retained-intent-run:${requestDigest.slice('sha256:'.length)}`, + } + let providerCalls = 0 + const provider = providerWithEnvironment({}) + provider.capabilities = async () => { + providerCalls += 1 + throw new Error('provider boundary reached') + } + expect(() => assertRetainedRunReplayMaterial(provider, replay, intent)).toThrow(label) + await expect( + startRetainedRun({ + ...replay, + provider, + intent, + onAdmission: async () => {}, + }), + ).rejects.toThrow(label) + expect(providerCalls).toBe(0) + }, + ) it('recovers a headless intent after a coordinator SIGKILL before provider.create', async () => { const stateFile = join(directory, 'intent-crash-provider.json') @@ -3428,19 +3526,26 @@ describe('retained runtime run control', () => { }) }) - it('bounds long retained identities without losing deterministic replay', () => { - const environmentKey = `environment-${'e'.repeat(256)}` - const turnId = `turn-${'t'.repeat(256)}` + it.each([ + ['environment', 'turn'], + [ + 'runtime:research-frontier-continuation-20260909:s0', + 'research-frontier-continuation-20260909:s0:turn:0', + ], + ['environment:/% α', 'turn:/% β'], + [`environment-${'e'.repeat(256)}`, `turn-${'t'.repeat(256)}`], + ])('mints deterministic filesystem-safe coordinates for %s', (environmentKey, turnId) => { const first = mintRetainedIdentity(environmentKey, turnId) const second = mintRetainedIdentity(environmentKey, turnId) expect(first).toEqual(second) - expect(first.sessionId).toMatch(/^retained-session:[a-f0-9]{64}$/u) - expect(first.executionId).toMatch(/^retained-execution:[a-f0-9]{64}$/u) + expect(first.sessionId).toMatch(/^retained-session-[a-f0-9]{64}$/u) + expect(first.executionId).toMatch(/^retained-execution-[a-f0-9]{64}$/u) expect(first.sessionId.length).toBeLessThanOrEqual(128) expect(first.executionId.length).toBeLessThanOrEqual(128) - expect(first.sessionId).not.toContain(environmentKey) - expect(first.executionId).not.toContain(turnId) + expect(mintRetainedIdentity(`${environmentKey}x`, turnId)).not.toEqual(first) + expect(mintRetainedIdentity(environmentKey, `${turnId}x`)).not.toEqual(first) + expect(mintRetainedIdentity('a:b', 'c')).not.toEqual(mintRetainedIdentity('a', 'b:c')) }) it('fails loud with the provider reference when dispatch dishonors the requested identity', async () => { diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 6aa634d7..1c602d6a 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:61556e3424692ea9c98c19432d0804fa6d580e4bcfcf250598e2566c5653d640", + "digest": "sha256:f666113f8a62b9055193ab5b121c409c59790ef7e28fd57ab4b17b507e68c15a", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.208.0" + "runtimeVersion": "0.208.1" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:fd1c6060eb44919695343ce8f3732565774f8033306138ea6303dbd6e7f0cad3", - "runId": "agent-runtime-0.208.0-proposal-fixture", + "recordDigest": "sha256:d726719e07878264cd67cbad7e11df3e93e947245d83864f070013f3cb0e6e88", + "runId": "agent-runtime-0.208.1-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.208.0-proposal-fixture" + "runId": "agent-runtime-0.208.1-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index c24e01a2..0b2b0a46 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:aaa7a9075df3d27f67a4a23fe3c3053edbf5fcfb3927923b7719ace5caa71b48", + "digest": "sha256:ef6030831b942d3552d6e374808691731c87268f53632145a3aea4a36675a06c", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.208.0" + "runtimeVersion": "0.208.1" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:a119f3321972fa002b186538e503fa09e8ef1fc5081d7616065012d7558f7a33", + "recordDigest": "sha256:4731856ecdc511cb3818a13dcbc5611013c8e95d236e8e0bd68fbb3e1a57bbe8", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" }