From aff6112822de81f48efed5309322152d4e16ae2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:02:11 +0000 Subject: [PATCH 01/17] chore(rewrite): add a reachability guard from app/cli.ts check-reachability.mjs walks every import (static, type-only, re-export, dynamic) from rewrite/src/app/cli.ts and fails when a rewrite/src file is never reached, unless reachability-allowlist.json gives a reason it stays. The allowlist starts with today's 36 unreached files; the bridge deletions that follow shrink it. Runs next to check-layers.mjs in CI (~0.6s). Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- .github/workflows/ci.yml | 1 + rewrite/scripts/check-reachability.mjs | 81 +++++++++++++++++++++ rewrite/scripts/reachability-allowlist.json | 38 ++++++++++ 3 files changed, 120 insertions(+) create mode 100644 rewrite/scripts/check-reachability.mjs create mode 100644 rewrite/scripts/reachability-allowlist.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcd0aa129..2a2369490 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,7 @@ jobs: run: | pnpm exec tsc -p rewrite/tsconfig.json node rewrite/scripts/check-layers.mjs + node rewrite/scripts/check-reachability.mjs node rewrite/scripts/check-complexity.mjs node --unhandled-rejections=strict --import tsx rewrite/bench/hops.mts - name: Check the direct headless path diff --git a/rewrite/scripts/check-reachability.mjs b/rewrite/scripts/check-reachability.mjs new file mode 100644 index 000000000..7b1ba3c4c --- /dev/null +++ b/rewrite/scripts/check-reachability.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Fail when a file under rewrite/src cannot be reached from the production entry. +// The walk follows every static, type-only, re-export and dynamic import from +// rewrite/src/app/cli.ts. A file kept on purpose without a production importer +// goes in reachability-allowlist.json with the reason it stays. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const scripts = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(scripts, '../..'); +const rewriteSrc = path.join(repo, 'rewrite/src'); +const ENTRY = path.join(rewriteSrc, 'app/cli.ts'); +const allowlistPath = path.join(scripts, 'reachability-allowlist.json'); +const SOURCE_FILE = /\.(?:ts|tsx|mts|cts)$/; + +const relative = (file) => path.relative(repo, file).split(path.sep).join('/'); + +function* files(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* files(full); + else if (SOURCE_FILE.test(entry.name) && !entry.name.endsWith('.d.ts')) yield full; + } +} + +function resolve(importer, specifier) { + if (!specifier.startsWith('.')) return null; + const base = path.resolve(path.dirname(importer), specifier); + const stem = base.replace(/\.(?:m|c)?jsx?$/, ''); + const candidates = [base, ...['.ts', '.tsx', '.mts', '.cts'].map((ext) => stem + ext)]; + candidates.push(...['index.ts', 'index.tsx'].map((name) => path.join(base, name))); + return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) ?? null; +} + +function imports(file) { + const info = ts.preProcessFile(fs.readFileSync(file, 'utf8'), true, true); + return info.importedFiles.map(({ fileName }) => resolve(file, fileName)).filter(Boolean); +} + +let allowlist; +try { + allowlist = JSON.parse(fs.readFileSync(allowlistPath, 'utf8')); +} catch (error) { + console.error(`cannot read ${relative(allowlistPath)}: ${error.message}`); + process.exit(1); +} + +const reached = new Set(); +const queue = [ENTRY]; +while (queue.length) { + const file = queue.pop(); + if (reached.has(file)) continue; + reached.add(file); + for (const target of imports(file)) if (target.startsWith(rewriteSrc + path.sep)) queue.push(target); +} + +const problems = []; +const all = [...files(rewriteSrc)].map(relative).sort(); +const reachedRelative = new Set([...reached].map(relative)); +for (const file of all) { + if (reachedRelative.has(file)) { + if (allowlist[file]) problems.push(`${file} is reached from cli.ts; remove its reachability allowlist entry.`); + continue; + } + const reason = allowlist[file]; + if (typeof reason !== 'string' || !reason.trim()) problems.push(`unreached from rewrite/src/app/cli.ts: ${file}`); +} +for (const file of Object.keys(allowlist)) + if (!all.includes(file)) problems.push(`reachability allowlist names missing file ${file}.`); + +console.log(`reachability: ${reachedRelative.size} of ${all.length} rewrite/src files reached from app/cli.ts`); +if (problems.length) { + console.error(problems.join('\n')); + console.error( + 'Delete code production no longer reaches. If a file must stay, add it to ' + + 'rewrite/scripts/reachability-allowlist.json with the reason.', + ); + process.exit(1); +} diff --git a/rewrite/scripts/reachability-allowlist.json b/rewrite/scripts/reachability-allowlist.json new file mode 100644 index 000000000..ddd9d6ef8 --- /dev/null +++ b/rewrite/scripts/reachability-allowlist.json @@ -0,0 +1,38 @@ +{ + "rewrite/src/clients/headless.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/clients/index.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/config/memory.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/config/scoped-settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/engine/adopted/conversion.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/engine/adopted/dependencies.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/engine/adopted/index.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/engine/adopted/provider-transports.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/engine/adopted/settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/engine/adopted/tool-catalog.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/engine/loop.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/host/in-process.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/model/scripted.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/protocol/commands.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/protocol/projection.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/compact-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/conversation-index.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/conversation-retry-projection.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/foreground.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/goal-authority.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/history-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/interrupt-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/network-authority.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/retry-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/session-handle.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/settings-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/submission.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/turn-control.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/turn-interaction.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/adopted/turn-prompt.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/direct-command-compaction.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/direct-import.ts": "Offline v3 conversation migration (scripts/conversation-migration/migration-command.mjs) imports its emitted module; no CLI command reaches it.", + "rewrite/src/session/local-store.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/session/reducer.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", + "rewrite/src/tools/notes.ts": "Pending deletion: reached only by the bench-only bridge path (#139)." +} From fd865823de23f3b4b9aa508b6754c333ea0428df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:04:38 +0000 Subject: [PATCH 02/17] bench(rewrite): measure the message path on the production direct-* path hops.mts ran a message through the bench-only LocalSessionStore and adopted engine, so its budget guarded a path no shipped mode takes. It now runs the real entry (rewrite/src/app/cli.ts) in-process as a headless prompt against a local fake OpenAI-compatible provider, and reports the chain of files active at the provider's HTTP request, from the headless client's submit: 14 hops, one per layer, clients -> host -> protocol -> session -> engine -> app -> model -> source. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/bench/hops.mts | 224 +++++++++++++------------ rewrite/scripts/complexity-budget.json | 4 +- 2 files changed, 121 insertions(+), 107 deletions(-) diff --git a/rewrite/bench/hops.mts b/rewrite/bench/hops.mts index 64a5622c9..6cf512941 100644 --- a/rewrite/bench/hops.mts +++ b/rewrite/bench/hops.mts @@ -1,32 +1,86 @@ -import { createBenchEngine } from './source-app.mjs'; -// Count the modules one user message passes through between Session submit and -// the provider call, and fail when that path grows past the recorded budget. +// Count the modules one user message passes through on the production path, +// from the headless client's submit to the provider's HTTP request, and fail +// when that path grows past the recorded budget. +// +// The real entry runs in this process as `term2-next "hello"` against a local +// fake OpenAI-compatible provider, so the path measured is the one a shipped +// headless message takes. The provider call is the first fetch to that server. import assert from 'node:assert/strict'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { createHook, executionAsyncId } from 'node:async_hooks'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const budget = JSON.parse( fs.readFileSync(path.join(repo, 'rewrite/scripts/complexity-budget.json'), 'utf8'), ).messagePath; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rewrite-hops-')); +const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rewrite-hops-'))); +const home = path.join(root, 'home'); const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); +for (const dir of [home, workspace, path.join(root, 'runtime'), path.join(root, 'tmp')]) + fs.mkdirSync(dir, { recursive: true }); + +// One streamed chat-completions answer for every POST. +const chunk = (delta: object, finish: string | null = null, extra: object = {}) => + `data: ${JSON.stringify({ + id: 'chatcmpl-hops', + object: 'chat.completion.chunk', + created: 1, + model: 'fixture', + choices: [{ index: 0, delta, finish_reason: finish }], + ...extra, + })}\n\n`; +const server = http.createServer((req, res) => { + if (req.method !== 'POST') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"object":"list","data":[]}'); + return; + } + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(chunk({ role: 'assistant', content: 'done' })); + res.write(chunk({}, 'stop', { usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } })); + res.end('data: [DONE]\n\n'); + }); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}/v1`; + +const stateDir = path.join(home, '.local', 'state', 'term2-nodejs'); +fs.mkdirSync(stateDir, { recursive: true }); +fs.writeFileSync( + path.join(stateDir, 'settings.json'), + JSON.stringify({ + agent: { retryAttempts: 0, modelSelection: { provider: 'mockprov', model: 'fixture' } }, + providers: [{ name: 'mockprov', type: 'openai-compatible', baseUrl, apiKey: 'test-key' }], + sandbox: { enabled: false }, + }), +); +Object.assign(process.env, { + LANG: 'C.UTF-8', + TZ: 'UTC', + TERM: 'dumb', + NO_COLOR: '1', + HOME: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + XDG_STATE_HOME: path.join(home, '.local', 'state'), + XDG_CACHE_HOME: path.join(home, '.cache'), + XDG_DATA_HOME: path.join(home, '.local', 'share'), + XDG_RUNTIME_DIR: path.join(root, 'runtime'), + TMPDIR: path.join(root, 'tmp'), + TERM2_CONFIG_DIR: path.join(home, '.term2'), + TERM2_CACHE_DIR: path.join(home, '.cache', 'term2'), + TERM2_CONVERSATIONS_DIR: path.join(root, 'conversations'), + DISABLE_LOGGING: '1', +}); +process.chdir(workspace); // Async stack traces stop at detached launches, so follow the async resource -// chain from the provider call back to submit and join each creation stack. +// chain from the provider call back to the entry and join each creation stack. Error.stackTraceLimit = 200; const created = new Map(); const hook = createHook({ @@ -47,114 +101,73 @@ const causalStack = () => { .map((frames) => frames.split('\n').reverse().join('\n')) .join('\n'); }; -const provider: any = { - id: 'openai', - label: 'fixture', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream() { - stack ??= causalStack(); - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }], - responseId: 'fixture', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const pair = source.forSessionBinding({ readable: Object.keys(host.getAll()) as any, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: new SessionContextService(), - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let ownership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - ownership = new ToolOwnershipRegistry(); - graphContext = new SessionContextService(); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: graphContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), +const realFetch = globalThis.fetch; +globalThis.fetch = ((input: Parameters[0], init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.startsWith(baseUrl) && init?.method === 'POST') stack ??= causalStack(); + return realFetch(input, init); +}) as typeof fetch; + +// The entry ends with process.exit; keep this process alive to report, and +// exit with the real one so the entry's open handles cannot hold it. +const realExit = process.exit.bind(process); +process.on('uncaughtExceptionMonitor', () => setImmediate(() => realExit(1))); +const exited = new Promise((resolve) => { + process.exit = ((code?: number) => resolve(Number(code ?? 0))) as typeof process.exit; }); +const output: string[] = []; +const write = process.stdout.write.bind(process.stdout); +process.stdout.write = ((chunk: string | Uint8Array) => (output.push(String(chunk)), true)) as typeof write; +process.argv = [process.execPath, path.join(repo, 'rewrite/src/app/cli.ts'), 'hello']; -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); +let code: number; hook.enable(); try { - await submission.submit({ requestId: 'hops', input: { text: 'hello' } }); - const until = Date.now() + 10000; - while (!stack) { - if (Date.now() > until) throw new Error('provider was never called'); - await new Promise((resolve) => setTimeout(resolve, 10)); - } + await import(pathToFileURL(path.join(repo, 'rewrite/src/app/cli.ts')).href); + code = await exited; } finally { hook.disable(); - await session.close().catch(() => {}); + process.stdout.write = write; + server.closeAllConnections(); + server.close(); fs.rmSync(root, { recursive: true, force: true }); } +assert.equal(code, 0, `cli exited ${code}: ${output.join('')}`); +assert.ok(stack, 'provider was never called'); -// Outermost caller first; consecutive frames in one file are one hop. -const files: string[] = []; -for (const line of stack!.split('\n')) { +// Read the joined stacks outermost caller first. `at async` frames re-list the +// awaiting callers that the async-resource chain already covers, so skip them. +const sequence: string[] = []; +for (const line of stack.split('\n')) { + if (line.trim().startsWith('at async ')) continue; const match = /\(?((?:file:\/\/)?\/[^():]+\.m?tsx?):\d+:\d+\)?$/.exec(line.trim()); if (!match) continue; const file = path.relative(repo, match[1]!.replace(/^file:\/\//, '')); if (file.startsWith('..') || file.includes('node_modules') || file.startsWith('rewrite/bench/')) continue; - if (files.at(-1) !== file) files.push(file); + if (sequence.at(-1) !== file) sequence.push(file); +} +// The message path starts where the client hands the message to the host, as a +// user message does in every mode; startup work before it is not message path. +const START = 'rewrite/src/clients/headless/direct.ts'; +assert.ok(sequence.includes(START), `the provider call was not caused by ${START}`); +// Fold the sequence into the chain of files still active at the provider call: +// returning to a file already on the chain pops back to it, so finished setup +// work and re-entered loops count once. Each file on the chain is one hop. +const files: string[] = []; +for (const file of sequence.slice(sequence.indexOf(START))) { + const index = files.indexOf(file); + if (index >= 0) files.length = index + 1; + else files.push(file); } const distinct = [...new Set(files)]; const sourceFiles = distinct.filter((file) => file.startsWith('source/')); const layerOf = (file: string) => /^rewrite\/src\/([^/]+)\//.exec(file)?.[1] ?? 'source'; const layers = files.map(layerOf).filter((layer, index, all) => layer !== all[index - 1]); -console.log(`message path: ${files.length} hops through ${distinct.length} files (${sourceFiles.length} in source/)`); +console.log( + `message path: ${files.length} hops through ${distinct.length} files (${sourceFiles.length} in source/); ` + + `${sequence.length} file transitions before folding`, +); console.log(`layers: ${layers.join(' -> ')}`); for (const file of files) console.log(` ${file}`); assert.ok(files.length > 0, 'stack trace named no repository files'); @@ -171,3 +184,4 @@ if (files.length < budget.maxHops || distinct.length < budget.maxFiles) `path is under budget: lower messagePath to { maxHops: ${files.length}, maxFiles: ${distinct.length} } in rewrite/scripts/complexity-budget.json`, ); console.log('PASS message path within budget'); +realExit(0); diff --git a/rewrite/scripts/complexity-budget.json b/rewrite/scripts/complexity-budget.json index 4cd5c82bb..fca964560 100644 --- a/rewrite/scripts/complexity-budget.json +++ b/rewrite/scripts/complexity-budget.json @@ -20,7 +20,7 @@ } }, "messagePath": { - "maxHops": 29, - "maxFiles": 7 + "maxHops": 14, + "maxFiles": 14 } } From 6d451a27d6699b8bd10885603fc8885e6f7f50e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:06:47 +0000 Subject: [PATCH 03/17] bench(rewrite): delete the benches that only exercise the bridge path The c1/c2/c3a probes, source-app.mts and the native-tracer fixture drive LocalSessionStore, the adopted engine or the pre-C2 native engine, none of which a shipped mode runs. Their CI step goes with them; parity drops the C2-proof-only assertion. direct-manual-unknown-capacity.mts covered session/direct-command-compaction.ts, which production replaced with engine/turn-model-compaction.ts. The generated native-log property now builds its two items literally instead of through engine/adopted/conversion. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- .github/workflows/ci.yml | 31 - .gitignore | 1 - rewrite/bench/c1-adopted-approval.mts | 281 ----- rewrite/bench/c1-adopted-kill-child.mts | 234 ---- rewrite/bench/c1-adopted-kill.mts | 104 -- rewrite/bench/c2-answer-kill.mjs | 196 --- rewrite/bench/c2-approval-kill.mjs | 195 --- rewrite/bench/c2-boundary-failure.mjs | 168 --- rewrite/bench/c2-proof-cli.ts | 23 - rewrite/bench/c2-proof-persistence.ts | 420 ------- rewrite/bench/c2-storage-failure.mjs | 118 -- rewrite/bench/c3a-background-interrupt.mts | 359 ------ rewrite/bench/c3a-budget.mts | 232 ---- rewrite/bench/c3a-capture.mts | 243 ---- rewrite/bench/c3a-compaction.mts | 1081 ----------------- rewrite/bench/c3a-foreground.mts | 184 --- rewrite/bench/c3a-goal.mts | 333 ----- rewrite/bench/c3a-held-tool.mts | 283 ----- rewrite/bench/c3a-history-commands.mts | 226 ---- rewrite/bench/c3a-input-fsync.mts | 183 --- rewrite/bench/c3a-interactions.mts | 282 ----- rewrite/bench/c3a-memory.mts | 280 ----- rewrite/bench/c3a-network-background.mts | 170 --- rewrite/bench/c3a-network-kill-worker.mts | 223 ---- rewrite/bench/c3a-network-kill.mts | 55 - rewrite/bench/c3a-network-nested.mts | 235 ---- rewrite/bench/c3a-network.mts | 190 --- rewrite/bench/c3a-owned-handle.mts | 170 --- rewrite/bench/c3a-postexecute.mts | 292 ----- rewrite/bench/c3a-retry-effects.mts | 260 ---- rewrite/bench/c3a-retry-fsync.mts | 257 ---- rewrite/bench/c3a-retry-kill-worker.mts | 209 ---- rewrite/bench/c3a-retry-kill.mts | 70 -- rewrite/bench/c3a-retry-turn.mts | 181 --- rewrite/bench/c3a-settings-legacy.mts | 68 -- rewrite/bench/c3a-settings.mts | 246 ---- rewrite/bench/c3a-surge-capability.mts | 265 ---- rewrite/bench/c3a-tool-dispatch.mts | 239 ---- rewrite/bench/c3a-toolkill-worker.mts | 209 ---- rewrite/bench/c3a-toolkill.mts | 77 -- .../bench/direct-manual-unknown-capacity.mts | 49 - rewrite/bench/native-tracer/jsonl-log.ts | 59 - rewrite/bench/native-tracer/local-store.ts | 264 ---- rewrite/bench/native-tracer/reducer.ts | 70 -- rewrite/bench/native-tracer/session-types.ts | 100 -- rewrite/bench/native-tracer/tracer.ts | 173 --- rewrite/bench/source-app.mts | 57 - rewrite/parity/run.mjs | 36 - rewrite/tsconfig.c2-proof.json | 9 - rewrite/tsconfig.native-tracer.json | 9 - scripts/generated-verification/native-log.mjs | 23 +- 51 files changed, 17 insertions(+), 9705 deletions(-) delete mode 100644 rewrite/bench/c1-adopted-approval.mts delete mode 100644 rewrite/bench/c1-adopted-kill-child.mts delete mode 100644 rewrite/bench/c1-adopted-kill.mts delete mode 100644 rewrite/bench/c2-answer-kill.mjs delete mode 100644 rewrite/bench/c2-approval-kill.mjs delete mode 100644 rewrite/bench/c2-boundary-failure.mjs delete mode 100644 rewrite/bench/c2-proof-cli.ts delete mode 100644 rewrite/bench/c2-proof-persistence.ts delete mode 100644 rewrite/bench/c2-storage-failure.mjs delete mode 100644 rewrite/bench/c3a-background-interrupt.mts delete mode 100644 rewrite/bench/c3a-budget.mts delete mode 100644 rewrite/bench/c3a-capture.mts delete mode 100644 rewrite/bench/c3a-compaction.mts delete mode 100644 rewrite/bench/c3a-foreground.mts delete mode 100644 rewrite/bench/c3a-goal.mts delete mode 100644 rewrite/bench/c3a-held-tool.mts delete mode 100644 rewrite/bench/c3a-history-commands.mts delete mode 100644 rewrite/bench/c3a-input-fsync.mts delete mode 100644 rewrite/bench/c3a-interactions.mts delete mode 100644 rewrite/bench/c3a-memory.mts delete mode 100644 rewrite/bench/c3a-network-background.mts delete mode 100644 rewrite/bench/c3a-network-kill-worker.mts delete mode 100644 rewrite/bench/c3a-network-kill.mts delete mode 100644 rewrite/bench/c3a-network-nested.mts delete mode 100644 rewrite/bench/c3a-network.mts delete mode 100644 rewrite/bench/c3a-owned-handle.mts delete mode 100644 rewrite/bench/c3a-postexecute.mts delete mode 100644 rewrite/bench/c3a-retry-effects.mts delete mode 100644 rewrite/bench/c3a-retry-fsync.mts delete mode 100644 rewrite/bench/c3a-retry-kill-worker.mts delete mode 100644 rewrite/bench/c3a-retry-kill.mts delete mode 100644 rewrite/bench/c3a-retry-turn.mts delete mode 100644 rewrite/bench/c3a-settings-legacy.mts delete mode 100644 rewrite/bench/c3a-settings.mts delete mode 100644 rewrite/bench/c3a-surge-capability.mts delete mode 100644 rewrite/bench/c3a-tool-dispatch.mts delete mode 100644 rewrite/bench/c3a-toolkill-worker.mts delete mode 100644 rewrite/bench/c3a-toolkill.mts delete mode 100644 rewrite/bench/direct-manual-unknown-capacity.mts delete mode 100644 rewrite/bench/native-tracer/jsonl-log.ts delete mode 100644 rewrite/bench/native-tracer/local-store.ts delete mode 100644 rewrite/bench/native-tracer/reducer.ts delete mode 100644 rewrite/bench/native-tracer/session-types.ts delete mode 100644 rewrite/bench/native-tracer/tracer.ts delete mode 100644 rewrite/bench/source-app.mts delete mode 100644 rewrite/tsconfig.c2-proof.json delete mode 100644 rewrite/tsconfig.native-tracer.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a2369490..b61203b69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,37 +112,6 @@ jobs: node rewrite/scripts/build.mjs node rewrite/parity/run.mjs check --cli rewrite/dist/rewrite/src/app/cli.js headless-plain-answer settings-batch node rewrite/bench/m1-direct.mjs - - name: Check adopted approval and crash recovery - run: | - pnpm exec tsx rewrite/bench/c1-adopted-approval.mts - pnpm exec tsx rewrite/bench/c1-adopted-kill.mts - pnpm exec tsx rewrite/bench/c3a-foreground.mts - for mode in normal fault partial unsupported; do PROBE_MODE="$mode" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-settings.mts; done - node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-settings-legacy.mts - for mode in set fsync achieve propose-approve propose-race propose-reject; do PROBE_MODE="$mode" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-goal.mts; done - for mode in human refusal fault; do PROBE_MODE="$mode" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-budget.mts; done - for mode in basic held fsync; do PROBE_MODE="$mode" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-history-commands.mts; done - for mode in local accounting native interleaving; do PROBE_MODE="$mode" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-compaction.mts; done - for mode in plain image skill disabled error retry approval failure cancel; do PROBE_MODE="$mode" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-memory.mts; done - pnpm exec tsx rewrite/bench/c3a-owned-handle.mts - pnpm exec tsx rewrite/bench/c3a-network.mts - pnpm exec tsx rewrite/bench/c3a-network-background.mts - for mode in running late; do PROBE_MODE="$mode" pnpm exec tsx rewrite/bench/c3a-network-nested.mts; done - node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-network-kill.mts - node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-capture.mts - for mode in repeat batch reject ask-nav ask-cancel; do PROBE_MODE="$mode" pnpm exec tsx rewrite/bench/c3a-interactions.mts; done - pnpm exec tsx rewrite/bench/c3a-retry-turn.mts - for target in turn tool; do RETRY_TARGET="$target" pnpm exec tsx rewrite/bench/c3a-retry-effects.mts; done - for target in turn tool; do RETRY_TARGET="$target" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-retry-fsync.mts; done - node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-retry-kill.mts - node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-background-interrupt.mts - node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-held-tool.mts - node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-toolkill.mts - for failure in none session_interaction_pending session_interaction_resolved; do FAIL_TYPE="$failure" node --unhandled-rejections=strict --import tsx rewrite/bench/c3a-postexecute.mts; done - pnpm exec tsx rewrite/bench/c3a-input-fsync.mts - PROBE_MODE=unknown pnpm exec tsx rewrite/bench/c3a-tool-dispatch.mts - PROBE_MODE=tool pnpm exec tsx rewrite/bench/c3a-tool-dispatch.mts - for mode in valid forged changed-image changed-skill reuse mismatch-reuse; do PROBE_MODE="$mode" pnpm exec tsx rewrite/bench/c3a-surge-capability.mts; done integration: runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index c5fd17f4e..a6137e7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules dist dist.bak -bench-dist # TypeScript *.tsbuildinfo diff --git a/rewrite/bench/c1-adopted-approval.mts b/rewrite/bench/c1-adopted-approval.mts deleted file mode 100644 index 054221534..000000000 --- a/rewrite/bench/c1-adopted-approval.mts +++ /dev/null @@ -1,281 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify(requests.length === 1 ? { kind: 'call' } : { kind: 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { type: 'tool_call', id: 'call1', name: 'shell', arguments: rawArgs }; - yield call; - yield { type: 'completion', output: [call], responseId: 'r1' }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const { engine, tools, turnPrompt } = createBenchEngine({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, -}); -assert.ok(tools.original('shell'), 'source shell assembled'); -const log = CanonicalSessionLog.create({ - root: path.join(root, 'log'), - id: 'bench', - metadata: {}, - workspace: { root: workspace, access: 'read_write', target: 'local' }, - model: { provider: 'openai', model: 'fixture' }, - logger, -}); -await log.append([ - { type: 'user_message', message: { id: 'u1', sender: 'user', text: 'Create requested file' } }, - { type: 'session_turn_started', version: 1, turnId: 'turn1', clientRequestId: null }, -]); -const journal: any[] = []; -const record = new ConversationRecord({ logger }); -record.setJournalSink((event) => journal.push(event)); -record.beginTurn(); -record.acceptInput('Create requested file'); -const isCurrent = () => true; -let accepted = 0, - dispatch = 0; -engine.registerTurnBridge('turn1' as any, { generation: 1, isCurrent }, (control) => ({ - prepare: async () => ({ input: record.getHistory(), options: { sessionId: 'bench' } }), - beginSegment() { - record.beginSegment(); - }, - acceptEvent(e: any) { - if (e.type === 'item') record.acceptObservedItem(e.item); - else if (e.type === 'text_delta') record.recordTextDelta(e.text); - else if (e.type === 'reasoning_delta') record.recordReasoningDelta(e.text); - else if (e.type === 'model_attempt_rollback') record.rollbackReasoning(e.reasoningCharacters); - }, - acceptToolDispatch(id) { - dispatch++; - record.markDispatched(id); - }, - beforeToolDispatch: async (id) => { - await log.append([ - ...journal.splice(0), - { type: 'tool_started', turnId: 'turn1', toolCallId: id, toolName: 'shell', arguments: JSON.parse(rawArgs) }, - ]); - assert.equal(fs.existsSync(target), false); - }, - acceptSegment(stream) { - record.acceptSegment(stream, 'full_history', { logger, sessionId: 'bench' }); - accepted++; - }, - getAcceptedHistory: () => record.getHistory(), - async consume(stream) { - for await (const _ of stream) { - } - }, - async interrupted(stream) { - const interruption = stream.interruptions?.[0] as any; - assert.ok(interruption); - await log.append([ - ...journal.splice(0), - { - type: 'session_interaction_pending', - version: 1, - turnId: 'turn1', - generation: '1', - interaction: { - version: 1, - interactionId: 'i1', - kind: 'tool_approval', - variant: 'ordinary_tool', - descriptor: { agentName: 'agent', toolName: 'shell', callId: 'call1', argumentsText: rawArgs }, - choices: [ - { id: 'approve', label: 'Approve' }, - { id: 'reject', label: 'Reject' }, - ], - revision: 1, - }, - }, - ]); - const decision = await control.park({ - generation: 1, - isCurrent, - decisionAddress: interruption, - approvals: [{ callId: 'call1' as any, toolName: 'shell', requirement: { kind: 'ask', summary: 'Create file' } }], - pendingCalls: [{ callId: 'call1' as any, toolName: 'shell', input: rawArgs }], - validatePendingSnapshot(calls) { - assert.equal(calls[0].input, rawArgs); - }, - validate(ds) { - assert.equal(ds.get('call1' as any)?.kind, 'approve'); - }, - }); - await log.append([ - { - type: 'session_interaction_resolved', - version: 1, - turnId: 'turn1', - interactionId: 'i1', - revision: 1, - generation: '1', - outcome: 'answered', - answer: { choiceId: 'approve' }, - }, - ]); - assert.equal(fs.existsSync(target), false); - assert.equal(decision.get('call1' as any)?.kind, 'approve'); - stream.state!.approve!(interruption); - return {}; - }, - async completed() { - await log.append([ - ...journal.splice(0), - { type: 'session_turn_ended', version: 1, turnId: 'turn1', outcome: 'completed' }, - ]); - }, - recover: async () => false, -})); -const input: any = { - turnId: 'turn1', - history: [], - newItems: [], - instructions: turnPrompt().instructions, - model: { provider: 'openai', model: 'fixture' }, - toolNames: ['shell'], - workspace: { root: workspace, access: 'read_write', target: 'local' }, - budget: { kind: 'adopted' }, -}; -const first: any[] = []; -for await (const e of engine.start(input).events) first.push(e); -assert.equal(first.at(-1)?.outcome.kind, 'suspended'); -assert.equal(fs.existsSync(target), false); -assert.equal(requests.length, 1); -assert.deepEqual( - requests[0].tools.map((tool: any) => tool.name), - ['shell'], -); -const checkpoint = first.at(-1).outcome.checkpoint; -assert.equal(JSON.parse(JSON.stringify(checkpoint)).modelCalls, 1); -const invalidCases: any[] = [ - { checkpoint: { ...checkpoint, modelCalls: 99 }, decisions: new Map([['call1', { kind: 'approve' }]]) }, - { checkpoint, decisions: new Map() }, - { - checkpoint, - decisions: new Map([ - ['call1', { kind: 'approve' }], - ['extra', { kind: 'approve' }], - ]), - }, - { checkpoint, decisions: new Map([['call1', { kind: 'approve', extra: true }]]) }, -]; -for (const candidate of invalidCases) { - const events: any[] = []; - for await (const e of engine.resume(candidate.checkpoint, candidate.decisions).events) events.push(e); - assert.equal(events.at(-1).outcome.kind, 'failed'); - assert.equal(requests.length, 1); - assert.equal(fs.existsSync(target), false); -} -const second: any[] = []; -for await (const e of engine.resume( - JSON.parse(JSON.stringify(checkpoint)), - new Map([['call1', { kind: 'approve' }]]) as any, -).events) - second.push(e); -assert.equal(second.at(-1)?.outcome.kind, 'completed'); -assert.equal(dispatch, 1); -assert.equal(requests.length, 2); -assert.equal(requests[1].input.filter((i: any) => i.type === 'tool_call' && i.id === 'call1').length, 1); -assert.equal(requests[1].input.filter((i: any) => i.type === 'tool_result' && i.id === 'call1').length, 1); -assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); -assert.equal(log.entries().filter((e) => e.fact.type === 'tool_started').length, 1); -assert.ok(record.getHistory().some((i: any) => i.type === 'function_call_result')); -assert.ok(accepted >= 2); -await engine.dispose(); -await log.dispose(); -const reopened = CanonicalSessionLog.open({ root: path.join(root, 'log'), id: 'bench', logger }); -const facts = reopened.entries().map((e) => e.fact); -assert.ok(facts.some((f: any) => f.type === 'user_message')); -assert.ok(facts.some((f: any) => f.type === 'assistant_journal_item' && f.item.type === 'tool_call')); -assert.ok(facts.some((f: any) => f.type === 'assistant_journal_item' && f.item.type === 'tool_result')); -await reopened.dispose(); -const restored = loadCanonicalConversation('bench', path.join(root, 'log')); -assert.ok(restored.history.some((i: any) => i.type === 'function_call' && i.callId === 'call1')); -assert.ok(restored.history.some((i: any) => i.type === 'function_call_result' && i.callId === 'call1')); -assert.ok(restored.messages.some((m: any) => m.text === 'Create requested file')); -await models.dispose(); -server.close(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS real adopted approval, C2 durable decision/gate, one effect and canonical history'); diff --git a/rewrite/bench/c1-adopted-kill-child.mts b/rewrite/bench/c1-adopted-kill-child.mts deleted file mode 100644 index e6b79742e..000000000 --- a/rewrite/bench/c1-adopted-kill-child.mts +++ /dev/null @@ -1,234 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = process.env.C1_PROOF_ROOT!; -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify(requests.length === 1 ? { kind: 'call' } : { kind: 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { type: 'tool_call', id: 'call1', name: 'shell', arguments: rawArgs }; - yield call; - yield { type: 'completion', output: [call], responseId: 'r1' }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const { engine, tools, turnPrompt } = createBenchEngine({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, -}); -assert.ok(tools.original('shell'), 'source shell assembled'); -const log = CanonicalSessionLog.create({ - root: path.join(root, 'log'), - id: 'bench', - metadata: {}, - workspace: { root: workspace, access: 'read_write', target: 'local' }, - model: { provider: 'openai', model: 'fixture' }, - logger, -}); -await log.append([ - { type: 'user_message', message: { id: 'u1', sender: 'user', text: 'Create requested file' } }, - { type: 'session_turn_started', version: 1, turnId: 'turn1', clientRequestId: null }, -]); -const journal: any[] = []; -const record = new ConversationRecord({ logger }); -record.setJournalSink((event) => journal.push(event)); -record.beginTurn(); -record.acceptInput('Create requested file'); -const isCurrent = () => true; -let accepted = 0, - dispatch = 0; -engine.registerTurnBridge('turn1' as any, { generation: 1, isCurrent }, (control) => ({ - prepare: async () => ({ input: record.getHistory(), options: { sessionId: 'bench' } }), - beginSegment() { - record.beginSegment(); - }, - acceptEvent(e: any) { - if (e.type === 'item') record.acceptObservedItem(e.item); - else if (e.type === 'text_delta') record.recordTextDelta(e.text); - else if (e.type === 'reasoning_delta') record.recordReasoningDelta(e.text); - else if (e.type === 'model_attempt_rollback') record.rollbackReasoning(e.reasoningCharacters); - }, - acceptToolDispatch(id) { - dispatch++; - record.markDispatched(id); - }, - beforeToolDispatch: async (id) => { - await log.append([ - ...journal.splice(0), - { type: 'tool_started', turnId: 'turn1', toolCallId: id, toolName: 'shell', arguments: JSON.parse(rawArgs) }, - ]); - assert.equal(fs.existsSync(target), false); - }, - acceptSegment(stream) { - record.acceptSegment(stream, 'full_history', { logger, sessionId: 'bench' }); - accepted++; - }, - getAcceptedHistory: () => record.getHistory(), - async consume(stream) { - for await (const _ of stream) { - } - }, - async interrupted(stream) { - const interruption = stream.interruptions?.[0] as any; - assert.ok(interruption); - await log.append([ - ...journal.splice(0), - { - type: 'session_interaction_pending', - version: 1, - turnId: 'turn1', - generation: '1', - interaction: { - version: 1, - interactionId: 'i1', - kind: 'tool_approval', - variant: 'ordinary_tool', - descriptor: { agentName: 'agent', toolName: 'shell', callId: 'call1', argumentsText: rawArgs }, - choices: [ - { id: 'approve', label: 'Approve' }, - { id: 'reject', label: 'Reject' }, - ], - revision: 1, - }, - }, - ]); - const decision = await control.park({ - generation: 1, - isCurrent, - decisionAddress: interruption, - approvals: [{ callId: 'call1' as any, toolName: 'shell', requirement: { kind: 'ask', summary: 'Create file' } }], - pendingCalls: [{ callId: 'call1' as any, toolName: 'shell', input: rawArgs }], - validatePendingSnapshot(calls) { - assert.equal(calls[0].input, rawArgs); - }, - validate(ds) { - assert.equal(ds.get('call1' as any)?.kind, 'approve'); - }, - }); - await log.append([ - { - type: 'session_interaction_resolved', - version: 1, - turnId: 'turn1', - interactionId: 'i1', - revision: 1, - generation: '1', - outcome: 'answered', - answer: { choiceId: 'approve' }, - }, - ]); - assert.equal(fs.existsSync(target), false); - assert.equal(decision.get('call1' as any)?.kind, 'approve'); - stream.state!.approve!(interruption); - return {}; - }, - async completed() { - await log.append([ - ...journal.splice(0), - { type: 'session_turn_ended', version: 1, turnId: 'turn1', outcome: 'completed' }, - ]); - }, - recover: async () => false, -})); -const input: any = { - turnId: 'turn1', - history: [], - newItems: [], - instructions: turnPrompt().instructions, - model: { provider: 'openai', model: 'fixture' }, - toolNames: ['shell'], - workspace: { root: workspace, access: 'read_write', target: 'local' }, - budget: { kind: 'adopted' }, -}; -const first: any[] = []; -for await (const e of engine.start(input).events) first.push(e); -assert.equal(first.at(-1)?.outcome.kind, 'suspended'); -assert.equal(fs.existsSync(target), false); -assert.equal(requests.length, 1); -assert.deepEqual( - requests[0].tools.map((tool: any) => tool.name), - ['shell'], -); -const checkpoint = first.at(-1).outcome.checkpoint; -assert.equal(JSON.parse(JSON.stringify(checkpoint)).modelCalls, 1); -fs.writeFileSync(path.join(root, 'checkpoint.json'), JSON.stringify(checkpoint)); -fs.writeFileSync(path.join(root, 'ready'), 'ready'); -await new Promise(() => {}); diff --git a/rewrite/bench/c1-adopted-kill.mts b/rewrite/bench/c1-adopted-kill.mts deleted file mode 100644 index d31c01706..000000000 --- a/rewrite/bench/c1-adopted-kill.mts +++ /dev/null @@ -1,104 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { setTimeout as delay } from 'node:timers/promises'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-kill-')); -const cwd = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -let stderr = ''; -const child = spawn(process.execPath, ['--import', 'tsx', path.join(cwd, 'rewrite/bench/c1-adopted-kill-child.mts')], { - cwd, - env: { ...process.env, C1_PROOF_ROOT: root }, - stdio: ['ignore', 'pipe', 'pipe'], -}); -child.stderr.on('data', (b) => (stderr += b)); -for (let n = 0; n < 400 && !fs.existsSync(path.join(root, 'ready')) && child.exitCode === null; n++) await delay(50); -if (!fs.existsSync(path.join(root, 'ready'))) { - child.kill('SIGKILL'); - throw new Error(`child failed to park: ${stderr}`); -} -const checkpoint = JSON.parse(fs.readFileSync(path.join(root, 'checkpoint.json'), 'utf8')); -const workspace = path.join(root, 'workspace'), - target = path.join(workspace, 'effect.txt'); -assert.equal(checkpoint.modelCalls, 1); -assert.equal(fs.existsSync(target), false); -child.kill('SIGKILL'); -await new Promise((resolve) => child.once('close', resolve)); -const logger = new LoggingService({ disableLogging: true }), - context = new SessionContextService(), - executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const store = new LocalSessionStore({ - root: path.join(root, 'log'), - logger, - defaultWorkspace: { root: workspace, access: 'read_write', target: 'local' }, - defaultModel: { provider: 'openai', model: 'fixture' }, - driverFactory: () => ({} as any), -}); -const session = await store.open('bench' as any); -assert.equal(session.state().status, 'interrupted'); -assert.equal(session.state().activeTurnId, null); -assert.equal(session.state().pendingInteraction, null); -const recoveredSeq = session.state().lastSeq; -await session.close(); -const reopened = await store.open('bench' as any); -assert.equal(reopened.state().status, 'interrupted'); -assert.equal(reopened.state().lastSeq, recoveredSeq); -assert.equal(reopened.state().pendingInteraction, null); -await reopened.close(); -let providerCalls = 0; -const providers = createProviderRegistry(); -providers.upsertProvider({ - id: 'openai', - label: 'sentinel', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - providerCalls++; - throw new Error('unexpected provider dispatch'); - }, -}); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { agent: { modelSelection: { provider: 'openai', model: 'fixture' } } as any }, -}); -const pair = source.forSessionBinding({ readable: Object.keys(host.getAll()) as any, writable: [] }); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const { engine } = createBenchEngine({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, -}); -const events: any[] = []; -for await (const e of engine.resume(checkpoint, new Map([['call1', { kind: 'approve' }]]) as any).events) - events.push(e); -assert.equal(events.at(-1).outcome.kind, 'failed'); -assert.match(events.at(-1).outcome.error.message, /checkpoint_unavailable/); -assert.equal(providerCalls, 0); -assert.equal(fs.existsSync(target), false); -await engine.dispose(); -await models.dispose(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS actual SIGKILL, canonical recovery and no replay'); diff --git a/rewrite/bench/c2-answer-kill.mjs b/rewrite/bench/c2-answer-kill.mjs deleted file mode 100644 index 7a059a5bf..000000000 --- a/rewrite/bench/c2-answer-kill.mjs +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node -// Real-process C2 approval recovery proof; requires tsconfig.c2-proof.json build. -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { spawn } from 'node:child_process'; -import { setTimeout as delay } from 'node:timers/promises'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const proofCli = path.join(repo, 'rewrite/bench-dist/rewrite/bench/c2-proof-cli.js'); -const storeModule = path.join(repo, 'rewrite/bench-dist/rewrite/src/session/local-store.js'); -if (!fs.existsSync(proofCli) || !fs.existsSync(storeModule)) throw new Error('build the C2 proof entry first'); - -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'term2-c2-kill-')); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'workspace'); -const conversations = path.join(root, 'conversations'); -const barrier = path.join(root, 'pending.ready'); -for (const dir of [home, workspace, path.join(root, 'runtime'), path.join(root, 'tmp')]) - fs.mkdirSync(dir, { recursive: true }); -let providerCalls = 0; -const server = http.createServer((request, response) => { - if (request.method === 'GET') { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ object: 'list', data: [{ id: 'mock-alpha' }] })); - return; - } - request.resume(); - request.on('end', () => { - providerCalls += 1; - const toolCall = { - index: 0, - id: 'call_1', - type: 'function', - function: { - name: 'shell', - arguments: JSON.stringify({ - command: `printf 'hi\\n' > ${path.join(workspace, 'effect.txt')}`, - description: 'Write the file', - sandbox: 'unsandboxed', - }), - }, - }; - const frames = - providerCalls === 1 - ? [ - { id: 'chatcmpl-1', choices: [{ index: 0, delta: { role: 'assistant', tool_calls: [toolCall] } }] }, - { id: 'chatcmpl-1', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }, - ] - : [ - { - id: `chatcmpl-${providerCalls}`, - choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]; - response.writeHead(200, { 'content-type': 'text/event-stream' }); - response.end([...frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`), 'data: [DONE]\n\n'].join('')); - }); -}); - -try { - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('fake provider did not bind'); - const settingsDir = path.join(home, '.local/state/term2-nodejs'); - fs.mkdirSync(settingsDir, { recursive: true }); - fs.writeFileSync( - path.join(settingsDir, 'settings.json'), - JSON.stringify({ - agent: { retryAttempts: 0, modelSelection: { provider: 'mockprov', model: 'mock-alpha' } }, - providers: [ - { - name: 'mockprov', - type: 'openai-compatible', - baseUrl: `http://127.0.0.1:${address.port}/v1`, - apiKey: 'test-key', - }, - ], - sandbox: { enabled: false }, - }), - ); - const env = { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: path.join(home, '.config'), - XDG_STATE_HOME: path.join(home, '.local/state'), - XDG_CACHE_HOME: path.join(home, '.cache'), - XDG_DATA_HOME: path.join(home, '.local/share'), - XDG_RUNTIME_DIR: path.join(root, 'runtime'), - TMPDIR: path.join(root, 'tmp'), - TERM2_CONFIG_DIR: path.join(home, '.term2'), - TERM2_CACHE_DIR: path.join(home, '.cache/term2'), - TERM2_CONVERSATIONS_DIR: conversations, - TERM2_C2_PROOF_DECISION_BARRIER_FILE: barrier, - DISABLE_LOGGING: '1', - NO_COLOR: '1', - TERM: 'dumb', - }; - const child = spawn(process.execPath, [proofCli, 'write a file'], { env, cwd: workspace, stdio: 'ignore' }); - for (let i = 0; i < 600 && !fs.existsSync(barrier); i += 1) await delay(50); - if (!fs.existsSync(barrier)) { - child.kill('SIGKILL'); - throw new Error('approval pending durability barrier was not reached'); - } - if (providerCalls !== 1) throw new Error(`expected one provider call before approval; observed ${providerCalls}`); - if (fs.existsSync(path.join(workspace, 'effect.txt'))) throw new Error('tool effect occurred before approval'); - child.kill('SIGKILL'); - if (child.exitCode === null) await new Promise((resolve) => child.once('close', resolve)); - - const logId = fs - .readdirSync(conversations) - .find((name) => name.endsWith('.jsonl')) - ?.slice(0, -6); - if (!logId) throw new Error('killed process did not create its canonical log'); - const { LocalSessionStore } = await import(`file://${storeModule}`); - const driverFactory = () => ({ - submit: async () => { - throw new Error('recovery proof must not submit'); - }, - decide: async () => { - throw new Error('recovered interactions must not be actionable'); - }, - cancel: async () => {}, - retry: async () => { - throw new Error('recovery proof must not retry'); - }, - compact: async () => {}, - setModel: async () => {}, - subscribe: () => () => {}, - }); - const store = new LocalSessionStore({ - root: conversations, - logger: { warn() {}, info() {} }, - defaultWorkspace: { root: workspace, access: 'read_write', target: 'local' }, - defaultModel: { provider: 'mockprov', model: 'mock-alpha' }, - driverFactory, - }); - const recovered = await store.open(logId); - const state1 = recovered.state(); - const seq1 = state1.lastSeq; - if (state1.status !== 'interrupted' || state1.pendingInteraction !== null) - throw new Error('recovered prompt remained actionable'); - await recovered.close(); - const reopened = await store.open(logId); - const state2 = reopened.state(); - if (state2.lastSeq !== seq1 || state2.status !== 'interrupted' || state2.pendingInteraction !== null) - throw new Error('reconciliation was not idempotent'); - await reopened.close(); - if (providerCalls !== 1) throw new Error('session open called the provider after restart'); - if (fs.existsSync(path.join(workspace, 'effect.txt'))) - throw new Error('session open executed the tool after restart'); - - const envelopes = fs - .readFileSync(path.join(conversations, `${logId}.jsonl`), 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line)); - const facts = envelopes.flatMap((envelope) => - envelope.event?.type === 'session_fact_batch' - ? envelope.event.entries.map((entry) => entry.event) - : [envelope.event], - ); - const count = (type) => facts.filter((fact) => fact.type === type).length; - if ( - count('session_interaction_pending') !== 1 || - count('session_interaction_recovered') !== 0 || - count('session_interaction_resolved') !== 1 || - count('session_interrupted') !== 1 - ) { - throw new Error('pending/recovery audit facts are missing or duplicated'); - } - if (count('tool_result') !== 0 || count('assistant_turn') !== 0) - throw new Error('approval kill unexpectedly completed the tool turn'); - console.log( - JSON.stringify({ - scenario: 'answer-then-kill-before-continuation', - logId, - providerCalls, - status: state2.status, - pendingInteraction: state2.pendingInteraction, - lastSeq: state2.lastSeq, - pending: count('session_interaction_pending'), - recovered: count('session_interaction_recovered'), - resolved: count('session_interaction_resolved'), - interrupted: count('session_interrupted'), - noEffect: true, - repeatOpenIdempotent: true, - }), - ); -} finally { - await new Promise((resolve) => server.close(resolve)); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c2-approval-kill.mjs b/rewrite/bench/c2-approval-kill.mjs deleted file mode 100644 index 7d56d96bc..000000000 --- a/rewrite/bench/c2-approval-kill.mjs +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env node -// Real-process C2 approval recovery proof; requires tsconfig.c2-proof.json build. -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { spawn } from 'node:child_process'; -import { setTimeout as delay } from 'node:timers/promises'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const proofCli = path.join(repo, 'rewrite/bench-dist/rewrite/bench/c2-proof-cli.js'); -const storeModule = path.join(repo, 'rewrite/bench-dist/rewrite/src/session/local-store.js'); -if (!fs.existsSync(proofCli) || !fs.existsSync(storeModule)) throw new Error('build the C2 proof entry first'); - -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'term2-c2-kill-')); -const home = path.join(root, 'home'); -const workspace = path.join(root, 'workspace'); -const conversations = path.join(root, 'conversations'); -const barrier = path.join(root, 'pending.ready'); -for (const dir of [home, workspace, path.join(root, 'runtime'), path.join(root, 'tmp')]) - fs.mkdirSync(dir, { recursive: true }); -let providerCalls = 0; -const server = http.createServer((request, response) => { - if (request.method === 'GET') { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ object: 'list', data: [{ id: 'mock-alpha' }] })); - return; - } - request.resume(); - request.on('end', () => { - providerCalls += 1; - const toolCall = { - index: 0, - id: 'call_1', - type: 'function', - function: { - name: 'shell', - arguments: JSON.stringify({ - command: `printf 'hi\\n' > ${path.join(workspace, 'effect.txt')}`, - description: 'Write the file', - sandbox: 'unsandboxed', - }), - }, - }; - const frames = - providerCalls === 1 - ? [ - { id: 'chatcmpl-1', choices: [{ index: 0, delta: { role: 'assistant', tool_calls: [toolCall] } }] }, - { id: 'chatcmpl-1', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }, - ] - : [ - { - id: `chatcmpl-${providerCalls}`, - choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]; - response.writeHead(200, { 'content-type': 'text/event-stream' }); - response.end([...frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`), 'data: [DONE]\n\n'].join('')); - }); -}); - -try { - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('fake provider did not bind'); - const settingsDir = path.join(home, '.local/state/term2-nodejs'); - fs.mkdirSync(settingsDir, { recursive: true }); - fs.writeFileSync( - path.join(settingsDir, 'settings.json'), - JSON.stringify({ - agent: { retryAttempts: 0, modelSelection: { provider: 'mockprov', model: 'mock-alpha' } }, - providers: [ - { - name: 'mockprov', - type: 'openai-compatible', - baseUrl: `http://127.0.0.1:${address.port}/v1`, - apiKey: 'test-key', - }, - ], - sandbox: { enabled: false }, - }), - ); - const env = { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: path.join(home, '.config'), - XDG_STATE_HOME: path.join(home, '.local/state'), - XDG_CACHE_HOME: path.join(home, '.cache'), - XDG_DATA_HOME: path.join(home, '.local/share'), - XDG_RUNTIME_DIR: path.join(root, 'runtime'), - TMPDIR: path.join(root, 'tmp'), - TERM2_CONFIG_DIR: path.join(home, '.term2'), - TERM2_CACHE_DIR: path.join(home, '.cache/term2'), - TERM2_CONVERSATIONS_DIR: conversations, - TERM2_C2_PROOF_BARRIER_FILE: barrier, - DISABLE_LOGGING: '1', - NO_COLOR: '1', - TERM: 'dumb', - }; - const child = spawn(process.execPath, [proofCli, 'write a file'], { env, cwd: workspace, stdio: 'ignore' }); - for (let i = 0; i < 600 && !fs.existsSync(barrier); i += 1) await delay(50); - if (!fs.existsSync(barrier)) { - child.kill('SIGKILL'); - throw new Error('approval pending durability barrier was not reached'); - } - if (providerCalls !== 1) throw new Error(`expected one provider call before approval; observed ${providerCalls}`); - if (fs.existsSync(path.join(workspace, 'effect.txt'))) throw new Error('tool effect occurred before approval'); - child.kill('SIGKILL'); - if (child.exitCode === null) await new Promise((resolve) => child.once('close', resolve)); - - const logId = fs - .readdirSync(conversations) - .find((name) => name.endsWith('.jsonl')) - ?.slice(0, -6); - if (!logId) throw new Error('killed process did not create its canonical log'); - const { LocalSessionStore } = await import(`file://${storeModule}`); - const driverFactory = () => ({ - submit: async () => { - throw new Error('recovery proof must not submit'); - }, - decide: async () => { - throw new Error('recovered interactions must not be actionable'); - }, - cancel: async () => {}, - retry: async () => { - throw new Error('recovery proof must not retry'); - }, - compact: async () => {}, - setModel: async () => {}, - subscribe: () => () => {}, - }); - const store = new LocalSessionStore({ - root: conversations, - logger: { warn() {}, info() {} }, - defaultWorkspace: { root: workspace, access: 'read_write', target: 'local' }, - defaultModel: { provider: 'mockprov', model: 'mock-alpha' }, - driverFactory, - }); - const recovered = await store.open(logId); - const state1 = recovered.state(); - const seq1 = state1.lastSeq; - if (state1.status !== 'interrupted' || state1.pendingInteraction !== null) - throw new Error('recovered prompt remained actionable'); - await recovered.close(); - const reopened = await store.open(logId); - const state2 = reopened.state(); - if (state2.lastSeq !== seq1 || state2.status !== 'interrupted' || state2.pendingInteraction !== null) - throw new Error('reconciliation was not idempotent'); - await reopened.close(); - if (providerCalls !== 1) throw new Error('session open called the provider after restart'); - if (fs.existsSync(path.join(workspace, 'effect.txt'))) - throw new Error('session open executed the tool after restart'); - - const envelopes = fs - .readFileSync(path.join(conversations, `${logId}.jsonl`), 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line)); - const facts = envelopes.flatMap((envelope) => - envelope.event?.type === 'session_fact_batch' - ? envelope.event.entries.map((entry) => entry.event) - : [envelope.event], - ); - const count = (type) => facts.filter((fact) => fact.type === type).length; - if ( - count('session_interaction_pending') !== 1 || - count('session_interaction_recovered') !== 1 || - count('session_interaction_resolved') !== 1 || - count('session_interrupted') !== 1 - ) { - throw new Error('pending/recovery audit facts are missing or duplicated'); - } - if (count('tool_result') !== 0 || count('assistant_turn') !== 0) - throw new Error('approval kill unexpectedly completed the tool turn'); - console.log( - JSON.stringify({ - logId, - providerCalls, - status: state2.status, - pendingInteraction: state2.pendingInteraction, - lastSeq: state2.lastSeq, - pending: count('session_interaction_pending'), - recovered: count('session_interaction_recovered'), - resolved: count('session_interaction_resolved'), - interrupted: count('session_interrupted'), - noEffect: true, - repeatOpenIdempotent: true, - }), - ); -} finally { - await new Promise((resolve) => server.close(resolve)); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c2-boundary-failure.mjs b/rewrite/bench/c2-boundary-failure.mjs deleted file mode 100644 index 405c72673..000000000 --- a/rewrite/bench/c2-boundary-failure.mjs +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env node -// Real-runner proof that failures at pending/final event boundaries block publication. -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const proofCli = path.join(repo, 'rewrite/bench-dist/rewrite/bench/c2-proof-cli.js'); -if (!fs.existsSync(proofCli)) throw new Error('build the C2 proof entry first'); -for (const failure of ['boundary-write', 'boundary-fsync']) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), `term2-c2-${failure}-`)); - const home = path.join(root, 'home'); - const workspace = path.join(root, 'workspace'); - const conversations = path.join(root, 'conversations'); - for (const dir of [home, workspace, path.join(root, 'runtime'), path.join(root, 'tmp')]) - fs.mkdirSync(dir, { recursive: true }); - let providerCalls = 0; - const server = http.createServer((request, response) => { - if (request.method === 'GET') { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ object: 'list', data: [{ id: 'mock-alpha' }] })); - return; - } - request.resume(); - request.on('end', () => { - providerCalls += 1; - const frames = - failure === 'boundary-write' - ? [ - { - id: 'chatcmpl-1', - choices: [ - { - index: 0, - delta: { - role: 'assistant', - tool_calls: [ - { - index: 0, - id: 'call_1', - type: 'function', - function: { - name: 'shell', - arguments: JSON.stringify({ - command: `printf 'bad\n' > ${path.join(workspace, 'effect.txt')}`, - description: 'Write a file', - sandbox: 'unsandboxed', - }), - }, - }, - ], - }, - }, - ], - }, - { id: 'chatcmpl-1', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }, - ] - : [ - { - id: 'chatcmpl-1', - choices: [ - { index: 0, delta: { role: 'assistant', content: 'must not publish' }, finish_reason: 'stop' }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]; - response.writeHead(200, { 'content-type': 'text/event-stream' }); - response.end([...frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`), 'data: [DONE]\n\n'].join('')); - }); - }); - try { - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('fake provider did not bind'); - const settingsDir = path.join(home, '.local/state/term2-nodejs'); - fs.mkdirSync(settingsDir, { recursive: true }); - fs.writeFileSync( - path.join(settingsDir, 'settings.json'), - JSON.stringify({ - agent: { retryAttempts: 0, modelSelection: { provider: 'mockprov', model: 'mock-alpha' } }, - providers: [ - { - name: 'mockprov', - type: 'openai-compatible', - baseUrl: `http://127.0.0.1:${address.port}/v1`, - apiKey: 'test-key', - }, - ], - sandbox: { enabled: false }, - }), - ); - const env = { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: path.join(home, '.config'), - XDG_STATE_HOME: path.join(home, '.local/state'), - XDG_CACHE_HOME: path.join(home, '.cache'), - XDG_DATA_HOME: path.join(home, '.local/share'), - XDG_RUNTIME_DIR: path.join(root, 'runtime'), - TMPDIR: path.join(root, 'tmp'), - TERM2_CONFIG_DIR: path.join(home, '.term2'), - TERM2_CACHE_DIR: path.join(home, '.cache/term2'), - TERM2_CONVERSATIONS_DIR: conversations, - TERM2_C2_PROOF_FAIL: failure, - DISABLE_LOGGING: '1', - NO_COLOR: '1', - TERM: 'dumb', - }; - const result = await new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [proofCli, failure === 'boundary-write' ? 'write a file' : 'respond with text'], - { env, cwd: workspace, stdio: ['ignore', 'pipe', 'pipe'] }, - ); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr })); - }); - if (result.code === 0 || result.signal) - throw new Error(`${failure} failure was reported as success or signal: ${JSON.stringify(result)}`); - if (providerCalls !== 1) throw new Error(`${failure} should reach exactly one provider call; got ${providerCalls}`); - if (failure === 'boundary-write' && fs.existsSync(path.join(workspace, 'effect.txt'))) - throw new Error('failed pending boundary allowed tool effect'); - const log = fs.readdirSync(conversations).find((name) => name.endsWith('.jsonl')); - if (!log) throw new Error('missing canonical C2 log'); - const envelopes = fs - .readFileSync(path.join(conversations, log), 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); - const facts = envelopes.flatMap((envelope) => - envelope.event?.type === 'session_fact_batch' - ? envelope.event.entries.map((entry) => entry.event) - : [envelope.event], - ); - if (failure === 'boundary-write' && facts.some((fact) => fact.type === 'session_interaction_pending')) - throw new Error('failed append exposed actionable pending state'); - const terminal = facts.filter((fact) => - ['assistant_turn', 'session_turn_ended', 'session_admission_settled'].includes(fact.type), - ); - if (failure === 'boundary-fsync' && terminal.length !== 0 && terminal.length !== 3) - throw new Error('failed fsync left a partial terminal transaction'); - console.log( - JSON.stringify({ - failure, - exitCode: result.code, - providerCalls, - noToolEffect: !fs.existsSync(path.join(workspace, 'effect.txt')), - transientTextObserved: result.stdout.includes('must not publish'), - completeTerminalBatchPresent: terminal.length === 3, - durableFacts: facts.map((fact) => fact.type), - }), - ); - } finally { - await new Promise((resolve) => server.close(resolve)); - fs.rmSync(root, { recursive: true, force: true }); - } -} diff --git a/rewrite/bench/c2-proof-cli.ts b/rewrite/bench/c2-proof-cli.ts deleted file mode 100644 index 132648cda..000000000 --- a/rewrite/bench/c2-proof-cli.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** C2-only composition entry: install canonical persistence then run the real CLI. */ -import { installNonInteractivePersistenceFactory } from '../../source/non-interactive.js'; -import { createProofPersistence } from './c2-proof-persistence.js'; - -const failureMode = process.env['TERM2_C2_PROOF_FAIL']; -const injectedFailure = - failureMode === 'write' || - failureMode === 'fsync' || - failureMode === 'boundary-write' || - failureMode === 'boundary-fsync' - ? failureMode - : undefined; -installNonInteractivePersistenceFactory((input) => - createProofPersistence(input, { - ...(process.env['TERM2_C2_PROOF_BARRIER_FILE'] ? { barrierFile: process.env['TERM2_C2_PROOF_BARRIER_FILE'] } : {}), - ...(process.env['TERM2_C2_PROOF_DECISION_BARRIER_FILE'] - ? { decisionBarrierFile: process.env['TERM2_C2_PROOF_DECISION_BARRIER_FILE'] } - : {}), - ...(injectedFailure ? { injectedFailure } : {}), - }), -); - -await import('../../source/cli.js'); diff --git a/rewrite/bench/c2-proof-persistence.ts b/rewrite/bench/c2-proof-persistence.ts deleted file mode 100644 index 7c97d8ea5..000000000 --- a/rewrite/bench/c2-proof-persistence.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** Proof-only composition: existing noninteractive runtime, canonical C2 store/writer. */ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import type { ConversationAdapter } from '../../source/services/conversation/conversation-adapter.js'; -import type { ConversationEvent } from '../../source/services/conversation/conversation-events.js'; -import { projectPendingInteraction } from '../../source/gateway/interaction-protocol.js'; -import type { LogEvent } from '../../source/services/logging/conversation-log-events.js'; -import type { SessionRuntime } from '../../source/core/session-runtime.js'; -import type { RestoredState } from '../../source/services/conversation/conversation-persistence.js'; -import type { ILoggingService, ISettingsService } from '../../source/services/service-interfaces.js'; -import { getConversationsDir } from '../../source/services/conversation/conversation-persistence.js'; -import { LocalSessionStore, type SessionDriverContext } from '../src/session/local-store.js'; -import type { - Admission, - ClientRequestId, - SessionDecision, - SessionFact, - SessionId, - SessionState, -} from '../src/session/index.js'; -import type { TurnId } from '../src/engine/index.js'; -import type { NonInteractivePersistenceFactoryInput } from '../../source/non-interactive.js'; - -export type ProofContext = NonInteractivePersistenceFactoryInput; -export type ProofPersistenceOptions = { - readonly root?: string; - readonly barrierFile?: string; - readonly decisionBarrierFile?: string; - readonly injectedFailure?: 'write' | 'fsync' | 'boundary-write' | 'boundary-fsync'; -}; - -export async function createProofPersistence(input: ProofContext, options: ProofPersistenceOptions = {}) { - const { sessionId, resumedConversation, metadata, settingsService, logger, runtime, adapter } = input; - const root = options.root ?? getConversationsDir(); - const selection = settingsService.get('agent.modelSelection'); - const workspace = { - root: metadata.projectPath ?? process.cwd(), - access: 'read_write' as const, - target: 'local' as const, - }; - const model = { provider: selection.provider, model: selection.model }; - const generationByInteraction = new Map(); - const fault = options.injectedFailure - ? failingFileSystem(options.injectedFailure.endsWith('write') ? 'write' : 'fsync') - : undefined; - let selectedSession: Awaited> | undefined; - let driverContext: SessionDriverContext | undefined; - let beforeDecisionPort: ((input: { answer: string; rejectionReason?: string }) => Promise) | undefined; - const legacyFile = path.join(root, `${sessionId}.jsonl`); - const legacyBytesBeforeOpen = resumedConversation && fs.existsSync(legacyFile) ? fs.readFileSync(legacyFile) : null; - const store = new LocalSessionStore({ - root, - logger: logger as ILoggingService, - defaultWorkspace: workspace, - defaultModel: model, - admissionTtlMs: 60_000, - ...(fault ? { fileSystem: fault.fileSystem } : {}), - driverFactory: (context) => { - driverContext = context; - return sourceDriver(adapter, runtime, context, (decision) => { - if (!beforeDecisionPort) throw new Error('C2 decision port is not initialized'); - return beforeDecisionPort(decision); - }); - }, - }); - selectedSession = resumedConversation - ? await store.open(sessionId as SessionId) - : await store.createWithId(sessionId as SessionId, workspace, model, undefined, metadata); - - if (legacyBytesBeforeOpen && !fs.readFileSync(legacyFile).equals(legacyBytesBeforeOpen)) { - await selectedSession.close(); - throw new Error('opening a settled old CLI log changed bytes before the resumed append'); - } - - if (resumedConversation) { - const projected = selectedSession.state().providerHistory; - if (JSON.stringify(projected) !== JSON.stringify(resumedConversation.history)) { - await selectedSession.close(); - throw new Error('C2 canonical replay differs from the source runner resume transcript'); - } - } - - const buffered: LogEvent[] = []; - let poison: unknown; - let disposed = false; - let currentTurnId: string | null = null; - let currentRequestId: string | null = null; - let admittedUserRef: { readonly logId: string; readonly eventId: string } | null = null; - let blockedRollbackDone = false; - let pendingObservation: { answer: string; rejectionReason?: string } | null = null; - const assertHealthy = () => { - if (poison !== undefined) throw poison; - if (disposed) throw new Error('C2 proof persistence port is disposed'); - }; - const capture = (event: LogEvent) => { - if (poison !== undefined) return; - if (blockedRollbackDone && event.type === 'error') return; - try { - if (event.type === 'approval_resolved' && pendingObservation) { - const expected = pendingObservation; - const expectedAnswer = expected.answer === 'y' ? 'y' : 'n'; - if (event.answer !== expectedAnswer || (event.rejectionReason ?? undefined) !== expected.rejectionReason) { - throw new Error('source approval resolution did not match the C2-owned decision observation'); - } - pendingObservation = null; - return; - } - buffered.push(structuredClone(event)); - } catch (error) { - poison = error; - } - }; - const commit = async (facts: readonly SessionFact[]) => { - assertHealthy(); - const all = [...buffered, ...facts]; - if (all.length) { - try { - const entries = await driverContext!.commit(all); - buffered.splice(0, all.length - facts.length); - assertHealthy(); - return entries.slice(all.length - facts.length); - } catch (error) { - poison ??= error; - throw poison; - } - } - assertHealthy(); - return []; - }; - const state = (): SessionState => selectedSession!.state(); - const port = { - capture, - admit: async ({ prompt, userMessageId }: { prompt: string; userMessageId: string }) => { - assertHealthy(); - if (options.injectedFailure === 'write' || options.injectedFailure === 'fsync') fault?.arm(); - const requestId = crypto.randomUUID(); - const turnId = crypto.randomUUID(); - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + 60_000).toISOString(); - currentTurnId = turnId; - currentRequestId = requestId; - blockedRollbackDone = false; - const admitted = await commit([ - { type: 'user_message', message: { id: userMessageId, sender: 'user', text: prompt } }, - { - type: 'session_admitted', - version: 1, - clientRequestId: requestId, - normalizedBodyHash: normalizedHash(prompt), - turnId, - disposition: 'started', - createdAt, - expiresAt, - }, - { type: 'session_turn_started', version: 1, turnId, clientRequestId: requestId }, - ]); - admittedUserRef = admitted[0]!.ref; - }, - beforeDecision: async ({ - answer, - rejectionReason, - approvalAnswer, - }: { - answer: string; - rejectionReason?: string; - approvalAnswer?: string; - }) => { - assertHealthy(); - const pending = state().pendingInteraction; - const turnId = currentTurnId ?? state().activeTurnId; - if (!pending || !turnId || pending.turnId !== turnId) throw new Error('no current C2 pending interaction'); - const identity = { - turnId: pending.turnId, - interactionId: pending.interaction.interactionId, - revision: pending.interaction.revision, - generation: pending.generation, - }; - pendingObservation = { answer: answer === 'y' ? 'y' : 'n', ...(rejectionReason ? { rejectionReason } : {}) }; - const approvalResolved: LogEvent = { - type: 'approval_resolved', - answer: answer === 'y' ? 'y' : 'n', - ...(rejectionReason ? { rejectionReason } : {}), - ...(approvalAnswer ? { approvalAnswer } : {}), - } as LogEvent; - await commit([ - approvalResolved, - { - type: 'session_interaction_resolved', - version: 1, - ...identity, - outcome: 'answered', - answer: approvalAnswer ? { answers: [approvalAnswer] } : rejectionReason ? { rejectionReason } : {}, - }, - ]); - if (options.decisionBarrierFile) { - await fs.promises.writeFile(options.decisionBarrierFile, `${state().lastSeq}\n`); - await new Promise(() => {}); - } - }, - eventBoundary: async (event: ConversationEvent) => { - assertHealthy(); - if (event.type === 'error' && event.droppedUserMessage && blockedRollbackDone) return; - if (!currentTurnId && state().activeTurnId) currentTurnId = state().activeTurnId; - if (!currentRequestId && currentTurnId) { - currentRequestId = - Object.values(state().admissions).find((admission) => admission.turnId === currentTurnId)?.clientRequestId ?? - null; - } - const facts: SessionFact[] = []; - if (event.type === 'approval_required') { - const snapshot = runtime.pendingInteraction.getSnapshot(); - if (!snapshot || snapshot.revision === undefined || !currentTurnId) - throw new Error('approval event lacks authoritative pending interaction snapshot'); - const interactionId = String(snapshot.interactionId); - const generation = generationByInteraction.get(interactionId) ?? `interaction-${crypto.randomUUID()}`; - generationByInteraction.set(interactionId, generation); - const interaction = projectPendingInteraction( - snapshot.approval, - interactionId, - snapshot.revision, - snapshot.askUserAnswers, - snapshot.currentAskUserQuestionIndex, - ); - facts.push({ type: 'session_interaction_pending', version: 1, turnId: currentTurnId, interaction, generation }); - } else if (event.type === 'final' || event.type === 'error') { - if (currentTurnId && currentRequestId) { - if (event.type === 'error' && event.droppedUserMessage) { - if (!admittedUserRef) throw new Error('blocked input lacks its admitted event reference'); - facts.push({ type: 'events_retracted', version: 1, refs: [admittedUserRef] }); - facts.push({ - type: 'undo', - removedUserTurns: 1, - snapshot: runtime.state.exportState() as Extract['snapshot'], - }); - } - const outcome = - event.type === 'error' - ? 'failed' - : event.terminalCause === 'budget_exhausted' - ? 'budget_exhausted' - : 'completed'; - facts.push({ type: 'session_turn_ended', version: 1, turnId: currentTurnId, outcome }); - facts.push({ - type: 'session_admission_settled', - version: 1, - clientRequestId: currentRequestId, - turnId: currentTurnId, - result: event.type === 'final' ? 'accepted' : 'failed', - }); - } - } - if (options.injectedFailure === 'boundary-write' || options.injectedFailure === 'boundary-fsync') { - const target = options.injectedFailure === 'boundary-write' ? 'approval_required' : 'final'; - if (event.type === target) fault?.arm(); - } - await commit(facts); - if (event.type === 'error' && event.droppedUserMessage) blockedRollbackDone = true; - if (event.type === 'approval_required' && options.barrierFile) { - await fs.promises.writeFile(options.barrierFile, `${state().lastSeq}\n`, { flag: 'w' }); - await new Promise(() => {}); - } - }, - flush: async () => { - assertHealthy(); - await commit([]); - }, - assertHealthy, - dispose: async () => { - if (disposed) return; - await selectedSession!.close(); - disposed = true; - }, - }; - beforeDecisionPort = port.beforeDecision; - return port; -} - -function failingFileSystem(kind: 'write' | 'fsync') { - let armed = false; - let failed = false; - const fileSystem = new Proxy(fs, { - get(target, property) { - if (property === 'writeSync' && kind === 'write') { - return (...args: Parameters) => { - if (armed && !failed) { - failed = true; - throw new Error('injected C2 write EIO'); - } - return target.writeSync(...args); - }; - } - if (property === 'fsyncSync' && kind === 'fsync') { - return (fd: number) => { - if (armed && !failed) { - failed = true; - throw new Error('injected C2 fsync EIO'); - } - return target.fsyncSync(fd); - }; - } - const value = Reflect.get(target, property, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }) as typeof fs; - return { - fileSystem, - arm: () => { - armed = true; - }, - }; -} - -function sourceDriver( - adapter: ConversationAdapter, - runtime: SessionRuntime, - context: SessionDriverContext, - persistDecision: (input: { answer: string; rejectionReason?: string }) => Promise, -) { - const listeners = new Set<(update: import('../src/session/index.js').SessionUpdate) => void>(); - return { - submit: async ( - requestId: ClientRequestId, - items: readonly import('../src/model/index.js').Item[], - ): Promise => { - const admission = context.state().admissions[requestId]; - if (!admission) throw new Error('source driver received a request without a durable C2 admission'); - const text = items - .map((item) => - item.type === 'user_message' - ? item.content.map((part) => (part.type === 'text' ? part.text : '')).join('') - : '', - ) - .join('\n'); - await adapter.sendMessage(text); - return { kind: 'started', turnId: admission.turnId as TurnId }; - }, - decide: async (decision: SessionDecision) => { - const current = context.state().pendingInteraction; - if ( - !current || - current.turnId !== decision.turnId || - current.interaction.interactionId !== decision.interactionId || - current.interaction.revision !== decision.revision || - current.generation !== decision.generation - ) - throw new Error('stale C2 interaction decision'); - const expectedCallId = current.interaction.descriptor.callId ?? current.interaction.interactionId; - if (decision.decisions.length !== 1 || decision.decisions[0]?.callId !== expectedCallId) { - throw new Error('C2 interaction decision does not match the pending tool call'); - } - const answer = decision.decisions.every((item) => item.approve) ? 'y' : 'n'; - await persistDecision({ answer, rejectionReason: decision.decisions.find((item) => item.reason)?.reason }); - await adapter.handleApprovalDecision(answer, decision.decisions.find((item) => item.reason)?.reason); - }, - cancel: async () => { - adapter.abort(); - }, - retry: async (): Promise => { - if (!runtime.state.retryLastUserTurn()) throw new Error('no user turn is available to retry'); - const requestId = crypto.randomUUID(); - const turnId = crypto.randomUUID(); - const createdAt = new Date().toISOString(); - await context.commit([ - { - type: 'session_admitted', - version: 1, - clientRequestId: requestId, - normalizedBodyHash: normalizedHash(runtime.state.exportState().history), - turnId, - disposition: 'started', - createdAt, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - }, - { type: 'session_turn_started', version: 1, turnId, clientRequestId: requestId }, - ]); - await adapter.sendMessage('', { replayFromHistory: true }); - return { kind: 'started', turnId: turnId as TurnId }; - }, - compact: async () => { - await runtime.compactContext(); - }, - setModel: async (model: import('../src/model/index.js').ModelRef) => { - runtime.settings.setModelSelection(model); - }, - subscribe: (_fromSeq: number, listener: (update: import('../src/session/index.js').SessionUpdate) => void) => { - listeners.add(listener); - const sink = (event: ConversationEvent) => { - for (const callback of listeners) - callback({ - kind: 'live', - turnId: context.state().activeTurnId ?? ('unknown' as TurnId), - event: event as never, - }); - }; - adapter.setEventSink(sink); - return () => { - listeners.delete(listener); - if (!listeners.size) adapter.setEventSink(null); - }; - }, - }; -} - -function normalizedHash(prompt: unknown): string { - const canonical = (value: unknown): unknown => - Array.isArray(value) - ? value.map(canonical) - : value && typeof value === 'object' - ? Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, canonical((value as Record)[key])]), - ) - : value; - return crypto - .createHash('sha256') - .update(JSON.stringify(canonical(prompt))) - .digest('hex'); -} diff --git a/rewrite/bench/c2-storage-failure.mjs b/rewrite/bench/c2-storage-failure.mjs deleted file mode 100644 index 2290e109f..000000000 --- a/rewrite/bench/c2-storage-failure.mjs +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env node -// Controlled append/fsync failure proof through the real noninteractive runner. -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const proofCli = path.join(repo, 'rewrite/bench-dist/rewrite/bench/c2-proof-cli.js'); -if (!fs.existsSync(proofCli)) throw new Error('build the C2 proof entry first'); - -for (const failure of ['write', 'fsync']) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), `term2-c2-${failure}-`)); - const home = path.join(root, 'home'); - const workspace = path.join(root, 'workspace'); - const conversations = path.join(root, 'conversations'); - for (const dir of [home, workspace, path.join(root, 'runtime'), path.join(root, 'tmp')]) - fs.mkdirSync(dir, { recursive: true }); - let providerCalls = 0; - const server = http.createServer((request, response) => { - if (request.method === 'GET') { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ object: 'list', data: [{ id: 'mock-alpha' }] })); - return; - } - providerCalls += 1; - response.writeHead(200, { 'content-type': 'text/event-stream' }); - response.end('data: [DONE]\n\n'); - }); - try { - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('fake provider did not bind'); - const settingsDir = path.join(home, '.local/state/term2-nodejs'); - fs.mkdirSync(settingsDir, { recursive: true }); - fs.writeFileSync( - path.join(settingsDir, 'settings.json'), - JSON.stringify({ - agent: { retryAttempts: 0, modelSelection: { provider: 'mockprov', model: 'mock-alpha' } }, - providers: [ - { - name: 'mockprov', - type: 'openai-compatible', - baseUrl: `http://127.0.0.1:${address.port}/v1`, - apiKey: 'test-key', - }, - ], - sandbox: { enabled: false }, - }), - ); - const env = { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: path.join(home, '.config'), - XDG_STATE_HOME: path.join(home, '.local/state'), - XDG_CACHE_HOME: path.join(home, '.cache'), - XDG_DATA_HOME: path.join(home, '.local/share'), - XDG_RUNTIME_DIR: path.join(root, 'runtime'), - TMPDIR: path.join(root, 'tmp'), - TERM2_CONFIG_DIR: path.join(home, '.term2'), - TERM2_CACHE_DIR: path.join(home, '.cache/term2'), - TERM2_CONVERSATIONS_DIR: conversations, - TERM2_C2_PROOF_FAIL: failure, - DISABLE_LOGGING: '1', - NO_COLOR: '1', - TERM: 'dumb', - }; - const result = await new Promise((resolve, reject) => { - const child = spawn(process.execPath, [proofCli, 'do not continue after storage failure'], { - env, - cwd: workspace, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stderr = ''; - child.stderr.on('data', (chunk) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', (code, signal) => resolve({ code, signal, stderr })); - }); - if (result.code === 0 || result.signal) - throw new Error(`${failure} failure was reported as success or signal: ${JSON.stringify(result)}`); - if (providerCalls !== 0) throw new Error(`${failure} failure allowed ${providerCalls} provider calls`); - const log = fs.readdirSync(conversations).find((name) => name.endsWith('.jsonl')); - if (!log) throw new Error(`${failure} failure did not leave the initial canonical session`); - const logText = fs.readFileSync(path.join(conversations, log), 'utf8'); - const envelopes = logText - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); - const facts = envelopes.flatMap((envelope) => - envelope.event?.type === 'session_fact_batch' - ? envelope.event.entries.map((entry) => entry.event) - : [envelope.event], - ); - if ( - facts.some( - (fact) => fact.type === 'assistant_turn' || fact.type === 'tool_started' || fact.type === 'tool_result', - ) - ) { - throw new Error(`${failure} failure published successful execution facts`); - } - console.log( - JSON.stringify({ - failure, - exitCode: result.code, - providerCalls, - noToolEffect: !fs.existsSync(path.join(workspace, 'effect.txt')), - logFacts: facts.map((fact) => fact.type), - }), - ); - } finally { - await new Promise((resolve) => server.close(resolve)); - fs.rmSync(root, { recursive: true, force: true }); - } -} diff --git a/rewrite/bench/c3a-background-interrupt.mts b/rewrite/bench/c3a-background-interrupt.mts deleted file mode 100644 index cb21000e0..000000000 --- a/rewrite/bench/c3a-background-interrupt.mts +++ /dev/null @@ -1,359 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import { AgentClient } from '../../source/lib/agent-client.ts'; -let capturedClient: any; -const capturedClients: any[] = []; -const originalSink = AgentClient.prototype.setBackgroundSubagentEventSink; -AgentClient.prototype.setBackgroundSubagentEventSink = function (...args: any[]) { - if (args[0]) { - capturedClient = this; - capturedClients.push(this); - } - return originalSink.apply(this, args as any); -}; -import { BackgroundShellRegistry } from '../../source/services/shell/background-shell-registry.ts'; -import assert from 'node:assert/strict'; -import { ModelBehaviorError } from '../../source/contracts/model-errors.ts'; -import { AmbiguousModelOutcomeError, WebSocketReceiveTimeoutError } from '../../source/services/retry/retry-errors.ts'; -import { UnsentWebSocketRequestError } from '../../source/providers/websocket-request-dispatch.ts'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -async function makeGraph(label: string) { - let releaseChild!: () => void, releaseForeground!: () => void; - const childGate = new Promise((r) => (releaseChild = r)), - foregroundGate = new Promise((r) => (releaseForeground = r)); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); - const workspace = path.join(root, 'workspace'); - fs.mkdirSync(workspace); - const target = path.join(workspace, 'effect.txt'); - const mode = 'background'; - const bg = new BackgroundShellRegistry(); - const limit = Number(process.env.RETRY_LIMIT ?? '2'); - let requests: any[] = []; - let budgets: any[] = []; - let budgetSnapshots: any[] = []; - const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ index: requests.length })); - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address() as any; - const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); - const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - budgets.push(request.recoveryBudget); - budgetSnapshots.push( - request.recoveryBudget && { - physical: request.recoveryBudget.physicalAttempts, - replays: request.recoveryBudget.automaticReplays, - started: request.recoveryBudget.startedAt !== undefined, - }, - ); - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - const n = data.index; - console.log( - 'REQUEST', - n, - JSON.stringify({ - input: request.input, - tools: request.tools?.map((x: any) => x.name), - instructions: request.instructions?.slice(0, 80), - }), - ); - if (n === 1) { - const calls = [ - { - type: 'tool_call', - id: 'bg-shell', - name: 'shell', - arguments: JSON.stringify({ - command: `printf '%s' "$$" > '${workspace}/bg.pid'; exec sleep 25`, - sandbox: 'unsandboxed', - background: true, - timeout_ms: 30000, - description: 'test', - }), - }, - { - type: 'tool_call', - id: 'bg-agent', - name: 'run_subagent', - arguments: JSON.stringify({ - execution: 'background', - role: 'explorer', - task: 'Inspect the workspace and report.', - }), - }, - ]; - for (const c of calls) yield c; - yield { type: 'completion', output: calls, responseId: 'root1' }; - } else if (request.tools?.some((x: any) => x.name === 'run_subagent') && n > 3) { - await foregroundGate; - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'late' }] }], - responseId: 'late', - }; - } else if (request.tools?.some((x: any) => x.name === 'run_subagent')) - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'launched' }] }], - responseId: 'root2', - }; - else { - await childGate; - } - }, - }; - }, - }; - const providers = createProviderRegistry(); - providers.upsertProvider(provider); - const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: limit, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'full' } as any, - }, - }); - const readable = Object.keys(host.getAll()) as any; - const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'full']]) as any, - ); - console.log('AUTO_MODE', pair.service.get('shell.autoApproveMode')); - const logger = new LoggingService({ disableLogging: true }); - const context = new SessionContextService(); - const executionContext = new ExecutionContext(); - executionContext.enterWorkspace(workspace); - const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', - }); - const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; - const modelRef: any = { provider: 'openai', model: 'fixture' }; - const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - allowBackgroundShell: true, - backgroundShellRegistry: bg, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - onSourceEvent: (update) => events.push({ kind: 'source', ...update }), - runtime: () => ({ - toolOwnership: new ToolOwnershipRegistry(), - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), - }); - const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - - const events: any[] = []; - session.subscribe(0, (e) => events.push(e)); - async function until(fn: () => any, label: string) { - const end = Date.now() + 15000; - while (Date.now() < end) { - const v = fn(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error(`timeout ${label}: ${JSON.stringify(events)}`); - } - const facts = () => events.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); - const settle = (id: string) => - until( - () => facts().find((f) => f.type === 'session_admission_settled' && f.clientRequestId === id), - `settlement ${id}`, - ); - return { - session, - submission, - bg, - requests, - events, - facts, - settle, - until, - root, - models, - server, - releaseChild, - releaseForeground, - }; -} -async function launch(g: any) { - await g.submission.submit({ requestId: 'request1', input: { text: 'Original input' } }); - const f = await g.until(() => g.facts().find((f: any) => f.type === 'session_interaction_pending'), 'approval'); - await g.submission.decide({ - kind: 'choose', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - decisions: [{ callId: f.interaction.descriptor.callId, choice: { answer: 'approve' } }], - }); - await g.settle('request1'); - await g.until(() => fs.existsSync(path.join(g.root, 'workspace', 'bg.pid')), 'shell pid'); - assert.equal(g.bg.list()[0].status, 'running'); - const runEvent = g.events.find( - (e: any) => - e.kind === 'source' && e.event.type === 'command_message' && e.event.message.toolName === 'run_subagent', - ); - const id = JSON.parse(runEvent.event.message.output).runId; - const client = capturedClients.find((c) => c.getBackgroundSubagentStatus(id).status === 'running'); - assert.ok(client); - return { client, id }; -} -const a = await makeGraph('a'), - b = await makeGraph('b'); -try { - const ca = await launch(a), - cb = await launch(b); - const aCount = a.requests.length, - bCount = b.requests.length; - await a.session.cancel(); - assert.equal(a.bg.list()[0].status, 'running'); - assert.equal(ca.client.getBackgroundSubagentStatus(ca.id).status, 'running'); - assert.equal(b.bg.list()[0].status, 'running'); - assert.equal(cb.client.getBackgroundSubagentStatus(cb.id).status, 'running'); - assert.equal(a.requests.length, aCount); - assert.equal(b.requests.length, bCount); - console.log( - 'PASS TWO_GRAPH_FOREGROUND_CANCEL', - JSON.stringify({ - aJobs: a.bg.list(), - aChild: ca.client.getBackgroundSubagentStatus(ca.id), - bJobs: b.bg.list(), - bChild: cb.client.getBackgroundSubagentStatus(cb.id), - aCount, - bCount, - }), - ); - assert.equal(typeof a.submission.interruptFromUser, 'function'); - { - await a.submission.submit({ requestId: 'held', input: { text: 'hold foreground' } }); - await a.until(() => a.requests.length > aCount, 'held foreground dispatch'); - const aPid = Number(fs.readFileSync(path.join(a.root, 'workspace', 'bg.pid'), 'utf8')); - const foregroundCount = a.requests.length; - let interruptSettled = false; - const interrupt = a.submission.interruptFromUser().finally(() => { - interruptSettled = true; - }); - await a.until( - () => a.bg.list()[0].status !== 'running' && ca.client.getBackgroundSubagentStatus(ca.id).status !== 'running', - 'background cancellation observation', - ); - assert.equal(b.bg.list()[0].status, 'running'); - assert.equal(cb.client.getBackgroundSubagentStatus(cb.id).status, 'running'); - console.log('FOREGROUND_GATE_AT_BACKGROUND_OBSERVATION', JSON.stringify({ interruptSettled })); - a.releaseForeground(); - a.releaseChild(); - await interrupt; - await a.until(() => !['running', 'cancelling'].includes(a.bg.list()[0].status), 'shell terminal'); - await a.until( - () => !['running', 'cancelling'].includes(ca.client.getBackgroundSubagentStatus(ca.id).status), - 'child terminal', - ); - await a.until(() => { - try { - process.kill(aPid, 0); - return false; - } catch (e: any) { - return e.code === 'ESRCH'; - } - }, 'shell process exit'); - assert.equal(a.requests.length, foregroundCount); - assert.equal(b.requests.length, bCount); - console.log( - 'PASS INTERRUPT', - JSON.stringify({ - aJobs: a.bg.list(), - aChild: ca.client.getBackgroundSubagentStatus(ca.id), - bJobs: b.bg.list(), - bChild: cb.client.getBackgroundSubagentStatus(cb.id), - }), - ); - const bPid = Number(fs.readFileSync(path.join(b.root, 'workspace', 'bg.pid'), 'utf8')); - await b.submission.interruptFromUser(); - b.releaseChild(); - await b.until(() => !['running', 'cancelling'].includes(b.bg.list()[0].status), 'idle shell terminal'); - await b.until( - () => !['running', 'cancelling'].includes(cb.client.getBackgroundSubagentStatus(cb.id).status), - 'idle child terminal', - ); - await b.until(() => { - try { - process.kill(bPid, 0); - return false; - } catch (e: any) { - return e.code === 'ESRCH'; - } - }, 'idle shell process exit'); - assert.equal(b.requests.length, bCount); - console.log( - 'PASS IDLE_INTERRUPT', - JSON.stringify({ jobs: b.bg.list(), child: cb.client.getBackgroundSubagentStatus(cb.id) }), - ); - } -} finally { - a.releaseChild(); - b.releaseChild(); - a.releaseForeground(); - b.releaseForeground(); - await Promise.allSettled([a.session.close(), b.session.close()]); - await Promise.allSettled([a.models.dispose(), b.models.dispose()]); - a.server.close(); - b.server.close(); - fs.rmSync(a.root, { recursive: true, force: true }); - fs.rmSync(b.root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-budget.mts b/rewrite/bench/c3a-budget.mts deleted file mode 100644 index 8773081a6..000000000 --- a/rewrite/bench/c3a-budget.mts +++ /dev/null @@ -1,232 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { AgentClient } from '../../source/lib/agent-client.ts'; -import { sanitizePendingInteractionDto, projectPendingInteraction } from '../../source/gateway/interaction-protocol.ts'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -const mode = process.env.PROBE_MODE || 'human'; -let failFsync = false; -const fileSystem = new Proxy(fs, { - get(target, key) { - if (key === 'fsyncSync') - return (fd: number) => { - if (failFsync) { - failFsync = false; - throw new Error('injected budget fsync'); - } - return target.fsyncSync(fd); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -let requests: any[] = []; -const provider: any = { - id: 'openai', - label: 'Fixture', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - const n = requests.length; - const call = { type: 'tool_call', id: `call${n}`, name: 'missing_fixture_tool', arguments: '{}' }; - yield call; - yield { type: 'completion', usage: { inputTokens: 11, outputTokens: 3 }, output: [call], responseId: `r${n}` }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { - escalation: 'contain', - maxUnpricedTokens: 14, - maxParentExtensions: 0, - extensionPercent: 100, - warningHeadroomUnpricedTokens: 0, - softHeadroomUnpricedTokens: 0, - }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }, new Map([['shell.autoApproveMode', 'off']]) as any); - -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const ownership = new ToolOwnershipRegistry(); -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - fileSystem, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - -const events: any[] = []; -session.subscribe(0, (e) => events.push(e)); -async function until(predicate: () => any, label: string) { - const end = Date.now() + 12000; - while (Date.now() < end) { - const v = predicate(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error(`timeout waiting ${label}: ${JSON.stringify(events)}`); -} -const facts = () => events.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -const pending = async (n: number) => - until(() => facts().filter((f) => f.type === 'session_interaction_pending')[n], `pending ${n}`); -const settle = async (id: string) => - until( - () => facts().find((f) => f.type === 'session_admission_settled' && f.clientRequestId === id), - `settlement ${id}`, - ); -const choose = async (f: any, answer: string, extra: any = {}) => - submission.decide({ - kind: 'choose', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - decisions: [{ callId: f.interaction.descriptor.callId, choice: { answer, ...extra } }], - }); -const nav = async (f: any, direction: string) => - submission.decide({ - kind: 'navigate', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - direction, - }); -try { - const result = await submission.submit({ requestId: 'request1', input: { text: 'Carry out request' } }); - assert.equal(result.kind, 'started'); - const first = await pending(0); - assert.equal(first.interaction.variant, 'run_budget'); - assert.deepEqual(sanitizePendingInteractionDto(first.interaction).descriptor.runBudgetEvidence, { - used: 14, - limit: 14, - headroom: 0, - }); - const malformed = structuredClone(first.interaction); - malformed.descriptor.argumentsText += '\t'; - assert.throws(() => sanitizePendingInteractionDto(malformed)); - malformed.descriptor.argumentsText = first.interaction.descriptor.argumentsText; - malformed.descriptor.runBudgetEvidence.used = -1; - assert.throws(() => sanitizePendingInteractionDto(malformed)); - const ordinary = { agentName: 'System', toolName: 'x', argumentsText: 'line\nline' }; - assert.throws(() => projectPendingInteraction(ordinary, 'x', 1)); - assert.equal(requests.length, 1); - await assert.rejects(choose({ ...first, generation: 'stale' }, 'continue')); - if (mode === 'refusal') { - const original = AgentClient.prototype.grantRunBudgetExtension; - AgentClient.prototype.grantRunBudgetExtension = () => ({ granted: false, extensionsGranted: 0 }); - try { - await assert.rejects(choose(first, 'continue'), /No further budget extension is available/); - assert.equal(facts().filter((f) => f.type === 'session_interaction_resolved').length, 0); - assert.equal(session.state().pendingInteraction?.interaction.interactionId, first.interaction.interactionId); - assert.equal(requests.length, 1); - } finally { - AgentClient.prototype.grantRunBudgetExtension = original; - } - await choose(first, 'stop'); - await settle('request1'); - assert.equal(requests.length, 1); - console.log('PASS budget refusal and stop'); - } else if (mode === 'fault') { - failFsync = true; - await assert.rejects(choose(first, 'continue')); - assert.equal(requests.length, 1); - await assert.rejects(submission.submit({ requestId: 'after', input: { text: 'after fault' } })); - assert.equal(facts().filter((f) => f.type === 'session_interaction_resolved').length, 0); - console.log('PASS budget fsync fail closed'); - } else { - await choose(first, 'continue'); - const second = await pending(1); - assert.notEqual(second.interaction.interactionId, first.interaction.interactionId); - await assert.rejects(choose(first, 'continue')); - await choose(second, 'continue'); - const third = await pending(2); - await choose(third, 'stop'); - await settle('request1'); - assert.equal(facts().filter((f) => f.type === 'session_interaction_resolved').length, 3); - assert.equal(requests.length, 3); - console.log('PASS actual human budget', JSON.stringify({ requests: requests.length, pending: 3 })); - } -} finally { - if (mode !== 'fault') await session.cancel(); - try { - await session.close(); - } catch (error) { - if (mode !== 'fault') throw error; - } - if (mode === 'fault') { - const { session: reopened } = await store.openAdopted(session.id); - assert.equal(reopened.state().status, 'interrupted'); - await reopened.close(); - } - await models.dispose(); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-capture.mts b/rewrite/bench/c3a-capture.mts deleted file mode 100644 index ac6e2e9fc..000000000 --- a/rewrite/bench/c3a-capture.mts +++ /dev/null @@ -1,243 +0,0 @@ -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ kind: 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - assert.equal(data.kind, 'done'); - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphs = 0; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - graphs++; - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -session.subscribe(session.state().lastSeq, (update) => { - if (update.kind === 'fact') (update.entry as any).seq = 999999; -}); -const received: any[] = []; -const suffix: any[] = []; -let nested: ReturnType | undefined; -const first = session.capture((item) => { - received.push(item); - if (!nested) nested = session.capture((next) => suffix.push(next)); -}); -assert.equal(first.position.position, 0); -const retained = structuredClone(first.state); -const throwing = session.capture(() => { - throw new Error('observer'); -}); -let stoppedCalls = 0; -const stopped = session.capture(() => { - stoppedCalls++; - stopped.unsubscribe(); -}); - -let resolveDone: () => void; -const done = new Promise((resolve) => { - resolveDone = resolve; -}); -session.subscribe(0, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolveDone(); -}); -const result = await submission.submit({ - requestId: 'request1' as any, - input: { - text: 'Hello from adopted session', - images: [ - { id: 'one', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }, - { id: 'two', data: 'd29ybGQ=', mimeType: 'image/jpeg', byteSize: 5, displayNumber: 2 }, - ], - skill: { name: 'fixture', description: 'desc', body: 'SKILL-BODY' }, - }, -}); -assert.equal(result.kind, 'started'); -await done; -assert.equal(session.state().status, 'idle'); -assert.deepEqual(first.state, retained, 'capture state stays immutable across commits'); -assert.ok(received.some((x) => x.update.kind === 'fact')); -assert.ok( - received.filter((x) => x.update.kind === 'fact').every((x) => x.update.entry.seq < 999999), - 'legacy listeners cannot mutate captured updates', -); -assert.ok(received.some((x) => x.update.kind === 'live')); -const admitted = received.find((x) => x.update.kind === 'fact' && x.update.entry.fact.type === 'session_admitted'); -const started = received.find((x) => x.update.kind === 'fact' && x.update.entry.fact.type === 'session_turn_started'); -assert.ok( - admitted && started && started.position.position > admitted.position.position, - 'private admission advances capture before later visible turn', -); -assert.ok( - started.update.entry.seq > admitted.update.entry.seq, - 'private sequence is retained across public projection gaps', -); -assert.equal(stoppedCalls, 1); -assert.ok(nested); -assert.ok(suffix.every((x) => x.position.position > nested!.position.position)); -assert.ok( - received.every((x, i) => x.position.generation === first.position.generation && x.position.position === i + 1), -); -assert.equal(nested!.state.lastSeq >= received[0].update.entry.seq, true); -first.unsubscribe(); -throwing.unsubscribe(); -stopped.unsubscribe(); -nested!.unsubscribe(); -assert.equal(requests.length, 1); -assert.ok(JSON.stringify(requests[0].input).includes('Hello from adopted session')); -assert.ok(JSON.stringify(requests[0].input).includes('SKILL-BODY')); -assert.ok(JSON.stringify(requests[0].input).includes('aGVsbG8=')); -assert.ok(JSON.stringify(requests[0].input).includes('d29ybGQ=')); -const id = session.id; -await session.close(); -const reopenedHandle = await store.openAdopted(id); -const reopened = reopenedHandle.session; -const fresh = reopened.capture(() => {}); -assert.notEqual(fresh.position.generation, first.position.generation); -assert.equal(fresh.position.position, 0); -fresh.unsubscribe(); -assert.equal(graphs, 2); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('Hello from adopted session')); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('aGVsbG8=')); -assert.ok(JSON.stringify(reopened.state().messages).includes('SKILL-BODY')); -const followupDone = new Promise((resolve) => { - reopened.subscribe(reopened.state().lastSeq, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolve(); - }); -}); -await reopenedHandle.submission.submit({ requestId: 'request2' as any, input: { text: 'Follow up' } }); -await followupDone; -assert.equal(requests.length, 2); -assert.ok(JSON.stringify(requests[1].input).includes('Hello from adopted session')); -assert.ok(JSON.stringify(requests[1].input).includes('Bench complete')); -await reopened.close(); -// A recovery-only reader may have no live driver. Capture still sees canonical writes. -const recoveryStore = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => ({} as any), -}); -const recovered = await recoveryStore.open(id); -const recoveryUpdates: any[] = []; -const recoveryCapture = recovered.capture((update) => recoveryUpdates.push(update)); -const recoverySeq = recoveryCapture.state.lastSeq; -await recovered.closeLog(); -assert.equal(recoveryUpdates.length, 1); -assert.equal(recoveryUpdates[0].update.kind, 'fact'); -assert.equal(recoveryUpdates[0].update.entry.fact.type, 'session_closed'); -assert.ok(recoveryUpdates[0].update.entry.seq > recoverySeq); -assert.equal(recoveryUpdates[0].position.position, recoveryCapture.position.position + 1); -recoveryCapture.unsubscribe(); -await recovered.close(); -await models.dispose(); -server.close(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS Session capture ordered fact/live, reentrant capture, listener isolation and new generation'); diff --git a/rewrite/bench/c3a-compaction.mts b/rewrite/bench/c3a-compaction.mts deleted file mode 100644 index f2e21dfc3..000000000 --- a/rewrite/bench/c3a-compaction.mts +++ /dev/null @@ -1,1081 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { AgentClient } from '../../source/lib/agent-client.ts'; -import { AgentChatFailure } from '../../source/lib/agent-chat-service.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { - decodeLogEnvelope, - validateCompactionSequence, -} from '../../source/services/conversation/conversation-decoder.ts'; -import { replayConversationEvents } from '../../source/services/conversation/conversation-replay.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { sanitizeLogFact } from '../../source/services/logging/conversation-log-writer.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createNetworkAuthority } from '../src/session/adopted/network-authority.ts'; -import { projectNetworkFacts } from '../src/session/adopted/network.ts'; -import { LocalSessionStore } from '../src/session/local-store.ts'; - -const requestedMode = process.env.PROBE_MODE; -const modes = requestedMode ? [requestedMode] : ['local', 'accounting', 'native', 'interleaving']; - -function makeHarness(options: { - providerId?: string; - modelId?: string; - streamHandler: (request: any, callIndex: number) => AsyncGenerator; - chatDetailedHandler?: (prompt: string, chatOptions: any) => Promise; - compactCodexHandler?: (history: readonly any[], signal?: AbortSignal) => Promise; - onSourceEvent?: (update: any) => void; - workspaceSetup?: (workspaceDir: string) => void; -}) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c3a-compaction-')); - const workspace = path.join(root, 'workspace'); - fs.mkdirSync(workspace, { recursive: true }); - options.workspaceSetup?.(workspace); - - const providerId = options.providerId ?? 'openai'; - const modelId = options.modelId ?? 'gpt-5'; - let failFsync = false; - const fileSystem = new Proxy(fs, { - get(target, key) { - if (key === 'fsyncSync') { - return (fd: number) => { - if (failFsync) { - failFsync = false; - throw new Error('injected compaction fsync failure'); - } - return target.fsyncSync(fd); - }; - } - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }); - - const requests: any[] = []; - const provider: any = { - id: providerId, - label: 'CompactionFixture', - fetchModels: async () => [{ id: modelId, name: modelId, contextWindow: 128_000, maxOutputTokens: 16_384 }], - capabilities: { supportsConversationChaining: true }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - yield* options.streamHandler(request, requests.length); - }, - }; - }, - }; - const providers = createProviderRegistry(); - providers.upsertProvider(provider); - if (providerId !== 'grok') { - providers.upsertProvider({ - id: 'grok', - label: 'GrokFixture', - fetchModels: async () => [{ id: 'grok-4', name: 'grok-4', contextWindow: 128_000, maxOutputTokens: 16_384 }], - capabilities: { supportsConversationChaining: true }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - yield* options.streamHandler(request, requests.length); - }, - }; - }, - } as any); - } - - const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: providerId, model: modelId }, - contextCompaction: { enabled: false, compactThreshold: 0.8 }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'always' } as any, - }, - }); - const readable = Object.keys(host.getAll()) as any; - const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'always']]) as any, - ); - - const logger = new LoggingService({ disableLogging: true }); - const context = new SessionContextService(); - const executionContext = new ExecutionContext(); - executionContext.enterWorkspace(workspace); - const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', - }); - const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; - const modelRef: any = { provider: providerId, model: modelId }; - const ownership = new ToolOwnershipRegistry(); - - const makeStore = (sourceEventSink?: (update: any) => void) => - new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - fileSystem, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - onSourceEvent: sourceEventSink ?? options.onSourceEvent, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: providerId, model: modelId }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), - }); - - return { - root, - workspace, - sessionsRoot: path.join(root, 'sessions'), - workspaceConfig, - modelRef, - requests, - setFailFsync(value: boolean) { - failFsync = value; - }, - makeStore, - }; -} - -async function waitForSettledAdmission(events: any[], requestId: string) { - const deadline = Date.now() + 12_000; - while (Date.now() < deadline) { - if ( - events.some( - (e) => - e.kind === 'fact' && - e.entry.fact.type === 'session_admission_settled' && - e.entry.fact.clientRequestId === requestId, - ) - ) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`Timed out waiting for admission ${requestId} to settle`); -} - -async function runTurn(submission: any, events: any[], requestId: string, input: any) { - const res = await submission.submit({ requestId, input }); - assert.equal(res.kind, 'started'); - await waitForSettledAdmission(events, requestId); -} - -async function testLocalMode() { - const sourceEvents: any[] = []; - let summaryCalls = 0; - const origCreateGen = (AgentClient.prototype as any).createContextSummaryGenerator; - const origChatDetailed = AgentClient.prototype.chatDetailed; - (AgentClient.prototype as any).createContextSummaryGenerator = undefined; - AgentClient.prototype.chatDetailed = async function (prompt: string) { - summaryCalls += 1; - return { - text: `Summary checkpoint #${summaryCalls}: preserved cold context.`, - usage: { prompt_tokens: 110, completion_tokens: 40, total_tokens: 150 }, - costRecords: [ - { - requestId: 'compaction-req-1', - provider: 'openai', - model: 'gpt-5', - serviceTier: 'standard', - outcome: 'completed', - usage: { prompt_tokens: 110, completion_tokens: 40, total_tokens: 150 }, - usdMicros: 420, - source: 'catalog', - pricingVersion: 'test-v1', - }, - // Duplicate requestId must be deduplicated within one operation - { - requestId: 'compaction-req-1', - provider: 'openai', - model: 'gpt-5', - serviceTier: 'standard', - outcome: 'completed', - usage: { prompt_tokens: 110, completion_tokens: 40, total_tokens: 150 }, - usdMicros: 420, - source: 'catalog', - pricingVersion: 'test-v1', - }, - // Credit-signed provider cost record with distinct requestId must be preserved - { - requestId: 'compaction-req-credit', - provider: 'openai', - model: 'gpt-5', - serviceTier: 'standard', - outcome: 'completed', - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - usdMicros: -50, - source: 'provider', - }, - ], - }; - }; - - try { - const harness = makeHarness({ - onSourceEvent: (update) => sourceEvents.push(update), - streamHandler: async function* (_req, idx) { - yield { - type: 'completion', - output: [ - { - type: 'message', - content: [{ type: 'text', text: `assistant reply ${idx} ` + 'detail '.repeat(250) }], - }, - ], - responseId: `resp-${idx}`, - usage: { inputTokens: 250, outputTokens: 250 }, - }; - }, - }); - const store = harness.makeStore(); - const { session, submission } = await store.createAdopted(harness.workspaceConfig, harness.modelRef); - const events: any[] = []; - session.subscribe(0, (e) => events.push(e)); - - // 1. Pre-start refusal when < 3 complete turns exist (2 newest turns are hot tail) - await runTurn(submission!, events, 'req-1', { - text: 'turn 1 literal JSON {"nestedRunResult":{"preserve":true}}', - }); - const earlyCompact = await submission!.compact(); - assert.equal(earlyCompact.kind, 'nothing_to_compact'); - assert.equal( - sourceEvents.filter((e) => e.event.type.startsWith('context_compaction')).length, - 0, - 'pre-start refusal must emit no compaction frames', - ); - - // 2. Complete turns 2 and 3 (turn 3 carries multimodal image + skill) - await runTurn(submission!, events, 'req-2', { text: 'turn 2 body' }); - const img: TurnImage = { - id: 'img-1', - data: Buffer.from('hello-image').toString('base64'), - mimeType: 'image/png', - byteSize: 11, - displayNumber: 1, - }; - const skill = { name: 'demo-skill', description: 'desc', body: 'Follow demo-skill rules.' }; - await runTurn(submission!, events, 'req-3', { - text: 'turn 3 with image and skill', - images: [img], - skill, - }); - - // 3. Run local manual compaction via submission.compact() - sourceEvents.length = 0; - const outcome = await submission!.compact(); - assert.equal(outcome.kind, 'compacted', `${outcome.kind}: ${outcome.message}`); - assert.equal(summaryCalls, 1); - - // Verify source event order: started -> usage_update -> cost_update (x2 deduplicated) -> completed - const compactionEventTypes = sourceEvents.map((u) => u.event.type); - assert.deepEqual(compactionEventTypes, [ - 'context_compaction_started', - 'usage_update', - 'cost_update', - 'cost_update', - 'context_compaction_completed', - ]); - - // Verify canonical log facts: context_checkpoint_created and context_compaction_recorded - const logEntries = CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id); - const checkpointEntries = logEntries.filter((e) => e.fact.type === 'context_checkpoint_created'); - const recordedEntries = logEntries.filter((e) => e.fact.type === 'context_compaction_recorded'); - assert.equal(checkpointEntries.length, 1); - assert.equal(recordedEntries.length, 1); - assert.ok(checkpointEntries[0]!.seq < recordedEntries[0]!.seq); - - const recordedFact = recordedEntries[0]!.fact as any; - assert.equal(recordedFact.strategy, 'local'); - assert.equal(recordedFact.provider, 'openai'); - assert.equal(recordedFact.model, 'gpt-5'); - assert.equal(recordedFact.result.kind, 'compacted'); - assert.equal(recordedFact.result.snapshot.previousResponseId, null); - assert.equal(recordedFact.result.snapshot.provider, 'openai'); - assert.equal(recordedFact.result.snapshot.model, 'gpt-5'); - assert.deepEqual(recordedFact.usage, { - prompt_tokens: 110, - completion_tokens: 40, - total_tokens: 150, - }); - assert.equal(recordedFact.costRecords.length, 2); - assert.equal(recordedFact.costRecords[0].requestId, 'compaction-req-1'); - assert.equal(recordedFact.costRecords[1].requestId, 'compaction-req-credit'); - assert.equal(recordedFact.costRecords[1].usdMicros, -50); - - // Verify literal user JSON {"nestedRunResult":{"preserve":true}} survived sanitizeLogFact in snapshot - assert.ok( - recordedFact.result.snapshot.history.some( - (item: any) => - typeof item?.content === 'string' && item.content.includes('{"nestedRunResult":{"preserve":true}}'), - ), - 'genuine user JSON with nestedRunResult key must be preserved in compaction snapshot', - ); - - // 4. Cold reopen in a fresh store and verify state, chain reset, and retry preservation - await session.close(); - const store2 = harness.makeStore(); - const reopened = await store2.openAdopted(session.id); - const restoredConv = loadCanonicalConversation(session.id, harness.sessionsRoot); - assert.equal(restoredConv.previousResponseId, null); - assert.equal(reopened.session.state().messages.filter((m: any) => m.sender === 'user').length, 3); - assert.deepEqual(reopened.session.state().retryEligibility, { turn: true, tool: false }); - assert.ok(restoredConv.costRecords?.some((r) => r.requestId === 'compaction-req-credit' && r.usdMicros === -50)); - - // Next turn after reopen must dispatch with previousResponseId === undefined and compacted history - const reopenedEvents: any[] = []; - reopened.session.subscribe(0, (e) => reopenedEvents.push(e)); - const reqCountBefore = harness.requests.length; - await runTurn(reopened.submission!, reopenedEvents, 'req-4', { text: 'turn 4 after compaction' }); - assert.equal(harness.requests.length, reqCountBefore + 1); - const postCompactionReq = harness.requests.at(-1); - assert.equal(postCompactionReq.previousResponseId, undefined); - assert.ok( - JSON.stringify(postCompactionReq.input).includes('Summary checkpoint #1'), - 'next request after cold reopen must include compacted summary checkpoint', - ); - - // Retry turn after compaction must still use canonical submittedTurn - const retryRes = await reopened.submission!.retry('turn'); - assert.equal(retryRes.kind, 'started'); - const deadline = Date.now() + 12_000; - while (Date.now() < deadline && reopened.session.state().status !== 'idle') { - await new Promise((r) => setTimeout(r, 10)); - } - assert.equal(reopened.session.state().status, 'idle'); - await reopened.session.close(); - console.log('PASS compaction local mode'); - } finally { - (AgentClient.prototype as any).createContextSummaryGenerator = origCreateGen; - AgentClient.prototype.chatDetailed = origChatDetailed; - } -} - -type TurnImage = { - id: string; - data: string; - mimeType: string; - byteSize: number; - displayNumber: number; -}; - -async function testAccountingMode() { - const origCreateGen = (AgentClient.prototype as any).createContextSummaryGenerator; - const origChatDetailed = AgentClient.prototype.chatDetailed; - (AgentClient.prototype as any).createContextSummaryGenerator = undefined; - - try { - let behavior: 'zero-usage' | 'absent-usage' | 'partial-throw' | 'no-accounting-fail' = 'zero-usage'; - let chunkIndex = 0; - AgentClient.prototype.chatDetailed = async function () { - chunkIndex += 1; - if (behavior === 'zero-usage') { - return { - text: 'Short summary.', - usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - }; - } - if (behavior === 'absent-usage') { - return { - text: 'Short summary without usage.', - }; - } - if (behavior === 'partial-throw') { - throw new AgentChatFailure( - [ - { - requestId: 'failed-chunk-cost-1', - provider: 'openai', - model: 'gpt-5', - serviceTier: 'standard', - outcome: 'failed', - usdMicros: 125, - source: 'provider', - }, - ], - new Error('upstream summary failure after billing'), - ); - } - throw new Error('upstream summary failure without accounting'); - }; - - const sourceEvents: any[] = []; - const harness = makeHarness({ - onSourceEvent: (u) => sourceEvents.push(u), - streamHandler: async function* (_req, idx) { - yield { - type: 'completion', - output: [ - { - type: 'message', - content: [{ type: 'text', text: `Long assistant response ${idx}: ` + 'word '.repeat(250) }], - }, - ], - responseId: `resp-${idx}`, - usage: { inputTokens: 300, outputTokens: 250 }, - }; - }, - }); - const store = harness.makeStore(); - const { session, submission } = await store.createAdopted(harness.workspaceConfig, harness.modelRef); - const events: any[] = []; - session.subscribe(0, (e) => events.push(e)); - - for (let i = 1; i <= 4; i += 1) { - await runTurn(submission!, events, `acct-req-${i}`, { - text: `User message ${i}: ` + 'context '.repeat(200), - }); - } - - // 1. Reported zero usage is preserved on context_compaction_recorded - behavior = 'zero-usage'; - const resZero = await submission!.compact(); - assert.equal(resZero.kind, 'compacted'); - let logEntries = CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id); - let compFacts = logEntries.filter((e) => e.fact.type === 'context_compaction_recorded').map((e) => e.fact as any); - assert.equal(compFacts.length, 1); - assert.deepEqual(compFacts[0].usage, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); - assert.equal(Object.hasOwn(compFacts[0], 'costRecords'), false); - - // Add 2 more turns so another cold turn exists - await runTurn(submission!, events, 'acct-req-5', { text: 'User message 5: ' + 'context '.repeat(200) }); - await runTurn(submission!, events, 'acct-req-6', { text: 'User message 6: ' + 'context '.repeat(200) }); - - // 2. Absent usage is omitted from context_compaction_recorded (not synthesized as zero) - behavior = 'absent-usage'; - const resAbsent = await submission!.compact(); - assert.equal(resAbsent.kind, 'compacted'); - logEntries = CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id); - compFacts = logEntries.filter((e) => e.fact.type === 'context_compaction_recorded').map((e) => e.fact as any); - assert.equal(compFacts.length, 2); - assert.equal(Object.hasOwn(compFacts[1], 'usage'), false); - assert.equal(Object.hasOwn(compFacts[1], 'costRecords'), false); - - await runTurn(submission!, events, 'acct-req-7', { text: 'User message 7: ' + 'context '.repeat(200) }); - await runTurn(submission!, events, 'acct-req-8', { text: 'User message 8: ' + 'context '.repeat(200) }); - - // 3. Throw with AgentChatFailure costRecords persists not_compacted with costRecords before rethrowing - behavior = 'partial-throw'; - sourceEvents.length = 0; - await assert.rejects(submission!.compact(), /upstream summary failure after billing/); - logEntries = CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id); - compFacts = logEntries.filter((e) => e.fact.type === 'context_compaction_recorded').map((e) => e.fact as any); - assert.equal(compFacts.length, 3); - assert.deepEqual(compFacts[2].result, { kind: 'not_compacted' }); - assert.equal(compFacts[2].costRecords.length, 1); - assert.equal(compFacts[2].costRecords[0].requestId, 'failed-chunk-cost-1'); - assert.deepEqual( - sourceEvents.map((u) => u.event.type), - ['context_compaction_started', 'context_compaction_failed'], - ); - - // 4. Throw with no reported accounting writes no durable fact - behavior = 'no-accounting-fail'; - await assert.rejects(submission!.compact(), /upstream summary failure without accounting/); - logEntries = CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id); - compFacts = logEntries.filter((e) => e.fact.type === 'context_compaction_recorded').map((e) => e.fact as any); - assert.equal(compFacts.length, 3); - - await session.close(); - console.log('PASS compaction accounting mode'); - } finally { - (AgentClient.prototype as any).createContextSummaryGenerator = origCreateGen; - AgentClient.prototype.chatDetailed = origChatDetailed; - } -} - -async function testNativeMode() { - const origCompactCodex = (AgentClient.prototype as any).compactCodexSessionHistory; - try { - let codexBehavior: 'unchanged' | 'failed' | 'compacted' = 'unchanged'; - (AgentClient.prototype as any).compactCodexSessionHistory = async function ( - _history: readonly any[], - signal?: AbortSignal, - ) { - signal?.throwIfAborted(); - if (codexBehavior === 'unchanged') return { kind: 'unchanged' as const }; - if (codexBehavior === 'failed') return { kind: 'failed' as const, provider: 'codex' }; - return { - kind: 'compacted' as const, - history: [ - { - type: 'provider_opaque', - provider: 'codex', - item: { - type: 'compaction', - encrypted_content: '{"nestedRunResult":"keep-opaque-encrypted-bytes"}', - }, - }, - { - type: 'message', - role: 'user', - content: 'compacted native user summary item', - }, - { - type: 'function_call', - callId: 'native-retained-call', - name: 'view_file', - arguments: '{"path":"a.txt"}', - }, - { - type: 'function_call_output', - callId: 'native-retained-call', - output: 'retained output', - }, - ], - }; - }; - - const sourceEvents: any[] = []; - let turnNum = 0; - const harness = makeHarness({ - providerId: 'codex', - modelId: 'gpt-5-codex', - onSourceEvent: (u) => sourceEvents.push(u), - workspaceSetup(ws) { - fs.writeFileSync(path.join(ws, 'sample.txt'), 'line 1\nline 2\n', 'utf8'); - }, - streamHandler: async function* () { - turnNum += 1; - if (turnNum === 1 || turnNum === 3) { - const call = { - type: 'tool_call', - id: 'shared-call-1', - name: 'view_file', - arguments: JSON.stringify({ path: 'sample.txt' }), - }; - yield call; - yield { - type: 'completion', - output: [call], - responseId: `codex-call-${turnNum}`, - usage: { inputTokens: 15, outputTokens: 5 }, - }; - return; - } - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: `codex answer ${turnNum}` }] }], - responseId: `codex-resp-${turnNum}`, - usage: { inputTokens: 20, outputTokens: 10 }, - }; - }, - }); - - const store = harness.makeStore(); - const { session, submission } = await store.createAdopted(harness.workspaceConfig, harness.modelRef); - const events: any[] = []; - session.subscribe(0, (e) => events.push(e)); - - // Turn 1 executes a real tool call (view_file) and completes on Turn 2 of the stream - await runTurn(submission!, events, 'codex-turn-1', { text: 'inspect sample.txt' }); - assert.deepEqual(session.state().retryEligibility, { turn: true, tool: true }); - - // 1. Native 'unchanged' -> native_unavailable, no durable fact - codexBehavior = 'unchanged'; - const unchangedRes = await submission!.compact(); - assert.deepEqual(unchangedRes, { - kind: 'failed', - message: 'Native context compaction is unavailable for this model.', - reason: 'native_unavailable', - }); - assert.equal( - CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id).filter( - (e) => e.fact.type === 'context_compaction_recorded', - ).length, - 0, - ); - - // 2. Native 'failed' -> native_failed, no durable fact - codexBehavior = 'failed'; - const failedRes = await submission!.compact(); - assert.deepEqual(failedRes, { - kind: 'failed', - message: 'Native context compaction failed.', - reason: 'native_failed', - }); - assert.equal( - CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id).filter( - (e) => e.fact.type === 'context_compaction_recorded', - ).length, - 0, - ); - - // 3. Native 'compacted' with direct provider_opaque item and covered toolLedger occurrence - codexBehavior = 'compacted'; - const compactedRes = await submission!.compact(); - assert.equal(compactedRes.kind, 'compacted'); - - const logEntries = CanonicalSessionLog.readOnly(harness.sessionsRoot, session.id); - assert.equal( - logEntries.filter((e) => e.fact.type === 'context_checkpoint_created').length, - 0, - 'native compaction must not manufacture a local checkpoint provenance event', - ); - const compFacts = logEntries.filter((e) => e.fact.type === 'context_compaction_recorded').map((e) => e.fact as any); - assert.equal(compFacts.length, 1); - assert.equal(compFacts[0].strategy, 'native'); - assert.equal(Object.hasOwn(compFacts[0], 'usage'), false); - assert.equal(Object.hasOwn(compFacts[0], 'costRecords'), false); - assert.equal(compFacts[0].result.kind, 'compacted'); - // Verify opaque encrypted_content with nestedRunResult substring was NOT corrupted by sanitizeLogFact - assert.equal( - compFacts[0].result.snapshot.history[0].item.encrypted_content, - '{"nestedRunResult":"keep-opaque-encrypted-bytes"}', - ); - // Verify pre-compaction toolLedger entry 'shared-call-1' was NOT re-injected into snapshot.history - assert.equal( - compFacts[0].result.snapshot.history.some((item: any) => item.callId === 'shared-call-1'), - false, - 'covered pre-compaction tool occurrence must not be re-inserted into native compacted history', - ); - - // Immediately after native compaction, retryEligibility must be { turn: false, tool: false } - // even though native replacement history contains a user message and function_call_output! - assert.deepEqual(session.state().retryEligibility, { turn: false, tool: false }); - assert.deepEqual(await submission!.retry('turn'), { kind: 'ineligible', target: 'turn' }); - assert.deepEqual(await submission!.retry('tool'), { kind: 'ineligible', target: 'tool' }); - - // Cold reopen after native compaction preserves coveredToolOccurrences, provider_opaque, and retryEligibility - await session.close(); - const store2 = harness.makeStore(); - const reopened = await store2.openAdopted(session.id); - assert.deepEqual(reopened.session.state().retryEligibility, { turn: false, tool: false }); - assert.equal( - reopened.session.state().providerHistory.some((item: any) => item.callId === 'shared-call-1'), - false, - 'cold reopen must not re-insert covered pre-compaction tool occurrence', - ); - - // 4. Next turn after native compaction adapts direct provider_opaque item for codex and executes a new tool call reusing 'shared-call-1' - const reopenedEvents: any[] = []; - reopened.session.subscribe(0, (e) => reopenedEvents.push(e)); - const reqBefore = harness.requests.length; - await runTurn(reopened.submission!, reopenedEvents, 'codex-turn-2', { - text: 'post-compaction user turn with tool', - }); - assert.ok(harness.requests.length > reqBefore); - const firstPostCompactReq = harness.requests[reqBefore]; - assert.ok( - firstPostCompactReq.input.some( - (item: any) => - item.type === 'provider_opaque' && - item.provider === 'codex' && - item.item?.type === 'compaction' && - item.item?.encrypted_content === '{"nestedRunResult":"keep-opaque-encrypted-bytes"}', - ), - 'direct provider_opaque item must be adapted to StreamedModelTurnInput on next request', - ); - - // Now that a genuine post-compaction user+tool turn completed, retryEligibility is { turn: true, tool: true }! - assert.deepEqual(reopened.session.state().retryEligibility, { turn: true, tool: true }); - const toolRetry = await reopened.submission!.retry('tool'); - assert.equal(toolRetry.kind, 'started'); - const deadline = Date.now() + 12_000; - while (Date.now() < deadline && reopened.session.state().status !== 'idle') { - await new Promise((r) => setTimeout(r, 10)); - } - assert.equal(reopened.session.state().status, 'idle'); - - // Foreign-provider switch refuses direct provider_opaque codex compaction item before model invocation - const modelSwitch = await reopened.submission!.setRuntimeSettings({ - kind: 'model', - selection: { provider: 'grok', model: 'grok-4' }, - }); - assert.equal(modelSwitch.kind, 'applied'); - const reqCountBeforeGrok = harness.requests.length; - await runTurn(reopened.submission!, reopenedEvents, 'grok-refusal-turn', { - text: 'attempt grok turn with codex opaque compaction item', - }); - const refusalDeadline = Date.now() + 5_000; - while ( - Date.now() < refusalDeadline && - !sourceEvents.some((u: any) => u.event?.type === 'error' && u.event?.kind === 'foreign_provider_opaque_history') - ) { - await new Promise((r) => setTimeout(r, 10)); - } - assert.equal( - harness.requests.length, - reqCountBeforeGrok, - 'foreign provider turn must be refused before model invocation', - ); - assert.ok( - sourceEvents.some( - (u: any) => - u.event?.type === 'error' && - u.event?.kind === 'foreign_provider_opaque_history' && - String(u.event?.message ?? '').includes('from codex to grok'), - ), - 'foreign provider refusal must emit foreign_provider_opaque_history error', - ); - await reopened.session.close(); - - // 5. Verify sanitizeLogFact scrubs actual tool result nestedRunResult while preserving user/opaque strings - const sanitizedAssistant = sanitizeLogFact({ - type: 'assistant_turn', - turn: { - items: [ - { - type: 'provider_opaque', - provider: 'codex', - item: { type: 'compaction', encrypted_content: '{"nestedRunResult":"preserve-opaque"}' }, - }, - ], - }, - providerHistory: [ - { role: 'user', type: 'message', content: '{"nestedRunResult":"preserve-user"}' }, - { - type: 'function_call_output', - callId: 'c1', - output: JSON.stringify({ ok: true, nestedRunResult: { secret: 'scrub-me' } }), - }, - ], - } as any) as any; - assert.equal(sanitizedAssistant.turn.items[0].item.encrypted_content, '{"nestedRunResult":"preserve-opaque"}'); - assert.equal(sanitizedAssistant.providerHistory[0].content, '{"nestedRunResult":"preserve-user"}'); - assert.equal( - JSON.parse(sanitizedAssistant.providerHistory[1].output).nestedRunResult, - undefined, - 'tool output nestedRunResult must be scrubbed', - ); - - console.log('PASS compaction native mode'); - } finally { - (AgentClient.prototype as any).compactCodexSessionHistory = origCompactCodex; - } -} - -async function testInterleavingMode() { - const origCreateGen = (AgentClient.prototype as any).createContextSummaryGenerator; - const origChatDetailed = AgentClient.prototype.chatDetailed; - (AgentClient.prototype as any).createContextSummaryGenerator = undefined; - - let holdSummary = false; - let summaryStartedResolve: (() => void) | undefined; - let releaseSummary: (() => void) | undefined; - AgentClient.prototype.chatDetailed = async function (_prompt: string, chatOptions: any) { - if (holdSummary) { - summaryStartedResolve?.(); - await new Promise((resolve, reject) => { - releaseSummary = resolve; - chatOptions?.signal?.addEventListener( - 'abort', - () => { - const err = new Error('Context compaction cancelled.'); - err.name = 'AbortError'; - reject(err); - }, - { once: true }, - ); - }); - } - return { - text: 'Interleaved summary checkpoint.', - usage: { prompt_tokens: 50, completion_tokens: 20, total_tokens: 70 }, - }; - }; - - try { - // 1. Post-preparation prompt refresh (21:53 UTC Correction 2): - // In a workspace with AGENTS.md, #prepareStart refreshes instructions after makeInput. - const harness = makeHarness({ - providerId: 'openai', - modelId: 'gpt-5', - workspaceSetup(ws) { - fs.writeFileSync(path.join(ws, 'AGENTS.md'), '# Custom Workspace Instructions\nAlways be concise.\n', 'utf8'); - }, - streamHandler: async function* (_req, idx) { - yield { - type: 'completion', - output: [ - { - type: 'message', - content: [{ type: 'text', text: `reply ${idx} ` + 'detail '.repeat(250) }], - }, - ], - responseId: `r-${idx}`, - usage: { inputTokens: 250, outputTokens: 250 }, - }; - }, - }); - const store = harness.makeStore(); - const { session, submission } = await store.createAdopted(harness.workspaceConfig, harness.modelRef); - const events: any[] = []; - session.subscribe(0, (e) => events.push(e)); - - // Modify AGENTS.md before first turn so #prepareStart rebuilds the agent prompt after makeInput() - fs.writeFileSync( - path.join(harness.workspace, 'AGENTS.md'), - '# Updated Workspace Instructions\nChanged after session creation.\n', - 'utf8', - ); - await runTurn(submission!, events, 'refresh-1', { text: 'turn 1 with refreshed AGENTS.md' }); - assert.ok( - String(harness.requests[0]?.instructions ?? '').includes('Updated Workspace Instructions'), - 'prepared turn must refresh selectedInput.instructions to match rebuilt agent instructions', - ); - await runTurn(submission!, events, 'refresh-2', { text: 'turn 2' }); - await runTurn(submission!, events, 'refresh-3', { text: 'turn 3' }); - - // Codex getApplicationAgent returns a copy, so a reference check against - // agentBuild.agent would skip the refresh and fail the next turn. - const codexHarness = makeHarness({ - providerId: 'codex', - modelId: 'gpt-5', - workspaceSetup(ws) { - fs.writeFileSync(path.join(ws, 'AGENTS.md'), '# Codex Workspace Instructions\nBe brief.\n', 'utf8'); - }, - streamHandler: async function* (_req, idx) { - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: `codex reply ${idx}` }] }], - responseId: `codex-${idx}`, - usage: { inputTokens: 20, outputTokens: 10 }, - }; - }, - }); - const codexStore = codexHarness.makeStore(); - const codexSession = await codexStore.createAdopted(codexHarness.workspaceConfig, codexHarness.modelRef); - const codexEvents: any[] = []; - codexSession.session.subscribe(0, (e) => codexEvents.push(e)); - fs.writeFileSync( - path.join(codexHarness.workspace, 'AGENTS.md'), - '# Updated Codex Workspace Instructions\nChanged after session creation.\n', - 'utf8', - ); - await runTurn(codexSession.submission!, codexEvents, 'codex-refresh-1', { - text: 'codex turn after AGENTS.md change', - }); - assert.equal(codexSession.session.state().status, 'idle'); - assert.ok( - String(codexHarness.requests[0]?.instructions ?? '').includes('Updated Codex Workspace Instructions'), - 'Codex post-preparation refresh must accept the rebuilt workspace prompt', - ); - - // 2. Cancellation while compaction is in flight + in-process duplicate join - holdSummary = true; - const summaryStarted = new Promise((resolve) => { - summaryStartedResolve = resolve; - }); - const compactPromise1 = submission!.compact(); - const compactPromise2 = submission!.compact(); - assert.equal(compactPromise1, compactPromise2, 'in-process duplicate compact() must join existing promise'); - await summaryStarted; - await submission!.interruptFromUser(); - const cancelledOutcome = await compactPromise1; - assert.deepEqual(cancelledOutcome, { - kind: 'failed', - message: 'Context compaction cancelled.', - reason: 'cancelled', - }); - - // 3. N/M Network Authority reservation & exact request identity (21:53 UTC Correction 1) - const netCommits: any[] = []; - let netState: any = { status: 'idle' }; - const netAuth = createNetworkAuthority( - { - id: 'net-sess' as any, - root: harness.sessionsRoot, - log: {} as any, - state: () => netState, - reserveSettings: () => () => {}, - poison: () => {}, - assertHealthy: () => {}, - commit: async (facts) => { - netCommits.push(...facts); - return []; - }, - }, - () => {}, - ); - const releaseNet = netAuth.reserveForCompaction(); - assert.ok(releaseNet, 'idle network authority must grant compaction reservation'); - assert.equal(netAuth.reserveForCompaction(), null, 'overlapping compaction reservation must be refused'); - const originalReq = { host: 'example.com', port: 443 }; - let netResolved = false; - const approvalPromise = netAuth - .requestApproval(originalReq, Promise.resolve({ kind: 'applied' as const }), new AbortController().signal) - .then((ans) => { - netResolved = true; - return ans; - }); - await new Promise((r) => setTimeout(r, 20)); - assert.equal( - netCommits.length, - 0, - 'network request must wait outside serial queue while compaction holds reservation', - ); - assert.equal(netAuth.hasPending(), true, 'waiting network request must report pending'); - // Release compaction reservation: waiting network request must now enter serial queue and commit session_network_requested - releaseNet!(); - const deadline = Date.now() + 5_000; - while (Date.now() < deadline && !netAuth.getSnapshot().active) { - await new Promise((r) => setTimeout(r, 10)); - } - const snap = netAuth.getSnapshot().active; - assert.ok(snap); - assert.equal(netCommits.length, 1); - assert.equal(netCommits[0].type, 'session_network_requested'); - await netAuth.decide({ requestId: snap.requestId, generation: snap.generation, answer: 'allow-once' }); - assert.equal(await approvalPromise, 'allow-once'); - assert.equal(netResolved, true); - - // Also verify closing network authority while compaction holds a waiter denies & wakes the waiter with exact request identity - const cancelledReqs: any[] = []; - const netAuth2 = createNetworkAuthority( - { - id: 'net-sess-2' as any, - root: harness.sessionsRoot, - log: {} as any, - state: () => netState, - reserveSettings: () => () => {}, - poison: () => {}, - assertHealthy: () => {}, - commit: async () => [], - }, - () => {}, - (req, reason) => cancelledReqs.push({ req, reason }), - ); - const releaseNet2 = netAuth2.reserveForCompaction()!; - const heldReq = { host: 'held.example.com', port: 8443 }; - const heldPromise = netAuth2.requestApproval( - heldReq, - Promise.resolve({ kind: 'applied' as const }), - new AbortController().signal, - ); - await netAuth2.close(); - assert.equal(await heldPromise, 'deny'); - assert.equal(cancelledReqs.length, 1); - assert.equal(cancelledReqs[0].req, heldReq, 'cancelled callback must receive the exact original request reference'); - releaseNet2(); - - // 4. Temporal and structural validation negative cases - assert.throws( - () => - projectNetworkFacts([ - { - type: 'session_bound', - version: 1, - workspace: harness.workspaceConfig, - model: { provider: 'codex', model: 'gpt-5-codex' }, - }, - { - type: 'session_settings_initialized', - version: 1, - model: { provider: 'codex', model: 'gpt-5-codex' }, - reasoningEffort: 'default', - }, - { - type: 'session_network_requested', - version: 1, - requestId: 'nr-1', - generation: 'g-1', - host: 'example.com', - }, - { - type: 'context_compaction_recorded', - version: 1, - operationId: 'op-1', - provider: 'codex', - model: 'gpt-5-codex', - strategy: 'local', - result: { kind: 'not_compacted' }, - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - /context_compaction_recorded while network approval is pending/, - ); - assert.throws( - () => - validateCompactionSequence( - [ - { - type: 'context_compaction_recorded', - version: 1, - operationId: 'op-unbound', - provider: 'codex', - model: 'gpt-5-codex', - strategy: 'local', - result: { kind: 'not_compacted' }, - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } as any, - ], - { requireSessionBound: true }, - ), - /before session binding/, - ); - assert.equal( - decodeLogEnvelope({ - v: 1, - seq: 1, - ts: new Date().toISOString(), - event: { - type: 'context_compaction_recorded', - version: 1, - operationId: 'op-empty-not-compacted', - provider: 'openai', - model: 'gpt-5', - strategy: 'local', - result: { kind: 'not_compacted' }, - }, - }), - null, - 'not_compacted without accounting must be rejected by decoder', - ); - - // 5. Fsync failure during compaction poisons the handle and suppresses completion - holdSummary = false; - harness.setFailFsync(true); - await assert.rejects(submission!.compact(), (err: any) => - String(err?.cause?.message ?? err?.message ?? '').includes('injected compaction fsync failure'), - ); - await assert.rejects(submission!.submit({ requestId: 'after-poison', input: { text: 'no' } })); - await assert.rejects(submission!.compact()); - await session.close().catch(() => {}); - - console.log('PASS compaction interleaving mode'); - } finally { - (AgentClient.prototype as any).createContextSummaryGenerator = origCreateGen; - AgentClient.prototype.chatDetailed = origChatDetailed; - } -} - -for (const mode of modes) { - if (mode === 'local') await testLocalMode(); - else if (mode === 'accounting') await testAccountingMode(); - else if (mode === 'native') await testNativeMode(); - else if (mode === 'interleaving') await testInterleavingMode(); - else throw new Error(`Unknown PROBE_MODE: ${mode}`); -} diff --git a/rewrite/bench/c3a-foreground.mts b/rewrite/bench/c3a-foreground.mts deleted file mode 100644 index fb2912189..000000000 --- a/rewrite/bench/c3a-foreground.mts +++ /dev/null @@ -1,184 +0,0 @@ -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ kind: 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - assert.equal(data.kind, 'done'); - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -let client: any; -const { engine, tools, turnPrompt } = createBenchEngine({ - bindSessionClient(value) { - client = value; - }, - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, -}); -assert.ok(tools.original('shell'), 'source shell assembled'); - -let foreground: any; -let runtime: any; -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => ({ - submit: (...args: any[]) => foreground.driver.submit(...args), - decide: (...args: any[]) => foreground.driver.decide(...args), - cancel: () => foreground.driver.cancel(), - retry: () => foreground.driver.retry(), - compact: () => foreground.driver.compact(), - setModel: (...args: any[]) => foreground.driver.setModel(...args), - subscribe: (...args: any[]) => foreground.driver.subscribe(...args), - }), - adoptedSubmissionFactory: (ctx) => { - runtime = createSessionRuntimeInternals({ - sessionId: ctx.id, - agentClient: client, - toolOwnership: new ToolOwnershipRegistry(), - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }); - foreground = createAdoptedForeground({ - context: ctx, - engine, - runtime, - onSourceEvent: async () => { - throw new Error('observer failure must not alter durable completion'); - }, - restored: loadCanonicalConversation(ctx.id, path.join(root, 'sessions')), - makeInput: (turnId) => ({ - turnId, - history: [], - newItems: [], - instructions: turnPrompt().instructions, - model: modelRef, - toolNames: [], - workspace: workspaceConfig, - budget: { kind: 'adopted' }, - }), - }); - return foreground.submission; - }, -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -let resolveDone: () => void; -const done = new Promise((resolve) => { - resolveDone = resolve; -}); -session.subscribe(0, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolveDone(); -}); -const result = await submission.submit({ - requestId: 'request1' as any, - input: { - text: 'Hello from adopted session', - images: [ - { id: 'one', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }, - { id: 'two', data: 'd29ybGQ=', mimeType: 'image/jpeg', byteSize: 5, displayNumber: 2 }, - ], - skill: { name: 'fixture', description: 'desc', body: 'SKILL-BODY' }, - }, -}); -assert.equal(result.kind, 'started'); -await done; -assert.equal(session.state().status, 'idle'); -assert.equal(requests.length, 1); -assert.ok(JSON.stringify(requests[0].input).includes('Hello from adopted session')); -assert.ok(JSON.stringify(requests[0].input).includes('SKILL-BODY')); -assert.ok(JSON.stringify(requests[0].input).includes('aGVsbG8=')); -assert.ok(JSON.stringify(requests[0].input).includes('d29ybGQ=')); -const id = session.id; -await session.close(); -await engine.dispose(); -const reopened = await store.open(id); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('Hello from adopted session')); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('aGVsbG8=')); -assert.ok(JSON.stringify(reopened.state().messages).includes('SKILL-BODY')); -await reopened.close(); -await models.dispose(); -server.close(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS actual Session + source graph + engine plain turn and reopen'); diff --git a/rewrite/bench/c3a-goal.mts b/rewrite/bench/c3a-goal.mts deleted file mode 100644 index d853e3fff..000000000 --- a/rewrite/bench/c3a-goal.mts +++ /dev/null @@ -1,333 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -// Goal slice evidence through the real app factory, owned AgentClient and canonical writer. -// PROBE_MODE: set | fsync | achieve | propose-approve | propose-reject | propose-race -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; - -const mode = process.env.PROBE_MODE ?? 'set'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c3a-goal-')); -const sessionsRoot = path.join(root, 'sessions'); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); - -// Each provider request takes the next scripted response; the input is kept for assertions. -const requests: { tools: string[]; input: unknown }[] = []; -let script: ('final' | { name: string; args: object })[] = []; -const provider: any = { - id: 'openai', - label: 'fixture', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push({ tools: (request.tools ?? []).map((tool: any) => tool.name), input: request.input }); - const next = script.shift() ?? 'final'; - if (next === 'final') { - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }], - responseId: `r${requests.length}`, - }; - return; - } - const call = { - type: 'tool_call', - id: `call${requests.length}`, - name: next.name, - arguments: JSON.stringify(next.args), - }; - yield call; - yield { type: 'completion', output: [call], responseId: `r${requests.length}` }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - settingsDir: path.join(root, 'settings'), - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - completionVerification: false, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - // Proposals need Pair's user-interaction capability; Bare deliberately omits it. - // Keep the other modes on default Bare to exercise its goal control/check path. - // With shell on, propose_goal is reachable only inside run_code, which refuses - // approval-requiring tools (old CLI behavior); the shell toggle makes it direct. - ...(mode.startsWith('propose') - ? { app: { activeProfileId: 'builtin:pair' }, tools: { shell: { enabled: false } } as any } - : {}), - }, -}); -const pair = source.forSessionBinding( - { readable: Object.keys(host.getAll()) as any, writable: [] }, - new Map([['shell.autoApproveMode', 'off']]) as any, -); -const logger = new LoggingService({ disableLogging: true }); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: new SessionContextService(), - providerRegistry: providers, - ownership: 'borrowed', -}); - -let failGoalFsync = false; -let goalWritten = false; -const faultFs = new Proxy(fs, { - get(target, key) { - if (key === 'writeSync') - return (...args: any[]) => { - if (String(args[1]).includes('"type":"goal_changed"')) goalWritten = true; - return (fs.writeSync as any)(...args); - }; - if (key === 'fsyncSync') - return (...args: any[]) => { - if (failGoalFsync && goalWritten) throw new Error('injected goal fsync'); - return (fs.fsyncSync as any)(...args); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); - -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -function makeStore() { - let ownership: ToolOwnershipRegistry; - let graphContext: SessionContextService; - return new LocalSessionStore({ - fileSystem: faultFs, - root: sessionsRoot, - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - ownership = new ToolOwnershipRegistry(); - graphContext = new SessionContextService(); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: graphContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), - }); -} - -async function until(read: () => T | undefined, label: string): Promise { - const end = Date.now() + 10000; - while (Date.now() < end) { - const value = read(); - if (value) return value; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`timeout waiting for ${label}`); -} -const goalFacts = (id: string) => - CanonicalSessionLog.readOnly(sessionsRoot, id) - .filter((entry: any) => entry.fact.type === 'goal_changed') - .map((entry: any) => entry); -const goal = (id: string, outcome = 'Ship the goal slice', status = 'active') => ({ id, outcome, status } as any); - -async function open(id?: string) { - const store = makeStore(); - const opened: any = id ? await (store as any).openAdopted(id) : await store.createAdopted(workspaceConfig, modelRef); - const facts: any[] = []; - opened.session.subscribe(0, (event: any) => { - if (event.kind === 'fact') facts.push(event.entry.fact); - }); - const settled = (requestId: string) => - until( - () => facts.find((fact) => fact.type === 'session_admission_settled' && fact.clientRequestId === requestId), - `settlement ${requestId}`, - ); - const pending = (index: number) => - until(() => facts.filter((fact) => fact.type === 'session_interaction_pending')[index], `pending ${index}`); - const choose = (fact: any, answer: string) => - opened.submission.decide({ - kind: 'choose', - turnId: fact.turnId, - interactionId: fact.interaction.interactionId, - revision: fact.interaction.revision, - generation: fact.generation, - decisions: [{ callId: fact.interaction.descriptor.callId, choice: { answer } }], - }); - return { ...opened, facts, settled, pending, choose }; -} -const propose = (outcome: string) => ({ name: 'propose_goal', args: { outcome } }); -const lastToolOutput = () => JSON.stringify(requests.at(-1)?.input ?? []); - -try { - if (mode === 'set') { - const first = await open(); - const control = first.submission.goal!; - assert.deepEqual(control.get(), { goal: undefined, revision: null }); - assert.equal(await control.set(goal('g1'), null), 'applied'); - const afterFirst = control.get(); - assert.deepEqual(afterFirst.goal, goal('g1')); - assert.ok(afterFirst.revision?.logId && afterFirst.revision.eventId); - assert.equal(await control.set(goal('g2'), null), 'stale', 'null is not a wildcard'); - assert.equal(goalFacts(first.session.id).length, 1, 'stale set appends nothing'); - assert.equal(await control.set(goal('g1'), afterFirst.revision), 'applied', 'identical content, exact ref'); - const afterSame = control.get(); - assert.notDeepEqual(afterSame.revision, afterFirst.revision, 'a repeated goal gets a new event identity'); - assert.equal(await control.set(goal('g3'), afterFirst.revision), 'stale', 'old ref is stale after same-value set'); - await assert.rejects(control.set({ id: '', outcome: 'x', status: 'active' } as any, afterSame.revision)); - await assert.rejects(control.set({ ...goal('g4'), extra: true } as any, afterSame.revision)); - await assert.rejects(control.set(goal('g4'), { logId: afterSame.revision!.logId } as any)); - assert.equal(goalFacts(first.session.id).length, 2, 'invalid commands append nothing'); - const racers = await Promise.all([ - control.set(goal('race-a'), afterSame.revision), - control.set(goal('race-b'), afterSame.revision), - ]); - assert.deepEqual([...racers].sort(), ['applied', 'stale'], 'concurrent setters: exactly one applies'); - const final = control.get(); - assert.equal(final.goal!.id, racers[0] === 'applied' ? 'race-a' : 'race-b'); - const id = first.session.id; - await first.session.close(); - const reopened = await open(id); - assert.deepEqual(reopened.submission.goal!.get(), final, 'cold open restores the exact goal and ref'); - await reopened.session.closeLog(); - await assert.rejects(reopened.submission.goal!.set(goal('late'), final.revision), /Session handle is closed/); - const closedTypes = CanonicalSessionLog.readOnly(sessionsRoot, id).map((entry: any) => entry.fact.type); - assert.equal(closedTypes.at(-1), 'session_closed', 'no goal is appended after the log closes'); - await reopened.session.close(); - console.log('PASS set: null/exact/stale/same-value/invalid/concurrent/reopen/closed'); - } else if (mode === 'fsync') { - const first = await open(); - failGoalFsync = true; - await assert.rejects( - first.submission.goal!.set(goal('g1'), null), - (error: any) => error.code === 'fatal_persistence' && /injected goal fsync/.test(String(error.cause)), - ); - assert.deepEqual(first.submission.goal!.get(), { goal: undefined, revision: null }, 'no read before fsync'); - failGoalFsync = false; - await assert.rejects(first.submission.goal!.set(goal('g2'), null), 'goal writer stays poisoned'); - await assert.rejects(first.submission.submit({ requestId: 'after', input: { text: 'hi' } }), 'session poisoned'); - assert.equal(requests.length, 0); - await first.session.close().catch(() => {}); - console.log('PASS fsync: failed goal append poisons the session and is never acknowledged'); - } else if (mode === 'achieve') { - const first = await open(); - assert.equal(await first.submission.goal!.set(goal('g1'), null), 'applied'); - script = [{ name: 'goal_check', args: { status: 'achieved', evidence: 'The slice is built.' } }, 'final']; - await first.submission.submit({ requestId: 't1', input: { text: 'finish it' } }); - await first.settled('t1'); - assert.ok(requests[0]!.tools.includes('goal_check'), 'adopted root agent exposes goal_check'); - const written = goalFacts(first.session.id); - assert.equal(written.length, 2, 'one achievement write'); - assert.deepEqual(written[1].fact, { - type: 'goal_changed', - version: 1, - goal: goal('g1', 'Ship the goal slice', 'achieved'), - source: 'goal_check', - }); - const types = CanonicalSessionLog.readOnly(sessionsRoot, first.session.id).map((entry: any) => entry.fact.type); - const achievedAt = types.lastIndexOf('goal_changed'); - assert.ok(achievedAt < types.lastIndexOf('session_turn_ended'), 'achievement is ordered inside the turn'); - assert.ok( - types.slice(types.lastIndexOf('session_turn_started'), achievedAt).includes('assistant_journal_item'), - `buffered turn facts are flushed before the achievement: ${types.join(',')}`, - ); - assert.equal(first.submission.goal!.get().goal!.status, 'achieved'); - await first.session.close(); - console.log('PASS achieve: recorded goal_check closes the goal once, durably, before the turn ends'); - } else if (mode === 'propose-approve' || mode === 'propose-race') { - const first = await open(); - script = [propose('Proposed outcome'), 'final']; - await first.submission.submit({ requestId: 't1', input: { text: 'start building' } }); - const prompt = await first.pending(0); - assert.ok(requests[0]!.tools.includes('propose_goal'), `propose_goal offered: ${requests[0]!.tools}`); - assert.equal(prompt.interaction.descriptor.toolName, 'propose_goal'); - if (mode === 'propose-race') { - assert.equal(await first.submission.goal!.set(goal('manual'), null), 'applied'); - } - await first.choose(prompt, 'approve'); - await first.settled('t1'); - const written = goalFacts(first.session.id); - if (mode === 'propose-race') { - assert.equal(written.length, 1, 'the manual goal wins; the proposal adds nothing'); - assert.equal(written[0].fact.goal.id, 'manual'); - // The tool's own execute-time check refuses first. The authority's - // compare-and-append guard behind it is not reachable from a bench. - assert.match(lastToolOutput(), /propose_goal refused because a durable goal is already set/); - assert.doesNotMatch(lastToolOutput(), /Goal proposed and persisted/); - console.log('PASS propose-race: a manual goal set while the proposal waits wins, with no success text'); - } else { - assert.equal(written.length, 1); - assert.equal(written[0].fact.goal.outcome, 'Proposed outcome'); - assert.equal(written[0].fact.source, undefined); - assert.match(lastToolOutput(), /Goal proposed and persisted/); - console.log('PASS propose-approve: approved proposal appends one goal before its success text'); - } - await first.session.close(); - } else if (mode === 'propose-reject') { - const first = await open(); - script = [propose('Proposed outcome'), 'final']; - await first.submission.submit({ requestId: 't1', input: { text: 'start building' } }); - await first.choose(await first.pending(0), 'reject'); - await first.settled('t1'); - assert.equal(goalFacts(first.session.id).length, 0, 'rejection changes nothing'); - const id = first.session.id; - await first.session.close(); - const replayed = loadCanonicalConversation(id, sessionsRoot).history as any[]; - assert.ok( - replayed.some((item) => item.type === 'function_call' && item.name === 'propose_goal'), - 'the rejected call is durable in canonical replay', - ); - const reopened = await open(id); - script = [propose('Second try'), 'final']; - await reopened.submission.submit({ requestId: 't2', input: { text: 'keep going' } }); - await reopened.settled('t2'); - const secondTurn = reopened.facts.find((fact) => fact.type === 'session_admitted' && fact.clientRequestId === 't2'); - assert.equal( - reopened.facts.filter((fact) => fact.type === 'session_interaction_pending' && fact.turnId === secondTurn.turnId) - .length, - 0, - 'the refused proposal never reaches the approval surface', - ); - assert.match(lastToolOutput(), /a proposal was already made this session/); - assert.equal(goalFacts(id).length, 0); - await reopened.session.close(); - console.log('PASS propose-reject: a rejected proposal survives reopen and refuses a second one unprompted'); - } else throw new Error(`unknown PROBE_MODE ${mode}`); -} finally { - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-held-tool.mts b/rewrite/bench/c3a-held-tool.mts deleted file mode 100644 index c6d1c4403..000000000 --- a/rewrite/bench/c3a-held-tool.mts +++ /dev/null @@ -1,283 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { AgentClient } from '../../source/lib/agent-client.ts'; -let runSettled = false; -const originalRun = AgentClient.prototype.runTurn; -AgentClient.prototype.runTurn = async function (...args: any[]) { - try { - return await originalRun.apply(this, args as any); - } finally { - runSettled = true; - } -}; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -const mode = 'noncooperative'; -let release: any; -const gate = new Promise((r) => (release = r)); -let invoked = false, - finished = false; -const postExecutePauseCapability: any = { - setActiveRunId() {}, - forTool(def: any) { - if (def.name !== 'shell') return; - return async (ctx: any) => { - invoked = true; - await gate; - finished = true; - fs.writeFileSync(path.join(workspace, 'late'), 'released'); - return ctx.result; - }; - }, -}; -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ kind: requests.length <= (mode === 'repeat' ? 2 : 1) ? 'call' : 'done', index: requests.length }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: `call${data.index}`, - name: 'shell', - arguments: - mode === 'ask-malformed' - ? JSON.stringify({ questions: [{ question: 'Bad', options: [{ label: 'Only' }] }] }) - : mode.startsWith('ask') || mode === 'batch-stop' - ? JSON.stringify({ - questions: [ - { question: 'First?', options: [{ label: 'Alpha' }, { label: 'Beta' }] }, - { question: 'Second?', options: [{ label: 'Red' }, { label: 'Blue' }], is_multi_select: true }, - ], - }) - : rawArgs, - }; - yield call; - const secondCall = - mode === 'batch-stop' - ? { type: 'tool_call', id: 'call2', name: 'shell', arguments: rawArgs } - : mode.startsWith('batch') - ? { ...call, id: 'call2' } - : null; - if (secondCall) yield secondCall; - yield { - type: 'completion', - usage: { inputTokens: 11, outputTokens: 3 }, - output: secondCall ? [call, secondCall] : [call], - responseId: `r${data.index}`, - }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'done', - usage: { inputTokens: 17, outputTokens: 5 }, - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }, new Map([['shell.autoApproveMode', 'off']]) as any); -console.log('AUTO_MODE', pair.service.get('shell.autoApproveMode')); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - postExecutePauseCapability, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: new ToolOwnershipRegistry(), - postExecutePauseCapability, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - -const events: any[] = []; -session.subscribe(0, (e) => events.push(e)); -const facts = () => events.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -async function until(f: () => any, label: string) { - const deadline = Date.now() + 12000; - while (Date.now() < deadline) { - const v = f(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error(`timeout ${label} ${JSON.stringify(events)}`); -} -try { - await submission.submit({ requestId: 'one', input: { text: 'Run held tool' } }); - let seen = 0; - while (!invoked) { - const p = await until( - () => facts().filter((f) => f.type === 'session_interaction_pending')[seen] || invoked, - 'approval or invoke', - ); - if (invoked) break; - seen++; - await submission.decide({ - kind: 'choose', - turnId: p.turnId, - interactionId: p.interaction.interactionId, - revision: p.interaction.revision, - generation: p.generation, - decisions: [ - { - callId: p.interaction.descriptor.callId, - choice: { answer: p.interaction.choices.find((c) => ['approve', 'allow-once'].includes(c.id)).id }, - }, - ], - }); - } - assert.ok(facts().some((f) => f.type === 'tool_started')); - let cancelled = false; - const cancellation = session.cancel().then(() => { - cancelled = true; - }); - let successorDone = false; - const successor = submission.submit({ requestId: 'two', input: { text: 'Fresh successor' } }).then((x) => { - successorDone = true; - return x; - }); - await new Promise((r) => setTimeout(r, 200)); - assert.equal(finished, false); - assert.equal(cancelled, false); - assert.equal(runSettled, false); - assert.equal(successorDone, false); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - assert.equal(requests.length, 1); - assert.equal( - facts().some((f) => f.type === 'tool_result'), - false, - ); - assert.equal( - facts().some((f) => f.type === 'session_admitted' && f.clientRequestId === 'two'), - false, - ); - release(); - await cancellation; - await successor; - await until( - () => facts().find((f) => f.type === 'session_admission_settled' && f.clientRequestId === 'two'), - 'successor settlement', - ); - assert.equal(requests.length, 2); - assert.equal(finished, true); - assert.equal(fs.readFileSync(path.join(workspace, 'late'), 'utf8'), 'released'); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - assert.equal(facts().filter((f) => f.type === 'session_turn_ended').length, 2); - for (const [id, outcome, result] of [ - ['one', 'cancelled', 'aborted'], - ['two', 'completed', 'accepted'], - ]) { - const admission = facts().find((f) => f.type === 'session_admitted' && f.clientRequestId === id); - assert.ok(admission); - assert.equal( - facts().filter((f) => f.type === 'session_admission_settled' && f.clientRequestId === id && f.result === result) - .length, - 1, - ); - assert.equal( - facts().filter((f) => f.type === 'session_turn_ended' && f.turnId === admission.turnId && f.outcome === outcome) - .length, - 1, - ); - if (id === 'one') - assert.equal( - events.filter((e) => e.kind === 'live' && e.turnId === admission.turnId && e.event.type === 'turn_ended') - .length, - 0, - ); - } - console.log('PASS noncooperative', JSON.stringify({ facts: facts(), events, requests })); -} finally { - release(); - await session.close(); - await models.dispose(); - server.close(); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-history-commands.mts b/rewrite/bench/c3a-history-commands.mts deleted file mode 100644 index 468b853fd..000000000 --- a/rewrite/bench/c3a-history-commands.mts +++ /dev/null @@ -1,226 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { CanonicalSessionLog } from '../src/session/adopted/conversation-log.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { SessionLifecycle } from '../../source/services/session/session-lifecycle.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c3a-history-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const requests: any[] = []; -const mode = process.env.PROBE_MODE ?? 'basic'; -let undoCalls = 0; -const undoOriginal = SessionLifecycle.prototype.undoLastUserTurn; -SessionLifecycle.prototype.undoLastUserTurn = function () { - undoCalls++; - return undoOriginal.call(this); -}; -let undoWritten = false; -const faultFs = new Proxy(fs, { - get(target, key) { - if (key === 'writeSync') - return (...args: any[]) => { - if (String(args[1]).includes('"type":"undo"')) undoWritten = true; - return (fs.writeSync as any)(...args); - }; - if (key === 'fsyncSync') - return (...args: any[]) => { - if (mode === 'fsync' && undoWritten) throw new Error('injected undo fsync'); - return (fs.fsyncSync as any)(...args); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -let release!: () => void; -const gate = new Promise((resolve) => { - release = resolve; -}); -const provider: any = { - id: 'openai', - label: 'fixture', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request.input); - if (mode === 'held' && requests.length === 3) await gate; - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }], - responseId: 'fixture', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const pair = source.forSessionBinding({ readable: Object.keys(host.getAll()) as any, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: new SessionContextService(), - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let ownership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - fileSystem: faultFs, - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - ownership = new ToolOwnershipRegistry(); - graphContext = new SessionContextService(); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: graphContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -const updates: any[] = []; -session.subscribe(0, (event) => updates.push(event)); -async function send(id: string, text: string, images?: any[]) { - await submission.submit({ requestId: id, input: { text, ...(images ? { images } : {}) } }); - const until = Date.now() + 10000; - while ( - !updates.some( - (e) => - e.kind === 'fact' && e.entry.fact.type === 'session_admission_settled' && e.entry.fact.clientRequestId === id, - ) - ) { - if (Date.now() > until) throw new Error('settlement timeout'); - await new Promise((r) => setTimeout(r, 10)); - } -} -try { - await send('a', 'same'); - const images = [{ id: 'image', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }]; - await send('b', 'same', images); - if (mode === 'fsync') { - await assert.rejects(submission.history({ kind: 'undo_last' }), /injected|Persistence/); - assert.equal(undoCalls, 1); - await assert.rejects(submission.history({ kind: 'undo_last' }), /injected|Persistence/); - assert.equal(undoCalls, 1, 'poison prevents second source mutation'); - assert.equal(requests.length, 2); - try { - await session.close(); - } catch { - /* writer remains poisoned */ - } - console.log('PASS fsync poison prevents success and repeat mutation'); - } else if (mode === 'held') { - await submission.submit({ requestId: 'held', input: { text: 'held' } }); - const until = Date.now() + 10000; - while (requests.length !== 3) { - if (Date.now() > until) throw new Error('held request timeout'); - await new Promise((r) => setTimeout(r, 10)); - } - const before = JSON.stringify(session.state()); - assert.equal((await submission.history({ kind: 'rewind', targetId: 'bad' })).kind, 'stale'); - assert.equal(JSON.stringify(session.state()), before, 'invalid target does not cancel held owner'); - const heldTargets = await submission.listRewindTargets(); - let settled = false; - const undo = submission.history({ kind: 'rewind', targetId: heldTargets[0]!.id }); - const duplicate = submission.history({ kind: 'rewind', targetId: heldTargets[0]!.id }); - assert.equal(duplicate, undo, 'duplicate command shares in-flight result'); - void undo.then(() => { - settled = true; - }); - await new Promise((r) => setTimeout(r, 100)); - assert.equal(settled, false, 'accepted command waits for physical provider settlement'); - release(); - assert.deepEqual(await undo, { kind: 'applied', restored: { text: 'same' } }); - assert.equal((await submission.listRewindTargets()).length, 0); - const log = CanonicalSessionLog.readOnly(path.join(root, 'sessions'), session.id); - assert.equal(log.filter((entry: any) => entry.fact.type === 'undo').length, 1); - await session.close(); - console.log('PASS held owner stale no-op and accepted physical settlement'); - } else { - const targets = await submission.listRewindTargets(); - assert.equal(targets.length, 2); - const state = JSON.stringify(session.state()); - assert.equal((await submission.history({ kind: 'rewind', targetId: 'bad' })).kind, 'stale'); - assert.equal(JSON.stringify(session.state()), state); - assert.deepEqual(await submission.history({ kind: 'undo_count', count: 0 }), { kind: 'not_found' }); - assert.equal(JSON.stringify(session.state()), state); - const removed = await submission.history({ kind: 'rewind', targetId: targets[1]!.id }); - assert.equal(removed.kind, 'applied'); - if (removed.kind !== 'applied') throw new Error('undo did not apply'); - assert.equal(removed.restored.text, 'same'); - assert.deepEqual( - removed.restored.images?.map(({ id: _id, ...image }) => image), - images.map(({ id: _id, ...image }) => image), - ); - assert.equal((await submission.listRewindTargets()).length, 1); - assert.equal((await submission.history({ kind: 'rewind', targetId: targets[0]!.id })).kind, 'stale'); - await send('c', 'after'); - assert.equal(requests.length, 3); - const log = CanonicalSessionLog.readOnly(path.join(root, 'sessions'), session.id); - assert.equal(log.filter((entry: any) => entry.fact.type === 'undo').length, 1); - await session.close(); - await assert.rejects(submission.history({ kind: 'undo_last' }), /closed/); - const reopened = await store.openAdopted(session.id); - assert.equal((await reopened.submission.listRewindTargets()).length, 2); - assert.deepEqual(await reopened.submission.history({ kind: 'undo_count', count: 9 }), { - kind: 'applied', - restored: { text: 'same' }, - }); - assert.equal((await reopened.submission.listRewindTargets()).length, 0); - await reopened.session.close(); - const empty = await store.openAdopted(session.id); - assert.equal((await empty.submission.listRewindTargets()).length, 0); - await empty.session.close(); - console.log('PASS history reservation, stale target, undo and cold reopen'); - } -} finally { - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-input-fsync.mts b/rewrite/bench/c3a-input-fsync.mts deleted file mode 100644 index 70bbff172..000000000 --- a/rewrite/bench/c3a-input-fsync.mts +++ /dev/null @@ -1,183 +0,0 @@ -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -let requests: any[] = []; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(structuredClone(request)); - const data = { kind: 'done' }; - if (data.kind === 'call') { - const call = { type: 'tool_call', id: 'call1', name: 'shell', arguments: rawArgs }; - yield call; - yield { type: 'completion', output: [call], responseId: 'r1' }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -let client: any; -const { engine, tools, turnPrompt } = createBenchEngine({ - bindSessionClient(value) { - client = value; - }, - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, -}); -assert.ok(tools.original('shell'), 'source shell assembled'); - -let acceptedWritten = false; -const faultFs = new Proxy(fs, { - get(target, key) { - if (key === 'writeSync') - return (...args: any[]) => { - if (String(args[1]).includes('user_input_accepted')) acceptedWritten = true; - return (fs.writeSync as any)(...args); - }; - if (key === 'fsyncSync') - return (...args: any[]) => { - if (acceptedWritten) throw Error('injected accepted-input fsync'); - return (fs.fsyncSync as any)(...args); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -let foreground: any; -let runtime: any; -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const store = new LocalSessionStore({ - fileSystem: faultFs, - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => ({ - submit: (...args: any[]) => foreground.driver.submit(...args), - decide: (...args: any[]) => foreground.driver.decide(...args), - cancel: () => foreground.driver.cancel(), - retry: () => foreground.driver.retry(), - compact: () => foreground.driver.compact(), - setModel: (...args: any[]) => foreground.driver.setModel(...args), - subscribe: (...args: any[]) => foreground.driver.subscribe(...args), - }), - adoptedSubmissionFactory: (ctx) => { - runtime = createSessionRuntimeInternals({ - sessionId: ctx.id, - agentClient: client, - toolOwnership: new ToolOwnershipRegistry(), - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }); - foreground = createAdoptedForeground({ - context: ctx, - engine, - runtime, - restored: loadCanonicalConversation(ctx.id, path.join(root, 'sessions')), - makeInput: (turnId) => ({ - turnId, - history: [], - newItems: [], - instructions: turnPrompt().instructions, - model: modelRef, - toolNames: [], - workspace: workspaceConfig, - budget: { kind: 'adopted' }, - }), - }); - return foreground.submission; - }, -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -let resolveDone: () => void; -const done = new Promise((resolve) => { - resolveDone = resolve; -}); -session.subscribe(0, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolveDone(); -}); -const result = await submission.submit({ - requestId: 'request1' as any, - input: { - text: 'Hello from adopted session', - skill: { name: 'fixture-skill', description: 'd', body: 'EXACT_SKILL_BODY_SENTINEL' }, - }, -}); -assert.equal(result.kind, 'started'); -await engine.waitForAdmission(new AbortController().signal); -assert.equal(acceptedWritten, true); -assert.equal(requests.length, 0, 'accepted fsync failure must block provider dispatch'); -assert.equal( - session.state().providerHistory.some((x: any) => JSON.stringify(x).includes('EXACT_SKILL_BODY_SENTINEL')), - false, - 'failed snapshot must not project accepted native history', -); -try { - await session.close(); -} catch {} -await engine.dispose(); -await models.dispose(); - -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS actual Session accepted-input fsync failure blocks provider dispatch'); diff --git a/rewrite/bench/c3a-interactions.mts b/rewrite/bench/c3a-interactions.mts deleted file mode 100644 index d6046b11d..000000000 --- a/rewrite/bench/c3a-interactions.mts +++ /dev/null @@ -1,282 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -const mode = process.env.PROBE_MODE || 'repeat'; -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ kind: requests.length <= (mode === 'repeat' ? 2 : 1) ? 'call' : 'done', index: requests.length }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: `call${data.index}`, - name: mode.startsWith('ask') ? 'ask_user' : 'shell', - arguments: mode.startsWith('ask') - ? JSON.stringify({ - questions: [ - { question: 'First?', options: [{ label: 'Alpha' }, { label: 'Beta' }] }, - { question: 'Second?', options: [{ label: 'Red' }, { label: 'Blue' }], is_multi_select: true }, - ], - }) - : rawArgs, - }; - yield call; - const secondCall = mode === 'batch' ? { ...call, id: 'call2' } : null; - if (secondCall) yield secondCall; - yield { - type: 'completion', - usage: { inputTokens: 11, outputTokens: 3 }, - output: secondCall ? [call, secondCall] : [call], - responseId: `r${data.index}`, - }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'done', - usage: { inputTokens: 17, outputTokens: 5 }, - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - settingsDir: path.join(root, 'settings'), - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - // ask_user needs Pair's user-interaction capability; retain Bare for shell approvals. - ...(mode.startsWith('ask') ? { app: { activeProfileId: 'builtin:pair' } } : {}), - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }, new Map([['shell.autoApproveMode', 'off']]) as any); - -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const ownership = new ToolOwnershipRegistry(); -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - -const events: any[] = []; -session.subscribe(0, (e) => events.push(e)); -async function until(predicate: () => any, label: string) { - const end = Date.now() + 12000; - while (Date.now() < end) { - const v = predicate(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error(`timeout waiting ${label}: ${JSON.stringify(events)}`); -} -const facts = () => events.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -const pending = async (n: number) => - until(() => facts().filter((f) => f.type === 'session_interaction_pending')[n], `pending ${n}`); -const settle = async (id: string) => - until( - () => facts().find((f) => f.type === 'session_admission_settled' && f.clientRequestId === id), - `settlement ${id}`, - ); -const choose = async (f: any, answer: string, extra: any = {}) => - submission.decide({ - kind: 'choose', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - decisions: [{ callId: f.interaction.descriptor.callId, choice: { answer, ...extra } }], - }); -const nav = async (f: any, direction: string) => - submission.decide({ - kind: 'navigate', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - direction, - }); -try { - const result = await submission.submit({ requestId: 'request1', input: { text: 'Carry out request' } }); - assert.equal(result.kind, 'started'); - const first = await pending(0); - assert.equal(fs.existsSync(target), false); - if (mode === 'batch') { - await choose(first, 'approve'); - const second = await pending(1); - assert.equal(fs.existsSync(target), false); - await choose(second, 'approve'); - await settle('request1'); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effectone physical effect'); - assert.equal(requests.length, 2); - assert.equal(facts().filter((f) => f.type === 'session_interaction_resolved').length, 2); - assert.equal(facts().filter((f) => f.type === 'command_message' && f.message.callId === 'call1').length, 1); - assert.equal(facts().filter((f) => f.type === 'command_message' && f.message.callId === 'call2').length, 1); - } else if (mode === 'repeat') { - await choose(first, 'approve'); - const second = await pending(1); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - await choose(second, 'approve'); - await settle('request1'); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effectone physical effect'); - assert.equal(requests.length, 3); - assert.equal(facts().filter((f) => f.type === 'session_interaction_resolved').length, 2); - const rows = facts().filter((f) => f.type === 'command_message'); - assert.equal(rows.filter((f) => f.message.callId === 'call1').length, 1); - assert.equal(rows.filter((f) => f.message.callId === 'call2').length, 1); - const turn = facts().find((f) => f.type === 'assistant_turn'); - assert.deepEqual(turn.usage, { prompt_tokens: 39, completion_tokens: 11, total_tokens: 50 }); - } else if (mode === 'reject') { - await choose(first, 'reject', { rejectionReason: 'fixture rejection' }); - await settle('request1'); - assert.equal(fs.existsSync(target), false); - assert.ok(JSON.stringify(requests[1]).includes('fixture rejection')); - assert.equal( - facts().find((f) => f.type === 'session_interaction_resolved').answer.rejectionReason, - 'fixture rejection', - ); - } else if (mode === 'ask-nav') { - assert.equal(first.interaction.kind, 'ask_user'); - await choose(first, 'option:0'); - const second = await pending(1); - assert.equal(second.interaction.askUser.currentQuestionIndex, 1); - assert.deepEqual(second.interaction.askUser.answers, ['Alpha']); - const seq = session.state().lastSeq; - await assert.rejects(nav(second, 'next')); - assert.equal(session.state().lastSeq, seq); - assert.deepEqual(session.state().pendingInteraction.interaction, second.interaction); - await nav(second, 'previous'); - const third = await pending(2); - assert.equal(third.interaction.askUser.currentQuestionIndex, 0); - await nav(third, 'next'); - const fourth = await pending(3); - assert.equal(fourth.interaction.askUser.currentQuestionIndex, 1); - await nav(fourth, 'previous'); - const fifth = await pending(4); - await choose(fifth, 'option:0'); - const sixth = await pending(5); - await choose(sixth, 'custom', { approvalAnswer: JSON.stringify(['Red', 'Blue']) }); - await settle('request1'); - assert.equal(requests.length, 2); - assert.ok(JSON.stringify(requests[1]).includes('Alpha')); - assert.ok(JSON.stringify(requests[1]).includes('Red')); - assert.ok(JSON.stringify(requests[1]).includes('Blue')); - assert.equal(facts().filter((f) => f.type === 'command_message' && f.message.callId === 'call1').length, 1); - } else if (mode === 'ask-cancel' || mode === 'ask-next' || mode === 'ask-queued') { - const queued = - mode === 'ask-queued' ? submission.submit({ requestId: 'request2', input: { text: 'A new task' } }) : null; - await choose(first, 'cancel'); - await settle('request1'); - assert.equal(requests.length, 1); - assert.equal(facts().filter((f) => f.type === 'session_interaction_resolved').length, 1); - assert.ok(JSON.stringify(facts()).includes('User did not provide an answer.')); - if (queued) { - await queued; - await settle('request2'); - assert.equal(requests.length, 2); - assert.equal( - JSON.stringify(requests[1]).includes('user cancelled the pending question without answering'), - false, - ); - } - } - assert.equal(facts().filter((f) => f.type === 'session_turn_ended').length, mode === 'ask-queued' ? 2 : 1); - assert.equal(facts().filter((f) => f.type === 'session_admission_settled').length, mode === 'ask-queued' ? 2 : 1); - if (mode === 'ask-next') { - await submission.submit({ requestId: 'request2', input: { text: 'A new task' } }); - await settle('request2'); - assert.equal(requests.length, 2); - assert.equal(JSON.stringify(requests[1]).includes('user cancelled the pending question without answering'), false); - } - console.log('PASS', mode, JSON.stringify({ requests: requests.length, facts: facts().map((f) => f.type) })); -} finally { - await session.close(); - await models.dispose(); - server.close(); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-memory.mts b/rewrite/bench/c3a-memory.mts deleted file mode 100644 index c8ae156e2..000000000 --- a/rewrite/bench/c3a-memory.mts +++ /dev/null @@ -1,280 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import { SessionTurnAdapter } from '../../source/services/session/session-turn-adapter.ts'; -import { TurnCoordinator } from '../../source/services/session/turn-coordinator.ts'; -for (const name of ['executeInitial', 'executeContinuation', 'beginAdoptedTurn', 'continuePostExecute']) - (SessionTurnAdapter.prototype as any)[name] = () => { - throw new Error('FORBIDDEN whole adapter ' + name); - }; -for (const name of ['start', 'continueAfterApproval', 'continueAfterPostExecuteApproval']) - (TurnCoordinator.prototype as any)[name] = () => { - throw new Error('FORBIDDEN coordinator ' + name); - }; -import assert from 'node:assert/strict'; -import { FileMemoryStore } from '../../source/services/memory/memory-store.js'; -import { AutomaticMemoryCanary } from '../../source/services/memory/automatic-memory-canary.js'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { CanonicalSessionLog } from '../src/session/adopted/conversation-log.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const mode = process.env.PROBE_MODE ?? 'plain'; -const memoryEvents: any[] = []; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c3a-memory-')); -const memoryStore = new FileMemoryStore({ root: path.join(root, 'memory') }); -let memoryCalls = 0; -const automaticMemory = ['error', 'retry', 'approval', 'failure', 'cancel'].includes(mode) - ? { - async record() { - memoryCalls++; - if (mode === 'error') throw new Error('controlled storage error'); - return null; - }, - } - : new AutomaticMemoryCanary(memoryStore); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ kind: (mode === 'approval' || mode === 'cancel') && requests.length === 1 ? 'call' : 'done' }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (mode === 'failure') throw new Error('controlled provider failure'); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: 'memory-approval-call', - name: 'shell', - arguments: JSON.stringify({ - command: `printf 'effect' > '${path.join(workspace, 'effect.txt')}'`, - sandbox: 'unsandboxed', - }), - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'approval' }; - return; - } - assert.equal(data.kind, 'done'); - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: mode !== 'disabled' } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }, new Map([['shell.autoApproveMode', 'off']]) as any); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphs = 0; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - graphs++; - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - onSourceEvent: (event: any) => memoryEvents.push(event), - runtime: () => ({ - automaticMemory, - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -const updates: any[] = []; -session.subscribe(0, (e) => updates.push(e)); -async function settle(id: string) { - const until = Date.now() + 10000; - while (Date.now() < until) { - if ( - updates.some( - (e) => - e.kind === 'fact' && e.entry.fact.type === 'session_admission_settled' && e.entry.fact.clientRequestId === id, - ) - ) - return; - await new Promise((r) => setTimeout(r, 10)); - } - throw new Error('settlement timeout ' + JSON.stringify(updates)); -} -try { - for (let i = 0; i < (mode === 'approval' || mode === 'failure' || mode === 'cancel' ? 1 : 2); i++) { - const input: any = { text: 'Remember for future sessions: I prefer concise explanations.' }; - if (mode === 'image') - input.images = [{ id: 'one', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }]; - if (mode === 'skill') input.skill = { name: 'fixture', description: 'desc', body: 'SKILL-BODY' }; - await submission.submit({ requestId: 'memory-' + i, input }); - if (mode === 'approval' || mode === 'cancel') { - const until = Date.now() + 10000; - let pending: any; - while ( - !(pending = updates.find((e) => e.kind === 'fact' && e.entry.fact.type === 'session_interaction_pending')?.entry - .fact) - ) { - if (Date.now() > until) throw new Error('approval timeout'); - await new Promise((r) => setTimeout(r, 10)); - } - if (mode === 'cancel') await session.cancel(); - else { - await submission.decide({ - kind: 'choose', - turnId: pending.turnId, - interactionId: pending.interaction.interactionId, - revision: pending.interaction.revision, - generation: pending.generation, - decisions: [{ callId: pending.interaction.descriptor.callId, choice: { answer: 'approve' } }], - }); - } - } - await settle('memory-' + i); - } - if (mode === 'retry') { - assert.equal(memoryCalls, 2, 'initial responses reach finalizer'); - const priorEnds = updates.filter((e) => e.kind === 'fact' && e.entry.fact.type === 'session_turn_ended').length; - assert.equal((await submission.retry('turn')).kind, 'started'); - const until = Date.now() + 10000; - while ( - updates.filter((e) => e.kind === 'fact' && e.entry.fact.type === 'session_turn_ended').length === priorEnds - ) { - if (Date.now() > until) throw new Error('retry settlement timeout'); - await new Promise((r) => setTimeout(r, 10)); - } - await session.cancel(); - assert.equal(memoryCalls, 2, 'retry does not reach finalizer even without canary deduplication'); - } - assert.equal( - requests.length, - mode === 'retry' ? 3 : mode === 'failure' || mode === 'cancel' ? 1 : 2, - 'provider request count', - ); - if (mode === 'approval') assert.equal(fs.readFileSync(path.join(workspace, 'effect.txt'), 'utf8'), 'effect'); - if (mode === 'approval' || mode === 'failure' || mode === 'cancel') - assert.equal(memoryCalls, 0, 'no finalizer call on approval continuation, failure or cancellation'); - await session.cancel(); - const events = memoryEvents - .map((e) => e.event) - .filter((e) => e.type === 'command_message' && e.message.toolName === 'automatic_memory'); - const stored = await memoryStore.list(); - const expected = ['image', 'skill', 'disabled', 'error', 'retry', 'approval', 'failure', 'cancel'].includes(mode) - ? 0 - : 1; - assert.equal(stored.length, expected, 'real memory storage writes once'); - assert.equal(events.length, mode === 'error' ? 1 : expected, 'one receipt or storage error'); - const durable = CanonicalSessionLog.readOnly(path.join(root, 'sessions'), session.id).filter( - (e: any) => e.fact.type === 'command_message' && e.fact.message.toolName === 'automatic_memory', - ); - assert.equal(durable.length, events.length, 'source events committed to canonical log'); - if (mode === 'error') { - assert.equal(memoryCalls, 1, 'graph lifetime error latch'); - assert.equal(events[0].message.status, 'failed'); - assert.equal(events[0].message.command, 'Automatic memory canary disabled'); - } else if (expected) { - const memory = stored[0]; - assert.equal(memory.title, 'Remember for future sessions: I prefer concise explanations.'); - assert.equal( - events[0].message.output, - `Learned (project): ${memory.title}\nSource session: ${session.id}\nReview with memory_get({scope:"project",id:"${memory.id}"}); undo with memory_delete({scope:"project",id:"${memory.id}"})`, - ); - assert.equal(events[0].message.status, 'completed'); - } - if (mode === 'retry') { - const requestCount = requests.length; - await session.close(); - const reopened = await store.openAdopted(session.id); - assert.equal(graphs, 2, 'cold reopen builds a fresh graph'); - assert.equal(requests.length, requestCount, 'replay never dispatches provider'); - assert.equal(memoryCalls, 2, 'replay never invokes automatic memory'); - await reopened.session.close(); - } - console.log( - 'PASS independent adopted no-whole-adapter memory ' + mode, - JSON.stringify({ calls: memoryCalls, events: events.length, requests: requests.length }), - ); -} finally { - await session.close(); - await models.dispose(); - server.close(); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-network-background.mts b/rewrite/bench/c3a-network-background.mts deleted file mode 100644 index eca74356b..000000000 --- a/rewrite/bench/c3a-network-background.mts +++ /dev/null @@ -1,170 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -// Controlled runner boundary: verifies background Session ownership, not sandbox enforcement. -import { BackgroundShellRegistry } from '../../source/services/shell/background-shell-registry.js'; -const bg = new BackgroundShellRegistry(); -import { ScopedSandboxRunner } from '../../source/utils/shell/sandbox/scoped-sandbox-runner.js'; -let capturedScope: any; -let cleanupCount = 0; -ScopedSandboxRunner.prototype.availability = async function () { - capturedScope = (this as any).network; - return { type: 'available' }; -}; -ScopedSandboxRunner.prototype.wrap = async function (command: any) { - return { command }; -}; -ScopedSandboxRunner.prototype.annotateFailure = async function (_command: any, stderr: any) { - return stderr; -}; -ScopedSandboxRunner.prototype.cleanupAfterCommand = async function () { - cleanupCount++; - await (this as any).onExecutionEnded(); - (this as any).network.cancelAll('cancelled'); -}; -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.js'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.js'; -import { createProviderRegistry } from '../../source/providers/registry.js'; -import { SessionContextService } from '../../source/services/session/session-context-service.js'; -import { LoggingService } from '../../source/services/logging/logging-service.js'; -import { ExecutionContext } from '../../source/services/execution-context.js'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.js'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - if (requests.length === 1) { - const call = { - type: 'tool_call', - id: 'warm-shell', - name: 'shell', - arguments: JSON.stringify({ command: 'sleep 30', sandbox: 'default', background: true }), - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'warm' }; - return; - } - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - backgroundShellRegistry: bg, - allowBackgroundShell: true, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); - -const until = async (p: () => boolean) => { - for (let i = 0; i < 1000; i++) { - if (p()) return; - await new Promise((r) => setTimeout(r, 2)); - } - throw Error('condition timeout'); -}; -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -await submission.submit({ requestId: 'warm-request' as any, input: { text: 'warm shell' } }); -await until(() => !!session.state().pendingInteraction || session.state().status === 'idle'); -await until(() => session.state().status === 'idle'); -assert.ok(capturedScope); -assert.equal(requests.length, 2); -const network = submission.networkApproval!; - -assert.equal(cleanupCount, 0); -assert.equal(session.state().activeTurnId, null); -assert.ok(JSON.stringify(session.state().providerHistory).includes('jobId')); -const pending = capturedScope.request({ host: 'sol-idle-background.example' }); -await until(() => !!network.getSnapshot().active); -await session.cancel(); -assert.ok(network.getSnapshot().active); -assert.equal(cleanupCount, 0); -const active = network.getSnapshot().active!; -assert.deepEqual( - await network.decide({ requestId: active.requestId, generation: active.generation, answer: 'allow-once' }), - { kind: 'applied', allowed: true }, -); -assert.equal(await pending, true); -assert.equal(requests.length, 2); -await session.close(); -await until(() => cleanupCount === 1); -await models.dispose(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS actual background shell idle network pending survives foreground cancel/answers/cleanup once'); diff --git a/rewrite/bench/c3a-network-kill-worker.mts b/rewrite/bench/c3a-network-kill-worker.mts deleted file mode 100644 index 88af83020..000000000 --- a/rewrite/bench/c3a-network-kill-worker.mts +++ /dev/null @@ -1,223 +0,0 @@ -// Controlled runner boundary; real Session and canonical log across process death. -import { CanonicalSessionLog } from '../src/session/adopted/conversation-log.js'; -import { ScopedSandboxRunner } from '../../source/utils/shell/sandbox/scoped-sandbox-runner.js'; -let capturedScope: any; -ScopedSandboxRunner.prototype.availability = async function () { - capturedScope = (this as any).network; - return { type: 'available' }; -}; -ScopedSandboxRunner.prototype.wrap = async function (command: any) { - return { command }; -}; -ScopedSandboxRunner.prototype.annotateFailure = async function (_command: any, stderr: any) { - return stderr; -}; -ScopedSandboxRunner.prototype.cleanupAfterCommand = async function () { - await (this as any).onExecutionEnded(); - (this as any).network.cancelAll('cancelled'); -}; -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.js'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.js'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.js'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.js'; -import { createProviderRegistry } from '../../source/providers/registry.js'; -import { SessionContextService } from '../../source/services/session/session-context-service.js'; -import { LoggingService } from '../../source/services/logging/logging-service.js'; -import { ExecutionContext } from '../../source/services/execution-context.js'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.js'; -const root = process.env.PROBE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace, { recursive: true }); -let requests: any[] = []; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - if (requests.length === 1) { - const call = { - type: 'tool_call', - id: 'warm-shell', - name: 'shell', - arguments: JSON.stringify({ command: 'printf warm', sandbox: 'default' }), - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'warm' }; - return; - } - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphs = 0; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -let failType = ''; -let hit = false; -let fault = false; -const faultFs = new Proxy(fs, { - get(target, key) { - if (key === 'writeSync') - return (...args: any[]) => { - if (failType && String(args[1]).includes(failType)) hit = true; - return (fs.writeSync as any)(...args); - }; - if (key === 'fsyncSync') - return (...args: any[]) => { - if (hit) { - fault = true; - throw Error('sol injected network fsync'); - } - return (fs.fsyncSync as any)(...args); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -const store = new LocalSessionStore({ - fileSystem: faultFs, - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - graphs++; - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); - -const until = async (p: () => boolean) => { - for (let i = 0; i < 1000; i++) { - if (p()) return; - await new Promise((r) => setTimeout(r, 2)); - } - throw Error('condition timeout'); -}; - -if (process.env.WORKER_MODE === 'reopen') { - const { session, submission } = await store.openAdopted(process.env.SESSION_ID as any); - assert.equal(requests.length, 0); - assert.equal(submission.networkApproval!.getSnapshot().active, null); - const rows = fs.readFileSync(path.join(root, 'sessions', session.id + '.jsonl'), 'utf8'); - const networkFacts = CanonicalSessionLog.readOnly(path.join(root, 'sessions'), session.id) - .map((e) => e.fact) - .filter((f) => f.type.startsWith('session_network_')) as any[]; - assert.equal(networkFacts.length, 2); - assert.equal(networkFacts[0].requestId, process.env.REQUEST_ID); - assert.equal(networkFacts[0].generation, process.env.GENERATION); - assert.deepEqual(networkFacts[1].resolution, { kind: 'recovered', reason: 'process_restarted' }); - assert.equal(networkFacts[1].requestId, process.env.REQUEST_ID); - assert.equal(networkFacts[1].generation, process.env.GENERATION); - const before = session.state().lastSeq; - await assert.rejects( - submission.networkApproval!.decide({ - requestId: process.env.REQUEST_ID!, - generation: process.env.GENERATION!, - answer: 'allow-session', - }), - ); - assert.equal(session.state().lastSeq, before); - assert.equal(requests.length, 0); - await session.close(); - const twice = await store.openAdopted(process.env.SESSION_ID as any); - assert.equal(twice.session.state().lastSeq, before); - assert.equal(twice.submission.networkApproval!.getSnapshot().active, null); - assert.equal(requests.length, 0); - await twice.session.close(); - await models.dispose(); - fs.writeFileSync(path.join(root, 'reopened.json'), JSON.stringify({ id: session.id, seq: before })); - console.log('REOPEN_PASS'); -} else { - const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - await submission.submit({ requestId: 'warm-before-kill' as any, input: { text: 'warm' } }); - await until(() => session.state().status === 'idle'); - assert.ok(capturedScope); - assert.equal(requests.length, 2); - const ignored = capturedScope.request({ host: 'sol-kill-pending.example', port: 443 }); - void ignored; - await until(() => !!submission.networkApproval!.getSnapshot().active); - const pending = submission.networkApproval!.getSnapshot().active!; - fs.writeFileSync( - path.join(root, 'ready.json'), - JSON.stringify({ - id: session.id, - root, - requestId: pending.requestId, - generation: pending.generation, - seq: session.state().lastSeq, - }), - ); - console.log('PENDING_READY'); - setInterval(() => {}, 1000); -} diff --git a/rewrite/bench/c3a-network-kill.mts b/rewrite/bench/c3a-network-kill.mts deleted file mode 100644 index 6420bc378..000000000 --- a/rewrite/bench/c3a-network-kill.mts +++ /dev/null @@ -1,55 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { once } from 'node:events'; -import { fileURLToPath } from 'node:url'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sol-network-kill-')); -const cwd = process.cwd(); -const worker = spawn( - process.execPath, - ['--import', 'tsx', fileURLToPath(new URL('./c3a-network-kill-worker.mts', import.meta.url))], - { cwd, env: { ...process.env, PROBE_ROOT: root }, stdio: ['ignore', 'pipe', 'pipe'] }, -); -let out = '', - err = ''; -worker.stderr.on('data', (b) => (err += b)); -let timer = setTimeout(() => worker.kill('SIGKILL'), 45000); -for (let i = 0; i < 2000 && !fs.existsSync(path.join(root, 'ready.json')); i++) - await new Promise((r) => setTimeout(r, 10)); -assert.ok(fs.existsSync(path.join(root, 'ready.json')), 'no durable ready ' + out + ' ' + err); -const pending = JSON.parse(fs.readFileSync(path.join(root, 'ready.json'), 'utf8')); -clearTimeout(timer); -worker.kill('SIGKILL'); -const [, signal] = await once(worker, 'exit'); -assert.equal(signal, 'SIGKILL'); -const reopen = spawn( - process.execPath, - ['--import', 'tsx', fileURLToPath(new URL('./c3a-network-kill-worker.mts', import.meta.url))], - { - cwd, - env: { - ...process.env, - PROBE_ROOT: root, - WORKER_MODE: 'reopen', - SESSION_ID: pending.id, - REQUEST_ID: pending.requestId, - GENERATION: pending.generation, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }, -); -let second = '', - secondErr = ''; -reopen.stdout.on('data', (b) => (second += b)); -reopen.stderr.on('data', (b) => (secondErr += b)); -timer = setTimeout(() => reopen.kill('SIGKILL'), 45000); -const [code] = await once(reopen, 'exit'); -clearTimeout(timer); -assert.equal(code, 0, second + ' ' + secondErr); -assert.ok(fs.existsSync(path.join(root, 'reopened.json'))); -fs.rmSync(root, { recursive: true, force: true }); -console.log( - 'PASS actual Session idle pending network SIGKILL/fresh process recovered/stale decision rejected/zero auto dispatch', -); diff --git a/rewrite/bench/c3a-network-nested.mts b/rewrite/bench/c3a-network-nested.mts deleted file mode 100644 index 485b06db9..000000000 --- a/rewrite/bench/c3a-network-nested.mts +++ /dev/null @@ -1,235 +0,0 @@ -// Controlled runner boundary; exercises real nested-shell lifecycle, not sandbox enforcement. -import { InvalidatableShellChildRegistry } from '../../source/utils/shell/shell-child-registry.js'; -let observedChild: any; -const originalAdd = InvalidatableShellChildRegistry.prototype.add; -InvalidatableShellChildRegistry.prototype.add = function (child: any) { - observedChild = child; - originalAdd.call(this, child); -}; -import { ScopedSandboxRunner } from '../../source/utils/shell/sandbox/scoped-sandbox-runner.js'; -let capturedScope: any; -let capturedRunner: any; -let rootCalls = 0, - childCalls = 0; -ScopedSandboxRunner.prototype.availability = async function () { - capturedRunner = this; - capturedScope = (this as any).network; - return { type: 'available' }; -}; -ScopedSandboxRunner.prototype.wrap = async function (command: any) { - if (process.env.PROBE_MODE === 'late') (this as any).onLost(); - return { command }; -}; -ScopedSandboxRunner.prototype.annotateFailure = async function (_command: any, stderr: any) { - return stderr; -}; -ScopedSandboxRunner.prototype.cleanupAfterCommand = async function () { - await (this as any).onExecutionEnded(); - (this as any).network.cancelAll('cancelled'); -}; -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.js'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.js'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.js'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.js'; -import { createProviderRegistry } from '../../source/providers/registry.js'; -import { SessionContextService } from '../../source/services/session/session-context-service.js'; -import { LoggingService } from '../../source/services/logging/logging-service.js'; -import { ExecutionContext } from '../../source/services/execution-context.js'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.js'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - const rootRequest = request.tools?.some((t: any) => t.name === 'run_subagent'); - if (rootRequest && ++rootCalls === 1) { - const call = { - type: 'tool_call', - id: 'nested-run', - name: 'run_subagent', - arguments: JSON.stringify({ - execution: 'background', - role: 'worker', - task: 'Run the shell command and report.', - }), - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'root-start' }; - return; - } - if (!rootRequest && ++childCalls === 1) { - const call = { - type: 'tool_call', - id: 'nested-shell', - name: 'shell', - arguments: JSON.stringify({ - command: `printf '%s' "$$" > '${workspace}/nested.pid'; exec sleep 30`, - sandbox: 'default', - }), - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'child-start' }; - return; - } - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'full' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphs = 0; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -let failType = ''; -let hit = false; -let fault = false; -const faultFs = new Proxy(fs, { - get(target, key) { - if (key === 'writeSync') - return (...args: any[]) => { - if (failType && String(args[1]).includes(failType)) hit = true; - return (fs.writeSync as any)(...args); - }; - if (key === 'fsyncSync') - return (...args: any[]) => { - if (hit) { - fault = true; - throw Error('sol injected network fsync'); - } - return (fs.fsyncSync as any)(...args); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -const store = new LocalSessionStore({ - fileSystem: faultFs, - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - graphs++; - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); - -const until = async (p: () => boolean) => { - for (let i = 0; i < 1000; i++) { - if (p()) return; - await new Promise((r) => setTimeout(r, 2)); - } - throw Error( - 'condition timeout ' + - JSON.stringify({ - state: session.state(), - requests: requests.map((r) => ({ tools: r.tools?.map((t: any) => t.name), input: r.input })), - }), - ); -}; - -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -await submission.submit({ requestId: 'nested-test' as any, input: { text: 'run nested shell' } }); -if (process.env.PROBE_MODE === 'late') { - await until(() => session.state().status === 'idle' && childCalls >= 1); - assert.equal(observedChild, undefined); - assert.equal(fs.existsSync(path.join(workspace, 'nested.pid')), false); - await session.close(); - await models.dispose(); - console.log('PASS actual nested helper-loss-before-spawn refuses command/no child registration/no effect'); -} else { - await until(() => fs.existsSync(path.join(workspace, 'nested.pid'))); - const pid = Number(fs.readFileSync(path.join(workspace, 'nested.pid'), 'utf8')); - assert.ok(pid > 1); - assert.ok(capturedRunner); - process.kill(pid, 0); - capturedRunner.onLost(); - const dead = () => { - try { - const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); - return stat.split(') ')[1]?.startsWith('Z'); - } catch { - return true; - } - }; - await until(dead); - await until(() => session.state().status === 'idle'); - assert.ok(rootCalls >= 1); - assert.ok(childCalls >= 1); - await session.close(); - await models.dispose(); - console.log('PASS actual nested source shell PID terminated by graph helper-loss callback/settled'); -} diff --git a/rewrite/bench/c3a-network.mts b/rewrite/bench/c3a-network.mts deleted file mode 100644 index 05be0f00c..000000000 --- a/rewrite/bench/c3a-network.mts +++ /dev/null @@ -1,190 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -// Controlled runner boundary: verifies actual Session ownership, not sandbox enforcement. -import { ScopedSandboxRunner } from '../../source/utils/shell/sandbox/scoped-sandbox-runner.js'; -let capturedScope: any; -const capturedScopes: any[] = []; -ScopedSandboxRunner.prototype.availability = async function () { - capturedScope = (this as any).network; - if (!capturedScopes.includes(capturedScope)) capturedScopes.push(capturedScope); - return { type: 'available' }; -}; -ScopedSandboxRunner.prototype.wrap = async function (command: any) { - return { command }; -}; -ScopedSandboxRunner.prototype.annotateFailure = async function (_command: any, stderr: any) { - return stderr; -}; -ScopedSandboxRunner.prototype.cleanupAfterCommand = async function () { - await (this as any).onExecutionEnded(); - (this as any).network.cancelAll('cancelled'); -}; -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.js'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.js'; -import { createProviderRegistry } from '../../source/providers/registry.js'; -import { SessionContextService } from '../../source/services/session/session-context-service.js'; -import { LoggingService } from '../../source/services/logging/logging-service.js'; -import { ExecutionContext } from '../../source/services/execution-context.js'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.js'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - if (!JSON.stringify(request.input).includes('warm-shell')) { - const call = { - type: 'tool_call', - id: 'warm-shell', - name: 'shell', - arguments: JSON.stringify({ command: 'printf warm', sandbox: 'default' }), - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'warm' }; - return; - } - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); - -const until = async (p: () => boolean) => { - for (let i = 0; i < 1000; i++) { - if (p()) return; - await new Promise((r) => setTimeout(r, 2)); - } - throw Error('condition timeout'); -}; - -const first = await store.createAdopted(workspaceConfig, modelRef); -await first.submission.submit({ requestId: 'warm-a' as any, input: { text: 'warm graph A' } }); -await until(() => first.session.state().status === 'idle'); -const second = await store.createAdopted(workspaceConfig, modelRef); -await second.submission.submit({ requestId: 'warm-b' as any, input: { text: 'warm graph B' } }); -await until(() => second.session.state().status === 'idle'); -assert.equal(capturedScopes.length, 2); -assert.equal(requests.length, 4); -const [a, b] = capturedScopes; -const an = first.submission.networkApproval!, - bn = second.submission.networkApproval!; -const ar = a.request({ host: 'sol-dual.example', port: 443 }), - br = b.request({ host: 'sol-dual.example', port: 443 }); -await until(() => !!an.getSnapshot().active && !!bn.getSnapshot().active); -assert.equal(first.session.state().activeTurnId, null); -assert.equal(second.session.state().activeTurnId, null); -assert.notEqual(an.getSnapshot().active!.generation, bn.getSnapshot().active!.generation); -const ac = an.getSnapshot().active!, - bc = bn.getSnapshot().active!; -await assert.rejects(an.decide({ requestId: bc.requestId, generation: bc.generation, answer: 'allow-session' })); -assert.deepEqual(await an.decide({ requestId: ac.requestId, generation: ac.generation, answer: 'allow-session' }), { - kind: 'applied', - allowed: true, -}); -assert.equal(await ar, true); -assert.deepEqual(await bn.decide({ requestId: bc.requestId, generation: bc.generation, answer: 'deny' }), { - kind: 'applied', - allowed: false, -}); -assert.equal(await br, false); -assert.equal(await a.request({ host: 'sol-dual.example', port: 443 }), true); -const retryB = b.request({ host: 'sol-dual.example', port: 443 }); -await until(() => !!bn.getSnapshot().active); -assert.equal(an.getSnapshot().active, null); -await first.session.close(); -const again = bn.getSnapshot().active!; -assert.deepEqual(await bn.decide({ requestId: again.requestId, generation: again.generation, answer: 'allow-once' }), { - kind: 'applied', - allowed: true, -}); -assert.equal(await retryB, true); -const next = b.request({ host: 'sol-surviving.example' }); -await until(() => !!bn.getSnapshot().active); -const nc = bn.getSnapshot().active!; -await bn.decide({ requestId: nc.requestId, generation: nc.generation, answer: false }); -assert.equal(await next, false); -assert.equal(requests.length, 4); -await second.session.close(); -await models.dispose(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS actual dual Session idle ownership/crossgraph rejection/session grant isolation/close A preserves B'); diff --git a/rewrite/bench/c3a-owned-handle.mts b/rewrite/bench/c3a-owned-handle.mts deleted file mode 100644 index f6207967e..000000000 --- a/rewrite/bench/c3a-owned-handle.mts +++ /dev/null @@ -1,170 +0,0 @@ -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ kind: 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - assert.equal(data.kind, 'done'); - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphs = 0; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - graphs++; - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -let resolveDone: () => void; -const done = new Promise((resolve) => { - resolveDone = resolve; -}); -session.subscribe(0, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolveDone(); -}); -const result = await submission.submit({ - requestId: 'request1' as any, - input: { - text: 'Hello from adopted session', - images: [ - { id: 'one', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }, - { id: 'two', data: 'd29ybGQ=', mimeType: 'image/jpeg', byteSize: 5, displayNumber: 2 }, - ], - skill: { name: 'fixture', description: 'desc', body: 'SKILL-BODY' }, - }, -}); -assert.equal(result.kind, 'started'); -await done; -assert.equal(session.state().status, 'idle'); -assert.equal(requests.length, 1); -assert.ok(JSON.stringify(requests[0].input).includes('Hello from adopted session')); -assert.ok(JSON.stringify(requests[0].input).includes('SKILL-BODY')); -assert.ok(JSON.stringify(requests[0].input).includes('aGVsbG8=')); -assert.ok(JSON.stringify(requests[0].input).includes('d29ybGQ=')); -const id = session.id; -await session.close(); -const reopenedHandle = await store.openAdopted(id); -const reopened = reopenedHandle.session; -assert.equal(graphs, 2); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('Hello from adopted session')); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('aGVsbG8=')); -assert.ok(JSON.stringify(reopened.state().messages).includes('SKILL-BODY')); -const followupDone = new Promise((resolve) => { - reopened.subscribe(reopened.state().lastSeq, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolve(); - }); -}); -await reopenedHandle.submission.submit({ requestId: 'request2' as any, input: { text: 'Follow up' } }); -await followupDone; -assert.equal(requests.length, 2); -assert.ok(JSON.stringify(requests[1].input).includes('Hello from adopted session')); -assert.ok(JSON.stringify(requests[1].input).includes('Bench complete')); -await reopened.close(); -await models.dispose(); -server.close(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS actual Session + source graph + engine plain turn and reopen'); diff --git a/rewrite/bench/c3a-postexecute.mts b/rewrite/bench/c3a-postexecute.mts deleted file mode 100644 index f94738951..000000000 --- a/rewrite/bench/c3a-postexecute.mts +++ /dev/null @@ -1,292 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { AgentClient } from '../../source/lib/agent-client.ts'; -let physicalSettled = false; -const originalRun = AgentClient.prototype.runTurn; -AgentClient.prototype.runTurn = async function (...args: any[]) { - try { - return await originalRun.apply(this, args as any); - } finally { - physicalSettled = true; - } -}; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { PostExecutePendingRegistry } from '../../source/services/session/post-execute-pending-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -const mode = process.env.PROBE_MODE || 'post'; -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ kind: requests.length <= (mode === 'repeat' ? 2 : 1) ? 'call' : 'done', index: requests.length }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: `call${data.index}`, - name: mode.startsWith('ask') || mode === 'batch-stop' ? 'ask_user' : 'shell', - arguments: - mode === 'ask-malformed' - ? JSON.stringify({ questions: [{ question: 'Bad', options: [{ label: 'Only' }] }] }) - : mode.startsWith('ask') || mode === 'batch-stop' - ? JSON.stringify({ - questions: [ - { question: 'First?', options: [{ label: 'Alpha' }, { label: 'Beta' }] }, - { question: 'Second?', options: [{ label: 'Red' }, { label: 'Blue' }], is_multi_select: true }, - ], - }) - : rawArgs, - }; - yield call; - const secondCall = - mode === 'batch-stop' - ? { type: 'tool_call', id: 'call2', name: 'shell', arguments: rawArgs } - : mode.startsWith('batch') - ? { ...call, id: 'call2' } - : null; - if (secondCall) yield secondCall; - yield { - type: 'completion', - usage: { inputTokens: 11, outputTokens: 3 }, - output: secondCall ? [call, secondCall] : [call], - responseId: `r${data.index}`, - }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'done', - usage: { inputTokens: 17, outputTokens: 5 }, - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }, new Map([['shell.autoApproveMode', 'off']]) as any); -console.log('AUTO_MODE', pair.service.get('shell.autoApproveMode')); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const postPending = new PostExecutePendingRegistry({ sessionId: 'probe', epoch: 1 }); -let executed = 0, - activeRunId: string | null = null; -const gateDecisions: string[] = []; -const postExecutePauseCapability: any = { - setActiveRunId(id: string | null) { - activeRunId = id; - }, - forTool(def: any) { - if (def.name !== 'shell') return; - return async (ctx: any) => { - executed++; - const decision = await postPending.register({ - runId: activeRunId!, - toolCallId: ctx.details.toolCall.callId, - toolName: 'shell', - argumentsText: rawArgs, - }); - gateDecisions.push(decision); - return ctx.result; - }; - }, -}; -let count = 0, - hit = false, - failed = false; -const failType = process.env.FAIL_TYPE || 'none'; -const faultFs = new Proxy(fs, { - get(target, key) { - if (key === 'writeSync') - return (...args: any[]) => { - if (String(args[1]).includes(failType) && ++count === 2) hit = true; - return (fs.writeSync as any)(...args); - }; - if (key === 'fsyncSync') - return (...args: any[]) => { - if (hit) { - failed = true; - throw Error('injected postexecute ' + failType + ' fsync'); - } - return (fs.fsyncSync as any)(...args); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -const store = new LocalSessionStore({ - fileSystem: faultFs, - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - postExecutePauseCapability, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: new ToolOwnershipRegistry(), - postExecutePending: postPending, - postExecutePauseCapability, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - -const events: any[] = []; -session.subscribe(0, (e) => events.push(e)); -async function until(predicate: () => any, label: string) { - const end = Date.now() + 12000; - while (Date.now() < end) { - const v = predicate(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error(`timeout waiting ${label}: ${JSON.stringify(events)}`); -} -const facts = () => events.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -const pending = async (n: number) => - until(() => facts().filter((f) => f.type === 'session_interaction_pending')[n], `pending ${n}`); -const settle = async (id: string) => - until( - () => facts().find((f) => f.type === 'session_admission_settled' && f.clientRequestId === id), - `settlement ${id}`, - ); -const choose = async (f: any, answer: string, extra: any = {}) => - submission.decide({ - kind: 'choose', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - decisions: [{ callId: f.interaction.descriptor.callId, choice: { answer, ...extra } }], - }); -const nav = async (f: any, direction: string) => - submission.decide({ - kind: 'navigate', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - direction, - }); -try { - await submission.submit({ requestId: 'request1' as any, input: { text: 'Carry out request' } }); - const first = await pending(0); - await choose(first, 'approve'); - if (failType === 'none') { - const second = await pending(1); - await choose(second, 'approve'); - await settle('request1'); - assert.equal(executed, 1); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - assert.equal(requests.length, 2); - assert.deepEqual(gateDecisions, ['approve']); - console.log('PASS held postexecute release and one effect'); - } else if (failType === 'session_interaction_resolved') { - const second = await pending(1); - assert.equal(physicalSettled, false); - await assert.rejects(choose(second, 'approve'), /injected|Persistence/); - } else { - await until(() => physicalSettled, 'physical settlement after held-gate pending failure'); - assert.equal(facts().filter((f) => f.type === 'session_interaction_pending').length, 1); - } - if (failType !== 'none') { - assert.equal(physicalSettled, true); - assert.equal(failed, true); - assert.equal(postPending.snapshot().entries.length, 0); - assert.equal(executed, 1); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - assert.equal(requests.length, 1); - assert.deepEqual(gateDecisions, ['reject']); - console.log( - 'PASS held postexecute', - failType, - 'fsync failure closes exact gate and awaits physical settlement without replay/next provider', - ); - } -} finally { - postPending.close(); - try { - await session.close(); - } catch {} - await models.dispose(); - server.close(); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-retry-effects.mts b/rewrite/bench/c3a-retry-effects.mts deleted file mode 100644 index 4955ef4a2..000000000 --- a/rewrite/bench/c3a-retry-effects.mts +++ /dev/null @@ -1,260 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { SessionLifecycle } from '../../source/services/session/session-lifecycle.ts'; -const operations = { turn: 0, tool: 0 }; -for (const [key, name] of [ - ['turn', 'retryLastUserTurn'], - ['tool', 'retryLastToolOutput'], -] as const) { - const original = (SessionLifecycle.prototype as any)[name]; - (SessionLifecycle.prototype as any)[name] = function (...args: any[]) { - const result = original.apply(this, args); - if (result) operations[key]++; - return result; - }; -} -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -const mode = process.env.PROBE_MODE || 'explicit'; -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ kind: requests.length <= (mode === 'repeat' ? 2 : 1) ? 'call' : 'done', index: requests.length }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: `call${data.index}`, - name: mode.startsWith('ask') || mode === 'batch-stop' ? 'ask_user' : 'shell', - arguments: - mode === 'ask-malformed' - ? JSON.stringify({ questions: [{ question: 'Bad', options: [{ label: 'Only' }] }] }) - : mode.startsWith('ask') || mode === 'batch-stop' - ? JSON.stringify({ - questions: [ - { question: 'First?', options: [{ label: 'Alpha' }, { label: 'Beta' }] }, - { question: 'Second?', options: [{ label: 'Red' }, { label: 'Blue' }], is_multi_select: true }, - ], - }) - : rawArgs, - }; - yield call; - const secondCall = - mode === 'batch-stop' - ? { type: 'tool_call', id: 'call2', name: 'shell', arguments: rawArgs } - : mode.startsWith('batch') - ? { ...call, id: 'call2' } - : null; - if (secondCall) yield secondCall; - yield { - type: 'completion', - usage: { inputTokens: 11, outputTokens: 3 }, - output: secondCall ? [call, secondCall] : [call], - responseId: `r${data.index}`, - }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'done', - usage: { inputTokens: 17, outputTokens: 5 }, - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'always' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'always']]) as any, -); -console.log('AUTO_MODE', pair.service.get('shell.autoApproveMode')); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const ownership = new ToolOwnershipRegistry(); -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - -const events: any[] = []; -session.subscribe(0, (e) => events.push(e)); -async function until(predicate: () => any, label: string) { - const end = Date.now() + 12000; - while (Date.now() < end) { - const v = predicate(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error(`timeout waiting ${label}: ${JSON.stringify(events)}`); -} -const facts = () => events.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -const pending = async (n: number) => - until(() => facts().filter((f) => f.type === 'session_interaction_pending')[n], `pending ${n}`); -const settle = async (id: string) => - until( - () => facts().find((f) => f.type === 'session_admission_settled' && f.clientRequestId === id), - `settlement ${id}`, - ); -const choose = async (f: any, answer: string, extra: any = {}) => - submission.decide({ - kind: 'choose', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - decisions: [{ callId: f.interaction.descriptor.callId, choice: { answer, ...extra } }], - }); -const nav = async (f: any, direction: string) => - submission.decide({ - kind: 'navigate', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - direction, - }); -try { - const seq = session.state().lastSeq; - const beforeIneligible = structuredClone(session.state()); - assert.deepEqual(await submission.retry('turn'), { kind: 'ineligible', target: 'turn' }); - assert.deepEqual(await submission.retry('tool'), { kind: 'ineligible', target: 'tool' }); - assert.equal(session.state().lastSeq, seq); - assert.equal(requests.length, 0); - assert.deepEqual(operations, { turn: 0, tool: 0 }); - assert.deepEqual(session.state(), beforeIneligible); - const input: any = { - text: 'ORIGINAL_EXPLICIT_RETRY', - images: [{ id: 'image1', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }], - skill: { name: 'test-skill', description: 'd', body: 'ONCE_SKILL_BODY' }, - }; - await submission.submit({ requestId: 'request1' as any, input }); - await settle('request1'); - assert.equal(requests.length, 2); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - const targetKind = (process.env.RETRY_TARGET || 'tool') as 'turn' | 'tool'; - const result = await submission.retry(targetKind); - assert.equal(result.kind, 'started'); - await until( - () => facts().some((f) => f.type === 'session_turn_ended' && f.turnId === result.turnId), - 'retry execution terminal', - ); - assert.equal(requests.length, 3); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect', 'rewind does not re-execute physical tool'); - assert.deepEqual(operations, targetKind === 'tool' ? { turn: 0, tool: 1 } : { turn: 1, tool: 0 }); - assert.equal(facts().filter((f) => f.type === 'user_message').length, 1); - assert.equal(facts().filter((f) => f.type === 'session_admitted').length, 1); - assert.equal(facts().filter((f) => f.type === 'session_admission_settled').length, 1); - assert.equal(facts().filter((f) => f.type === 'session_turn_started' && f.clientRequestId === null).length, 1); - assert.equal(facts().filter((f) => f.type === 'session_turn_ended').length, 2); - const replay = JSON.stringify(requests[2].input); - assert.equal((replay.match(/ORIGINAL_EXPLICIT_RETRY/g) || []).length, 1); - assert.equal((replay.match(/ONCE_SKILL_BODY/g) || []).length, 1); - assert.ok(replay.includes('aGVsbG8=')); - assert.equal(replay.includes('Bench complete'), false, 'discarded later assistant suffix absent'); - if (targetKind === 'tool') { - assert.equal(requests[2].input.filter((i: any) => i.type === 'tool_call').length, 1); - assert.equal(requests[2].input.filter((i: any) => i.type === 'tool_result').length, 1); - } - assert.equal((await submission.submit({ requestId: 'request1' as any, input })).kind, 'duplicate'); - assert.equal(requests.length, 3); - assert.equal(session.state().admissions.request1.result, 'accepted'); - console.log( - 'PASS explicit retry', - targetKind, - 'one source operation, no duplicate admission/user/input/skill, retained effect once and actual request replay', - ); -} finally { - await session.close(); - await models.dispose(); - server.close(); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-retry-fsync.mts b/rewrite/bench/c3a-retry-fsync.mts deleted file mode 100644 index b478ce9ad..000000000 --- a/rewrite/bench/c3a-retry-fsync.mts +++ /dev/null @@ -1,257 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { SessionLifecycle } from '../../source/services/session/session-lifecycle.ts'; -const operations = { turn: 0, tool: 0 }; -for (const [key, name] of [ - ['turn', 'retryLastUserTurn'], - ['tool', 'retryLastToolOutput'], -] as const) { - const original = (SessionLifecycle.prototype as any)[name]; - (SessionLifecycle.prototype as any)[name] = function (...args: any[]) { - const result = original.apply(this, args); - if (result) operations[key]++; - return result; - }; -} -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -const mode = process.env.PROBE_MODE || 'explicit'; -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ kind: requests.length <= (mode === 'repeat' ? 2 : 1) ? 'call' : 'done', index: requests.length }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: `call${data.index}`, - name: mode.startsWith('ask') || mode === 'batch-stop' ? 'ask_user' : 'shell', - arguments: - mode === 'ask-malformed' - ? JSON.stringify({ questions: [{ question: 'Bad', options: [{ label: 'Only' }] }] }) - : mode.startsWith('ask') || mode === 'batch-stop' - ? JSON.stringify({ - questions: [ - { question: 'First?', options: [{ label: 'Alpha' }, { label: 'Beta' }] }, - { question: 'Second?', options: [{ label: 'Red' }, { label: 'Blue' }], is_multi_select: true }, - ], - }) - : rawArgs, - }; - yield call; - const secondCall = - mode === 'batch-stop' - ? { type: 'tool_call', id: 'call2', name: 'shell', arguments: rawArgs } - : mode.startsWith('batch') - ? { ...call, id: 'call2' } - : null; - if (secondCall) yield secondCall; - yield { - type: 'completion', - usage: { inputTokens: 11, outputTokens: 3 }, - output: secondCall ? [call, secondCall] : [call], - responseId: `r${data.index}`, - }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'done', - usage: { inputTokens: 17, outputTokens: 5 }, - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'always' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'always']]) as any, -); -console.log('AUTO_MODE', pair.service.get('shell.autoApproveMode')); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let retryWrite = false, - failed = false; -const retryFact = process.env.RETRY_TARGET === 'turn' ? 'user_turn_retried' : 'tool_output_rewound'; -const faultFs = new Proxy(fs, { - get(target, key) { - if (key === 'writeSync') - return (...args: any[]) => { - if (String(args[1]).includes(retryFact)) retryWrite = true; - return (fs.writeSync as any)(...args); - }; - if (key === 'fsyncSync') - return (...args: any[]) => { - if (retryWrite) { - failed = true; - throw Error('injected retry boundary fsync'); - } - return (fs.fsyncSync as any)(...args); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -const ownership = new ToolOwnershipRegistry(); -const store = new LocalSessionStore({ - fileSystem: faultFs, - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - -const events: any[] = []; -session.subscribe(0, (e) => events.push(e)); -async function until(predicate: () => any, label: string) { - const end = Date.now() + 12000; - while (Date.now() < end) { - const v = predicate(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error(`timeout waiting ${label}: ${JSON.stringify(events)}`); -} -const facts = () => events.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -const pending = async (n: number) => - until(() => facts().filter((f) => f.type === 'session_interaction_pending')[n], `pending ${n}`); -const settle = async (id: string) => - until( - () => facts().find((f) => f.type === 'session_admission_settled' && f.clientRequestId === id), - `settlement ${id}`, - ); -const choose = async (f: any, answer: string, extra: any = {}) => - submission.decide({ - kind: 'choose', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - decisions: [{ callId: f.interaction.descriptor.callId, choice: { answer, ...extra } }], - }); -const nav = async (f: any, direction: string) => - submission.decide({ - kind: 'navigate', - turnId: f.turnId, - interactionId: f.interaction.interactionId, - revision: f.interaction.revision, - generation: f.generation, - direction, - }); -try { - await submission.submit({ requestId: 'request1' as any, input: { text: 'RETRY_FAILURE_ORIGINAL' } }); - await settle('request1'); - assert.equal(requests.length, 2); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - const targetKind = (process.env.RETRY_TARGET || 'tool') as 'turn' | 'tool'; - await assert.rejects(submission.retry(targetKind), /injected|Persistence/); - assert.equal(failed, true); - assert.equal(requests.length, 2); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - assert.equal(facts().filter((f) => f.type === retryFact).length, 0); - assert.equal(facts().filter((f) => f.type === 'session_turn_started').length, 1); - assert.equal(operations[targetKind], 1); - await assert.rejects(submission.retry(targetKind), /injected|Persistence/); - assert.equal(operations[targetKind], 1, 'poisoned retry must not mutate source twice'); - assert.equal(requests.length, 2); - assert.equal(facts().filter((f) => f.type === 'session_admitted').length, 1); - console.log( - 'PASS explicit retry', - targetKind, - 'boundary fsync failure has no launch/public success and repeated poisoned command cannot rewind again', - ); -} finally { - try { - await session.close(); - } catch {} - await models.dispose(); - server.close(); - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/c3a-retry-kill-worker.mts b/rewrite/bench/c3a-retry-kill-worker.mts deleted file mode 100644 index da2ae6c27..000000000 --- a/rewrite/bench/c3a-retry-kill-worker.mts +++ /dev/null @@ -1,209 +0,0 @@ -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = process.env.PROBE_ROOT!; -const targetKind = process.env.RETRY_TARGET!; -const targetFile = path.join(root, 'workspace', 'effect.txt'); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace, { recursive: true }); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - fs.appendFileSync(path.join(root, 'requests.jsonl'), body + '\n'); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ - kind: targetKind === 'tool' && requests.length === 1 && !process.env.REOPEN_ID ? 'call' : 'done', - }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: 'effect-call', - name: 'shell', - arguments: JSON.stringify({ command: `printf one >> '${targetFile}'`, sandbox: 'unsandboxed' }), - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'r-call' }; - return; - } - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'always' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'always']]) as any, -); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphs = 0; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - graphs++; - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); - -const observedCount = () => - fs.existsSync(path.join(root, 'requests.jsonl')) - ? fs.readFileSync(path.join(root, 'requests.jsonl'), 'utf8').trim().split('\n').length - : 0; -if (process.env.REOPEN_ID) { - const expected = JSON.parse(fs.readFileSync(path.join(root, 'boundary.json'), 'utf8')); - const before = observedCount(); - const opened = await store.openAdopted(process.env.REOPEN_ID as any); - const state = opened.session.state(); - assert.equal(graphs, 1); - assert.equal(state.activeTurnId, null); - assert.equal(state.pendingInteraction, null); - assert.deepEqual(state.providerHistory, expected.history); - assert.equal(state.messages.filter((x: any) => x.sender === 'user').length, 1); - const original: any = state.messages.find((x: any) => x.sender === 'user'); - assert.deepEqual(original.submittedTurn, expected.original); - assert.equal(state.admissions.request1.result, 'accepted'); - assert.equal(Object.keys(state.admissions).length, 1); - await new Promise((resolve) => setTimeout(resolve, 50)); - assert.equal(observedCount(), before); - assert.equal(requests.length, 0); - assert.equal( - fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : '', - targetKind === 'tool' ? 'one' : '', - ); - await opened.session.close(); - await models.dispose(); - server.close(); - console.log('REOPEN_PASS ' + targetKind); - process.exit(0); -} -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -const settled = new Promise((resolve) => - session.subscribe(0, (e) => { - if (e.kind === 'fact' && e.entry.fact.type === 'session_admission_settled') resolve(); - }), -); -await submission.submit({ - requestId: 'request1' as any, - input: { - text: 'original input', - images: [{ id: 'image', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }], - skill: { name: 'skill', description: 'description', body: 'SKILL-BODY' }, - }, -}); -await settled; -assert.equal(session.state().status, 'idle'); -session.subscribe(session.state().lastSeq, (e) => { - if ( - e.kind === 'fact' && - e.entry.fact.type === (targetKind === 'tool' ? 'tool_output_rewound' : 'user_turn_retried') - ) { - const state = session.state(); - fs.writeFileSync( - path.join(root, 'boundary.json'), - JSON.stringify({ - id: session.id, - history: state.providerHistory, - original: (state.messages.find((x: any) => x.sender === 'user') as any).submittedTurn, - count: observedCount(), - }), - ); - process.kill(process.pid, 'SIGKILL'); - } -}); -await submission.retry(targetKind as any); -throw Error('retry dispatch escaped synchronous fsynced-boundary kill'); diff --git a/rewrite/bench/c3a-retry-kill.mts b/rewrite/bench/c3a-retry-kill.mts deleted file mode 100644 index 13c0b21ab..000000000 --- a/rewrite/bench/c3a-retry-kill.mts +++ /dev/null @@ -1,70 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; -for (const target of ['turn', 'tool']) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c3a-retry-kill-')); - let child: any; - const run = (extra: any = {}) => - spawn( - process.execPath, - ['--import', 'tsx', fileURLToPath(new URL('./c3a-retry-kill-worker.mts', import.meta.url))], - { - env: { - ...process.env, - PROBE_ROOT: root, - RETRY_TARGET: target, - NODE_OPTIONS: '--unhandled-rejections=strict', - ...extra, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - try { - child = run(); - let stdout = '', - stderr = ''; - child.stdout.on('data', (x: any) => (stdout += x)); - child.stderr.on('data', (x: any) => (stderr += x)); - const exit: any = await new Promise((resolve, reject) => { - const t = setTimeout(() => { - child.kill('SIGKILL'); - reject(Error('boundary timeout ' + stderr + ' ' + stdout)); - }, 20000); - child.on('exit', (code: any, signal: any) => { - clearTimeout(t); - resolve({ code, signal }); - }); - }); - assert.equal(exit.signal, 'SIGKILL', stderr + ' ' + stdout); - const boundary = JSON.parse(fs.readFileSync(path.join(root, 'boundary.json'), 'utf8')); - assert.equal(boundary.count, target === 'tool' ? 2 : 1); - child = run({ REOPEN_ID: boundary.id }); - stdout = ''; - stderr = ''; - child.stdout.on('data', (x: any) => (stdout += x)); - child.stderr.on('data', (x: any) => (stderr += x)); - const code = await new Promise((resolve, reject) => { - const t = setTimeout(() => { - child.kill('SIGKILL'); - reject(Error('reopen timeout ' + stderr)); - }, 20000); - child.on('exit', (code: any) => { - clearTimeout(t); - resolve(code); - }); - }); - assert.equal(code, 0, stderr + ' ' + stdout); - assert.ok(stdout.includes('REOPEN_PASS ' + target), stdout); - console.log( - 'PASS ' + - target + - ' actual app/Engine fsynced retry-before-dispatch SIGKILL -> fresh process graph exact native original + no automatic provider/effect', - ); - } finally { - child?.kill('SIGKILL'); - fs.rmSync(root, { recursive: true, force: true }); - } -} diff --git a/rewrite/bench/c3a-retry-turn.mts b/rewrite/bench/c3a-retry-turn.mts deleted file mode 100644 index 3eee77c7b..000000000 --- a/rewrite/bench/c3a-retry-turn.mts +++ /dev/null @@ -1,181 +0,0 @@ -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ kind: 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - assert.equal(data.kind, 'done'); - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -let graphs = 0; -let graphOwnership: ToolOwnershipRegistry; -let graphContext: SessionContextService; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => { - throw new Error('combined handle expected'); - }, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => { - const ownership = (graphOwnership = new ToolOwnershipRegistry()); - const sessionContext = (graphContext = new SessionContextService()); - const execution = new ExecutionContext(); - execution.enterWorkspace(workspace); - graphs++; - return { - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: sessionContext, executionContext: execution }, - }, - }; - }, - runtime: () => ({ - toolOwnership: graphOwnership, - deps: { logger, settingsService: pair.service, sessionContextService: graphContext }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -let resolveDone: () => void; -const done = new Promise((resolve) => { - resolveDone = resolve; -}); -session.subscribe(0, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolveDone(); -}); -const result = await submission.submit({ - requestId: 'request1' as any, - input: { - text: 'Hello from adopted session', - images: [ - { id: 'one', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }, - { id: 'two', data: 'd29ybGQ=', mimeType: 'image/jpeg', byteSize: 5, displayNumber: 2 }, - ], - skill: { name: 'fixture', description: 'desc', body: 'SKILL-BODY' }, - }, -}); -assert.equal(result.kind, 'started'); -await done; -assert.equal(session.state().status, 'idle'); -assert.equal(requests.length, 1); -assert.ok(JSON.stringify(requests[0].input).includes('Hello from adopted session')); -assert.ok(JSON.stringify(requests[0].input).includes('SKILL-BODY')); -assert.ok(JSON.stringify(requests[0].input).includes('aGVsbG8=')); -assert.ok(JSON.stringify(requests[0].input).includes('d29ybGQ=')); -const retryDone = new Promise((resolve) => { - session.subscribe(session.state().lastSeq, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_turn_ended') resolve(); - }); -}); -const retry = await submission.retry('turn'); -assert.equal(retry.kind, 'started'); -await retryDone; -assert.equal(requests.length, 2); -assert.ok(JSON.stringify(requests[1].input).includes('Hello from adopted session')); -assert.equal(session.state().messages.filter((message: any) => message.sender === 'user').length, 1); -const id = session.id; -await session.close(); -const reopenedHandle = await store.openAdopted(id); -const reopened = reopenedHandle.session; -assert.equal(graphs, 2); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('Hello from adopted session')); -assert.ok(JSON.stringify(reopened.state().providerHistory).includes('aGVsbG8=')); -assert.ok(JSON.stringify(reopened.state().messages).includes('SKILL-BODY')); -const followupDone = new Promise((resolve) => { - reopened.subscribe(reopened.state().lastSeq, (event) => { - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolve(); - }); -}); -await reopenedHandle.submission.submit({ requestId: 'request2' as any, input: { text: 'Follow up' } }); -await followupDone; -assert.equal(requests.length, 3); -assert.ok(JSON.stringify(requests[2].input).includes('Hello from adopted session')); -assert.ok(JSON.stringify(requests[2].input).includes('Bench complete')); -await reopened.close(); -await models.dispose(); -server.close(); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS actual Session + source graph + engine plain turn and reopen'); diff --git a/rewrite/bench/c3a-settings-legacy.mts b/rewrite/bench/c3a-settings-legacy.mts deleted file mode 100644 index afd802894..000000000 --- a/rewrite/bench/c3a-settings-legacy.mts +++ /dev/null @@ -1,68 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { projectRuntimeSettings } from '../src/session/adopted/settings.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-settings-')); -const workspace = { root, access: 'read_write', target: 'local' } as const; -const model = { provider: 'openai', model: 'a' }; -const logger = { debug() {}, info() {}, warn() {}, error() {} } as any; -const log = CanonicalSessionLog.create({ root, id: 'legacy', metadata: {}, workspace, model, logger }); -await log.append([ - { - type: 'assistant_turn', - turn: { items: [] }, - state: { previousResponseId: 'old-chain', provider: 'openai', model: 'a' }, - } as any, - { type: 'settings_changed', key: 'agent.modelSelection', value: { provider: ' openai ', model: ' a ' } }, -]); -await log.append([ - { - type: 'session_settings_initialized', - version: 1, - model: { provider: 'openai', model: 'a' }, - reasoningEffort: 'default', - }, -]); -let replay = loadCanonicalConversation('legacy', root, 'live'); -assert.equal(replay.previousResponseId, 'old-chain'); -assert.equal(replay.model, 'a'); -await log.append([{ type: 'settings_changed', key: 'agent.reasoningEffort', value: 'high' }]); -replay = loadCanonicalConversation('legacy', root, 'live'); -assert.equal(replay.previousResponseId, 'old-chain'); -assert.equal(replay.reasoningEffort, 'high'); -assert.equal( - projectRuntimeSettings( - log.entries().map((e) => e.fact), - { selection: model, reasoningEffort: 'default' }, - ).snapshot.reasoningEffort, - 'high', -); -await log.append([{ type: 'settings_changed', key: 'agent.reasoningEffort', value: 'high', sessionMutation: 1 }]); -assert.equal(loadCanonicalConversation('legacy', root, 'live').previousResponseId, null); -await assert.rejects( - log.append([ - { - type: 'session_settings_initialized', - version: 1, - model: { provider: 'openai', model: 'a' }, - reasoningEffort: 'default', - }, - ]), -); -await log.dispose(); -fs.appendFileSync( - path.join(root, 'legacy.jsonl'), - JSON.stringify({ - v: 3, - seq: 999, - ts: new Date(0).toISOString(), - logId: 'legacy', - eventId: 'corrupt', - event: { type: 'session_settings_initialized', version: 2, model, reasoningEffort: 'default' }, - }) + '\n', -); -assert.throws(() => CanonicalSessionLog.readOnly(root, 'legacy')); -fs.rmSync(root, { recursive: true, force: true }); -console.log('PASS canonical mixed source settings chronology and chain'); diff --git a/rewrite/bench/c3a-settings.mts b/rewrite/bench/c3a-settings.mts deleted file mode 100644 index ff5d37018..000000000 --- a/rewrite/bench/c3a-settings.mts +++ /dev/null @@ -1,246 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { AgentClient } from '../../source/lib/agent-client.ts'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -const mode = process.env.PROBE_MODE || 'normal'; -let failFsync = false; -const fileSystem = new Proxy(fs, { - get(target, key) { - if (key === 'fsyncSync') - return (fd: number) => { - if (failFsync) { - failFsync = false; - throw new Error('injected budget fsync'); - } - return target.fsyncSync(fd); - }; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, -}); -let requests: any[] = []; -const provider: any = { - id: 'openai', - label: 'Fixture', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - requests.push(request); - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }], - responseId: `r${requests.length}`, - usage: { inputTokens: 3, outputTokens: 2 }, - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - temperature: 0.7, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'off' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding({ readable, writable: [] }, new Map([['shell.autoApproveMode', 'off']]) as any); - -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const ownership = new ToolOwnershipRegistry(); -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - fileSystem, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: ownership, - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: ownership, - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - -const other = await store.createAdopted(workspaceConfig, modelRef); -const events: any[] = []; -session.subscribe(0, (e) => events.push(e)); -const until = async (id: string) => { - const end = Date.now() + 12000; - while (Date.now() < end) { - if ( - events.some( - (e) => - e.kind === 'fact' && e.entry.fact.type === 'session_admission_settled' && e.entry.fact.clientRequestId === id, - ) - ) - return; - await new Promise((r) => setTimeout(r, 10)); - } - throw new Error('settle timeout ' + JSON.stringify(events)); -}; -await (async () => { - try { - const baseline = CanonicalSessionLog.readOnly(path.join(root, 'sessions'), session.id).find( - (e) => e.fact.type === 'session_settings_initialized', - ); - assert.ok(baseline); - assert.equal((baseline.fact as any).temperature, 0.7); - if (mode === 'fault') { - failFsync = true; - await assert.rejects(submission.setRuntimeSettings({ kind: 'temperature', temperature: 0.2 })); - await assert.rejects(submission.submit({ requestId: 'after', input: { text: 'no' } })); - await assert.rejects(session.closeLog()); - assert.equal(requests.length, 0); - console.log('PASS settings fsync poison'); - return; - } - if (mode === 'partial') { - const setter = AgentClient.prototype.setTemperature; - AgentClient.prototype.setTemperature = function (value) { - setter.call(this, value); - throw new Error('partial setter'); - }; - try { - await assert.rejects( - submission.setRuntimeSettings({ kind: 'temperature', temperature: 0.2 }), - /partial setter/, - ); - } finally { - AgentClient.prototype.setTemperature = setter; - } - await assert.rejects(submission.submit({ requestId: 'after', input: { text: 'no' } })); - await assert.rejects(session.closeLog()); - assert.equal(requests.length, 0); - console.log('PASS settings partial setter poison'); - return; - } - if (mode === 'unsupported') { - const setter = AgentClient.prototype.setTemperature; - (AgentClient.prototype as any).setTemperature = undefined; - const seq = session.state().lastSeq; - try { - assert.deepEqual(await submission.setRuntimeSettings({ kind: 'temperature', temperature: 0.2 }), { - kind: 'unsupported', - }); - } finally { - AgentClient.prototype.setTemperature = setter; - } - assert.equal(session.state().lastSeq, seq); - console.log('PASS settings unsupported pure'); - return; - } - - const first = submission.setRuntimeSettings({ kind: 'temperature', temperature: 0.2 }); - const duplicate = submission.setRuntimeSettings({ kind: 'temperature', temperature: 0.2 }); - assert.equal(first, duplicate); - assert.equal((await first).kind, 'applied'); - assert.equal( - ( - await submission.setRuntimeSettings({ - kind: 'model', - selection: { provider: ' openai ', model: ' fixture-b ' }, - }) - ).kind, - 'applied', - ); - assert.deepEqual(session.state().model, { provider: 'openai', model: 'fixture-b' }); - assert.equal((await submission.setRuntimeSettings({ kind: 'reasoning', effort: 'high' })).kind, 'applied'); - assert.equal(host.get('agent.temperature'), 0.7); - await submission.submit({ requestId: 'r1', input: { text: 'test' } }); - await until('r1'); - assert.equal(requests[0].temperature, 0.2); - assert.equal(requests[0].reasoning?.effort, 'high'); - const otherEvents: any[] = []; - other.session.subscribe(0, (e) => otherEvents.push(e)); - await other.submission.submit({ requestId: 'other', input: { text: 'other' } }); - const end = Date.now() + 12000; - while ( - !otherEvents.some((e) => e.kind === 'fact' && e.entry.fact.type === 'session_admission_settled') && - Date.now() < end - ) - await new Promise((r) => setTimeout(r, 10)); - assert.equal(requests[1].temperature, 0.7); - await submission.setRuntimeSettings({ kind: 'temperature' }); - await session.close(); - const reopened = await store.openAdopted(session.id); - const reopenEvents: any[] = []; - reopened.session.subscribe(0, (e) => reopenEvents.push(e)); - await reopened.submission.submit({ requestId: 'r2', input: { text: 'again' } }); - const end2 = Date.now() + 12000; - while ( - !reopenEvents.some( - (e) => - e.kind === 'fact' && - e.entry.fact.type === 'session_admission_settled' && - e.entry.fact.clientRequestId === 'r2', - ) && - Date.now() < end2 - ) - await new Promise((r) => setTimeout(r, 10)); - assert.equal(requests[2].temperature, undefined); - await reopened.session.close(); - console.log('PASS settings normal two graph unset reopen'); - } finally { - await session.close().catch(() => {}); - await other.session.close(); - await models.dispose(); - fs.rmSync(root, { recursive: true, force: true }); - } -})(); diff --git a/rewrite/bench/c3a-surge-capability.mts b/rewrite/bench/c3a-surge-capability.mts deleted file mode 100644 index c66388278..000000000 --- a/rewrite/bench/c3a-surge-capability.mts +++ /dev/null @@ -1,265 +0,0 @@ -import assert from 'node:assert/strict'; -import { injectSkillIntoTurn } from '../../source/types/user-turn.ts'; -import { issueInputSurgeApproval } from '../../source/services/input-surge-approval.ts'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ kind: 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: 'call1', - name: process.env.PROBE_MODE === 'unknown' ? 'missing_tool' : 'shell', - arguments: rawArgs, - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'r1' }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'always' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'always']]) as any, -); -assert.equal(pair.service.get('shell.autoApproveMode'), 'always'); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -let client: any; -const { engine, tools, turnPrompt } = createBenchEngine({ - bindSessionClient(value) { - client = value; - }, - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, -}); -assert.ok(tools.original('shell'), 'source shell assembled'); - -let sourceDone: any; -const sourceSeen = new Promise((r) => (sourceDone = r)); -let foreground: any; -let runtime: any; -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => ({ - submit: (...args: any[]) => foreground.driver.submit(...args), - decide: (...args: any[]) => foreground.driver.decide(...args), - cancel: () => foreground.driver.cancel(), - retry: () => foreground.driver.retry(), - compact: () => foreground.driver.compact(), - setModel: (...args: any[]) => foreground.driver.setModel(...args), - subscribe: (...args: any[]) => foreground.driver.subscribe(...args), - }), - adoptedSubmissionFactory: (ctx) => { - runtime = createSessionRuntimeInternals({ - sessionId: ctx.id, - agentClient: client, - toolOwnership: new ToolOwnershipRegistry(), - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }); - foreground = createAdoptedForeground({ - onSourceEvent: (update) => { - observed.push({ kind: 'source', ...update }); - if (update.event.type === 'error' && update.event.kind === 'input_surge_guard') sourceDone(); - }, - context: ctx, - engine, - runtime, - restored: loadCanonicalConversation(ctx.id, path.join(root, 'sessions')), - makeInput: (turnId) => ({ - turnId, - history: [], - newItems: [], - instructions: turnPrompt().instructions, - model: modelRef, - toolNames: ['shell'], - workspace: workspaceConfig, - budget: { kind: 'adopted' }, - }), - }); - return foreground.submission; - }, -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -const repeated: any[] = [{ type: 'message', role: 'user', content: 'prior' }]; -for (let n = 0; n < 3; n++) - for (let copy = 0; copy < 2; copy++) { - repeated.push({ type: 'function_call', callId: `dup-${n}`, name: 'shell', arguments: '{}' }); - repeated.push({ type: 'function_call_result', callId: `dup-${n}`, name: 'shell', output: 'ok' }); - } -const rev = runtime.record.getProviderHistorySnapshot().revision; -assert.equal(runtime.record.replaceHistoryAtRevision(rev, repeated), true); -runtime.state.pendingModeNotice = 'MODE_NOTICE_SENTINEL'; - -let resolveDone: () => void; -const done = new Promise((resolve) => { - resolveDone = resolve; -}); -let terminalDone: any; -const liveDone = new Promise((r) => (terminalDone = r)); -const startedEffects: boolean[] = []; -const observed: any[] = []; -session.subscribe(0, (event) => { - observed.push(event); - if (event.kind === 'live' && event.event.type === 'turn_ended') terminalDone(); - if (event.kind === 'fact' && event.entry.fact.type === 'tool_started') startedEffects.push(fs.existsSync(target)); - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolveDone(); -}); -const original: any = { - text: 'Hello from adopted session', - images: [ - { id: 'one', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }, - { id: 'two', data: 'd29ybGQ=', mimeType: 'image/jpeg', byteSize: 5, displayNumber: 2 }, - ], - skill: { name: 'fixture', description: 'desc', body: 'SKILL-BODY' }, -}; -const mode = process.env.PROBE_MODE; -const token = mode === 'forged' ? ({} as any) : issueInputSurgeApproval(injectSkillIntoTurn(original)); -const current = structuredClone(original); -if (mode === 'changed-image' || mode === 'mismatch-reuse') current.images[0].data = 'd29ybGQ='; -if (mode === 'changed-skill') current.skill.body = 'OTHER_SKILL'; -const result = await submission.submit({ requestId: 'request1' as any, input: current }, { inputSurgeApproval: token }); -assert.equal(result.kind, 'started'); -await done; -const valid = mode === 'valid' || mode === 'reuse'; -assert.equal(requests.length, valid ? 1 : 0); -const facts = observed.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -if (!valid) { - await Promise.race([sourceSeen, new Promise((_, r) => setTimeout(() => r(Error('missing source drop')), 5000))]); - assert.ok(facts.some((f) => f.type === 'error' && f.kind === 'input_surge_guard')); - assert.ok(facts.some((f) => f.type === 'events_retracted')); - const drop = observed.find( - (e) => e.kind === 'source' && e.event.type === 'error' && e.event.kind === 'input_surge_guard', - ); - assert.ok(drop); - assert.deepEqual(drop.droppedInput, current); - assert.ok( - observed.indexOf(drop) > - observed.findIndex((e) => e.kind === 'fact' && e.entry.fact.type === 'session_admission_settled'), - ); - assert.equal(runtime.state.pendingModeNotice, 'MODE_NOTICE_SENTINEL'); -} else { - assert.equal(runtime.state.pendingModeNotice, null); - const first = JSON.stringify(requests[0].input); - assert.ok(first.includes('Hello from adopted session')); - assert.ok(first.includes('aGVsbG8=')); - assert.ok(first.includes('SKILL-BODY')); -} -if (mode === 'reuse' || mode === 'mismatch-reuse') { - const rev2 = runtime.record.getProviderHistorySnapshot().revision; - assert.equal(runtime.record.replaceHistoryAtRevision(rev2, repeated), true); - runtime.state.pendingModeNotice = 'SECOND_NOTICE'; - let secondDone: any; - const finished = new Promise((r) => (secondDone = r)); - session.subscribe(0, (e) => { - if ( - e.kind === 'fact' && - e.entry.fact.type === 'session_admission_settled' && - e.entry.fact.clientRequestId === 'request2' - ) - secondDone(); - }); - const second = await submission.submit( - { requestId: 'request2' as any, input: original }, - { inputSurgeApproval: token }, - ); - assert.equal(second.kind, 'started'); - await finished; - assert.equal(requests.length, mode === 'reuse' ? 1 : 0); - assert.ok( - observed.some( - (e) => e.kind === 'fact' && e.entry.fact.type === 'error' && e.entry.fact.kind === 'input_surge_guard', - ), - ); - assert.equal(runtime.state.pendingModeNotice, 'SECOND_NOTICE'); -} -console.log('PASS surge capability', mode); -await session.close(); -await engine.dispose(); -await models.dispose(); -server.close(); -fs.rmSync(root, { recursive: true, force: true }); diff --git a/rewrite/bench/c3a-tool-dispatch.mts b/rewrite/bench/c3a-tool-dispatch.mts deleted file mode 100644 index 161ad4736..000000000 --- a/rewrite/bench/c3a-tool-dispatch.mts +++ /dev/null @@ -1,239 +0,0 @@ -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createBenchEngine } from './source-app.mjs'; -import { loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c1-bench-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace); -const target = path.join(workspace, 'effect.txt'); -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ kind: requests.length === 1 ? 'call' : 'done' })); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ command: `printf 'one physical effect' >> '${target}'`, sandbox: 'unsandboxed' }); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: 'call1', - name: process.env.PROBE_MODE === 'unknown' ? 'missing_tool' : 'shell', - arguments: rawArgs, - }; - yield call; - yield { type: 'completion', output: [call], responseId: 'r1' }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'r2', - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'always' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'always']]) as any, -); -assert.equal(pair.service.get('shell.autoApproveMode'), 'always'); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -let client: any; -const { engine, tools, turnPrompt } = createBenchEngine({ - bindSessionClient(value) { - client = value; - }, - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, -}); -assert.ok(tools.original('shell'), 'source shell assembled'); - -let foreground: any; -let runtime: any; -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - driverFactory: () => ({ - submit: (...args: any[]) => foreground.driver.submit(...args), - decide: (...args: any[]) => foreground.driver.decide(...args), - cancel: () => foreground.driver.cancel(), - retry: () => foreground.driver.retry(), - compact: () => foreground.driver.compact(), - setModel: (...args: any[]) => foreground.driver.setModel(...args), - subscribe: (...args: any[]) => foreground.driver.subscribe(...args), - }), - adoptedSubmissionFactory: (ctx) => { - runtime = createSessionRuntimeInternals({ - sessionId: ctx.id, - agentClient: client, - toolOwnership: new ToolOwnershipRegistry(), - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }); - foreground = createAdoptedForeground({ - context: ctx, - engine, - runtime, - restored: loadCanonicalConversation(ctx.id, path.join(root, 'sessions')), - makeInput: (turnId) => ({ - turnId, - history: [], - newItems: [], - instructions: turnPrompt().instructions, - model: modelRef, - toolNames: ['shell'], - workspace: workspaceConfig, - budget: { kind: 'adopted' }, - }), - }); - return foreground.submission; - }, -}); -const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); -let resolveDone: () => void; -const done = new Promise((resolve) => { - resolveDone = resolve; -}); -let terminalDone: any; -const liveDone = new Promise((r) => (terminalDone = r)); -const startedEffects: boolean[] = []; -const observed: any[] = []; -session.subscribe(0, (event) => { - observed.push(event); - if (event.kind === 'live' && event.event.type === 'turn_ended') terminalDone(); - if (event.kind === 'fact' && event.entry.fact.type === 'tool_started') startedEffects.push(fs.existsSync(target)); - if (event.kind === 'fact' && event.entry.fact.type === 'session_admission_settled') resolveDone(); -}); -const result = await submission.submit({ - requestId: 'request1' as any, - input: { - text: 'Hello from adopted session', - images: [ - { id: 'one', data: 'aGVsbG8=', mimeType: 'image/png', byteSize: 5, displayNumber: 1 }, - { id: 'two', data: 'd29ybGQ=', mimeType: 'image/jpeg', byteSize: 5, displayNumber: 2 }, - ], - skill: { name: 'fixture', description: 'desc', body: 'SKILL-BODY' }, - }, -}); -assert.equal(result.kind, 'started'); -await done; -await Promise.race([ - liveDone, - new Promise((_, reject) => setTimeout(() => reject(Error('missing live terminal')), 5000)), -]); -const facts = observed.filter((e) => e.kind === 'fact').map((e) => e.entry.fact); -const live = observed.filter((e) => e.kind === 'live').map((e) => e.event); -assert.equal(live.filter((e) => e.type === 'turn_ended').length, 1); -assert.equal(facts.filter((f) => f.type === 'session_turn_ended').length, 1); -assert.equal(facts.filter((f) => f.type === 'session_admission_settled').length, 1); -assert.ok( - observed.findIndex((e) => e.kind === 'fact' && e.entry.fact.type === 'session_turn_ended') < - observed.findIndex((e) => e.kind === 'live' && e.event.type === 'turn_ended'), -); -if (process.env.PROBE_MODE === 'unknown') { - assert.equal(requests.length, 2); - assert.equal(fs.existsSync(target), false); - assert.equal(facts.filter((f) => f.type === 'tool_started').length, 0); - const input = requests[1].input; - assert.equal(input.filter((i: any) => i.type === 'tool_call' && i.id === 'call1').length, 1); - assert.equal( - input.filter((i: any) => i.type === 'tool_result' && i.id === 'call1' && i.output === 'Unknown tool: missing_tool') - .length, - 1, - ); - assert.equal(facts.find((f) => f.type === 'session_admission_settled').result, 'accepted'); -} -if (process.env.PROBE_MODE === 'tool') { - assert.equal(requests.length, 2); - assert.equal(fs.readFileSync(target, 'utf8'), 'one physical effect'); - assert.equal(facts.filter((f) => f.type === 'tool_started' && f.toolCallId === 'call1').length, 1); - assert.deepEqual(startedEffects, [false]); - const persisted = loadCanonicalConversation(session.id, path.join(root, 'sessions')); - assert.equal(persisted.history.filter((i: any) => i.type === 'function_call' && i.callId === 'call1').length, 1); - assert.equal( - persisted.history.filter((i: any) => i.type === 'function_call_result' && i.callId === 'call1').length, - 1, - ); - assert.ok( - observed.findIndex((e) => e.kind === 'live' && e.event.type === 'item') < - observed.findIndex((e) => e.kind === 'fact' && e.entry.fact.type === 'assistant_journal_item'), - ); - const input = requests[1].input; - assert.equal(input.filter((i: any) => i.type === 'tool_call' && i.id === 'call1').length, 1); - assert.equal(input.filter((i: any) => i.type === 'tool_result' && i.id === 'call1').length, 1); - assert.equal(facts.find((f) => f.type === 'session_admission_settled').result, 'accepted'); -} -console.log(`PASS foreground ${process.env.PROBE_MODE} dispatch and durable replay`); -await session.close(); -await engine.dispose(); -await models.dispose(); -server.close(); - -fs.rmSync(root, { recursive: true, force: true }); diff --git a/rewrite/bench/c3a-toolkill-worker.mts b/rewrite/bench/c3a-toolkill-worker.mts deleted file mode 100644 index 22712a55b..000000000 --- a/rewrite/bench/c3a-toolkill-worker.mts +++ /dev/null @@ -1,209 +0,0 @@ -import { createBenchEngine } from './source-app.mjs'; -import assert from 'node:assert/strict'; -import { LocalSessionStore } from '../src/session/local-store.ts'; -import { createAdoptedForeground } from '../src/session/adopted/foreground.ts'; -import { createSessionRuntimeInternals } from '../../source/services/session/session-composition.ts'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import { once } from 'node:events'; -import { createBenchSettings } from './source-app.mjs'; -import { createSourceModelTransports } from '../src/engine/adopted/provider-transports.ts'; -import { createLegacySessionHandleFactory } from '../src/session/adopted/session-handle.js'; -import { CanonicalSessionLog, loadCanonicalConversation } from '../src/session/adopted/conversation-log.ts'; -import { createProviderRegistry } from '../../source/providers/registry.ts'; -import { SessionContextService } from '../../source/services/session/session-context-service.ts'; -import { LoggingService } from '../../source/services/logging/logging-service.ts'; -import { ExecutionContext } from '../../source/services/execution-context.ts'; -import { ToolOwnershipRegistry } from '../../source/services/approval/tool-ownership-registry.ts'; -import { ConversationRecord } from '../../source/services/conversation/conversation-record.ts'; -const root = process.env.PROBE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), 'c3a-stage2-kill-')); -const workspace = path.join(root, 'workspace'); -fs.mkdirSync(workspace, { recursive: true }); -const target = path.join(workspace, 'effect.txt'); -const mode = process.env.PROBE_MODE || 'repeat'; -let requests: any[] = []; -const server = http.createServer(async (req, res) => { - let body = ''; - for await (const part of req) body += part; - requests.push(JSON.parse(body)); - fs.appendFileSync(path.join(root, 'provider-count'), 'x'); - res.setHeader('content-type', 'application/json'); - res.end( - JSON.stringify({ - kind: process.env.REOPEN_ID ? 'done' : requests.length === 1 ? 'call' : 'done', - index: requests.length, - }), - ); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -const address = server.address() as any; -const rawArgs = JSON.stringify({ - command: `printf 'one' >> '${target}'; echo $$ > '${root}/shell-pid'; while [ ! -f '${root}/release' ]; do sleep 0.05; done`, - sandbox: 'unsandboxed', -}); -const provider: any = { - id: 'openai', - label: 'Local fake', - fetchModels: async () => [], - capabilities: { supportsConversationChaining: false }, - createStreamedModel() { - return { - async *stream(request: any) { - const response = await fetch(`http://127.0.0.1:${address.port}/model`, { - method: 'POST', - body: JSON.stringify({ tools: request.tools, input: request.input }), - }); - const data: any = await response.json(); - if (data.kind === 'call') { - const call = { - type: 'tool_call', - id: `call${data.index}`, - name: mode.startsWith('ask') ? 'ask_user' : 'shell', - arguments: mode.startsWith('ask') - ? JSON.stringify({ - questions: [ - { question: 'First?', options: [{ label: 'Alpha' }, { label: 'Beta' }] }, - { question: 'Second?', options: [{ label: 'Red' }, { label: 'Blue' }], is_multi_select: true }, - ], - }) - : rawArgs, - }; - yield call; - const secondCall = mode === 'batch' ? { ...call, id: 'call2' } : null; - if (secondCall) yield secondCall; - yield { - type: 'completion', - usage: { inputTokens: 11, outputTokens: 3 }, - output: secondCall ? [call, secondCall] : [call], - responseId: `r${data.index}`, - }; - } else - yield { - type: 'completion', - output: [{ type: 'message', content: [{ type: 'text', text: 'Bench complete' }] }], - responseId: 'done', - usage: { inputTokens: 17, outputTokens: 5 }, - }; - }, - }; - }, -}; -const providers = createProviderRegistry(); -providers.upsertProvider(provider); -const { service: host, source } = createBenchSettings({ - disableLogging: true, - disableFilePersistence: true, - cli: { - agent: { - modelSelection: { provider: 'openai', model: 'fixture' }, - contextCompaction: { enabled: false }, - runBudget: { escalation: 'disabled' }, - retryAttempts: 0, - } as any, - memory: { enabled: false } as any, - shell: { autoApproveMode: 'always' } as any, - }, -}); -const readable = Object.keys(host.getAll()) as any; -const pair = source.forSessionBinding( - { readable, writable: [] }, - new Map([['shell.autoApproveMode', 'always']]) as any, -); -console.log('AUTO_MODE', pair.service.get('shell.autoApproveMode')); -const logger = new LoggingService({ disableLogging: true }); -const context = new SessionContextService(); -const executionContext = new ExecutionContext(); -executionContext.enterWorkspace(workspace); -const models = createSourceModelTransports({ - settingsService: pair.service, - loggingService: logger, - sessionContextService: context, - providerRegistry: providers, - ownership: 'borrowed', -}); -const workspaceConfig: any = { root: workspace, access: 'read_write', target: 'local' }; -const modelRef: any = { provider: 'openai', model: 'fixture' }; -const store = new LocalSessionStore({ - root: path.join(root, 'sessions'), - logger, - defaultWorkspace: workspaceConfig, - defaultModel: modelRef, - adoptedHandleFactory: createLegacySessionHandleFactory({ - assembleEngine: createBenchEngine, - engine: () => ({ - models, - settings: pair.settings, - clientOptions: { - selection: { provider: 'openai', model: 'fixture' }, - toolOwnership: new ToolOwnershipRegistry(), - deps: { settings: pair.service, logger, sessionContextService: context, executionContext }, - }, - }), - runtime: () => ({ - toolOwnership: new ToolOwnershipRegistry(), - deps: { logger, settingsService: pair.service, sessionContextService: context }, - }), - }), -}); - -const events: any[] = []; -async function until(f: () => any, label: string) { - const end = Date.now() + 12000; - while (Date.now() < end) { - const v = f(); - if (v) return v; - await new Promise((r) => setTimeout(r, 10)); - } - throw Error('timeout ' + label); -} -if (process.env.REOPEN_ID) { - const id = process.env.REOPEN_ID; - let { session, submission } = await store.openAdopted(id as any); - assert.equal(session.state().status, 'interrupted'); - assert.equal(session.state().activeTurnId, null); - assert.equal(session.state().pendingInteraction, null); - assert.equal(session.state().admissions.request1.result, 'aborted'); - let entries = CanonicalSessionLog.readOnly(path.join(root, 'sessions'), session.id); - assert.equal(entries.filter((e) => e.fact.type === 'session_interrupted').length, 1); - assert.equal(entries.filter((e) => e.fact.type === 'tool_started').length, 1); - const seq = session.state().lastSeq; - assert.equal(requests.length, 0); - await session.close(); - ({ session, submission } = await store.openAdopted(id as any)); - assert.equal(session.state().lastSeq, seq); - assert.equal(requests.length, 0); - session.subscribe(0, (e) => events.push(e)); - await submission.submit({ requestId: 'request2', input: { text: 'Fresh follow-up' } }); - await until( - () => - events.find( - (e) => - e.kind === 'fact' && - e.entry.fact.type === 'session_admission_settled' && - e.entry.fact.clientRequestId === 'request2', - ), - 'followup', - ); - assert.equal(requests.length, 1); - assert.equal(fs.readFileSync(target, 'utf8'), 'one'); - const items = requests[0].input; - assert.equal(items.filter((i) => i.type === 'tool_call' && i.id === 'call1').length, 1); - const results = items.filter((i) => i.type === 'tool_result' && i.id === 'call1'); - assert.equal(results.length, 1); - assert.ok(results[0].output.includes('Outcome unobserved')); - assert.ok(JSON.stringify(items).includes('Fresh follow-up')); - console.log('FOLLOWUP', JSON.stringify(items)); - await session.close(); - await models.dispose(); - server.close(); - console.log('REOPEN_PASS'); -} else { - const { session, submission } = await store.createAdopted(workspaceConfig, modelRef); - session.subscribe(0, (e) => { - if (e.kind === 'fact' && e.entry.fact.type === 'tool_started') console.log('STARTED ' + session.id); - }); - await submission.submit({ requestId: 'request1', input: { text: 'Execute once' } }); -} diff --git a/rewrite/bench/c3a-toolkill.mts b/rewrite/bench/c3a-toolkill.mts deleted file mode 100644 index eae3815e0..000000000 --- a/rewrite/bench/c3a-toolkill.mts +++ /dev/null @@ -1,77 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn } from 'node:child_process'; -const root = fs.mkdtempSync(path.join(os.tmpdir(), 'c3a-stage4-kill-')); -const run = (extra: any) => - spawn(process.execPath, ['--import', 'tsx', fileURLToPath(new URL('./c3a-toolkill-worker.mts', import.meta.url))], { - env: { ...process.env, PROBE_ROOT: root, ...extra }, - stdio: ['ignore', 'pipe', 'pipe'], - }); -let child = run({}), - out = '', - err = ''; -child.stdout.on('data', (x) => (out += x)); -child.stderr.on('data', (x) => (err += x)); -let shellPid = 0; -async function until(f: () => any, label: string) { - const end = Date.now() + 15000; - while (Date.now() < end) { - const v = f(); - if (v) return v; - if (child.exitCode !== null) throw Error('early exit ' + err + ' ' + out); - await new Promise((r) => setTimeout(r, 10)); - } - throw Error('timeout ' + label + ' ' + err + ' ' + out); -} -try { - const match = await until(() => out.match(/STARTED (\S+)/), 'durable tool_started'); - await until( - () => fs.existsSync(path.join(root, 'shell-pid')) && fs.existsSync(path.join(root, 'workspace', 'effect.txt')), - 'in-flight effect', - ); - shellPid = Number(fs.readFileSync(path.join(root, 'shell-pid'), 'utf8')); - assert.ok(shellPid > 0); - assert.equal(fs.readFileSync(path.join(root, 'workspace', 'effect.txt'), 'utf8'), 'one'); - const exited = new Promise((r) => child.once('exit', r)); - child.kill('SIGKILL'); - await exited; - assert.equal(fs.readFileSync(path.join(root, 'provider-count'), 'utf8'), 'x'); - process.kill(shellPid, 'SIGKILL'); - await until(() => { - try { - const st = fs.readFileSync(`/proc/${shellPid}/stat`, 'utf8'); - return st.slice(st.lastIndexOf(')') + 2).startsWith('Z'); - } catch { - return true; - } - }, 'shell process dead'); - shellPid = 0; - child = run({ REOPEN_ID: match[1] }); - out = ''; - err = ''; - child.stdout.on('data', (x) => (out += x)); - child.stderr.on('data', (x) => (err += x)); - const code = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(Error('reopen timeout ' + err)), 15000); - child.on('exit', (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - assert.equal(code, 0, err + ' ' + out); - assert.ok(out.includes('REOPEN_PASS')); - assert.equal(fs.readFileSync(path.join(root, 'provider-count'), 'utf8'), 'xx'); - assert.equal(fs.readFileSync(path.join(root, 'workspace', 'effect.txt'), 'utf8'), 'one'); - console.log(out); - console.log('PASS actual tool SIGKILL fresh process idempotent recovery and explicit followup no replay'); -} finally { - child.kill('SIGKILL'); - if (shellPid) - try { - process.kill(shellPid, 'SIGKILL'); - } catch {} - fs.rmSync(root, { recursive: true, force: true }); -} diff --git a/rewrite/bench/direct-manual-unknown-capacity.mts b/rewrite/bench/direct-manual-unknown-capacity.mts deleted file mode 100644 index 724c9c99c..000000000 --- a/rewrite/bench/direct-manual-unknown-capacity.mts +++ /dev/null @@ -1,49 +0,0 @@ -// Integration probe: manual Session plan through the real local compactor and model stream. -import assert from 'node:assert/strict'; -import { compactDirectHistory } from '../src/engine/direct-recovery-compaction.js'; -import { prepareDirectManualCompaction } from '../src/session/direct-command-compaction.js'; - -const ref = { provider: 'codex', model: 'unknown-capacity' }; -let calls = 0; -const model = { - ref, - async *stream() { - calls++; - yield { type: 'text_delta' as const, delta: 'Summary of earlier work.' }; - yield { type: 'done' as const, stopReason: 'end_turn' as const }; - }, -}; -const history = Array.from({ length: 3 }, (_, i) => [ - { type: 'user_message' as const, id: `u${i}`, content: [{ type: 'text' as const, text: `request ${i}` }] }, - { - type: 'assistant_message' as const, - id: `a${i}`, - content: [{ type: 'text' as const, text: 'long result '.repeat(500) }], - }, -]).flat(); -const request = { model: ref, instructions: 'help', input: history, tools: [] }; -const input = { - model, - request, - settings: { enabled: true, mode: 'local' as const, compactThreshold: 0.8, compactThresholdTokens: null }, - signal: new AbortController().signal, -}; -const automatic = await compactDirectHistory({ ...input, manual: false }); -assert.equal(automatic.kind, 'not_needed'); -assert.equal(calls, 0); -const plan = await prepareDirectManualCompaction({ executionsSettled: true, history, revision: 'r1' } as any, input); -assert.equal(plan.kind, 'replace'); -if (plan.kind !== 'replace') throw Error('manual compaction did not replace history'); -assert.equal(plan.fact.type, 'history_replaced'); -assert.equal('rearmAtEstimatedTokens' in plan.fact, false); -assert.ok(calls > 0); -assert.ok(plan.result.estimate.renderedInputTokens < plan.result.before.renderedInputTokens); -const constrained = await prepareDirectManualCompaction({ executionsSettled: true, history, revision: 'r1' } as any, { - ...input, - maxRequestInputTokens: 1, -}); -assert.equal(constrained.kind, 'not_compacted'); -assert.equal(constrained.kind === 'not_compacted' && constrained.result.kind, 'blocked'); -console.log( - 'PASS: manual unknown capacity replaces history; automatic remains below fallback threshold; explicit ceiling remains enforced', -); diff --git a/rewrite/bench/native-tracer/jsonl-log.ts b/rewrite/bench/native-tracer/jsonl-log.ts deleted file mode 100644 index 7f0816ebe..000000000 --- a/rewrite/bench/native-tracer/jsonl-log.ts +++ /dev/null @@ -1,59 +0,0 @@ -import fs from 'node:fs'; -import type { TracerFact, TracerLogEntry, TracerSessionLog } from './session-types.js'; - -/** - * Append-only JSONL file. One `append` call is one write plus fsync, so a - * step's facts land together; a torn final line from a crash is ignored on read. - */ -export class JsonlSessionLog implements TracerSessionLog { - readonly #path: string; - #lastSeq: number; - - constructor(path: string) { - this.#path = path; - const { entries, validBytes } = readEntries(path); - // Drop a torn tail now, so the next append does not land after garbage. - if (fs.existsSync(path) && fs.statSync(path).size > validBytes) fs.truncateSync(path, validBytes); - this.#lastSeq = entries.at(-1)?.seq ?? 0; - } - - get lastSeq(): number { - return this.#lastSeq; - } - - async append(facts: readonly TracerFact[]): Promise { - const at = new Date().toISOString(); - const entries = facts.map((fact, index): TracerLogEntry => ({ seq: this.#lastSeq + index + 1, at, fact })); - const fd = fs.openSync(this.#path, 'a'); - try { - fs.writeSync(fd, entries.map((entry) => JSON.stringify(entry) + '\n').join('')); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - this.#lastSeq += entries.length; - return entries.map((entry) => entry.seq); - } - - async *read(fromSeq = 0): AsyncIterable { - for (const entry of readEntries(this.#path).entries) if (entry.seq > fromSeq) yield entry; - } -} - -function readEntries(path: string): { entries: TracerLogEntry[]; validBytes: number } { - if (!fs.existsSync(path)) return { entries: [], validBytes: 0 }; - const text = fs.readFileSync(path, 'utf8'); - const entries: TracerLogEntry[] = []; - let validBytes = 0; - for (const line of text.split('\n')) { - const end = validBytes + Buffer.byteLength(line) + 1; - if (end > Buffer.byteLength(text)) break; // last line has no newline: torn - try { - if (line) entries.push(JSON.parse(line) as TracerLogEntry); - } catch { - break; // torn tail from a crash mid-write - } - validBytes = end; - } - return { entries, validBytes }; -} diff --git a/rewrite/bench/native-tracer/local-store.ts b/rewrite/bench/native-tracer/local-store.ts deleted file mode 100644 index f895bdeaf..000000000 --- a/rewrite/bench/native-tracer/local-store.ts +++ /dev/null @@ -1,264 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import type { Item, ModelRef, ToolCallId } from '../../src/model/index.js'; -import type { ToolRegistry, Workspace } from '../../src/tools/index.js'; -import type { SettingsReader } from '../../src/config/index.js'; -import type { ApprovalDecision, Engine, TurnBudget, TurnId, TurnRun } from '../../src/engine/index.js'; -import type { - TracerAdmission, - TracerClientRequestId, - TracerInteractionId, - TracerLogEntry, - TracerSession, - TracerFact, - TracerSessionId, - TracerSessionLog, - TracerSessionState, - TracerStore, - TracerSessionSummary, - TracerSessionUpdate, -} from './session-types.js'; -import { JsonlSessionLog } from './jsonl-log.js'; -import { initialState, project } from './reducer.js'; - -export type LocalStoreOptions = { - readonly root: string; - readonly engine: Engine; - readonly tools: ToolRegistry; - readonly settings: SettingsReader; - readonly instructions: string; - readonly budget: TurnBudget; -}; - -const newId = (prefix: string): T => `${prefix}_${crypto.randomUUID()}` as T; - -/** Sessions as JSONL files under one directory. */ -export class NativeTracerStore implements TracerStore { - readonly #options: LocalStoreOptions; - - constructor(options: LocalStoreOptions) { - this.#options = options; - fs.mkdirSync(options.root, { recursive: true }); - } - - async create(workspace: Workspace, model: ModelRef): Promise { - const id = newId('ses'); - const session = new NativeTracerSession(id, new JsonlSessionLog(this.#file(id)), [], this.#options); - await session.commit([{ type: 'session_created', workspace, model }]); - return session; - } - - async open(id: TracerSessionId): Promise { - const file = this.#file(id); - if (!fs.existsSync(file)) throw new Error(`no session ${id}`); - const log = new JsonlSessionLog(file); - const entries: TracerLogEntry[] = []; - for await (const entry of log.read()) entries.push(entry); - const session = new NativeTracerSession(id, log, entries, this.#options); - await session.reconcile(); - return session; - } - - async list(): Promise<{ readonly sessions: readonly TracerSessionSummary[]; readonly nextCursor: string | null }> { - const sessions: TracerSessionSummary[] = []; - for (const name of fs.readdirSync(this.#options.root)) { - if (!name.endsWith('.jsonl')) continue; - const id = name.slice(0, -'.jsonl'.length) as TracerSessionId; - const log = new JsonlSessionLog(this.#file(id)); - let state = initialState(id); - let updatedAt = ''; - for await (const entry of log.read()) { - state = project(state, entry); - updatedAt = entry.at; - } - sessions.push({ - id, - status: state.status, - workspace: state.workspace, - lastSeq: state.lastSeq, - title: null, - updatedAt, - }); - } - return { sessions, nextCursor: null }; - } - - #file(id: TracerSessionId): string { - if (!/^ses_[0-9a-f-]{36}$/.test(id)) throw new Error('invalid session id'); - return path.join(this.#options.root, `${id}.jsonl`); - } -} - -class NativeTracerSession implements TracerSession { - readonly id: TracerSessionId; - readonly #log: TracerSessionLog; - readonly #options: LocalStoreOptions; - readonly #entries: TracerLogEntry[]; - readonly #listeners = new Set<(update: TracerSessionUpdate) => void>(); - readonly #seenRequests = new Map(); - #state: TracerSessionState; - #run: TurnRun | null = null; - - constructor(id: TracerSessionId, log: TracerSessionLog, entries: TracerLogEntry[], options: LocalStoreOptions) { - this.id = id; - this.#log = log; - this.#options = options; - this.#entries = [...entries]; - this.#state = entries.reduce(project, initialState(id)); - for (const entry of entries) { - if (entry.fact.type === 'turn_started' && entry.fact.requestId) { - this.#seenRequests.set(entry.fact.requestId, entry.fact.turnId); - } - } - } - - state(): TracerSessionState { - return this.#state; - } - - async submit(requestId: TracerClientRequestId, items: readonly Item[]): Promise { - const seen = this.#seenRequests.get(requestId); - if (seen) return { kind: 'duplicate', turnId: seen }; - if (this.#state.status === 'closed') return { kind: 'rejected', reason: 'closed' }; - // The tracer has no queue: a busy session refuses instead of queueing. - if (this.#state.status === 'running' || this.#state.status === 'awaiting_interaction') { - return { kind: 'rejected', reason: 'queue_full' }; - } - const turnId = newId('turn'); - this.#seenRequests.set(requestId, turnId); - await this.commit([ - { type: 'turn_started', turnId, requestId }, - ...items.map((item): TracerFact => ({ type: 'item_committed', turnId, item })), - ]); - this.#startTurn(turnId); - return { kind: 'started', turnId }; - } - - async decide( - interactionId: TracerInteractionId, - decisions: readonly { readonly callId: ToolCallId; readonly decision: ApprovalDecision }[], - ): Promise { - const pending = this.#state.pendingInteraction; - const checkpoint = this.#state.lastCheckpoint; - if (!pending || pending.interactionId !== interactionId || !checkpoint) throw new Error('stale interaction'); - await this.commit([{ type: 'interaction_resolved', interactionId, decisions }]); - this.#drive(this.#options.engine.resume(checkpoint, new Map(decisions.map((d) => [d.callId, d.decision])))); - } - - async cancel(): Promise { - this.#run?.cancel(); - } - - async retry(): Promise { - if (!this.#state.canRetry) return { kind: 'rejected', reason: 'closed' }; - const turnId = newId('turn'); - await this.commit([{ type: 'turn_started', turnId, requestId: null }]); - this.#startTurn(turnId); - return { kind: 'started', turnId }; - } - - async compact(): Promise { - throw new Error('compaction is not part of the tracer'); - } - - async setModel(model: ModelRef): Promise { - await this.commit([{ type: 'model_changed', model }]); - } - - subscribe(fromSeq: number, listener: (update: TracerSessionUpdate) => void): () => void { - for (const entry of this.#entries) if (entry.seq > fromSeq) listener({ kind: 'fact', entry }); - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - - async close(): Promise { - this.#run?.cancel(); - this.#listeners.clear(); - } - - /** The fixture never claims that native engine checkpoints survive process restart. */ - async reconcile(): Promise { - const { status } = this.#state; - if (status !== 'running' && status !== 'awaiting_interaction') return; - const activeTurnId = this.#state.activeTurnId ?? this.#state.pendingInteraction?.turnId; - if (!activeTurnId) return; - await this.commit([ - { type: 'turn_interrupted', turnId: activeTurnId, reason: 'process stopped; native continuation is not resumed' }, - ]); - } - - async commit(facts: readonly TracerFact[]): Promise { - const seqs = await this.#log.append(facts); - const at = new Date().toISOString(); - facts.forEach((fact, index) => { - const entry: TracerLogEntry = { seq: seqs[index]!, at, fact }; - this.#entries.push(entry); - this.#state = project(this.#state, entry); - this.#emit({ kind: 'fact', entry }); - }); - } - - #startTurn(turnId: TurnId): void { - this.#drive( - this.#options.engine.start({ - turnId, - history: this.#state.history, - newItems: [], - instructions: this.#options.instructions, - model: this.#state.model, - toolNames: this.#options.tools - .available({ workspace: this.#state.workspace, settings: this.#options.settings }) - .map((tool) => tool.spec.name), - workspace: this.#state.workspace, - budget: this.#options.budget, - }), - ); - } - - /** Runs a turn to its end, turning each step boundary into one atomic append. */ - #drive(run: TurnRun): void { - this.#run = run; - void (async () => { - let items: TracerFact[] = []; - for await (const event of run.events) { - switch (event.type) { - case 'item': - items.push({ type: 'item_committed', turnId: run.turnId, item: event.item }); - break; - case 'checkpoint': - await this.commit([...items, { type: 'checkpoint_saved', checkpoint: event.checkpoint }]); - items = []; - break; - case 'turn_ended': { - const outcome = event.outcome; - if (outcome.kind === 'suspended') { - await this.commit([ - ...items, - { type: 'checkpoint_saved', checkpoint: outcome.checkpoint }, - { - type: 'interaction_opened', - turnId: run.turnId, - interactionId: newId('int'), - approvals: outcome.approvals, - }, - ]); - } else { - const ended = outcome.kind === 'completed' ? 'completed' : outcome.kind; - await this.commit([...items, { type: 'turn_ended', turnId: run.turnId, outcome: ended }]); - } - items = []; - break; - } - default: - this.#emit({ kind: 'live', turnId: run.turnId, event }); - } - } - if (this.#run === run) this.#run = null; - })(); - } - - #emit(update: TracerSessionUpdate): void { - for (const listener of this.#listeners) listener(update); - } -} diff --git a/rewrite/bench/native-tracer/reducer.ts b/rewrite/bench/native-tracer/reducer.ts deleted file mode 100644 index 05d01c624..000000000 --- a/rewrite/bench/native-tracer/reducer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { TracerLogEntry, TracerProject, TracerSessionId, TracerSessionState } from './session-types.js'; - -export function initialState(id: TracerSessionId): TracerSessionState { - return { - id, - status: 'idle', - workspace: { root: '', access: 'read', target: 'local' }, - model: { provider: '', model: '' }, - history: [], - activeTurnId: null, - pendingInteraction: null, - queue: [], - canRetry: false, - lastCheckpoint: null, - lastSeq: 0, - }; -} - -export const project: TracerProject = (state, entry: TracerLogEntry) => { - const next = { ...state, lastSeq: entry.seq }; - const fact = entry.fact; - switch (fact.type) { - case 'session_created': - return { ...next, workspace: fact.workspace, model: fact.model }; - case 'input_queued': - return { ...next, queue: [...state.queue, { requestId: fact.requestId, items: fact.items }] }; - case 'turn_started': - return { - ...next, - status: 'running', - activeTurnId: fact.turnId, - canRetry: false, - lastCheckpoint: null, - queue: state.queue.filter((queued) => queued.requestId !== fact.requestId), - }; - case 'item_committed': - return { ...next, history: [...state.history, fact.item] }; - case 'checkpoint_saved': - return { ...next, lastCheckpoint: fact.checkpoint }; - case 'interaction_opened': - return { - ...next, - status: 'awaiting_interaction', - pendingInteraction: { interactionId: fact.interactionId, turnId: fact.turnId, approvals: fact.approvals }, - }; - case 'interaction_resolved': - return { ...next, status: 'running', pendingInteraction: null }; - case 'turn_ended': - return { - ...next, - status: 'idle', - activeTurnId: null, - lastCheckpoint: null, - canRetry: fact.outcome === 'failed', - }; - case 'turn_interrupted': - return { - ...next, - status: 'interrupted', - activeTurnId: null, - pendingInteraction: null, - lastCheckpoint: null, - canRetry: true, - }; - case 'history_compacted': - return { ...next, history: fact.history }; - case 'model_changed': - return { ...next, model: fact.model }; - } -}; diff --git a/rewrite/bench/native-tracer/session-types.ts b/rewrite/bench/native-tracer/session-types.ts deleted file mode 100644 index b52929df1..000000000 --- a/rewrite/bench/native-tracer/session-types.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Fixture-only types for the pre-C2 native engine experiment. */ -import type { Item, ModelRef } from '../../src/model/index.js'; -import type { Workspace } from '../../src/tools/index.js'; -import type { - ApprovalDecision, - ApprovalRequest, - EngineEvent, - TurnCheckpoint, - TurnId, - TurnRun, -} from '../../src/engine/index.js'; - -export type TracerSessionId = string & { readonly __brand: 'TracerSessionId' }; -export type TracerClientRequestId = string & { readonly __brand: 'TracerClientRequestId' }; -export type TracerInteractionId = string & { readonly __brand: 'TracerInteractionId' }; -export type TracerFact = - | { readonly type: 'session_created'; readonly workspace: Workspace; readonly model: ModelRef } - | { readonly type: 'input_queued'; readonly requestId: TracerClientRequestId; readonly items: readonly Item[] } - | { readonly type: 'turn_started'; readonly turnId: TurnId; readonly requestId: TracerClientRequestId | null } - | { readonly type: 'item_committed'; readonly turnId: TurnId; readonly item: Item } - | { readonly type: 'checkpoint_saved'; readonly checkpoint: TurnCheckpoint } - | { - readonly type: 'interaction_opened'; - readonly turnId: TurnId; - readonly interactionId: TracerInteractionId; - readonly approvals: readonly ApprovalRequest[]; - } - | { - readonly type: 'interaction_resolved'; - readonly interactionId: TracerInteractionId; - readonly decisions: readonly { - readonly callId: ApprovalRequest['callId']; - readonly decision: ApprovalDecision; - }[]; - } - | { - readonly type: 'turn_ended'; - readonly turnId: TurnId; - readonly outcome: 'completed' | 'cancelled' | 'failed' | 'budget_exhausted'; - } - | { readonly type: 'turn_interrupted'; readonly turnId: TurnId; readonly reason: string } - | { readonly type: 'history_compacted'; readonly history: readonly Item[] } - | { readonly type: 'model_changed'; readonly model: ModelRef }; -export type TracerLogEntry = { readonly seq: number; readonly at: string; readonly fact: TracerFact }; -export interface TracerSessionLog { - append(facts: readonly TracerFact[]): Promise; - read(fromSeq?: number): AsyncIterable; - readonly lastSeq: number; -} -export type TracerSessionStatus = 'idle' | 'running' | 'awaiting_interaction' | 'interrupted' | 'closed'; -export type TracerPendingInteraction = { - readonly interactionId: TracerInteractionId; - readonly turnId: TurnId; - readonly approvals: readonly ApprovalRequest[]; -}; -export type TracerSessionState = { - readonly id: TracerSessionId; - readonly status: TracerSessionStatus; - readonly workspace: Workspace; - readonly model: ModelRef; - readonly history: readonly Item[]; - readonly activeTurnId: TurnId | null; - readonly pendingInteraction: TracerPendingInteraction | null; - readonly queue: readonly { readonly requestId: TracerClientRequestId; readonly items: readonly Item[] }[]; - readonly canRetry: boolean; - readonly lastCheckpoint: TurnCheckpoint | null; - readonly lastSeq: number; -}; -export type TracerSessionUpdate = - | { readonly kind: 'fact'; readonly entry: TracerLogEntry } - | { readonly kind: 'live'; readonly turnId: TurnId; readonly event: EngineEvent }; -export type TracerAdmission = - | { readonly kind: 'started'; readonly turnId: TurnId } - | { readonly kind: 'queued'; readonly position: number } - | { readonly kind: 'duplicate'; readonly turnId: TurnId | null } - | { readonly kind: 'rejected'; readonly reason: 'queue_full' | 'closed' }; -export interface TracerSession { - readonly id: TracerSessionId; - state(): TracerSessionState; - submit(requestId: TracerClientRequestId, items: readonly Item[]): Promise; - decide( - interactionId: TracerInteractionId, - decisions: readonly { readonly callId: ApprovalRequest['callId']; readonly decision: ApprovalDecision }[], - ): Promise; - cancel(): Promise; - retry(): Promise; - compact(): Promise; - setModel(model: ModelRef): Promise; - subscribe(fromSeq: number, listener: (update: TracerSessionUpdate) => void): () => void; - close(): Promise; -} -export interface TracerStore { - create(workspace: Workspace, model: ModelRef): Promise; - open(id: TracerSessionId): Promise; -} -export type TracerSessionSummary = Pick & { - readonly title: string | null; - readonly updatedAt: string; -}; -export type TracerProject = (state: TracerSessionState, entry: TracerLogEntry) => TracerSessionState; diff --git a/rewrite/bench/native-tracer/tracer.ts b/rewrite/bench/native-tracer/tracer.ts deleted file mode 100644 index d565def46..000000000 --- a/rewrite/bench/native-tracer/tracer.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** Explicit native-engine fixture; none of these checks prove C2 persistence. */ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { memorySettings } from '../../src/config/memory.js'; -import { createEngine } from '../../src/engine/loop.js'; -import { scriptedModel } from '../../src/model/scripted.js'; -import { readNote, toolRegistry, writeNote } from '../../src/tools/notes.js'; -import type { Tool } from '../../src/tools/index.js'; -import { NativeTracerStore } from './local-store.js'; -import type { TracerInteractionId, TracerSession, TracerSessionId, TracerSessionUpdate } from './session-types.js'; - -const workspace = (root: string) => ({ root, access: 'read_write' as const, target: 'local' as const }); -const modelRef = { provider: 'scripted', model: 'tracer' }; - -function buildStore(root: string, crashInTool?: string) { - let providerCalls = 0; - const baseModel = scriptedModel(); - const model = { - ...baseModel, - async *stream(...args: Parameters) { - providerCalls += 1; - yield* baseModel.stream(...args); - }, - }; - const crashing = (tool: Tool): Tool => - tool.spec.name === crashInTool - ? { - ...tool, - execute: () => { - process.kill(process.pid, 'SIGKILL'); - return new Promise(() => {}); - }, - } - : tool; - const tools = toolRegistry([readNote, writeNote].map(crashing)); - const settings = memorySettings(); - const store = new NativeTracerStore({ - root: path.join(root, 'sessions'), - engine: createEngine({ models: { resolve: () => model, list: () => [model.ref] }, tools, settings }), - tools, - settings, - instructions: 'You are the tracer model.', - budget: { maxModelCalls: 4, maxToolCalls: 4 }, - }); - return { store, providerCalls: () => providerCalls }; -} - -function check(condition: unknown, message: string): asserts condition { - if (!condition) throw new Error(`FAIL: ${message}`); - console.log(` ok ${message}`); -} - -async function waitForFact(session: TracerSession, type: string): Promise { - return new Promise((resolve) => { - const stop = session.subscribe(session.state().lastSeq, (update) => { - if (update.kind === 'fact' && update.entry.fact.type === type) { - stop(); - resolve(update); - } - }); - }); -} - -async function approvalRecovery(root: string): Promise { - console.log('1. approval restart is interrupted and stale decisions are rejected'); - const { store } = buildStore(root); - const first = await store.create(workspace(path.join(root, 'workspace')), modelRef); - const openedPromise = waitForFact(first, 'interaction_opened'); - const started = await first.submit('req-1' as never, [ - { - type: 'user_message', - id: 'm-1' as never, - content: [{ type: 'text', text: 'write greeting: hello from the tracer' }], - }, - ]); - check(started.kind === 'started', 'submit is admitted'); - const opened = await openedPromise; - check(opened.kind === 'fact' && opened.entry.fact.type === 'interaction_opened', 'approval becomes pending'); - check(!fs.existsSync(path.join(root, 'workspace', 'greeting.txt')), 'write has no effect before decision'); - const staleId = ( - opened.entry.fact.type === 'interaction_opened' ? opened.entry.fact.interactionId : '' - ) as TracerInteractionId; - await first.close(); - const { store: reopenedStore, providerCalls } = buildStore(root); - const second = await reopenedStore.open(first.id); - check(second.state().status === 'interrupted', 'awaiting interaction reopens as interrupted'); - check(second.state().pendingInteraction === null, 'recovery removes the actionable prompt'); - let staleRejected = false; - try { - await second.decide(staleId, []); - } catch { - staleRejected = true; - } - check(staleRejected, 'a pre-restart decision is stale'); - check(providerCalls() === 0, 'opening does not call the provider'); - await second.close(); - const logPath = path.join(root, 'sessions', `${first.id}.jsonl`); - const reconciledBytes = fs.readFileSync(logPath); - const stable = await reopenedStore.open(first.id); - check(stable.state().status === 'interrupted', 'reopen after reconciliation remains interrupted'); - check(fs.readFileSync(logPath).equals(reconciledBytes), 'reopen after reconciliation appends no duplicate facts'); - await stable.close(); -} - -function crashChild(root: string, scenario: 'read' | 'write'): string { - const child = spawnSync(process.execPath, [process.argv[1]!, 'child', root, scenario], { encoding: 'utf8' }); - check( - child.signal === 'SIGKILL', - `child process was killed inside ${scenario === 'read' ? 'read_note' : 'write_note'}`, - ); - const sessionId = /session=(\S+)/.exec(child.stdout)?.[1]; - if (!sessionId) throw new Error(`child did not report a session: ${child.stdout}${child.stderr}`); - return sessionId; -} - -async function crashDuringToolDoesNotResume(root: string, scenario: 'read' | 'write'): Promise { - console.log(`2. crash during ${scenario}_note never resumes the native checkpoint`); - const sessionId = crashChild(root, scenario); - const { store, providerCalls } = buildStore(root); - const session = await store.open(sessionId as TracerSessionId); - check(session.state().status === 'interrupted', 'reopened session is interrupted'); - check(providerCalls() === 0, 'reopen issues no provider calls'); - check(!fs.existsSync(path.join(root, 'workspace', 'other.txt')), 'the write was not replayed'); - await session.close(); -} - -async function child(root: string, scenario: 'read' | 'write'): Promise { - const { store } = buildStore(root, scenario === 'read' ? 'read_note' : 'write_note'); - const session = await store.create(workspace(path.join(root, 'workspace')), modelRef); - fs.writeSync(1, `session=${session.id}\n`); - const pending = scenario === 'write' ? waitForFact(session, 'interaction_opened') : null; - await session.submit('child-request' as never, [ - { - type: 'user_message', - id: 'm-child' as never, - content: [{ type: 'text', text: scenario === 'read' ? 'read greeting' : 'write other: should never land' }], - }, - ]); - if (pending) { - const update = await pending; - if (update.kind !== 'fact' || update.entry.fact.type !== 'interaction_opened') - throw new Error('write approval did not open'); - await session.decide( - update.entry.fact.interactionId, - update.entry.fact.approvals.map((approval) => ({ - callId: approval.callId, - decision: { kind: 'approve' as const }, - })), - ); - } - throw new Error('child should have been killed inside the tool'); -} - -async function main(): Promise { - if (process.argv[2] === 'child') return child(process.argv[3]!, process.argv[4] as 'read' | 'write'); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'term2-native-tracer-')); - fs.mkdirSync(path.join(root, 'workspace')); - try { - await approvalRecovery(root); - await crashDuringToolDoesNotResume(root, 'read'); - await crashDuringToolDoesNotResume(root, 'write'); - console.log('native tracer fixture passed'); - process.exit(0); - } catch (error) { - console.error(error instanceof Error ? error.message : error); - console.error(`fixture logs kept in ${root}`); - process.exit(1); - } -} - -void main(); diff --git a/rewrite/bench/source-app.mts b/rewrite/bench/source-app.mts deleted file mode 100644 index a5fd95b4c..000000000 --- a/rewrite/bench/source-app.mts +++ /dev/null @@ -1,57 +0,0 @@ -/** Historical source-agent composition for benches; Session owns graph lifetime. */ -import { buildAgent } from '../../source/lib/agent-factory.js'; -import type { AnyToolDefinition } from '../../source/tools/types.js'; -import { createAdoptedEngine, InvalidAdoptedInputError } from '../src/engine/adopted/index.js'; -import { createOriginalToolCatalog } from '../src/engine/adopted/tool-catalog.js'; -import { createLegacySettingsSource } from '../src/engine/adopted/settings.js'; -import { turnPrompt } from '../src/session/adopted/turn-prompt.js'; -import type { LegacyEngineOptions, LegacyEngineAssembly } from '../src/session/adopted/session-handle.js'; -import { createAppSettings, type AppSettingsOptions } from '../src/app/settings.js'; -import type { SettingsWriteMode } from '../src/config/scoped-settings.js'; - -/** Keep the real scoped writable settings binding separate from native startup. */ -export function createBenchSettings(options: AppSettingsOptions & { readonly writeMode?: SettingsWriteMode } = {}) { - const { writeMode = 'persistent', ...serviceOptions } = options; - const { service } = createAppSettings(serviceOptions); - return { service, source: createLegacySettingsSource(service, writeMode) }; -} - -/** Build each source generation once and publish its exact final tools into the engine catalog. */ -export function createBenchEngine(options: LegacyEngineOptions) { - const tools = createOriginalToolCatalog([]); - let current: ReturnType | undefined; - const assembleAgent: typeof buildAgent = (buildOptions, deps) => { - const built = buildAgent(buildOptions, deps); - tools.replaceGeneration(built.agent.tools as readonly AnyToolDefinition[]); - current = built; - return built; - }; - const engine = createAdoptedEngine({ - deps: { models: options.models, tools, settings: options.settings }, - clientOptions: options.clientOptions, - bindSessionClient: options.bindSessionClient, - assembleAgent, - refreshPreparedInput(input, agent) { - // Codex may return a transformed agent object; use the one about to be validated. - return { ...input, instructions: turnPrompt(agent).instructions }; - }, - validateTurnInput(input, agent, providerId) { - if ( - input.model.provider !== providerId || - input.model.model !== agent.model || - input.instructions !== turnPrompt(agent).instructions || - (input.reasoning !== undefined && input.reasoning !== agent.modelSettings?.reasoning?.effort) - ) { - throw new InvalidAdoptedInputError(); - } - }, - }); - return { - engine, - tools, - turnPrompt() { - if (!current) throw new Error('Agent assembly is not initialized'); - return turnPrompt(current.agent); - }, - } satisfies LegacyEngineAssembly & { tools: typeof tools }; -} diff --git a/rewrite/parity/run.mjs b/rewrite/parity/run.mjs index 5110df56e..6a59d1bb7 100644 --- a/rewrite/parity/run.mjs +++ b/rewrite/parity/run.mjs @@ -170,9 +170,6 @@ async function runScenario(cli, scenario, cliForRun = []) { const args = run.args.map((arg) => substitute(arg).replaceAll('{{LAST_CONVERSATION}}', last ?? '')); const selectedCli = cliForRun[runs.length] ?? cli; const result = await spawnCli(selectedCli, args, env, workspace); - if (selectedCli.endsWith('c2-proof-cli.js')) { - assertC2ProofRun(root, env.TERM2_CONVERSATIONS_DIR, last ?? latestConversation(env.TERM2_CONVERSATIONS_DIR)); - } runs.push({ ...result, requests: provider.requests.slice(before) }); } const normalize = normalizer({ root, home, workspace, baseUrl: provider.baseUrl }); @@ -192,39 +189,6 @@ async function runScenario(cli, scenario, cliForRun = []) { } } -function assertC2ProofRun(root, conversationsDir, id) { - if (!id) throw new Error('C2 proof CLI did not create or resume a canonical conversation'); - const file = path.join(conversationsDir, `${id}.jsonl`); - if (!fs.existsSync(file)) throw new Error(`C2 proof session log is missing: ${id}`); - const envelopes = fs - .readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); - const batches = envelopes.filter((envelope) => envelope.event?.type === 'session_fact_batch'); - const nested = batches.flatMap((batch) => batch.event.entries.map((entry) => entry.event)); - if ( - !nested.some((event) => event.type === 'session_admitted') || - !nested.some((event) => event.type === 'session_turn_started') - ) { - throw new Error('C2 proof resumed append did not persist admission/start lifecycle batches'); - } - if ( - !nested.some((event) => event.type === 'assistant_turn') || - !nested.some((event) => event.type === 'session_turn_ended') - ) { - throw new Error('C2 proof resumed append did not persist the source terminal transcript and lifecycle fact'); - } - const files = Object.keys(readTree(root)); - if ( - files.some((name) => - /(?:gateway.*(?:db|journal|checkpoint)|(?:admission|interaction-checkpoint).*(?:db|journal|jsonl))/i.test(name), - ) - ) { - throw new Error('C2 proof created a gateway journal, checkpoint or admission database'); - } -} - function latestConversation(dir) { if (!fs.existsSync(dir)) return null; const newest = fs diff --git a/rewrite/tsconfig.c2-proof.json b/rewrite/tsconfig.c2-proof.json deleted file mode 100644 index ce97f9bba..000000000 --- a/rewrite/tsconfig.c2-proof.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "outDir": "./bench-dist", - "rootDir": ".." - }, - "include": ["bench/c2-proof-cli.ts", "bench/c2-proof-persistence.ts"] -} diff --git a/rewrite/tsconfig.native-tracer.json b/rewrite/tsconfig.native-tracer.json deleted file mode 100644 index 0a7656eb4..000000000 --- a/rewrite/tsconfig.native-tracer.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "outDir": "./bench-dist", - "rootDir": ".." - }, - "include": ["bench/native-tracer/**/*.ts"] -} diff --git a/scripts/generated-verification/native-log.mjs b/scripts/generated-verification/native-log.mjs index 01ea51c3c..41ea94079 100644 --- a/scripts/generated-verification/native-log.mjs +++ b/scripts/generated-verification/native-log.mjs @@ -5,7 +5,6 @@ import { join } from 'node:path'; import fc from 'fast-check'; import { createLog, openLog, readFactLines } from '../../rewrite/src/session/log.ts'; import { captureDirectFinalWindow, recoverDirectFinalWindow } from '../../rewrite/src/session/direct-final.ts'; -import { inputToItem } from '../../rewrite/src/engine/adopted/conversion.ts'; // Decode embedded provider JSON once, while also inspecting literal ref framing. function storedStrings(value, out = []) { @@ -61,10 +60,16 @@ export const nativeLogProperties = [ `BEGIN_ARGUMENT_${n}_` + argumentUnit.repeat(Math.ceil(u.size / argumentUnit.length)) + '_END'; sentinels.push(body, argument); const input = { code: argument }; - const call = inputToItem( - { type: 'tool_call', id: callId, name: 'probe', arguments: JSON.stringify(input) }, - 'mock', - ); + const rawArguments = JSON.stringify(input); + const call = { + type: 'tool_call', + id: callId, + callId, + toolName: 'probe', + input: JSON.parse(rawArguments), + rawArguments, + argumentsSnapshot: JSON.parse(rawArguments), + }; const sourceOutput = u.structured ? [{ type: 'text', text: body + '\n"\\\ud83d\ude42' }] : `model prefix\n${body}`; @@ -142,7 +147,13 @@ export const nativeLogProperties = [ paths.push(writer.path); } const item = u.structured - ? inputToItem({ type: 'tool_result', id: callId, output: sourceOutput }, 'mock') + ? // Structured tool output has no native item, so it is stored as provider input. + { + type: 'provider_input', + id: `result-${n}`, + provider: 'mock', + input: { type: 'tool_result', id: callId, output: sourceOutput }, + } : { type: 'tool_result', id: `result-${n}`, callId, output, isError: false }; assert.equal(item.type, u.structured ? 'provider_input' : 'tool_result'); accept([{ type: 'item', turnId, ...(u.structured ? {} : { executionId }), item }]); From acece2eca0587f517f19127a685c80efcbe5ca22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:06:48 +0000 Subject: [PATCH 04/17] refactor(rewrite): delete the bridge's client, host and protocol files host/in-process.ts, protocol/projection.ts, protocol/commands.ts and the headless Client contract (clients/headless.ts, clients/index.ts) served only the bridge; the direct host, protocol and headless client replace them. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/reachability-allowlist.json | 5 - rewrite/src/clients/headless.ts | 43 ---- rewrite/src/clients/index.ts | 39 ---- rewrite/src/host/in-process.ts | 80 ------- rewrite/src/protocol/commands.ts | 65 ------ rewrite/src/protocol/projection.ts | 218 -------------------- 6 files changed, 450 deletions(-) delete mode 100644 rewrite/src/clients/headless.ts delete mode 100644 rewrite/src/clients/index.ts delete mode 100644 rewrite/src/host/in-process.ts delete mode 100644 rewrite/src/protocol/commands.ts delete mode 100644 rewrite/src/protocol/projection.ts diff --git a/rewrite/scripts/reachability-allowlist.json b/rewrite/scripts/reachability-allowlist.json index ddd9d6ef8..fc3a6af31 100644 --- a/rewrite/scripts/reachability-allowlist.json +++ b/rewrite/scripts/reachability-allowlist.json @@ -1,6 +1,4 @@ { - "rewrite/src/clients/headless.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/clients/index.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/config/memory.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/config/scoped-settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/engine/adopted/conversion.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", @@ -10,10 +8,7 @@ "rewrite/src/engine/adopted/settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/engine/adopted/tool-catalog.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/engine/loop.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/host/in-process.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/model/scripted.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/protocol/commands.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/protocol/projection.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/session/adopted/compact-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/session/adopted/conversation-index.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/session/adopted/conversation-retry-projection.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", diff --git a/rewrite/src/clients/headless.ts b/rewrite/src/clients/headless.ts deleted file mode 100644 index e06443d70..000000000 --- a/rewrite/src/clients/headless.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { WireInteraction } from '../protocol/index.js'; -import type { RunHeadless } from './index.js'; - -/** Submits one prompt, settles approvals by policy, and returns when the turn ends. */ -export const runHeadless: RunHeadless = async (connection, options) => { - const requestId = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; - const submitted = await connection.send({ type: 'submit', requestId, text: options.prompt }); - if (!submitted.ok) return { exitCode: 2, finalText: submitted.message }; - - let finalText = ''; - for await (const event of connection.events) { - if (event.turnId !== null && event.turnId !== submitted.turnId) continue; - if (options.output === 'jsonl') process.stdout.write(JSON.stringify(event) + '\n'); - switch (event.type) { - case 'text_delta': - if (options.output === 'text') process.stdout.write(String(event.payload.delta)); - break; - case 'message': { - const message = event.payload.message as { role: string; text: string }; - if (message.role === 'assistant') finalText = message.text; - break; - } - case 'interaction_opened': { - const interaction = event.payload.interaction as WireInteraction; - await connection.send({ - type: 'decide', - interactionId: interaction.interactionId, - decisions: interaction.approvals.map((approval) => ({ - callId: approval.callId, - approve: - options.onApproval === 'approve_all' || - (options.onApproval === 'approve_low_risk' && approval.risk === 'low'), - })), - }); - break; - } - case 'turn_ended': - if (options.output === 'text') process.stdout.write('\n'); - return { exitCode: event.payload.outcome === 'completed' ? 0 : 1, finalText }; - } - } - return { exitCode: 1, finalText }; -}; diff --git a/rewrite/src/clients/index.ts b/rewrite/src/clients/index.ts deleted file mode 100644 index ae5412f94..000000000 --- a/rewrite/src/clients/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * clients/ — thin renderers of the protocol. - * - * Each client implements `Client`: headless today, and TUI, ACP and Discord - * as they are ported. A client reads a `WireSnapshot`, folds `WireEvent`s into - * its own view and turns user actions into `WireCommand`s. It never imports - * session/, engine/ or below; anything it needs that the protocol lacks is a - * protocol change. - * Depends on: host, protocol. - */ - -import type { Connection, Host, Principal } from '../host/index.js'; -import type { WireEvent, WireSnapshot } from '../protocol/index.js'; - -export interface Client { - readonly name: 'tui' | 'headless' | 'acp' | 'discord'; - /** Runs until the user quits or the connection ends. */ - run(host: Host, principal: Principal): Promise; -} - -/** Shared by clients that keep a transcript view, so each one does not re-derive it. */ -export interface TranscriptView { - initial(snapshot: WireSnapshot): View; - apply(view: View, event: WireEvent): View; -} - -export type HeadlessOptions = { - readonly prompt: string; - /** Approval policy for unattended runs. */ - readonly onApproval: 'deny' | 'approve_low_risk' | 'approve_all'; - readonly output: 'text' | 'jsonl'; -}; - -export type HeadlessResult = { - readonly exitCode: number; - readonly finalText: string; -}; - -export type RunHeadless = (connection: Connection, options: HeadlessOptions) => Promise; diff --git a/rewrite/src/host/in-process.ts b/rewrite/src/host/in-process.ts deleted file mode 100644 index 42b6dd641..000000000 --- a/rewrite/src/host/in-process.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { projection } from '../protocol/projection.js'; -import { applyCommand } from '../protocol/commands.js'; -import type { WireEvent } from '../protocol/index.js'; -import type { Session, SessionId } from '../session/index.js'; -import type { Connection, Host, HostDeps, InProcessHostFactory } from './index.js'; - -/** The host the TUI would use: same `Connection` contract as the server, no network. */ -export const createInProcessHost: InProcessHostFactory = (deps: HostDeps): Host => { - const open = new Map>(); - const session = (id: string): Promise => { - let pending = open.get(id); - if (!pending) { - pending = deps.sessions.open(id as SessionId); - open.set(id, pending); - } - return pending; - }; - - return { - async listSessions() { - const page = await deps.sessions.list(); - return { - sessions: page.sessions.map((summary) => ({ - id: summary.id, - title: summary.title, - updatedAt: summary.updatedAt, - })), - nextCursor: page.nextCursor, - }; - }, - async createSession(_principal, grant) { - const created = await deps.sessions.create( - { root: deps.resolveWorkspace(grant), access: grant.access, target: 'local' }, - deps.defaultModel, - ); - open.set(created.id, Promise.resolve(created)); - return connection(created); - }, - async connect(_principal, sessionId, fromSeq) { - return connection(await session(sessionId), fromSeq); - }, - }; -}; - -function connection(session: Session, fromSeq?: number): Connection { - const snapshot = projection.snapshot(session.id, session.state()); - const queue: WireEvent[] = []; - let wake: (() => void) | null = null; - let closed = false; - const unsubscribe = session.subscribe(fromSeq ?? snapshot.seq, (update) => { - const event = projection.toWire(session.id, update); - if (!event) return; - queue.push(event); - wake?.(); - }); - - async function* events(): AsyncGenerator { - while (!closed) { - const next = queue.shift(); - if (next) { - yield next; - continue; - } - await new Promise((resolve) => (wake = resolve)); - wake = null; - } - } - - return { - sessionId: session.id, - snapshot, - events: events(), - send: (command) => applyCommand(session, command), - close() { - closed = true; - unsubscribe(); - wake?.(); - }, - }; -} diff --git a/rewrite/src/protocol/commands.ts b/rewrite/src/protocol/commands.ts deleted file mode 100644 index 6899f6c75..000000000 --- a/rewrite/src/protocol/commands.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Item, ItemId, ToolCallId } from '../model/index.js'; -import type { ClientRequestId, InteractionId, Session } from '../session/index.js'; -import type { WireCommand, WireCommandResult } from './index.js'; - -let nextItem = 0; - -/** The inbound half of the protocol: one place turns wire commands into session calls. */ -export async function applyCommand(session: Session, command: WireCommand): Promise { - try { - switch (command.type) { - case 'submit': { - const item: Item = { - type: 'user_message', - id: `item_user_${Date.now().toString(36)}_${(nextItem++).toString(36)}` as ItemId, - content: [{ type: 'text', text: command.text }], - }; - const admission = await session.submit(command.requestId as ClientRequestId, [item]); - return admissionResult(admission); - } - case 'decide': { - const pending = session.state().pendingInteraction; - if (!pending || pending.interaction.interactionId !== command.interactionId) - throw new Error('stale interaction'); - await session.decide({ - turnId: pending.turnId, - interactionId: command.interactionId as InteractionId, - revision: pending.interaction.revision, - generation: pending.generation, - decisions: command.decisions.map((decision) => ({ - callId: decision.callId as ToolCallId, - approve: decision.approve, - ...(decision.reason ? { reason: decision.reason } : {}), - })), - }); - return { ok: true }; - } - case 'cancel': - await session.cancel(); - return { ok: true }; - case 'retry': - return admissionResult(await session.retry()); - case 'compact': - await session.compact(); - return { ok: true }; - case 'set_model': - await session.setModel({ provider: command.provider, model: command.model }); - return { ok: true }; - } - } catch (error) { - return { ok: false, code: 'command_failed', message: error instanceof Error ? error.message : String(error) }; - } -} - -function admissionResult(admission: Awaited>): WireCommandResult { - switch (admission.kind) { - case 'started': - return { ok: true, turnId: admission.turnId }; - case 'duplicate': - return admission.turnId ? { ok: true, turnId: admission.turnId } : { ok: true }; - case 'queued': - return { ok: true, queuedPosition: admission.position }; - case 'rejected': - return { ok: false, code: admission.reason, message: `session refused input: ${admission.reason}` }; - } -} diff --git a/rewrite/src/protocol/projection.ts b/rewrite/src/protocol/projection.ts deleted file mode 100644 index ede0b73d9..000000000 --- a/rewrite/src/protocol/projection.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { Item } from '../model/index.js'; -import type { LogEntry, SessionState, SessionUpdate } from '../session/index.js'; -import { PROTOCOL_VERSION, type Projection, type WireEvent, type WireInteraction, type WireMessage } from './index.js'; - -const MAX_TEXT = 8_192; -const bounded = (text: string): string => (text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text); - -function toCompatibilityMessage(item: Item): WireMessage | null { - switch (item.type) { - case 'user_message': - case 'assistant_message': - return { - id: item.id, - role: item.type === 'user_message' ? 'user' : 'assistant', - text: bounded(item.content.map((part) => (part.type === 'text' ? part.text : '[image]')).join('')), - }; - case 'tool_result': - return { id: item.id, role: 'tool', text: bounded(item.output), isError: item.isError }; - default: - return null; - } -} - -function toSavedMessage(value: unknown): WireMessage | null { - if (!value || typeof value !== 'object') return null; - const message = value as { id?: unknown; sender?: unknown; text?: unknown }; - if (typeof message.id !== 'string' || typeof message.text !== 'string') return null; - if (message.sender === 'user') return { id: message.id, role: 'user', text: bounded(message.text) }; - if (message.sender === 'bot') return { id: message.id, role: 'assistant', text: bounded(message.text) }; - return null; -} - -function pendingAsLegacyWire(pending: NonNullable): WireInteraction | null { - const dto = pending.interaction; - if (dto.kind !== 'tool_approval') return null; - const label = dto.choices - .map((choice) => choice.label) - .filter(Boolean) - .join(' / '); - const summary = label || dto.descriptor.display?.warning || dto.descriptor.toolName; - return { - interactionId: dto.interactionId, - approvals: [ - { - callId: dto.descriptor.callId ?? dto.interactionId, - toolName: dto.descriptor.toolName, - summary: bounded(summary), - risk: 'medium', - }, - ], - }; -} - -function factEvent(sessionId: string, entry: LogEntry): WireEvent | null { - const base = { sessionId, seq: entry.seq }; - const fact = entry.fact; - switch (fact.type) { - case 'session_turn_started': - return { ...base, turnId: fact.turnId, type: 'turn_started', payload: {} }; - case 'session_interaction_pending': { - const pending = pendingAsLegacyWire({ - turnId: fact.turnId as never, - interaction: fact.interaction, - generation: fact.generation, - }); - return pending - ? { ...base, turnId: fact.turnId, type: 'interaction_opened', payload: { interaction: pending } } - : null; - } - case 'session_interaction_resolved': - return { - ...base, - turnId: fact.turnId, - type: 'interaction_resolved', - payload: { interactionId: fact.interactionId }, - }; - case 'session_turn_ended': - return { ...base, turnId: fact.turnId, type: 'turn_ended', payload: { outcome: fact.outcome } }; - case 'session_interrupted': - return { ...base, turnId: fact.turnId, type: 'turn_ended', payload: { outcome: 'interrupted' } }; - case 'user_message': - return { - ...base, - turnId: null, - type: 'message', - payload: { message: { id: fact.message.id, role: 'user', text: bounded(fact.message.text) } }, - }; - case 'assistant_turn': { - const text = fact.turn.items - .filter((item) => item.type === 'assistant_text') - .map((item) => item.text) - .join(''); - return text - ? { - ...base, - turnId: null, - type: 'message', - payload: { message: { id: entry.ref.eventId, role: 'assistant', text: bounded(text) } }, - } - : null; - } - case 'tool_started': - return { - ...base, - turnId: fact.turnId ?? null, - type: 'tool_started', - payload: { callId: fact.toolCallId, toolName: fact.toolName }, - }; - case 'tool_result': - return { - ...base, - turnId: fact.turnId ?? null, - type: 'tool_finished', - payload: { - callId: fact.callId, - output: bounded(typeof fact.output === 'string' ? fact.output : JSON.stringify(fact.output ?? '') ?? ''), - isError: fact.status !== 'completed', - }, - }; - case 'session_bound': - case 'session_admitted': - case 'session_admission_settled': - case 'session_interaction_recovered': - case 'session_closed': - case 'session_init': - case 'settings_changed': - case 'goal_changed': - case 'events_retracted': - case 'context_checkpoint_created': - case 'context_compaction_recorded': - case 'command_message': - case 'approval_required': - case 'approval_resolved': - case 'openai_root_selector_parity': - case 'openai_root_checkpoint_lifecycle': - case 'assistant_journal_delta': - case 'assistant_journal_item': - case 'memory_injected': - case 'subagent_started': - case 'subagent_tool_started': - case 'subagent_completed': - case 'subagent_interrupted': - case 'subagent_transferred': - case 'subagent_question': - case 'background_shell_started': - case 'background_shell_completed': - case 'background_shell_output': - case 'error': - case 'undo': - case 'session_cleared': - case 'session_rollover': - return null; - default: - // Unknown and truncated legacy records remain readable storage records - // but do not claim public lifecycle meaning. - return null; - } -} - -/** The compatibility projection for G1's existing public protocol. */ -export const projection: Projection = { - toWire(sessionId: string, update: SessionUpdate): WireEvent | null { - if (update.kind === 'fact') return factEvent(sessionId, update.entry); - const base = { sessionId, seq: null, turnId: update.turnId }; - const event = update.event; - switch (event.type) { - case 'text_delta': - case 'reasoning_delta': - return { ...base, type: event.type, payload: { delta: bounded(event.delta) } }; - case 'tool_started': - return { ...base, type: 'tool_started', payload: { callId: event.callId, toolName: event.toolName } }; - case 'tool_progress': - return { ...base, type: 'tool_progress', payload: { callId: event.callId, chunk: bounded(event.chunk) } }; - case 'tool_finished': - return { - ...base, - type: 'tool_finished', - payload: { callId: event.callId, output: bounded(event.result.output), isError: event.result.isError }, - }; - case 'usage': - return { ...base, type: 'usage', payload: { ...event.usage } }; - case 'retrying': - return { - ...base, - type: 'retrying', - payload: { attempt: event.attempt, delayMs: event.delayMs, errorKind: event.error.kind }, - }; - case 'compacted': - return { ...base, type: 'compacted', payload: { replaced: event.replaced } }; - case 'subagent': - return { ...base, type: 'subagent', payload: { agentId: event.agentId, role: event.role } }; - case 'item': - case 'segment_started': - case 'segment_committed': - case 'tool_dispatched': - case 'model_attempt_rollback': - case 'checkpoint': - case 'turn_ended': - return null; - } - }, - - snapshot(sessionId: string, state: SessionState) { - return { - protocolVersion: PROTOCOL_VERSION, - sessionId, - status: state.status, - model: { provider: state.model.provider, model: state.model.model }, - messages: (state.messages.length - ? state.messages.map(toSavedMessage) - : state.history.map(toCompatibilityMessage) - ).filter((message): message is WireMessage => message !== null), - pendingInteraction: state.pendingInteraction ? pendingAsLegacyWire(state.pendingInteraction) : null, - canRetry: state.canRetry, - seq: state.lastSeq, - }; - }, -}; From cfbcfbb706aaf576da8456297e302c78eede6b1d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:06:49 +0000 Subject: [PATCH 05/17] refactor(rewrite): delete the bridge session store, reducer and adopted handle session/local-store.ts, session/reducer.ts, the adopted foreground, submission, command and handle files, and direct-command-compaction.ts are unreached from app/cli.ts. session/adopted keeps conversation-log.ts (the fact union production and migration decode) and network.ts. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/reachability-allowlist.json | 19 - .../src/session/adopted/compact-command.ts | 338 ------- .../src/session/adopted/conversation-index.ts | 2 - .../adopted/conversation-retry-projection.ts | 15 - rewrite/src/session/adopted/foreground.ts | 591 ------------- rewrite/src/session/adopted/goal-authority.ts | 159 ---- .../src/session/adopted/history-command.ts | 74 -- .../src/session/adopted/interrupt-command.ts | 66 -- .../src/session/adopted/network-authority.ts | 346 -------- rewrite/src/session/adopted/retry-command.ts | 100 --- rewrite/src/session/adopted/session-handle.ts | 334 ------- .../src/session/adopted/settings-command.ts | 104 --- rewrite/src/session/adopted/settings.ts | 50 -- rewrite/src/session/adopted/submission.ts | 124 --- rewrite/src/session/adopted/turn-control.ts | 90 -- .../src/session/adopted/turn-interaction.ts | 492 ----------- rewrite/src/session/adopted/turn-prompt.ts | 26 - .../src/session/direct-command-compaction.ts | 54 -- rewrite/src/session/local-store.ts | 827 ------------------ rewrite/src/session/reducer.ts | 178 ---- 20 files changed, 3989 deletions(-) delete mode 100644 rewrite/src/session/adopted/compact-command.ts delete mode 100644 rewrite/src/session/adopted/conversation-index.ts delete mode 100644 rewrite/src/session/adopted/conversation-retry-projection.ts delete mode 100644 rewrite/src/session/adopted/foreground.ts delete mode 100644 rewrite/src/session/adopted/goal-authority.ts delete mode 100644 rewrite/src/session/adopted/history-command.ts delete mode 100644 rewrite/src/session/adopted/interrupt-command.ts delete mode 100644 rewrite/src/session/adopted/network-authority.ts delete mode 100644 rewrite/src/session/adopted/retry-command.ts delete mode 100644 rewrite/src/session/adopted/session-handle.ts delete mode 100644 rewrite/src/session/adopted/settings-command.ts delete mode 100644 rewrite/src/session/adopted/settings.ts delete mode 100644 rewrite/src/session/adopted/submission.ts delete mode 100644 rewrite/src/session/adopted/turn-control.ts delete mode 100644 rewrite/src/session/adopted/turn-interaction.ts delete mode 100644 rewrite/src/session/adopted/turn-prompt.ts delete mode 100644 rewrite/src/session/direct-command-compaction.ts delete mode 100644 rewrite/src/session/local-store.ts delete mode 100644 rewrite/src/session/reducer.ts diff --git a/rewrite/scripts/reachability-allowlist.json b/rewrite/scripts/reachability-allowlist.json index fc3a6af31..a04373942 100644 --- a/rewrite/scripts/reachability-allowlist.json +++ b/rewrite/scripts/reachability-allowlist.json @@ -9,25 +9,6 @@ "rewrite/src/engine/adopted/tool-catalog.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/engine/loop.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/model/scripted.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/compact-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/conversation-index.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/conversation-retry-projection.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/foreground.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/goal-authority.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/history-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/interrupt-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/network-authority.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/retry-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/session-handle.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/settings-command.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/submission.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/turn-control.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/turn-interaction.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/adopted/turn-prompt.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/direct-command-compaction.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/session/direct-import.ts": "Offline v3 conversation migration (scripts/conversation-migration/migration-command.mjs) imports its emitted module; no CLI command reaches it.", - "rewrite/src/session/local-store.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/reducer.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/tools/notes.ts": "Pending deletion: reached only by the bench-only bridge path (#139)." } diff --git a/rewrite/src/session/adopted/compact-command.ts b/rewrite/src/session/adopted/compact-command.ts deleted file mode 100644 index b3edb3011..000000000 --- a/rewrite/src/session/adopted/compact-command.ts +++ /dev/null @@ -1,338 +0,0 @@ -/** Manual context compaction submission command: runs shared M0 compaction and records durable M1 state/accounting. */ -import crypto from 'node:crypto'; -import type { ConversationEvent } from '../../../../source/services/conversation/conversation-events.js'; -import type { ModelRequestCost } from '../../../../source/services/cost/model-cost.js'; -import type { - ContextCompactionRecordedEvent, - StateSnapshot, -} from '../../../../source/services/logging/conversation-log-events.js'; -import { - runManualCompaction, - type ConversationCompactionOutcome, -} from '../../../../source/services/session/manual-compaction.js'; -import type { NormalizedUsage } from '../../../../source/utils/ai/token-usage.js'; -import type { AdoptedCommandDeps } from './submission.js'; - -export function createCompactCommand( - deps: AdoptedCommandDeps, - options: { - isSubmissionPending: () => boolean; - reserveNetworkForCompaction?: () => (() => void) | null; - emitSourceEvent?: (update: { turnId: string; generation: number; event: ConversationEvent }) => void; - }, -) { - const { context, runtime, turn, commit, poison } = deps; - let inFlight: Promise | undefined; - - const isBusy = (): boolean => { - const state = context.state(); - return ( - options.isSubmissionPending() || - state.status === 'running' || - state.status === 'awaiting_interaction' || - state.activeTurnId !== null || - state.pendingInteraction !== null || - state.queue.length > 0 || - !runtime.appState.statusMachine.is('idle') || - runtime.pendingInteraction.getSnapshot() !== null || - runtime.approvalState.getPending() !== null || - runtime.postExecutePending.snapshot().entries.length > 0 || - runtime.nestedApprovalOwner.getSnapshot() !== null || - runtime.backgroundSubagentApprovals.getSnapshot().pendingCount > 0 - ); - }; - - const refuseBusy = (): Promise => - runManualCompaction({ - holdForegroundQueue: () => false, - attachCompactionAbort: () => {}, - detachCompactionAbort: () => {}, - releaseForegroundQueue: () => {}, - compactContext: runtime.compactContext, - getEventSink: () => undefined, - logger: runtime.logger, - getProvider: () => context.state().model.provider, - getSessionId: () => context.id, - }); - - return { - compact(): Promise { - try { - context.assertHealthy(); - } catch (error) { - return Promise.reject(error); - } - if (turn.poisoned !== undefined) return Promise.reject(turn.poisoned); - if (turn.closing || context.state().status === 'closed') - return Promise.reject(new Error('Session handle is closed')); - if (turn.interrupting || turn.settingMutation) - return Promise.reject(new Error('Session mutation is in progress')); - if (inFlight) return inFlight; - if (isBusy()) return refuseBusy(); - - const releaseNetwork = options.reserveNetworkForCompaction ? options.reserveNetworkForCompaction() : () => {}; - if (!releaseNetwork) return refuseBusy(); - - let releaseSettings: (() => void) | undefined; - try { - releaseSettings = context.reserveSettings(); - } catch (error) { - releaseNetwork(); - return Promise.reject(error); - } - turn.compacting = true; - - const operation = turn.queue(async (): Promise => { - await turn.completion; - context.assertHealthy(); - if (turn.poisoned !== undefined) throw turn.poisoned; - if (turn.closing || context.state().status === 'closed') throw new Error('Session handle is closed'); - - const operationId = crypto.randomUUID(); - const token = runtime.generationGuard.capture(); - const model = { ...context.state().model }; - const emitSource = (event: ConversationEvent) => { - try { - const result: unknown = options.emitSourceEvent?.({ - turnId: operationId, - generation: token, - event, - }); - if ( - result != null && - (typeof result === 'object' || typeof result === 'function') && - typeof (result as { then?: unknown }).then === 'function' - ) { - void Promise.resolve(result).catch(() => {}); - } - } catch { - /* Observer failures do not alter compaction. */ - } - }; - - const bufferedEvents: ConversationEvent[] = []; - let observedStrategy: 'local' | 'native' = model.provider === 'codex' ? 'native' : 'local'; - let sawAccountingObservation = false; - let observedUsage: { inputTokens: number; outputTokens: number } | undefined; - const observedCosts: ModelRequestCost[] = []; - const seenRequestIds = new Set(); - let rawOutcome: Awaited> | undefined; - let capturedSnapshot: StateSnapshot | undefined; - - const abortListeners = new Map void }>(); - const attachCompactionAbort = (abort: AbortController) => { - const signal = turn.cancellation.signal; - const listener = () => abort.abort(); - if ( - signal.aborted || - turn.closing || - turn.poisoned !== undefined || - !runtime.generationGuard.isCurrent(token) - ) { - abort.abort(); - return; - } - signal.addEventListener('abort', listener, { once: true }); - abortListeners.set(abort, { signal, listener }); - }; - const detachCompactionAbort = (abort: AbortController) => { - const entry = abortListeners.get(abort); - if (entry) { - entry.signal.removeEventListener('abort', entry.listener); - abortListeners.delete(abort); - } - }; - - const buildAccountingFields = (): { - usage?: NormalizedUsage; - costRecords?: ModelRequestCost[]; - } => { - let usage: NormalizedUsage | undefined; - const costRecords: ModelRequestCost[] = []; - if (sawAccountingObservation) { - if (observedUsage !== undefined) { - usage = { - prompt_tokens: observedUsage.inputTokens, - completion_tokens: observedUsage.outputTokens, - total_tokens: observedUsage.inputTokens + observedUsage.outputTokens, - }; - } - for (const record of observedCosts) costRecords.push(structuredClone(record)); - } else if (rawOutcome) { - if ( - 'usage' in rawOutcome && - rawOutcome.usage && - (rawOutcome.usage.inputTokens > 0 || rawOutcome.usage.outputTokens > 0) - ) { - usage = { - prompt_tokens: rawOutcome.usage.inputTokens, - completion_tokens: rawOutcome.usage.outputTokens, - total_tokens: rawOutcome.usage.inputTokens + rawOutcome.usage.outputTokens, - }; - } - if ('costRecords' in rawOutcome && Array.isArray(rawOutcome.costRecords)) { - const seen = new Set(); - for (const record of rawOutcome.costRecords) { - if (record.requestId && seen.has(record.requestId)) continue; - if (record.requestId) seen.add(record.requestId); - costRecords.push(structuredClone(record)); - } - } - } - return { - ...(usage ? { usage } : {}), - ...(costRecords.length > 0 ? { costRecords } : {}), - }; - }; - - let outcome: ConversationCompactionOutcome; - try { - outcome = await runManualCompaction({ - holdForegroundQueue: () => { - if (turn.closing || turn.poisoned !== undefined || context.state().status === 'closed') return false; - if ( - !runtime.generationGuard.isCurrent(token) || - turn.active !== undefined || - turn.activePostExecuteRunId !== undefined || - isBusy() - ) { - return false; - } - return true; - }, - attachCompactionAbort, - detachCompactionAbort, - releaseForegroundQueue: () => {}, - compactContext: async (compactOptions) => { - const raw = await runtime.compactContext({ - ...compactOptions, - onStrategy(strategy) { - observedStrategy = strategy; - }, - onAccounting(observation) { - sawAccountingObservation = true; - if (observation.usage !== undefined) { - observedUsage ??= { inputTokens: 0, outputTokens: 0 }; - observedUsage.inputTokens += observation.usage.inputTokens; - observedUsage.outputTokens += observation.usage.outputTokens; - } - if (observation.costRecords) { - for (const record of observation.costRecords) { - if (record.requestId && seenRequestIds.has(record.requestId)) continue; - if (record.requestId) seenRequestIds.add(record.requestId); - observedCosts.push(structuredClone(record)); - } - } - }, - }); - rawOutcome = raw; - if (raw.kind === 'compacted') { - if (sawAccountingObservation) { - raw.costRecords = observedCosts.map((record) => structuredClone(record)); - } - capturedSnapshot = runtime.state.getCurrentSnapshot(); - } - return raw; - }, - getEventSink: () => (event) => { - if (event.type === 'context_compaction_started') { - emitSource(event); - } else { - bufferedEvents.push(event); - } - }, - logger: runtime.logger, - getProvider: () => model.provider, - getSessionId: () => context.id, - }); - } catch (error) { - const accounting = buildAccountingFields(); - if (accounting.usage !== undefined || accounting.costRecords !== undefined) { - try { - await commit([ - { - type: 'context_compaction_recorded', - version: 1, - operationId, - provider: model.provider, - model: model.model, - strategy: observedStrategy, - result: { kind: 'not_compacted' }, - ...accounting, - }, - ]); - context.assertHealthy(); - if (turn.poisoned !== undefined) throw turn.poisoned; - } catch (commitError) { - poison(commitError ?? new Error('Context compaction persistence failed')); - turn.cancelRun(); - throw turn.poisoned; - } - } - for (const event of bufferedEvents) emitSource(event); - throw error; - } - - const accounting = buildAccountingFields(); - if (outcome.kind === 'compacted') { - if (!capturedSnapshot) { - poison(new Error('Compacted outcome missing state snapshot')); - turn.cancelRun(); - throw turn.poisoned; - } - const fact: ContextCompactionRecordedEvent = { - type: 'context_compaction_recorded', - version: 1, - operationId, - provider: model.provider, - model: model.model, - strategy: observedStrategy, - result: { kind: 'compacted', snapshot: capturedSnapshot }, - ...accounting, - }; - try { - await commit([fact]); - context.assertHealthy(); - if (turn.poisoned !== undefined) throw turn.poisoned; - } catch (commitError) { - poison(commitError ?? new Error('Context compaction persistence failed')); - turn.cancelRun(); - throw turn.poisoned; - } - } else if (accounting.usage !== undefined || accounting.costRecords !== undefined) { - const fact: ContextCompactionRecordedEvent = { - type: 'context_compaction_recorded', - version: 1, - operationId, - provider: model.provider, - model: model.model, - strategy: observedStrategy, - result: { kind: 'not_compacted' }, - ...accounting, - }; - try { - await commit([fact]); - context.assertHealthy(); - if (turn.poisoned !== undefined) throw turn.poisoned; - } catch (commitError) { - poison(commitError ?? new Error('Context compaction persistence failed')); - turn.cancelRun(); - throw turn.poisoned; - } - } - - for (const event of bufferedEvents) emitSource(event); - return outcome; - }); - - const promise = operation.finally(() => { - turn.compacting = false; - releaseSettings?.(); - releaseNetwork(); - if (inFlight === promise) inFlight = undefined; - }); - inFlight = promise; - return promise; - }, - }; -} diff --git a/rewrite/src/session/adopted/conversation-index.ts b/rewrite/src/session/adopted/conversation-index.ts deleted file mode 100644 index ac84c6ac6..000000000 --- a/rewrite/src/session/adopted/conversation-index.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Adopted derived-index reconciliation; the SQLite index is never authoritative. */ -export { SessionIndexService } from '../../../../source/services/conversation/session-index/session-index-service.js'; diff --git a/rewrite/src/session/adopted/conversation-retry-projection.ts b/rewrite/src/session/adopted/conversation-retry-projection.ts deleted file mode 100644 index 8f38a2b58..000000000 --- a/rewrite/src/session/adopted/conversation-retry-projection.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Retry target selection stays owned by the source conversation store. */ -import type { ProviderInputItem } from '../../../../source/contracts/provider-input.js'; -import { ConversationStore } from '../../../../source/services/conversation/conversation-store.js'; - -export function projectRetryEligibility(history: readonly unknown[]): { - readonly turn: boolean; - readonly tool: boolean; -} { - const source = new ConversationStore(); - for (const item of history) source.addImportedItem(item as ProviderInputItem); - return { - turn: source.listUserTurns().length > 0, - tool: source.peekLastToolOutput() !== null, - }; -} diff --git a/rewrite/src/session/adopted/foreground.ts b/rewrite/src/session/adopted/foreground.ts deleted file mode 100644 index d1ecdbbbf..000000000 --- a/rewrite/src/session/adopted/foreground.ts +++ /dev/null @@ -1,591 +0,0 @@ -/** - * Drives one session's foreground turns: admission, launch, the turn's - * completion facts, and the submit and decide commands. Each turn's pending - * interaction lives in turn-interaction.ts; the other commands have their own - * files. - */ -import crypto from 'node:crypto'; -import { toTerminalEvent } from '../../../../source/services/conversation/conversation-result-builder.js'; -import type { ConversationEvent } from '../../../../source/services/conversation/conversation-events.js'; -import type { LogEvent } from '../../../../source/services/logging/conversation-log-events.js'; -import type { SessionRuntimeInternals } from '../../../../source/services/session/session-composition.js'; -import { parseToolCallArguments } from '../../../../source/services/tool-call-arguments.js'; -import { injectSkillIntoTurn } from '../../../../source/types/user-turn.js'; -import type { UserTurn } from '../../../../source/types/user-turn.js'; -import type { AdoptedEngine, AdoptedTurnControl, AdoptedTurnRun } from '../../engine/adopted/index.js'; -import { FatalPersistenceError, isFatalPersistenceError } from '../../engine/adopted/index.js'; -import type { TurnInput } from '../../engine/index.js'; -import type { SessionDriver } from '../local-store.js'; -import type { Admission, SessionFact, SessionUpdate } from '../index.js'; -import { createCompactCommand } from './compact-command.js'; -import { createHistoryCommands } from './history-command.js'; -import { createInterruptCommand } from './interrupt-command.js'; -import { createRetryCommand } from './retry-command.js'; -import { createSettingsCommand, type AdoptedRuntimeSettings } from './settings-command.js'; -import { - captureAdoptedInput, - type AdoptedAdmissionContext, - type AdoptedAdmissionReceipt, - type AdoptedCommandDeps, - type AdoptedSubmissionInput, - type AdoptedSubmissionOptions, - type AdoptedInteractionCommand, -} from './submission.js'; -import { createTurnControl } from './turn-control.js'; -import { createTurnInteraction } from './turn-interaction.js'; - -/** Installs the source collaborators on the exact engine which owns their client. */ -export function createAdoptedForeground(options: { - context: AdoptedAdmissionContext; - engine: AdoptedEngine; - runtime: SessionRuntimeInternals; - /** Canonical source replay for this handle, including native history and tool ledger. */ - restored: { - history: unknown[]; - previousResponseId: string | null; - toolLedger?: Parameters[0]['toolLedger']; - coveredToolOccurrences?: Iterable; - }; - onSourceEvent?: (update: { - requestId?: string; - turnId: string; - generation: number; - event: ConversationEvent; - droppedInput?: UserTurn; - }) => void; - makeInput: (turnId: TurnInput['turnId']) => TurnInput; - /** Request cancellation of this handle's source-owned background work. */ - cancelBackground?: () => void; - reserveNetworkForCompaction?: () => (() => void) | null; - runtimeSettings?: AdoptedRuntimeSettings; - /** Human grant on the exact client owning this foreground. */ - grantHumanRunBudgetExtension?: () => { granted: boolean; extensionsGranted: number }; - /** Combined store handles publish canonical facts themselves. */ - publishFacts?: boolean; -}) { - const { context, engine, runtime } = options; - runtime.state.importState(options.restored); - const listeners = new Set<(update: SessionUpdate) => void>(); - const pending = new Map }>(); - const turn = createTurnControl({ engine, runtime }); - let shutdownPromise: Promise | undefined; - let decideCurrent: ((command: AdoptedInteractionCommand) => Promise) | undefined; - const buffered: LogEvent[] = []; - const poison = (error: unknown): unknown => { - turn.poisoned = error ?? new Error('Session mutation failed'); - context.poison(turn.poisoned); - return turn.poisoned; - }; - const capture = (event: LogEvent) => { - if (turn.poisoned !== undefined) return; - try { - buffered.push(structuredClone(event)); - } catch (error) { - poison(error); - } - }; - runtime.record.setJournalSink(capture); - runtime.conversationLogger.setLogSink(capture); - const publish = (update: SessionUpdate) => { - for (const listener of listeners) { - try { - const result: unknown = listener(update); - if ( - result != null && - (typeof result === 'object' || typeof result === 'function') && - typeof (result as { then?: unknown }).then === 'function' - ) - void Promise.resolve(result).catch(() => {}); - } catch { - /* A subscriber cannot undo a durable append. */ - } - } - }; - let writeTail = Promise.resolve(); - const commit = (facts: readonly SessionFact[]) => { - const operation = writeTail.then(async () => { - if (turn.poisoned !== undefined) throw new FatalPersistenceError(turn.poisoned); - const count = buffered.length; - try { - const entries = await context.commit([...buffered.slice(0, count), ...facts]); - context.assertHealthy(); - buffered.splice(0, count); - if (options.publishFacts !== false) for (const entry of entries) publish({ kind: 'fact', entry }); - return facts.length ? entries.slice(-facts.length) : []; - } catch (error) { - throw new FatalPersistenceError(poison(error)); - } - }); - writeTail = operation.then( - () => undefined, - () => undefined, - ); - return operation; - }; - const unsupported = async (): Promise => { - throw new Error('Foreground capability does not support this command'); - }; - const driver: SessionDriver = { - submit: unsupported, - decide: unsupported, - retry: unsupported, - compact: async () => { - await compact.compact(); - }, - setModel: unsupported, - async cancel() { - turn.stop(); - await turn.serial; - await turn.completion; - }, - subscribe(_fromSeq, listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }; - const execute = async ( - submitted: AdoptedSubmissionInput, - signal: AbortSignal, - inputSurgeApproval?: AdoptedSubmissionOptions['inputSurgeApproval'], - ): Promise => { - if (turn.poisoned !== undefined) throw turn.poisoned; - await turn.completion; - if (turn.active) await turn.active.waitForSettlement(signal); - await engine.waitForAdmission(signal); - signal.throwIfAborted(); - const admitted = await context.admit(submitted, { - id: crypto.randomUUID(), - sender: 'user', - text: submitted.input.text, - ...(submitted.input.skill ? { skill: submitted.input.skill } : {}), - submittedTurn: submitted.input, - }); - if (admitted.kind !== 'admitted') return admitted; - const receipt = admitted.receipt; - const token = runtime.generationGuard.capture(); - const isCurrent = () => !signal.aborted && runtime.generationGuard.isCurrent(token); - const settleBeforeActivation = async () => { - await commit([ - { - type: 'session_admission_settled', - version: 1, - clientRequestId: receipt.requestId, - turnId: receipt.turnId, - result: 'aborted', - }, - ]); - }; - if (!isCurrent()) { - await settleBeforeActivation(); - throw new Error('Admission cancelled'); - } - try { - await context.activate(receipt); - } catch (error) { - throw error; - } - if (!isCurrent()) { - await commit([ - { type: 'session_turn_ended', version: 1, turnId: receipt.turnId, outcome: 'cancelled' }, - { - type: 'session_admission_settled', - version: 1, - clientRequestId: receipt.requestId, - turnId: receipt.turnId, - result: 'aborted', - }, - ]); - throw new Error('Activation cancelled'); - } - return launch(receipt, token, signal, false, inputSurgeApproval); - }; - const launch = async ( - receipt: Omit & { - requestId?: AdoptedAdmissionReceipt['requestId']; - userMessageRef?: AdoptedAdmissionReceipt['userMessageRef']; - }, - token: number, - signal: AbortSignal, - retryOrigin: boolean, - inputSurgeApproval?: AdoptedSubmissionOptions['inputSurgeApproval'], - ): Promise => { - const isCurrent = () => !signal.aborted && runtime.generationGuard.isCurrent(token); - const sourceEvents: ConversationEvent[] = []; - let control: AdoptedTurnControl; - const emitSource = (update: { event: ConversationEvent; droppedInput?: UserTurn }) => { - if (!isCurrent() || update.event.type === 'approval_required') return; - try { - const result: unknown = options.onSourceEvent?.({ - ...(receipt.requestId ? { requestId: receipt.requestId } : {}), - turnId: receipt.turnId, - generation: token, - ...update, - }); - if ( - result != null && - (typeof result === 'object' || typeof result === 'function') && - typeof (result as { then?: unknown }).then === 'function' - ) - void Promise.resolve(result).catch(() => {}); - } catch { - /* Observer failures do not alter the turn. */ - } - }; - const interactions = createTurnInteraction({ - runtime, - engine, - turn, - commit, - grantHumanRunBudgetExtension: () => options.grantHumanRunBudgetExtension?.(), - turnId: receipt.turnId, - token, - signal, - isCurrent, - control: () => control, - bound: () => bound, - }); - const domain = runtime.createTurnDomain(); - const postExecuteRunId = `${context.id}:adopted:${receipt.turnId}:${token}`; - const bound = domain.beginAdoptedTurn( - retryOrigin ? receipt.input : injectSkillIntoTurn(receipt.input), - (event) => { - sourceEvents.push(event); - if ( - isCurrent() && - event.type !== 'error' && - event.type !== 'final' && - event.type !== 'approval_required' && - event.type !== 'tool_started' - ) { - runtime.conversationLogger.dispatchEventToLog(event); - emitSource({ event }); - } - }, - { - token, - signal, - ...(inputSurgeApproval ? { inputSurgeApproval } : {}), - ...(retryOrigin ? { skipUserMessage: true, replayFromHistory: true } : {}), - }, - async (terminal, address) => { - const answer = await interactions.requestDecision(terminal, address); - if (!isCurrent()) throw new Error('Adopted turn is stale'); - return { - init: runtime.approvalFlow.buildApprovalDecision( - answer.answer, - answer.rejectionReason, - answer.stopAfterApprovalResolution, - ), - }; - }, - postExecuteRunId, - ); - let accepted = false; - let acceptedRef: { logId: string; eventId: string } | undefined; - let unregister: () => void = () => {}; - let run: AdoptedTurnRun; - const watcherStop = new AbortController(); - let watcher: Promise = Promise.resolve(); - decideCurrent = interactions.decide; - try { - unregister = engine.registerTurnBridge(receipt.turnId, { generation: token, isCurrent }, (boundControl) => { - control = boundControl; - turn.activePostExecuteRunId = bound.postExecuteRunId; - watcher = interactions.watchPostExecute(watcherStop.signal); - void watcher.catch(() => turn.cancelRun()); - return { - ...bound.session, - async prepare(s) { - const ready = await bound.session.prepare(s); - if (!ready || accepted || retryOrigin) return ready; - if (!isCurrent()) throw new Error('Turn became stale before acceptance'); - const entries = await commit([ - { - type: 'user_input_accepted', - version: 1, - turnId: receipt.turnId, - userMessageRef: receipt.userMessageRef!, - snapshot: runtime.state.getCurrentSnapshot(), - }, - ]); - acceptedRef = entries[0]!.ref; - accepted = true; - if (!isCurrent()) throw new Error('Turn became stale after acceptance'); - return ready; - }, - async beforeToolDispatch(callId) { - const call = runtime.record - .exportToolEvidence() - .reverse() - .find((entry) => entry.callId === callId && !entry.dispatchedAt); - if (!call || !isCurrent()) - throw new FatalPersistenceError(new Error('Missing current tool dispatch evidence')); - const event: ConversationEvent = { - type: 'tool_started', - toolCallId: callId, - toolName: call.toolName, - arguments: parseToolCallArguments(call.arguments, { - callId, - toolName: call.toolName, - sessionId: context.id, - traceId: runtime.logger.getCorrelationId() ?? 'trace-unknown', - }).arguments, - }; - runtime.conversationLogger.dispatchEventToLog(event); - await commit([]); - if (!isCurrent()) throw new FatalPersistenceError(new Error('Tool dispatch became stale')); - emitSource({ event }); - }, - isFatalError: isFatalPersistenceError, - getAcceptedHistory: () => runtime.record.getHistory(), - }; - }); - run = engine.start(options.makeInput(receipt.turnId)); - } catch (error) { - watcherStop.abort(); - runtime.postExecutePending.closeRun(bound.postExecuteRunId); - await watcher.catch(() => {}); - if (turn.activePostExecuteRunId === bound.postExecuteRunId) turn.activePostExecuteRunId = undefined; - unregister(); - bound.finish(); - await commit([ - { type: 'session_turn_ended', version: 1, turnId: receipt.turnId, outcome: 'failed' }, - ...(receipt.requestId - ? [ - { - type: 'session_admission_settled', - version: 1 as const, - clientRequestId: receipt.requestId, - turnId: receipt.turnId, - result: 'failed' as const, - } as const, - ] - : []), - ]); - throw error; - } - turn.active = run; - turn.completion = (async () => { - let terminal: Extract['event'] | undefined; - try { - let view = run; - while (true) { - for await (const event of view.events) { - if (event.type === 'checkpoint') interactions.offerCheckpoint(event.checkpoint); - if (event.type === 'turn_ended') terminal = event; - else if (event.type !== 'checkpoint' && isCurrent()) - publish({ kind: 'live', turnId: receipt.turnId, event }); - } - if (terminal?.type !== 'turn_ended' || terminal.outcome.kind !== 'suspended') break; - terminal = undefined; - const next = await Promise.race([interactions.takeView(), run.waitForSettlement().then(() => undefined)]); - if (!next) { - terminal = { type: 'turn_ended', outcome: { kind: 'cancelled' } }; - break; - } - view = next; - } - await run.waitForSettlement(); - const outcome = bound.finish(); - if (outcome.kind === 'response') sourceEvents.push(toTerminalEvent(outcome.terminal)); - if (outcome.kind === 'failed' && !sourceEvents.some((event) => event.type === 'error')) { - sourceEvents.push({ - type: 'error', - message: 'Conversation turn failed before producing an authoritative terminal event.', - }); - } - if (isCurrent()) - for (const event of sourceEvents) - if (event.type === 'error' || event.type === 'final') runtime.conversationLogger.dispatchEventToLog(event); - const dropped = sourceEvents.some((event) => event.type === 'error' && !!event.droppedUserMessage); - const kind = terminal?.type === 'turn_ended' ? terminal.outcome.kind : 'failed'; - const result = - kind === 'completed' && outcome.kind === 'response' - ? 'accepted' - : kind === 'cancelled' - ? 'aborted' - : 'failed'; - const facts: SessionFact[] = []; - const pendingInteraction = context.state().pendingInteraction; - if (pendingInteraction?.turnId === receipt.turnId) { - facts.push({ - type: 'session_interaction_resolved', - version: 1, - turnId: receipt.turnId, - interactionId: pendingInteraction.interaction.interactionId, - revision: pendingInteraction.interaction.revision, - generation: pendingInteraction.generation, - outcome: 'cancelled', - }); - } - if (dropped) { - if (!receipt.userMessageRef) - throw new FatalPersistenceError(new Error('Dropped input has no canonical user reference')); - facts.push({ - type: 'events_retracted', - version: 1, - refs: [receipt.userMessageRef, ...(acceptedRef ? [acceptedRef] : [])], - }); - facts.push({ type: 'undo', removedUserTurns: 1, snapshot: runtime.state.getCurrentSnapshot() }); - } - facts.push({ - type: 'session_turn_ended', - version: 1, - turnId: receipt.turnId, - outcome: result === 'accepted' ? 'completed' : result === 'aborted' ? 'cancelled' : 'failed', - }); - if (receipt.requestId) - facts.push({ - type: 'session_admission_settled', - version: 1, - clientRequestId: receipt.requestId, - turnId: receipt.turnId, - result, - }); - await commit(facts); - if (result === 'accepted' && !retryOrigin && !interactions.askedForDecision && isCurrent()) { - for await (const event of runtime.automaticMemoryFinalizer.record(receipt.input)) { - if (!isCurrent()) break; - runtime.conversationLogger.dispatchEventToLog(event); - await commit([]); - if (isCurrent()) emitSource({ event }); - } - } - if (isCurrent()) { - for (const event of sourceEvents) { - if (event.type === 'error' || event.type === 'final' || event.type === 'approval_required') { - emitSource({ - event, - ...(event.type === 'error' && event.droppedUserMessage - ? { droppedInput: structuredClone(receipt.input) } - : {}), - }); - } - } - } - if (terminal && isCurrent()) publish({ kind: 'live', turnId: receipt.turnId, event: terminal }); - } finally { - watcherStop.abort(); - runtime.postExecutePending.closeRun(bound.postExecuteRunId); - await watcher.catch(() => {}); - if (turn.activePostExecuteRunId === bound.postExecuteRunId) turn.activePostExecuteRunId = undefined; - decideCurrent = undefined; - unregister(); - if (turn.active === run) turn.active = undefined; - } - })(); - // Observe failure immediately; later submissions still await the original promise. - void turn.completion.catch(() => {}); - return { kind: 'started', turnId: receipt.turnId }; - }; - const commandDeps: AdoptedCommandDeps = { context, runtime, turn, commit, poison }; - const history = createHistoryCommands(commandDeps); - const interrupt = createInterruptCommand(commandDeps, { - foreground: () => driver.cancel(), - background: options.cancelBackground?.bind(options), - }); - const settings = createSettingsCommand(commandDeps, options.runtimeSettings); - const retry = createRetryCommand(commandDeps, launch); - const compact = createCompactCommand(commandDeps, { - isSubmissionPending: () => pending.size > 0, - reserveNetworkForCompaction: options.reserveNetworkForCompaction, - emitSourceEvent: options.onSourceEvent, - }); - return { - driver, - /** The ordered canonical writer; it flushes buffered source facts before the given ones. */ - commit, - submission: { - ...history, - ...interrupt, - ...settings, - ...retry, - ...compact, - decide(command: AdoptedInteractionCommand): Promise { - try { - context.assertHealthy(); - } catch (error) { - return Promise.reject(error); - } - if (turn.poisoned !== undefined) return Promise.reject(turn.poisoned); - if (turn.interrupting) return Promise.reject(new Error('User interrupt is in progress')); - const owned = structuredClone(command); - const operation = turn.decisionTail - .then(() => { - context.assertHealthy(); - if (turn.closing || !decideCurrent) throw new Error('No current interaction'); - return decideCurrent(owned); - }) - .catch(async (error) => { - if (isFatalPersistenceError(error)) { - turn.cancelRun(); - try { - await turn.completion; - } catch { - /* Preserve the persistence error. */ - } - } - throw error; - }); - turn.decisionTail = operation.then( - () => undefined, - () => undefined, - ); - return operation; - }, - submit(value: AdoptedSubmissionInput, submitOptions?: AdoptedSubmissionOptions): Promise { - try { - context.assertHealthy(); - } catch (error) { - return Promise.reject(error); - } - if (turn.poisoned !== undefined) return Promise.reject(turn.poisoned); - if (turn.closing) return Promise.resolve({ kind: 'rejected', reason: 'closed' }); - if (turn.interrupting || turn.settingMutation || turn.compacting) - return Promise.reject(new Error('Session mutation is in progress')); - const captured = captureAdoptedInput(value.input); - if (context.state().status === 'closed') return Promise.resolve({ kind: 'rejected', reason: 'closed' }); - const existing = pending.get(value.requestId); - if (existing) { - if (existing.hash !== captured.hash) return Promise.resolve({ kind: 'rejected', reason: 'request_conflict' }); - return existing.result.then((result) => - result.kind === 'started' ? { kind: 'duplicate', turnId: result.turnId } : result, - ); - } - const prior = context.state().admissions[value.requestId]; - if (prior && Date.parse(prior.expiresAt) > Date.now()) { - return Promise.resolve( - prior.normalizedBodyHash === captured.hash - ? { kind: 'duplicate', turnId: prior.turnId } - : { kind: 'rejected', reason: 'request_conflict' }, - ); - } - const submitted = { requestId: value.requestId, input: captured.input }; - const signal = turn.cancellation.signal; - const inputSurgeApproval = submitOptions?.inputSurgeApproval; - const result = turn.queue(() => execute(submitted, signal, inputSurgeApproval)); - pending.set(value.requestId, { hash: captured.hash, result }); - void result.finally(() => pending.delete(value.requestId)).catch(() => {}); - return result; - }, - }, - failPersistence(error: unknown): void { - poison(error); - turn.cancellation.abort(); - turn.cancelRun(); - }, - shutdown(): Promise { - shutdownPromise ??= (async () => { - turn.closing = true; - turn.cancellation.abort(); - turn.cancelRun(); - await turn.decisionTail; - await turn.serial; - await turn.completion; - await writeTail; - if (turn.interrupting) await turn.interrupting; - })(); - return shutdownPromise; - }, - }; -} diff --git a/rewrite/src/session/adopted/goal-authority.ts b/rewrite/src/session/adopted/goal-authority.ts deleted file mode 100644 index 4bb071601..000000000 --- a/rewrite/src/session/adopted/goal-authority.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { isDeepStrictEqual } from 'node:util'; -import type { DurableGoal } from '../../../../source/services/logging/conversation-log-events.js'; -import type { SessionEventReference, SessionFact } from '../index.js'; -import { validateSessionFact, type SessionLogEntry } from './conversation-log.js'; - -export type GoalRevision = SessionEventReference | null; -export type GoalMutationResult = 'applied' | 'stale'; - -/** - * Manual goal commands. A revision is the canonical event that set the current - * goal, or null while no goal has been set. - */ -export interface SessionGoalControl { - /** - * The goal as of the last completed write. A subscriber told about a new - * `goal_changed` fact may still read the previous goal here, because the - * fact is published before its commit resolves. - */ - get(): { readonly goal: DurableGoal | undefined; readonly revision: GoalRevision }; - /** - * Replaces the goal if `expected` is still the current revision. Losing that - * race resolves 'stale' and writes nothing; invalid input, a closed session - * or a failed write rejects. - */ - set(goal: DurableGoal, expected: GoalRevision): Promise; -} - -type Commit = (facts: readonly SessionFact[]) => Promise; -type Binding = { - /** The foreground's ordered writer, which flushes buffered turn facts first. */ - readonly commit: Commit; - /** Replayed provider history, used to find an earlier proposal. */ - readonly history: readonly unknown[]; - readonly isClosed: () => boolean; -}; - -/** - * The session goal is the last canonical `goal_changed` fact, and this is the - * only adopted writer of that fact. Every write compares and appends inside one - * queue, and the queue never waits on a running turn, so a goal callback from - * inside a turn and a manual `/goal` can never deadlock. Like the old CLI, a - * manual change does not interrupt a running turn: a `goal_check` names the - * goal it judged, so it cannot close a goal that replaced it. - */ -export function createGoalAuthority(entries: readonly SessionLogEntry[]) { - let current: { goal: DurableGoal | undefined; revision: GoalRevision } = { goal: undefined, revision: null }; - for (const entry of entries) - if (entry.fact.type === 'goal_changed') current = { goal: { ...entry.fact.goal }, revision: entry.ref }; - let proposalAttempted = false; - let binding: Binding | undefined; - let queue: Promise = Promise.resolve(); - - /** - * Inside the goal queue, append `goal` only if `expected` still holds. - * Resolves false, without writing, when it no longer does. - */ - const write = (goal: DurableGoal, expected: () => boolean, source?: 'goal_check'): Promise => { - const operation = queue.then(async () => { - if (!binding) throw new Error('Goal writer is not bound'); - if (binding.isClosed()) throw new Error('Session handle is closed'); - if (!expected()) return false; - const owned = { ...goal }; - const fact: SessionFact = { type: 'goal_changed', version: 1, goal: owned, ...(source ? { source } : {}) }; - const [entry] = await binding.commit([fact]); - current = { goal: owned, revision: entry!.ref }; - return true; - }); - queue = operation.catch(() => {}); - return operation; - }; - - const control: SessionGoalControl = { - get: () => ({ - goal: current.goal && { ...current.goal }, - revision: current.revision && { ...current.revision }, - }), - set(goal, expected) { - let owned: DurableGoal; - let expectedRef: GoalRevision; - try { - owned = parseGoal(goal); - expectedRef = parseRevision(expected); - } catch (error) { - return Promise.reject(error); - } - return write(owned, () => sameRevision(current.revision, expectedRef)).then((applied) => - applied ? 'applied' : 'stale', - ); - }, - }; - - return { - control, - /** - * Late-bound because the foreground that owns the writer is built after the - * client that needs these callbacks. A proposal already in the replayed - * history, approved or rejected, uses up the once-per-session proposal, as - * in the old CLI. - */ - bind(next: Binding): void { - binding = next; - proposalAttempted = next.history.some( - (item) => - (item as { type?: unknown }).type === 'function_call' && (item as { name?: unknown }).name === 'propose_goal', - ); - }, - /** Source callbacks for the session's own AgentClient. */ - clientDeps: { - getGoal: () => current.goal && { ...current.goal }, - /** The stop seam read an active goal; close exactly that goal or report failure. */ - async onGoalAchieved(achieved: DurableGoal): Promise { - // The policy passes the goal it judged with only its status flipped, so - // flipping the current goal the same way and comparing finds out - // whether a manual set replaced it in the meantime. - const stillJudged = () => - current.goal?.status === 'active' && isDeepStrictEqual({ ...current.goal, status: 'achieved' }, achieved); - if (!(await write(achieved, stillJudged, 'goal_check'))) - throw new Error('The goal changed before completion was recorded.'); - }, - proposeGoal: { - async appendGoal(goal: DurableGoal): Promise { - if (!(await write(goal, () => current.goal === undefined))) - throw new Error('A durable goal was set while the proposal waited.'); - proposalAttempted = true; - }, - hasPriorProposal: () => proposalAttempted, - }, - }, - }; -} - -/** Checks a caller's goal with the log's own `goal_changed` validator, which owns the goal schema. */ -function parseGoal(value: unknown): DurableGoal { - validateSessionFact({ type: 'goal_changed', version: 1, goal: value }); - const goal = value as DurableGoal; - // The validator allows extra keys; a manual command must not persist them. - const keys: readonly string[] = ['id', 'outcome', 'successCriteria', 'status'] satisfies (keyof DurableGoal)[]; - if (Object.keys(goal).some((key) => !keys.includes(key))) throw new Error('Invalid goal'); - return { ...goal }; -} - -function parseRevision(value: unknown): GoalRevision { - if (value === null) return null; - if ( - !value || - typeof value !== 'object' || - Object.keys(value).length !== 2 || - typeof (value as SessionEventReference).logId !== 'string' || - typeof (value as SessionEventReference).eventId !== 'string' - ) - throw new Error('Invalid goal revision'); - return { logId: (value as SessionEventReference).logId, eventId: (value as SessionEventReference).eventId }; -} - -function sameRevision(left: GoalRevision, right: GoalRevision): boolean { - return left === null || right === null - ? left === right - : left.logId === right.logId && left.eventId === right.eventId; -} diff --git a/rewrite/src/session/adopted/history-command.ts b/rewrite/src/session/adopted/history-command.ts deleted file mode 100644 index 0ae1b057a..000000000 --- a/rewrite/src/session/adopted/history-command.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** History submission commands: undo, rewind, and listing the rewind targets. */ -import type { AdoptedCommandDeps, HistoryMutation, HistoryMutationResult } from './submission.js'; - -export function createHistoryCommands(deps: AdoptedCommandDeps) { - const { context, runtime, turn, commit, poison } = deps; - const historyOperations = new Map>(); - return { - listRewindTargets() { - if (turn.closing) return Promise.reject(new Error('Session handle is closed')); - return turn.serial.then(() => { - if (turn.closing) throw new Error('Session handle is closed'); - return structuredClone(runtime.state.listRewindTargets()); - }); - }, - history(command: HistoryMutation): Promise { - try { - context.assertHealthy(); - } catch (error) { - return Promise.reject(error); - } - if (turn.poisoned !== undefined) return Promise.reject(turn.poisoned); - if (turn.closing || context.state().status === 'closed') - return Promise.reject(new Error('Session handle is closed')); - if (turn.interrupting || turn.compacting) return Promise.reject(new Error('User interrupt is in progress')); - const owned = structuredClone(command); - if (!owned || typeof owned !== 'object' || !['undo_last', 'undo_count', 'rewind'].includes(owned.kind)) - return Promise.reject(new Error('Invalid history command')); - const key = JSON.stringify( - owned.kind === 'undo_count' - ? [owned.kind, owned.count] - : owned.kind === 'rewind' - ? [owned.kind, owned.targetId] - : [owned.kind], - ); - const existing = historyOperations.get(key); - if (existing) return existing; - const operation = turn.queue(async (): Promise => { - context.assertHealthy(); - if (turn.closing) throw new Error('Session handle is closed'); - if (turn.poisoned !== undefined) throw turn.poisoned; - const reservation = runtime.state.reserveHistoryMutation(owned); - if (reservation.kind !== 'reserved') return reservation; - // Close the old callback gate before awaiting any physical settlement. - const { signal, token } = turn.stop({ captureToken: true }); - await turn.settle(signal); - if (turn.closing || signal.aborted || !runtime.generationGuard.isCurrent(token)) return { kind: 'stale' }; - let restored: ReturnType; - try { - restored = reservation.apply(); - } catch (error) { - throw poison(error ?? new Error('History mutation failed')); - } - if (!restored) return { kind: 'stale' }; - const committedGeneration = runtime.generationGuard.capture(); - await commit([]); - context.assertHealthy(); - if (turn.poisoned !== undefined) throw turn.poisoned; - if (turn.closing || signal.aborted || !runtime.generationGuard.isCurrent(committedGeneration)) - return { kind: 'stale' }; - return { - kind: 'applied', - restored: { text: restored.text, ...(restored.images ? { images: restored.images } : {}) }, - }; - }); - historyOperations.set(key, operation); - void operation - .finally(() => { - if (historyOperations.get(key) === operation) historyOperations.delete(key); - }) - .catch(() => {}); - return operation; - }, - }; -} diff --git a/rewrite/src/session/adopted/interrupt-command.ts b/rewrite/src/session/adopted/interrupt-command.ts deleted file mode 100644 index 2671302d4..000000000 --- a/rewrite/src/session/adopted/interrupt-command.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** User interrupt command: cancels the foreground turn and the handle's background work together. */ -import type { AdoptedCommandDeps } from './submission.js'; - -export function createInterruptCommand( - deps: AdoptedCommandDeps, - cancels: { - /** Cancels the foreground turn and resolves once it has settled. */ - foreground: () => Promise; - /** Requests cancellation of source-owned background work; must be synchronous. */ - background?: () => void; - }, -) { - const { context, turn } = deps; - return { - interruptFromUser(): Promise { - if (turn.closing || context.state().status === 'closed') - return Promise.reject(new Error('Session handle is closed')); - if (turn.interrupting) return turn.interrupting; - if (!cancels.background) return Promise.reject(new Error('User interrupt is not supported')); - let resolve!: () => void; - let reject!: (error: unknown) => void; - const operation = new Promise((ok, fail) => { - resolve = ok; - reject = fail; - }); - turn.interrupting = operation; - const foreground = cancels.foreground(); - void foreground.catch(() => {}); - let failure: unknown; - let failed = false; - try { - const result: unknown = cancels.background(); - if ( - result != null && - (typeof result === 'object' || typeof result === 'function') && - typeof (result as { then?: unknown }).then === 'function' - ) { - void Promise.resolve(result).catch(() => {}); - throw new TypeError('Background cancellation must be synchronous'); - } - } catch (error) { - failed = true; - failure = error; - } - void (async () => { - try { - await foreground; - } catch (error) { - if (!failed) failure = error; - failed = true; - } - if (failed) throw failure; - })().then( - () => { - if (turn.interrupting === operation) turn.interrupting = undefined; - resolve(); - }, - (error) => { - if (turn.interrupting === operation) turn.interrupting = undefined; - reject(error); - }, - ); - return operation; - }, - }; -} diff --git a/rewrite/src/session/adopted/network-authority.ts b/rewrite/src/session/adopted/network-authority.ts deleted file mode 100644 index e3785c983..000000000 --- a/rewrite/src/session/adopted/network-authority.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { SandboxNetworkApprovalCoordinator } from '../../../../source/utils/shell/sandbox/sandbox-network-approval-coordinator.js'; -import type { - SandboxNetworkAccessRequest, - NetworkApprovalAnswer, -} from '../../../../source/utils/shell/sandbox/sandbox-network-approval.js'; -import type { SandboxNetworkCompletion } from '../../../../source/utils/shell/sandbox/scoped-sandbox-network-access.js'; -import type { SessionDriverContext } from '../local-store.js'; -import { isNetworkAnswer } from './network.js'; - -type Entry = { - id: string; - request: SandboxNetworkAccessRequest; - completion: Promise; - signal: AbortSignal; - host: string; - port?: number; -}; -/** Private session authority; the canonical log remains the only durable writer. */ -export function createNetworkAuthority( - context: SessionDriverContext, - failClosed: () => void, - cancelSource: ( - request: SandboxNetworkAccessRequest, - reason: 'cancelled' | 'helper_lost' | 'closed', - ) => void = () => {}, -) { - const generation = randomUUID(); - const coordinator = new SandboxNetworkApprovalCoordinator(); - const entries = new Map(); - const applying = new Set(); - const listeners = new Set<() => void>(); - let queue = Promise.resolve(); - let closed = false; - let closePromise: Promise | undefined; - let stopReason: 'execution_ended' | 'helper_lost' | 'session_closed' | 'graph_replaced' = 'execution_ended'; - let poisoned = false; - let visible: Entry | undefined; - let queued = 0; - let pendingRequests = 0; - let compactionReserved = false; - const compactionWaiters = new Set<{ - request: SandboxNetworkAccessRequest; - release: () => void; - cancel: () => void; - }>(); - const decisions = new Map }>(); - const serial = (fn: () => Promise): Promise => { - const result = queue.then(fn); - queue = result.then( - () => {}, - () => {}, - ); - return result; - }; - const publish = () => { - const active = coordinator.getSnapshot().activeRequest; - visible = active ? entries.get(active) : undefined; - queued = Math.max(0, entries.size - (visible ? 1 : 0)); - for (const listener of listeners) { - try { - const result: unknown = listener(); - if (result && typeof (result as { then?: unknown }).then === 'function') - void Promise.resolve(result).catch(() => {}); - } catch { - /* Subscribers cannot undo durable state. */ - } - } - }; - const poison = () => { - poisoned = true; - for (const waiter of [...compactionWaiters]) { - compactionWaiters.delete(waiter); - waiter.cancel(); - } - coordinator.dispose(); - entries.clear(); - publish(); - failClosed(); - }; - const healthy = () => { - try { - context.assertHealthy(); - return true; - } catch { - return false; - } - }; - const reserveForCompaction = (): (() => void) | null => { - if ( - closed || - poisoned || - !healthy() || - context.state().status === 'closed' || - pendingRequests > 0 || - entries.size > 0 || - applying.size > 0 || - compactionReserved - ) { - return null; - } - compactionReserved = true; - let released = false; - return () => { - if (released) return; - released = true; - compactionReserved = false; - for (const waiter of [...compactionWaiters]) { - compactionWaiters.delete(waiter); - waiter.release(); - } - }; - }; - const requestApproval = async ( - request: SandboxNetworkAccessRequest, - completion: Promise, - signal: AbortSignal, - ): Promise => { - const { host, port } = request; - pendingRequests++; - try { - while (compactionReserved) { - if (closed || poisoned || context.state().status === 'closed' || signal.aborted) return 'deny'; - const cancelled = await new Promise((resolve) => { - const waiter = { - request, - release: () => { - signal.removeEventListener('abort', onAbort); - compactionWaiters.delete(waiter); - resolve(false); - }, - cancel: () => { - signal.removeEventListener('abort', onAbort); - compactionWaiters.delete(waiter); - resolve(true); - }, - }; - const onAbort = () => waiter.cancel(); - compactionWaiters.add(waiter); - signal.addEventListener('abort', onAbort, { once: true }); - }); - if (cancelled || closed || poisoned || context.state().status === 'closed' || signal.aborted) return 'deny'; - } - let answer: Promise | undefined; - await serial(async () => { - context.assertHealthy(); - if (closed || poisoned || context.state().status === 'closed' || signal.aborted) return; - const entry: Entry = { - id: randomUUID(), - request, - completion, - signal, - host, - ...(port === undefined ? {} : { port }), - }; - try { - await context.commit([ - { - type: 'session_network_requested', - version: 1, - requestId: entry.id, - generation, - host: entry.host, - ...(entry.port === undefined ? {} : { port: entry.port }), - }, - ]); - context.assertHealthy(); - if (signal.aborted || closed || context.state().status === 'closed') { - await context.commit([ - { - type: 'session_network_resolved', - version: 1, - requestId: entry.id, - generation, - resolution: { kind: 'cancelled', reason: stopReason }, - }, - ]); - return; - } - entries.set(request, entry); - answer = coordinator.request(request); - publish(); - } catch (error) { - poison(); - throw error; - } - }); - return answer ? await answer : 'deny'; - } finally { - pendingRequests--; - } - }; - const cancelAll = async (reason: 'execution_ended' | 'helper_lost' | 'session_closed' | 'graph_replaced') => { - stopReason = reason; - const sourceReason = - reason === 'helper_lost' ? 'helper_lost' : reason === 'execution_ended' ? 'cancelled' : 'closed'; - for (const waiter of [...compactionWaiters]) { - compactionWaiters.delete(waiter); - cancelSource(waiter.request, sourceReason); - waiter.cancel(); - } - for (const entry of applying) { - cancelSource(entry.request, sourceReason); - } - return serial(async () => { - for (const entry of [...entries.values()]) { - try { - await context.commit([ - { - type: 'session_network_resolved', - version: 1, - requestId: entry.id, - generation, - resolution: { kind: 'cancelled', reason }, - }, - ]); - } catch (error) { - poison(); - throw error; - } - cancelSource( - entry.request, - reason === 'helper_lost' ? 'helper_lost' : reason === 'execution_ended' ? 'cancelled' : 'closed', - ); - coordinator.cancel(entry.request); - entries.delete(entry.request); - } - publish(); - }); - }; - const decide = (command: { - requestId: string; - generation: string; - answer: NetworkApprovalAnswer; - }): Promise => { - if ( - !command || - typeof command !== 'object' || - Object.keys(command).some((key) => !['requestId', 'generation', 'answer'].includes(key)) || - typeof command.requestId !== 'string' || - typeof command.generation !== 'string' || - !isNetworkAnswer(command.answer) - ) - return Promise.reject(new Error('Malformed network decision')); - try { - context.assertHealthy(); - } catch (error) { - return Promise.reject(error); - } - const owned = { requestId: command.requestId, generation: command.generation, answer: command.answer }; - if (closed || poisoned || context.state().status === 'closed') - return Promise.reject(new Error('Network authority is closed')); - const previous = decisions.get(owned.requestId); - if (previous) - return owned.generation === generation && owned.answer === previous.answer - ? previous.result - : Promise.reject(new Error('Conflicting network decision')); - const result = serial(async () => { - context.assertHealthy(); - const entry = visible; - if ( - closed || - poisoned || - context.state().status === 'closed' || - !entry || - owned.requestId !== entry.id || - owned.generation !== generation || - !isNetworkAnswer(owned.answer) - ) - throw new Error('Stale network decision'); - try { - await context.commit([ - { - type: 'session_network_resolved', - version: 1, - requestId: entry.id, - generation, - resolution: { kind: 'answered', answer: owned.answer }, - }, - ]); - } catch (error) { - poison(); - throw error; - } - context.assertHealthy(); - entries.delete(entry.request); - if (entry.signal.aborted || closed || context.state().status === 'closed') { - coordinator.cancel(entry.request); - publish(); - return { kind: 'failed', reason: 'cancelled' }; - } - applying.add(entry); - try { - coordinator.resolve(entry.request, owned.answer); - publish(); - const completed = await entry.completion; - context.assertHealthy(); - return completed; - } finally { - applying.delete(entry); - } - }); - decisions.set(owned.requestId, { answer: owned.answer, result }); - void result.catch(() => { - if (decisions.get(owned.requestId)?.result === result) decisions.delete(owned.requestId); - }); - return result; - }; - return { - requestApproval, - reserveForCompaction, - isLive: () => healthy() && !closed && !poisoned && context.state().status !== 'closed', - hasPending: () => pendingRequests > 0, - cancelAll, - decide, - getSnapshot: () => ({ - active: visible - ? { - requestId: visible.id, - generation, - choices: ['deny', 'allow-once', 'allow-session', 'allow-project'] as const, - host: visible.host, - ...(visible.port === undefined ? {} : { port: visible.port }), - } - : null, - queued, - }), - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - close(): Promise { - if (closePromise) return closePromise; - closed = true; - closePromise = (async () => { - try { - await cancelAll('session_closed'); - } finally { - coordinator.dispose(); - listeners.clear(); - } - })(); - return closePromise; - }, - }; -} diff --git a/rewrite/src/session/adopted/retry-command.ts b/rewrite/src/session/adopted/retry-command.ts deleted file mode 100644 index ed7c41f28..000000000 --- a/rewrite/src/session/adopted/retry-command.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Retry submission command: rewinds the last user turn or tool output and relaunches it. */ -import crypto from 'node:crypto'; -import { rewindRemovedUserCount } from '../../../../source/services/conversation/conversation-replay.js'; -import { ConversationStore } from '../../../../source/services/conversation/conversation-store.js'; -import type { UserTurn } from '../../../../source/types/user-turn.js'; -import type { TurnInput } from '../../engine/index.js'; -import type { Admission, SessionFact } from '../index.js'; -import { loadCanonicalRetryTarget } from './conversation-log.js'; -import type { - AdoptedAdmissionReceipt, - AdoptedCommandDeps, - AdoptedRetryResult, - AdoptedRetryTarget, -} from './submission.js'; - -/** Starts a turn that is already recorded as started, as the foreground's `launch` does. */ -type LaunchTurn = ( - receipt: { turnId: TurnInput['turnId']; input: UserTurn; userMessageRef?: AdoptedAdmissionReceipt['userMessageRef'] }, - token: number, - signal: AbortSignal, - retryOrigin: boolean, -) => Promise; - -export function createRetryCommand(deps: AdoptedCommandDeps, launch: LaunchTurn) { - const { context, runtime, turn, commit, poison } = deps; - return { - retry(target: AdoptedRetryTarget): Promise { - try { - context.assertHealthy(); - } catch (error) { - return Promise.reject(error); - } - if (target !== 'turn' && target !== 'tool') return Promise.reject(new Error('Invalid retry target')); - if (turn.closing || context.state().status === 'closed') - return Promise.resolve({ kind: 'rejected', reason: 'closed' }); - if (turn.interrupting || turn.settingMutation || turn.compacting) - return Promise.reject(new Error('Session mutation is in progress')); - if (turn.poisoned !== undefined) return Promise.reject(turn.poisoned); - if (!context.state().retryEligibility[target]) return Promise.resolve({ kind: 'ineligible', target }); - const hasToolResult = () => { - const preview = new ConversationStore(); - for (const item of runtime.record.getHistory()) preview.addImportedItem(item); - return preview.peekLastToolOutput() !== null; - }; - if (target === 'tool' && !hasToolResult()) return Promise.resolve({ kind: 'ineligible', target }); - const signal = turn.stop(); - return turn.queue(async (): Promise => { - context.assertHealthy(); - if (turn.poisoned !== undefined) throw turn.poisoned; - await turn.settle(signal); - signal.throwIfAborted(); - runtime.pendingInteraction.clear(); - if (turn.closing) return { kind: 'rejected', reason: 'closed' }; - if (!context.state().retryEligibility[target]) return { kind: 'ineligible', target }; - if (target === 'tool' && !hasToolResult()) return { kind: 'ineligible', target }; - const original = loadCanonicalRetryTarget(context.id, context.root); - if (target === 'turn' && !original) return { kind: 'ineligible', target }; - if (target === 'tool' && !runtime.state.peekLastToolOutput()) { - throw poison(new Error('Canonical retry eligibility disagrees with source state')); - } - const before = structuredClone(runtime.record.getHistory()); - const rewound = - target === 'turn' ? runtime.state.retryLastUserTurn({ logUndo: false }) : runtime.state.retryLastToolOutput(); - if (!rewound) { - throw poison(new Error('Canonical retry eligibility disagrees with source state')); - } - const turnId = crypto.randomUUID() as TurnInput['turnId']; - const token = runtime.generationGuard.capture(); - const snapshot = runtime.state.getCurrentSnapshot(); - try { - rewindRemovedUserCount(before, snapshot.history); - } catch (error) { - throw poison(error); - } - const fact: SessionFact = - target === 'turn' - ? { type: 'user_turn_retried', version: 1, userMessageRef: original!.userMessageRef, snapshot } - : { type: 'tool_output_rewound', version: 1, snapshot }; - await commit([fact, { type: 'session_turn_started', version: 1, turnId, clientRequestId: null }]); - if (signal.aborted || turn.closing || !runtime.generationGuard.isCurrent(token)) { - await commit([{ type: 'session_turn_ended', version: 1, turnId, outcome: 'cancelled' }]); - throw new Error('Retry became stale'); - } - const retainedUser = target === 'tool' ? loadCanonicalRetryTarget(context.id, context.root) : original; - const admitted = await launch( - { - turnId, - input: retainedUser?.submittedTurn ?? { text: '' }, - ...(retainedUser ? { userMessageRef: retainedUser.userMessageRef } : {}), - }, - token, - signal, - true, - ); - if (admitted.kind !== 'started') throw new Error('Retry did not start'); - return admitted; - }); - }, - }; -} diff --git a/rewrite/src/session/adopted/session-handle.ts b/rewrite/src/session/adopted/session-handle.ts deleted file mode 100644 index c9ecedec9..000000000 --- a/rewrite/src/session/adopted/session-handle.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { isDeepStrictEqual } from 'node:util'; -import { forkLegacySettingsBinding } from '../../engine/adopted/settings.js'; -import { forkSourceModelTransports, type SourceModelTransports } from '../../engine/adopted/provider-transports.js'; -import { projectRuntimeSettings } from './settings.js'; -import { - isReasoningSetting, - isTemperatureSetting, -} from '../../../../source/services/settings/session-runtime-settings.js'; -import type { AgentClient } from '../../../../source/lib/agent-client.js'; -import { InvalidatableShellChildRegistry } from '../../../../source/utils/shell/shell-child-registry.js'; -import { createNetworkAuthority } from './network-authority.js'; -import { createScopedSandboxNetworkAccess } from '../../../../source/utils/shell/sandbox/scoped-sandbox-network-access.js'; -import { ScopedSandboxRunner } from '../../../../source/utils/shell/sandbox/scoped-sandbox-runner.js'; -import { createSessionRuntimeInternals } from '../../../../source/services/session/session-composition.js'; -import type { SessionRuntimeInternals } from '../../../../source/services/session/session-composition.js'; -import { createAdoptedForeground } from './foreground.js'; -import { createGoalAuthority } from './goal-authority.js'; -import { loadCanonicalConversation } from './conversation-log.js'; -import type { AdoptedAdmissionContext } from './submission.js'; -import type { AdoptedSessionHandle } from '../local-store.js'; -import type { createAdoptedEngine, AdoptedEngine } from '../../engine/adopted/index.js'; -import type { SettingsReader } from '../../config/index.js'; -import type { TurnPrompt } from './turn-prompt.js'; - -/** Source-agent construction is supplied by the caller, never imported upward from app. */ -export type LegacyEngineOptions = { - readonly models: SourceModelTransports; - readonly settings: SettingsReader; - readonly clientOptions: Parameters[0]['clientOptions']; - readonly bindSessionClient?: (client: AgentClient) => void; -}; -export type LegacyEngineAssembly = { - readonly engine: AdoptedEngine; - readonly turnPrompt: () => TurnPrompt; -}; - -function synchronous(value: T, name: string): T { - if ( - value != null && - (typeof value === 'object' || typeof value === 'function') && - typeof (value as { then?: unknown }).then === 'function' - ) { - void Promise.resolve(value).catch(() => {}); - throw new TypeError(`${name} must be synchronous`); - } - return value; -} - -/** Construct one owned engine and source graph for each open session handle. */ -export function createLegacySessionHandleFactory(options: { - assembleEngine: (options: LegacyEngineOptions) => LegacyEngineAssembly; - engine: (context: AdoptedAdmissionContext) => Omit; - runtime: ( - context: AdoptedAdmissionContext, - ) => Omit[0], 'sessionId' | 'agentClient'>; - onSourceEvent?: Parameters[0]['onSourceEvent']; -}): (context: AdoptedAdmissionContext) => Promise { - return async (context) => { - let runtime: SessionRuntimeInternals | undefined; - let assembly: LegacyEngineAssembly | undefined; - let cancelBackground: (() => void) | undefined; - let ownedClient: AgentClient | undefined; - let graphModels: SourceModelTransports | undefined; - let grantHumanRunBudgetExtension: (() => { granted: boolean; extensionsGranted: number }) | undefined; - const scopedChildren = new InvalidatableShellChildRegistry(); - let clientForNetwork: import('../../../../source/lib/agent-client.js').AgentClient | undefined; - let foregroundForNetwork: ReturnType | undefined; - let scope: ReturnType; - const network = createNetworkAuthority( - context, - () => { - const failure = new Error('Network persistence failed'); - context.poison(failure); - foregroundForNetwork?.failPersistence(failure); - scopedChildren.invalidate(); - clientForNetwork?.disposeShellChildren(); - void runner.dispose(); - }, - (request, reason) => { - scope?.cancel(request, reason); - }, - ); - scope = createScopedSandboxNetworkAccess({ - workspaceRoot: context.state().workspace.root, - requestApproval: network.requestApproval, - isLive: network.isLive, - }); - const runner = new ScopedSandboxRunner( - scope, - () => { - scopedChildren.invalidate(); - void network.cancelAll('helper_lost').catch(() => {}); - }, - () => network.cancelAll('execution_ended'), - ); - try { - const engineOptions = synchronous(options.engine(context), 'engine options'); - const runtimeOptions = synchronous(options.runtime(context), 'runtime options'); - const hostSettings = engineOptions.clientOptions.deps.settings; - const reason = engineOptions.clientOptions.reasoningEffort ?? hostSettings.get('agent.reasoningEffort'); - const temp = hostSettings.get('agent.temperature'); - const entries = []; - for await (const entry of context.log.read()) entries.push(entry); - const facts = entries.map((entry) => entry.fact); - const goals = createGoalAuthority(entries); - const initial = projectRuntimeSettings(facts, { - selection: context.state().model, - reasoningEffort: isReasoningSetting(reason) ? reason : 'default', - ...(isTemperatureSetting(temp) ? { temperature: temp } : {}), - }); - const binding = forkLegacySettingsBinding(engineOptions.settings, hostSettings, initial.snapshot); - graphModels = forkSourceModelTransports(engineOptions.models, binding.service); - assembly = synchronous( - options.assembleEngine({ - ...engineOptions, - models: graphModels, - settings: binding.settings, - clientOptions: { - ...engineOptions.clientOptions, - selection: initial.snapshot.selection, - reasoningEffort: initial.snapshot.reasoningEffort, - deps: { - ...engineOptions.clientOptions.deps, - settings: binding.service, - scopedSandbox: { - runner, - registerPauseController: scope.registerPauseController, - childRegistry: scopedChildren, - }, - // Last: the session's goal authority replaces any host goal callbacks. - ...goals.clientDeps, - }, - }, - bindSessionClient(boundClient) { - ownedClient = boundClient; - const client = boundClient; - clientForNetwork = client; - grantHumanRunBudgetExtension = () => client.grantRunBudgetExtension(); - cancelBackground = () => { - let failure: unknown; - let failed = false; - try { - client.cancelBackgroundRuns(); - } catch (error) { - failed = true; - failure = error; - } - try { - client.cancelBackgroundShellJobs(); - } catch (error) { - if (!failed) failure = error; - failed = true; - } - if (failed) throw failure; - }; - runtime = createSessionRuntimeInternals({ - ...runtimeOptions, - deps: { ...runtimeOptions.deps, settingsService: binding.service }, - sessionId: context.id, - conversationLogRoot: context.root, - agentClient: client, - }); - }, - }), - 'engine assembly', - ); - if (!runtime || !ownedClient) throw new Error('Session runtime was not bound'); - const actual = ownedClient.getRuntimeSettingsSnapshot(); - if (!isDeepStrictEqual(actual, initial.snapshot)) - throw new Error('Built client settings disagree with canonical baseline'); - if (!initial.initialized) - await context.commit([ - ...(facts.some((f) => f.type === 'session_bound') - ? [] - : [ - { - type: 'session_bound' as const, - version: 1 as const, - workspace: context.state().workspace, - model: actual.selection, - }, - ]), - { - type: 'session_settings_initialized', - version: 1, - model: actual.selection, - reasoningEffort: actual.reasoningEffort, - ...(actual.temperature === undefined ? {} : { temperature: actual.temperature }), - }, - ]); - const graph = runtime; - const originalPrepareRollover = graph.prepareRollover; - const assertNetworkIdle = () => { - if (network.hasPending()) throw new Error('Session rollover is blocked while a network approval is pending.'); - }; - graph.prepareRollover = (id, startedAt) => { - assertNetworkIdle(); - const commit = originalPrepareRollover(id, startedAt); - return () => { - assertNetworkIdle(); - commit(); - }; - }; - graph.rollover = (id, startedAt) => graph.prepareRollover(id, startedAt)(); - const owned = assembly; - const restored = loadCanonicalConversation(context.id, context.root); - const foreground = createAdoptedForeground({ - context, - engine: owned.engine, - runtime: graph, - restored, - onSourceEvent: options.onSourceEvent, - publishFacts: false, - cancelBackground, - reserveNetworkForCompaction: () => network.reserveForCompaction(), - runtimeSettings: { - supports: (command) => - typeof (command.kind === 'model' - ? ownedClient!.setModelSelection - : command.kind === 'reasoning' - ? ownedClient!.setReasoningEffort - : ownedClient!.setTemperature) === 'function', - setOverride: binding.setRuntimeOverride, - snapshot: () => ownedClient!.getRuntimeSettingsSnapshot(), - }, - grantHumanRunBudgetExtension, - makeInput(turnId) { - const state = context.state(); - const prompt = owned.turnPrompt(); - return { - turnId, - history: [], - newItems: [], - instructions: prompt.instructions, - model: state.model, - toolNames: prompt.toolNames, - workspace: state.workspace, - budget: { kind: 'adopted' }, - }; - }, - }); - foregroundForNetwork = foreground; - goals.bind({ - commit: foreground.commit, - history: restored.history, - isClosed: () => context.state().status === 'closed', - }); - let closing: Promise | undefined; - let networkClosing: Promise | undefined; - const closeNetwork = () => { - networkClosing ??= (async () => { - let failure: unknown; - try { - scopedChildren.invalidate(); - } catch (error) { - failure ??= error; - } - try { - clientForNetwork?.disposeShellChildren(); - } catch (error) { - failure ??= error; - } - try { - cancelBackground?.(); - } catch (error) { - failure ??= error; - } - try { - await network.close(); - } catch (error) { - failure ??= error; - } - try { - await runner.dispose(); - } catch (error) { - failure ??= error; - } - if (failure !== undefined) throw failure; - })(); - return networkClosing; - }; - return { - driver: foreground.driver, - submission: { ...foreground.submission, networkApproval: network, goal: goals.control }, - closeNetwork, - shutdown() { - closing ??= (async () => { - let failure: unknown; - for (const cleanup of [ - () => closeNetwork(), - () => foreground.shutdown(), - () => graph.shutdown(), - () => owned.engine.dispose(), - () => graphModels!.dispose(), - ]) { - try { - await cleanup(); - } catch (error) { - failure ??= error; - } - } - graph.record.setJournalSink(null); - graph.conversationLogger.setLogSink(null); - if (failure !== undefined) throw failure; - })(); - return closing; - }, - }; - } catch (error) { - try { - await network.close(); - await runner.dispose(); - } catch { - /* Preserve construction error. */ - } - try { - await runtime?.shutdown(); - } catch { - /* Preserve the construction error. */ - } - try { - await assembly?.engine.dispose(); - } catch { - /* Preserve the construction error. */ - } - try { - await graphModels?.dispose(); - } catch { - /* Preserve construction error. */ - } - throw error; - } - }; -} diff --git a/rewrite/src/session/adopted/settings-command.ts b/rewrite/src/session/adopted/settings-command.ts deleted file mode 100644 index 0519e3c30..000000000 --- a/rewrite/src/session/adopted/settings-command.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** Runtime settings submission command: model, reasoning effort and temperature changes. */ -import { - parseRuntimeMutation, - type RuntimeSettingMutation, - type RuntimeSettingsSnapshot, -} from '../../../../source/services/settings/session-runtime-settings.js'; -import type { AdoptedCommandDeps } from './submission.js'; - -export interface AdoptedRuntimeSettings { - supports(command: RuntimeSettingMutation): boolean; - setOverride(command: RuntimeSettingMutation): void; - snapshot(): RuntimeSettingsSnapshot; -} - -export function createSettingsCommand(deps: AdoptedCommandDeps, runtimeSettings: AdoptedRuntimeSettings | undefined) { - const { context, runtime, turn, commit, poison } = deps; - const settingPending = new Map< - string, - Promise<{ kind: 'applied'; settings: RuntimeSettingsSnapshot } | { kind: 'unsupported' }> - >(); - return { - setRuntimeSettings(input: RuntimeSettingMutation) { - try { - context.assertHealthy(); - } catch (error) { - return Promise.reject(error); - } - if (turn.poisoned !== undefined) return Promise.reject(turn.poisoned); - let command: RuntimeSettingMutation; - try { - command = parseRuntimeMutation(input); - } catch (error) { - return Promise.reject(error); - } - const key = - command.kind === 'model' - ? JSON.stringify(['model', command.selection.provider, command.selection.model]) - : command.kind === 'reasoning' - ? JSON.stringify(['reasoning', command.effort]) - : JSON.stringify(['temperature', command.temperature ?? null]); - const existing = settingPending.get(key); - if (existing) return existing; - if (!runtimeSettings || !runtimeSettings.supports(command)) - return Promise.resolve({ kind: 'unsupported' as const }); - if (turn.closing || turn.interrupting || turn.compacting || context.state().status === 'closed') - return Promise.reject(new Error('Session is unavailable')); - if (turn.poisoned !== undefined) return Promise.reject(turn.poisoned); - if (!turn.settingMutation) { - try { - turn.releaseSettings = context.reserveSettings(); - } catch (error) { - return Promise.reject(error); - } - turn.settingMutation = true; - } - const operation = turn.queue(async () => { - context.assertHealthy(); - if (turn.closing || turn.poisoned !== undefined) throw new Error('Session is unavailable'); - const signal = turn.stop(); - await turn.settle(signal); - signal.throwIfAborted(); - if (turn.closing || turn.poisoned !== undefined) throw new Error('Session is unavailable'); - try { - runtimeSettings!.setOverride(command); - if (command.kind === 'model') runtime.runtimeController.setModelSelection(command.selection); - else if (command.kind === 'reasoning') runtime.runtimeController.setReasoningEffort(command.effort); - else runtime.runtimeController.setTemperature(command.temperature); - const settings = runtimeSettings!.snapshot(); - const key = - command.kind === 'model' - ? 'agent.modelSelection' - : command.kind === 'reasoning' - ? 'agent.reasoningEffort' - : 'agent.temperature'; - const value = - command.kind === 'model' - ? settings.selection - : command.kind === 'reasoning' - ? settings.reasoningEffort - : settings.temperature; - await commit([{ type: 'settings_changed', key, value, sessionMutation: 1 }]); - context.assertHealthy(); - if (turn.poisoned !== undefined) throw turn.poisoned; - if (turn.closing || signal.aborted) throw new Error('Settings became stale'); - return { kind: 'applied' as const, settings }; - } catch (error) { - poison(error ?? new Error('Settings mutation failed')); - turn.cancelRun(); - throw turn.poisoned; - } - }); - const promise = operation.finally(() => { - settingPending.delete(key); - if (!settingPending.size) { - turn.settingMutation = false; - turn.releaseSettings?.(); - turn.releaseSettings = undefined; - } - }); - settingPending.set(key, promise); - return promise; - }, - }; -} diff --git a/rewrite/src/session/adopted/settings.ts b/rewrite/src/session/adopted/settings.ts deleted file mode 100644 index 417a5b356..000000000 --- a/rewrite/src/session/adopted/settings.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ModelSelectionSchema } from '../../../../source/services/settings/model-selection.js'; -import { - isReasoningSetting, - isTemperatureSetting, - type RuntimeSettingsSnapshot, -} from '../../../../source/services/settings/session-runtime-settings.js'; -import type { SessionFact } from './conversation-log.js'; -import type { ModelRef } from '../../model/index.js'; - -export function normalizedModelSelection(value: unknown) { - const result = ModelSelectionSchema.safeParse(value); - return result.success ? result.data : undefined; -} - -/** Derive the graph's settings from canonical facts before constructing the client. */ -export function projectRuntimeSettings( - facts: readonly SessionFact[], - fallback: RuntimeSettingsSnapshot, -): { snapshot: RuntimeSettingsSnapshot; initialized: boolean } { - let snapshot = structuredClone(fallback); - let initialized = false; - for (const fact of facts) { - if (fact.type === 'session_init' && isReasoningSetting(fact.reasoningEffort)) - snapshot.reasoningEffort = fact.reasoningEffort; - else if (fact.type === 'session_bound') snapshot.selection = fact.model; - else if (fact.type === 'session_settings_initialized') { - snapshot = { - selection: fact.model, - reasoningEffort: fact.reasoningEffort, - ...(fact.temperature === undefined ? {} : { temperature: fact.temperature }), - }; - initialized = true; - } else if (fact.type === 'settings_changed') { - if (fact.key === 'agent.modelSelection') { - const parsed = ModelSelectionSchema.safeParse(fact.value); - if (parsed.success) snapshot.selection = parsed.data; - } else if (fact.key === 'agent.model' && typeof fact.value === 'string') - snapshot.selection = { ...snapshot.selection, model: fact.value }; - else if (fact.key === 'agent.provider' && typeof fact.value === 'string') - snapshot.selection = { ...snapshot.selection, provider: fact.value }; - else if (fact.key === 'agent.reasoningEffort' && isReasoningSetting(fact.value)) - snapshot.reasoningEffort = fact.value; - else if (fact.key === 'agent.temperature') { - if (isTemperatureSetting(fact.value)) snapshot.temperature = fact.value; - else if (fact.value === undefined) delete snapshot.temperature; - } - } - } - return { snapshot, initialized }; -} diff --git a/rewrite/src/session/adopted/submission.ts b/rewrite/src/session/adopted/submission.ts deleted file mode 100644 index 7bb40b31f..000000000 --- a/rewrite/src/session/adopted/submission.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { - RuntimeSettingMutation, - RuntimeSettingsSnapshot, -} from '../../../../source/services/settings/session-runtime-settings.js'; -/** Live adopted submission authority; it is not a serializable session API. */ -import type { decideInteraction } from '../../../../source/gateway/interaction-protocol.js'; -import type { UserMessage } from '../../../../source/types/message.js'; -import type { InputSurgeApproval } from '../../../../source/services/input-surge-approval.js'; -import { normalizeUserTurn, type UserTurn } from '../../../../source/types/user-turn.js'; -import type { - ConversationCompactionFailureReason, - ConversationCompactionOutcome, -} from '../../../../source/services/session/manual-compaction.js'; -import { normalizedBodyHash } from './conversation-log.js'; -import type { SessionRuntimeInternals } from '../../../../source/services/session/session-composition.js'; - -export type { ConversationCompactionFailureReason, ConversationCompactionOutcome }; -import type { TurnId } from '../../engine/index.js'; -import type { ToolCallId } from '../../model/index.js'; -import type { - Admission, - ClientRequestId, - InteractionId, - LogEntry, - SessionEventReference, - SessionFact, -} from '../index.js'; -import type { SessionDriverContext } from '../local-store.js'; -import type { TurnControl } from './turn-control.js'; - -export interface AdoptedSubmissionInput { - readonly requestId: ClientRequestId; - readonly input: UserTurn; -} -export interface AdoptedAdmissionReceipt { - readonly requestId: ClientRequestId; - readonly turnId: TurnId; - readonly userMessageRef: SessionEventReference; - readonly input: UserTurn; -} -export interface AdoptedSubmissionOptions { - readonly inputSurgeApproval?: InputSurgeApproval; -} -export type AdoptedInteractionIdentity = { - readonly turnId: TurnId; - readonly interactionId: InteractionId; - readonly revision: number; - readonly generation: string; -}; -export type AdoptedInteractionCommand = AdoptedInteractionIdentity & - ( - | { - readonly kind: 'choose'; - readonly decisions: readonly { - readonly callId?: ToolCallId; - readonly choice: Parameters[1]; - }[]; - } - | { readonly kind: 'navigate'; readonly direction: 'previous' | 'next' } - ); -export type AdoptedRetryTarget = 'turn' | 'tool'; -export type AdoptedRetryResult = - | { readonly kind: 'started'; readonly turnId: TurnId } - | { readonly kind: 'ineligible'; readonly target: AdoptedRetryTarget } - | { readonly kind: 'rejected'; readonly reason: 'closed' }; -export type HistoryMutation = - | { readonly kind: 'undo_last' } - | { readonly kind: 'undo_count'; readonly count: number } - | { readonly kind: 'rewind'; readonly targetId: string }; -export type HistoryMutationResult = - | { readonly kind: 'applied'; readonly restored: { text: string; images?: UserTurn['images'] } } - | { readonly kind: 'not_found' | 'stale' }; -export interface AdoptedSubmissionPort { - history(command: HistoryMutation): Promise; - listRewindTargets(): Promise< - readonly import('../../../../source/services/conversation/conversation-store.js').RewindTarget[] - >; - submit(input: AdoptedSubmissionInput, options?: AdoptedSubmissionOptions): Promise; - decide(command: AdoptedInteractionCommand): Promise; - retry(target: AdoptedRetryTarget): Promise; - compact(): Promise; - interruptFromUser(): Promise; - setRuntimeSettings( - command: RuntimeSettingMutation, - ): Promise<{ kind: 'applied'; settings: RuntimeSettingsSnapshot } | { kind: 'unsupported' }>; - /** Present on app-composed handles: the session goal and its manual commands. */ - readonly goal?: import('./goal-authority.js').SessionGoalControl; - readonly networkApproval?: Pick< - ReturnType, - 'getSnapshot' | 'subscribe' | 'decide' - >; -} -export interface AdoptedAdmissionContext extends SessionDriverContext { - admit( - input: AdoptedSubmissionInput, - message: UserMessage, - ): Promise< - | { readonly kind: 'admitted'; readonly receipt: AdoptedAdmissionReceipt } - | Extract - >; - activate(receipt: AdoptedAdmissionReceipt): Promise; -} - -/** What the foreground hands every submission command it builds. */ -export interface AdoptedCommandDeps { - readonly context: AdoptedAdmissionContext; - readonly runtime: SessionRuntimeInternals; - readonly turn: TurnControl; - /** Durably appends the buffered log events and these facts. */ - commit(facts: readonly SessionFact[]): Promise; - /** Marks the session poisoned and returns the stored error. */ - poison(error: unknown): unknown; -} - -/** Own the normalized input before any asynchronous admission work. */ -export function captureAdoptedInput(input: UserTurn): { input: UserTurn; hash: string } { - const normalized = normalizeUserTurn(input); - const owned = { - ...normalized, - ...(normalized.images ? { images: normalized.images.map((image) => ({ ...image })) } : {}), - ...(normalized.skill ? { skill: { ...normalized.skill } } : {}), - }; - return { input: owned, hash: `adopted-user-turn-v1:${normalizedBodyHash(owned)}` }; -} diff --git a/rewrite/src/session/adopted/turn-control.ts b/rewrite/src/session/adopted/turn-control.ts deleted file mode 100644 index b61b58204..000000000 --- a/rewrite/src/session/adopted/turn-control.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** Turn state shared by the adopted foreground and its commands, with the stop, settle and queue steps they repeat. */ -import type { SessionRuntimeInternals } from '../../../../source/services/session/session-composition.js'; -import type { AdoptedEngine, AdoptedTurnRun } from '../../engine/adopted/index.js'; - -export interface TurnControl { - /** The run that is executing now, if any. */ - active: AdoptedTurnRun | undefined; - activePostExecuteRunId: string | undefined; - /** Settles when the latest launched turn has been recorded. */ - completion: Promise; - /** Orders interaction decisions. */ - decisionTail: Promise; - /** Orders submissions and mutating commands. */ - serial: Promise; - /** Gate for callbacks of the current generation; replaced whenever a command stops the turn. */ - cancellation: AbortController; - /** The first fatal mutation or persistence error; later commands reject with it. */ - poisoned: unknown; - closing: boolean; - /** In flight while a user interrupt runs. */ - interrupting: Promise | undefined; - settingMutation: boolean; - compacting: boolean; - releaseSettings: (() => void) | undefined; - /** - * Aborts the callback gate and installs a fresh one, invalidates the generation, then cancels the active run. - * Returns the fresh gate's signal, plus a generation token captured right after invalidation when asked. - */ - stop(): AbortSignal; - stop(options: { captureToken: true }): { signal: AbortSignal; token: number }; - /** Invalidates the generation and cancels the active run, closing `runId` or else the active post-execute run. */ - cancelRun(runId?: string): void; - /** Waits until decisions, the launched turn and engine admission have settled, or `signal` aborts. */ - settle(signal: AbortSignal): Promise; - /** Runs `run` after everything already queued on `serial`; later queued work waits for it. */ - queue(run: () => T | PromiseLike): Promise; -} - -export function createTurnControl(deps: { engine: AdoptedEngine; runtime: SessionRuntimeInternals }): TurnControl { - const { engine, runtime } = deps; - const closeAndCancel = (runId: string | undefined) => { - if (runId) runtime.postExecutePending.closeRun(runId); - turn.active?.cancel(); - }; - function stop(): AbortSignal; - function stop(options: { captureToken: true }): { signal: AbortSignal; token: number }; - function stop(options?: { captureToken?: boolean }) { - turn.cancellation.abort(); - turn.cancellation = new AbortController(); - const signal = turn.cancellation.signal; - runtime.generationGuard.invalidate(); - const token = options?.captureToken ? runtime.generationGuard.capture() : undefined; - closeAndCancel(turn.activePostExecuteRunId); - return options?.captureToken ? { signal, token } : signal; - } - const turn: TurnControl = { - active: undefined, - activePostExecuteRunId: undefined, - completion: Promise.resolve(), - decisionTail: Promise.resolve(), - serial: Promise.resolve(), - cancellation: new AbortController(), - poisoned: undefined, - closing: false, - interrupting: undefined, - settingMutation: false, - compacting: false, - releaseSettings: undefined, - stop, - cancelRun(runId) { - runtime.generationGuard.invalidate(); - closeAndCancel(runId ?? turn.activePostExecuteRunId); - }, - async settle(signal) { - await turn.decisionTail; - await turn.completion; - if (turn.active) await turn.active.waitForSettlement(signal); - await engine.waitForAdmission(signal); - }, - queue(run) { - const operation = turn.serial.then(run); - turn.serial = operation.then( - () => undefined, - () => undefined, - ); - return operation; - }, - }; - return turn; -} diff --git a/rewrite/src/session/adopted/turn-interaction.ts b/rewrite/src/session/adopted/turn-interaction.ts deleted file mode 100644 index fa69b4d3f..000000000 --- a/rewrite/src/session/adopted/turn-interaction.ts +++ /dev/null @@ -1,492 +0,0 @@ -/** - * One turn's pending interaction. It parks the turn when the source asks for - * a decision, records the interaction durably, applies the user's commands to - * it, and resumes the engine with the answer. A new owner is made for each - * turn launch; the foreground keeps turn start and turn completion. - */ -import { decideInteraction, projectPendingInteraction } from '../../../../source/gateway/interaction-protocol.js'; -import type { PendingInteractionDto } from '../../../../source/gateway/interaction-protocol.js'; -import { isRunBudgetInteraction } from '../../../../source/services/agent-runtime/run-budget.js'; -import { getCallIdFromObject, getToolInfoFromInterruption } from '../../../../source/services/interruption-info.js'; -import type { SessionRuntimeInternals } from '../../../../source/services/session/session-composition.js'; -import type { AdoptedEngine, AdoptedTurnControl, AdoptedTurnRun } from '../../engine/adopted/index.js'; -import type { ApprovalDecision, TurnCheckpoint, TurnInput } from '../../engine/index.js'; -import type { ToolCallId } from '../../model/index.js'; -import type { AdoptedCommandDeps, AdoptedInteractionCommand } from './submission.js'; -import type { TurnControl } from './turn-control.js'; - -export class RunBudgetExtensionRefused extends Error { - constructor() { - super('No further budget extension is available.'); - this.name = 'RunBudgetExtensionRefused'; - } -} - -type BoundTurn = ReturnType['beginAdoptedTurn']>; - -export function createTurnInteraction(deps: { - runtime: SessionRuntimeInternals; - engine: AdoptedEngine; - turn: TurnControl; - commit: AdoptedCommandDeps['commit']; - grantHumanRunBudgetExtension: () => { granted: boolean; extensionsGranted: number } | undefined; - turnId: TurnInput['turnId']; - token: number; - signal: AbortSignal; - isCurrent: () => boolean; - /** Late-bound: the engine hands over the turn control when the turn bridge opens. */ - control: () => AdoptedTurnControl; - /** Late-bound: the source turn begins after this owner exists. */ - bound: () => Pick; -}) { - const { runtime, engine, turn, commit, grantHumanRunBudgetExtension, turnId, token, signal, isCurrent } = deps; - const { control, bound } = deps; - let askedForDecision = false; - let nextView: ((run: AdoptedTurnRun) => void) | undefined; - let queuedView: AdoptedTurnRun | undefined; - const takeView = () => - queuedView - ? Promise.resolve( - (() => { - const value = queuedView!; - queuedView = undefined; - return value; - })(), - ) - : new Promise((resolve) => { - nextView = resolve; - }); - const deliverView = (view: AdoptedTurnRun) => { - if (nextView) { - const resolve = nextView; - nextView = undefined; - resolve(view); - } else queuedView = view; - }; - let interaction: - | { - dto: PendingInteractionDto; - checkpoint?: TurnCheckpoint; - checkpointReady: Promise; - setCheckpoint: (value: TurnCheckpoint) => void; - ready: Promise; - controlAnswer?: string; - validateSource?: () => void; - postExecute?: boolean; - runBudget: boolean; - } - | undefined; - let signalInteraction!: () => void; - let interactionChanged = new Promise((resolve) => { - signalInteraction = resolve; - }); - const notifyInteraction = () => { - signalInteraction(); - interactionChanged = new Promise((resolve) => { - signalInteraction = resolve; - }); - }; - const requestDecision = async ( - terminal: import('../../../../source/contracts/conversation.js').ConversationTerminal, - address: unknown, - ) => { - askedForDecision = true; - if (terminal.type !== 'approval_required' || !isCurrent()) throw new Error('Interaction is stale'); - const staged = runtime.pendingInteraction.prepareTransition((draft) => draft.present(terminal.approval)); - const snapshot = staged.snapshot!; - const dto = projectPendingInteraction( - snapshot.approval, - String(snapshot.interactionId), - snapshot.revision!, - snapshot.askUserAnswers, - snapshot.currentAskUserQuestionIndex, - ); - if (dto.variant === 'sandbox_network_access') throw new Error('Network interaction owner is not installed'); - let setCheckpoint!: (value: TurnCheckpoint) => void; - const checkpointReady = new Promise((resolve) => { - setCheckpoint = resolve; - }); - let setReady!: () => void; - let failReady!: (error: unknown) => void; - const ready = new Promise((resolve, reject) => { - setReady = resolve; - failReady = reject; - }); - void ready.catch(() => {}); - const owner: NonNullable = { - dto, - checkpointReady, - setCheckpoint, - ready, - runBudget: isRunBudgetInteraction(address), - }; - interaction = owner; - notifyInteraction(); - const callId = dto.descriptor.callId as ToolCallId | undefined; - if (dto.kind !== 'check_in' && !callId) throw new Error('Tool interaction lacks a call ID'); - const postExecute = terminal.approval.postExecute; - owner.postExecute = !!postExecute; - const pending = postExecute ? undefined : runtime.approvalFlow.getPending(); - if (!postExecute && (!pending || pending.interruption !== address)) - throw new Error('Missing pending source authority'); - const occurrences = pending ? pending.interruptions ?? [pending.interruption] : []; - const snapshotCalls = () => - occurrences.flatMap((item) => { - if (isRunBudgetInteraction(item)) return []; - const id = getCallIdFromObject(item); - if (!id) return []; - const info = getToolInfoFromInterruption(item); - return [{ callId: id as ToolCallId, toolName: info.toolName, input: info.rawArguments }]; - }); - const postEvidence = - postExecute && callId - ? runtime.record - .exportToolEvidence() - .reverse() - .find( - (item) => - item.callId === callId && - item.toolName === dto.descriptor.toolName && - !!item.dispatchedAt && - !item.completedAt, - ) - : undefined; - const pendingCalls = postEvidence - ? [{ callId: callId!, toolName: postEvidence.toolName, input: postEvidence.arguments }] - : snapshotCalls(); - const pendingIdentity = [...occurrences]; - const serializedPending = JSON.stringify(pendingCalls); - const validateSource = () => { - if (postExecute) { - const current = runtime.postExecutePending.snapshot(); - if ( - current.closed || - current.sessionId !== postExecute.sessionId || - current.epoch !== postExecute.epoch || - current.revision !== postExecute.revision || - !postExecute.ids.length || - new Set(postExecute.ids).size !== postExecute.ids.length || - postExecute.ids.some( - (id) => - !current.entries.some( - (entry) => - entry.id === id && - entry.runId === bound().postExecuteRunId && - entry.toolCallId === callId && - entry.toolName === dto.descriptor.toolName, - ), - ) - ) - throw new Error('Post-execute authority changed'); - return; - } - const current = runtime.approvalFlow.getPending(); - const currentOccurrences = current?.interruptions ?? (current ? [current.interruption] : []); - if ( - !current || - current !== pending || - current.interruption !== address || - currentOccurrences.length !== pendingIdentity.length || - currentOccurrences.some((item, index) => item !== pendingIdentity[index]) || - JSON.stringify(snapshotCalls()) !== serializedPending - ) - throw new Error('Pending source authority changed'); - }; - const exposed = pendingCalls.find((item) => item.callId === callId); - if (callId && !exposed) throw new Error('Missing pending source call'); - owner.validateSource = validateSource; - const parked = control().park({ - generation: token, - isCurrent, - decisionAddress: address, - approvals: exposed - ? [ - { - callId: callId!, - toolName: exposed.toolName, - requirement: { kind: 'ask', summary: dto.descriptor.toolName }, - }, - ] - : [], - pendingCalls, - validatePendingSnapshot(calls) { - validateSource(); - if (JSON.stringify(calls) !== serializedPending) throw new Error('Pending calls changed'); - }, - validate(values) { - validateSource(); - if (values.size !== (callId ? 1 : 0) || (callId && !values.has(callId))) - throw new Error('Invalid decision set'); - }, - }); - void parked.catch(() => {}); - await Promise.race([ - checkpointReady, - parked.then(() => { - throw new Error('Unexpected early decision'); - }), - ]); - try { - await commit([ - { - type: 'session_interaction_pending', - version: 1, - turnId, - generation: String(token), - interaction: dto, - }, - ]); - if (!isCurrent() || interaction !== owner) throw new Error('Interaction became stale'); - staged.commit(); - setReady(); - } catch (error) { - failReady(error); - turn.cancelRun(bound().postExecuteRunId); - throw error; - } - const decisions = await parked; - const decision = callId ? decisions.get(callId) : undefined; - if (decision?.kind === 'answer') control().setAskUserAnswer(callId!, decision.answer); - const answer = - decision?.kind === 'reject' ? 'n' : decision?.kind === 'grant' ? decision.grant : owner.controlAnswer ?? 'y'; - if (postExecute) { - validateSource(); - const result = runtime.postExecutePending.decide({ - revision: postExecute.revision, - ids: postExecute.ids, - decision: (answer === 'y' - ? 'approve' - : answer === 'n' - ? 'reject' - : answer) as import('../../../../source/contracts/conversation.js').PostExecuteDecision, - }); - if (result.kind !== 'settled') throw new Error('Post-execute decision became stale'); - } - return { - answer, - ...(decision?.kind === 'reject' && decision.reason ? { rejectionReason: decision.reason } : {}), - ...(decision?.stopAfterApprovalResolution ? { stopAfterApprovalResolution: true } : {}), - }; - }; - const watchPostExecute = async (stopSignal: AbortSignal) => { - while (!stopSignal.aborted && isCurrent()) { - const changed = interactionChanged; - const registry = runtime.postExecutePending.snapshot(); - const entries = registry.entries.filter((entry) => entry.runId === bound().postExecuteRunId); - if (entries.length && !interaction) { - askedForDecision = true; - const terminal = bound().postExecuteTerminal(entries); - await requestDecision( - terminal, - terminal.type === 'approval_required' ? terminal.approval.rawInterruption : undefined, - ); - continue; - } - const watch = runtime.postExecutePending.watchForChange(registry.revision); - let stopped!: () => void; - const stop = new Promise((resolve) => { - stopped = resolve; - }); - stopSignal.addEventListener('abort', stopped, { once: true }); - try { - if (!stopSignal.aborted) await Promise.race([watch.promise, changed, stop]); - } finally { - stopSignal.removeEventListener('abort', stopped); - watch.unsubscribe(); - } - } - }; - const decide = async (command: AdoptedInteractionCommand) => { - const owner = interaction; - if (!command || typeof command !== 'object' || !['choose', 'navigate'].includes(command.kind)) - throw new Error('Malformed interaction command'); - const keys = - command.kind === 'choose' - ? ['kind', 'turnId', 'interactionId', 'revision', 'generation', 'decisions'] - : ['kind', 'turnId', 'interactionId', 'revision', 'generation', 'direction']; - if (Object.keys(command).some((key) => !keys.includes(key))) throw new Error('Malformed interaction command'); - if ( - !owner || - !isCurrent() || - command.turnId !== turnId || - command.generation !== String(token) || - command.interactionId !== owner.dto.interactionId || - command.revision !== owner.dto.revision - ) - throw new Error('Interaction is stale'); - const checkpoint = await Promise.race([ - Promise.all([owner.ready, owner.checkpointReady]).then(([, value]) => value), - new Promise((_, reject) => { - if (signal.aborted) reject(new Error('Interaction cancelled')); - else signal.addEventListener('abort', () => reject(new Error('Interaction cancelled')), { once: true }); - }), - ]); - if (!isCurrent() || interaction !== owner) throw new Error('Interaction is stale'); - owner.validateSource?.(); - const snapshot = runtime.pendingInteraction.getSnapshot(); - if (!snapshot) throw new Error('Interaction is absent'); - let decision: ApprovalDecision | undefined; - let choiceId: string | undefined; - let controlAnswer: string | undefined; - let resolvedAnswers: readonly (string | readonly string[])[] | undefined; - let outcome: 'answered' | 'cancelled' = 'answered'; - const staged = runtime.pendingInteraction.prepareTransition((draft) => { - if (command.kind === 'navigate') { - if (command.direction !== 'next' && command.direction !== 'previous') throw new Error('Malformed navigation'); - if (owner.dto.kind !== 'ask_user') throw new Error('Navigation is not available'); - if (command.direction === 'next') { - if (snapshot.currentAskUserQuestionIndex + 1 >= owner.dto.askUser!.questions.length) - throw new Error('Navigation is out of bounds'); - draft.goToNextQuestion(); - } else draft.goToPreviousQuestion(); - return undefined; - } - if ( - !Array.isArray(command.decisions) || - command.decisions.length !== 1 || - !command.decisions[0] || - Object.keys(command.decisions[0]).some((key) => !['callId', 'choice'].includes(key)) || - command.decisions[0]!.callId !== owner.dto.descriptor.callId - ) - throw new Error('Invalid exposed decision set'); - const choice = command.decisions[0]!.choice; - if ( - !choice || - typeof choice !== 'object' || - typeof choice.answer !== 'string' || - Object.keys(choice).some((key) => !['answer', 'rejectionReason', 'approvalAnswer'].includes(key)) || - (choice.rejectionReason !== undefined && typeof choice.rejectionReason !== 'string') || - (choice.approvalAnswer !== undefined && typeof choice.approvalAnswer !== 'string') - ) - throw new Error('Malformed interaction choice'); - const translated = decideInteraction(owner.dto, choice); - if ( - !owner.postExecute && - owner.dto.kind === 'tool_approval' && - translated.answer !== 'y' && - translated.answer !== 'n' && - !(translated.answer === 'allow-once' && owner.dto.variant !== 'denied_read') - ) - runtime.approvalFlow.validateGrant(translated.answer, snapshot.approval); - choiceId = choice.answer; - outcome = translated.outcome === 'cancelled' ? 'cancelled' : 'answered'; - const result = draft.resolve({ - expectedInteractionId: snapshot.interactionId, - expectedRevision: snapshot.revision, - answer: translated.answer, - rejectionReason: translated.rejectionReason, - approvalAnswer: translated.approvalAnswer, - }); - if (result.kind === 'resolved') { - if (owner.dto.kind === 'ask_user' && result.approvalAnswer) { - try { - const parsed: unknown = JSON.parse(result.approvalAnswer); - if ( - Array.isArray(parsed) && - parsed.every( - (answer) => - typeof answer === 'string' || - (Array.isArray(answer) && answer.every((value) => typeof value === 'string')), - ) - ) - resolvedAnswers = parsed; - } catch { - /* Terminal answers are not multi-question arrays. */ - } - } - decision = - owner.dto.kind === 'ask_user' - ? { - kind: 'answer', - answer: result.approvalAnswer ?? '', - ...(outcome === 'cancelled' ? { stopAfterApprovalResolution: true } : {}), - } - : translated.answer === 'n' - ? { kind: 'reject', ...(translated.rejectionReason ? { reason: translated.rejectionReason } : {}) } - : translated.answer === 'y' || (translated.answer === 'allow-once' && owner.dto.variant !== 'denied_read') - ? { kind: 'approve' } - : { kind: 'grant', grant: translated.answer as Extract['grant'] }; - controlAnswer = translated.answer; - } - return result; - }); - if (staged.snapshot) { - const next = projectPendingInteraction( - staged.snapshot.approval, - String(staged.snapshot.interactionId), - staged.snapshot.revision!, - staged.snapshot.askUserAnswers, - staged.snapshot.currentAskUserQuestionIndex, - ); - if (next.revision === owner.dto.revision) return; - await commit([ - { - type: 'session_interaction_pending', - version: 1, - turnId, - generation: String(token), - interaction: next, - }, - ]); - if (!isCurrent() || interaction !== owner) throw new Error('Interaction became stale'); - owner.validateSource?.(); - staged.commit(); - owner.dto = next; - return; - } - if (!decision) throw new Error('Interaction did not resolve'); - if (owner.runBudget && controlAnswer === 'y') { - if (!isCurrent() || interaction !== owner) throw new Error('Interaction is stale'); - owner.validateSource?.(); - const grant = grantHumanRunBudgetExtension(); - if (!grant?.granted) throw new RunBudgetExtensionRefused(); - } - await commit([ - { - type: 'session_interaction_resolved', - version: 1, - turnId, - generation: String(token), - interactionId: owner.dto.interactionId, - revision: owner.dto.revision, - outcome, - answer: { - choiceId, - ...(decision.kind === 'reject' && decision.reason ? { rejectionReason: decision.reason } : {}), - ...(resolvedAnswers ? { answers: resolvedAnswers } : {}), - }, - }, - ]); - if (!isCurrent() || interaction !== owner) throw new Error('Interaction became stale'); - owner.validateSource?.(); - if (decision.kind === 'grant' && !owner.postExecute) - runtime.approvalFlow.validateGrant(decision.grant, snapshot.approval); - staged.commit(); - owner.controlAnswer = controlAnswer; - interaction = undefined; - notifyInteraction(); - const decisions = new Map(); - if (owner.dto.descriptor.callId) decisions.set(owner.dto.descriptor.callId as ToolCallId, decision); - const view = engine.resume(checkpoint, decisions); - turn.active = view; - deliverView(view); - }; - return { - /** Parks the turn on a source approval and resolves with the user's answer. */ - requestDecision, - /** Applies one user command to the pending interaction. */ - decide, - /** Raises post-execute approvals for this turn until `stopSignal` aborts. */ - watchPostExecute, - /** The engine view to continue with once a decision resumes the turn. */ - takeView, - /** Gives the suspended turn's checkpoint to the pending interaction, if any. */ - offerCheckpoint(checkpoint: TurnCheckpoint): void { - if (interaction) interaction.setCheckpoint(checkpoint); - }, - /** True once the turn has asked for any decision; such turns skip automatic memory. */ - get askedForDecision(): boolean { - return askedForDecision; - }, - }; -} diff --git a/rewrite/src/session/adopted/turn-prompt.ts b/rewrite/src/session/adopted/turn-prompt.ts deleted file mode 100644 index c1e81b205..000000000 --- a/rewrite/src/session/adopted/turn-prompt.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Source-free view of one current app agent assembly. The application boundary - * passes the same agent that supplied the final tools to the adopted registry. - */ -export type TurnPromptSource = { - readonly instructions: string; - readonly resolveInstructionsForRequest?: () => string; - readonly tools: readonly { readonly name: string }[]; -}; - -export type TurnPrompt = { - readonly instructions: string; - readonly toolNames: readonly string[]; -}; - -/** - * Project one current assembly into plain TurnInput data. Call at each model - * request boundary so request-time context stays current; do not cache the - * result across requests or combine fields from different agent generations. - */ -export function turnPrompt(source: TurnPromptSource): TurnPrompt { - return { - instructions: source.resolveInstructionsForRequest?.() ?? source.instructions, - toolNames: source.tools.map((tool) => tool.name), - }; -} diff --git a/rewrite/src/session/direct-command-compaction.ts b/rewrite/src/session/direct-command-compaction.ts deleted file mode 100644 index d0194e3e5..000000000 --- a/rewrite/src/session/direct-command-compaction.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Actual native manual compaction preparation; only Session may accept its resulting replacement. */ -import { isDeepStrictEqual } from 'node:util'; -import { - compactDirectHistory, - type DirectCompactionInput, - type DirectCompactionResult, -} from '../engine/direct-recovery-compaction.js'; -import type { DirectHistoryFact } from './facts.js'; -import type { DirectCommandSnapshot } from './direct-command-types.js'; - -export type DirectManualCompactionPlan = - | { readonly kind: 'rejected'; readonly code: 'busy' | 'stale'; readonly message: string } - | { readonly kind: 'not_compacted'; readonly result: Exclude } - | { - readonly kind: 'replace'; - readonly expectedRevision: string; - readonly fact: DirectHistoryFact; - readonly clearContinuity: true; - readonly result: Extract; - }; - -/** - * Invoke the real compactor against an owned settled capture. No append, receipt, - * hidden retry, provider substitute or no-op success. Session supplies the actual - * model/request/settings/signal/accounting and retains its command reservation - * across await; cancellation/errors propagate, including already-incurred costs. - */ -export async function prepareDirectManualCompaction( - snapshot: DirectCommandSnapshot, - input: Omit, -): Promise { - if (!snapshot.executionsSettled) - return { kind: 'rejected', code: 'busy', message: 'Foreground execution and cleanup must settle first.' }; - if (!isDeepStrictEqual(snapshot.history, input.request.input)) - return { kind: 'rejected', code: 'stale', message: 'Compaction request differs from the owned history capture.' }; - // Own transcript data while retaining live request capabilities (notably the - // engine recovery reservation, whose prototype cannot be structured-cloned). - const request = { ...input.request, input: structuredClone(input.request.input) }; - const result = await compactDirectHistory({ ...input, request, manual: true }); - if (result.kind !== 'compacted') return { kind: 'not_compacted', result }; - return { - kind: 'replace', - expectedRevision: snapshot.revision, - clearContinuity: true, - result, - fact: { - type: 'history_replaced', - reason: 'compaction', - history: structuredClone(result.history), - ...(result.rearmAtEstimatedTokens === undefined ? {} : { rearmAtEstimatedTokens: result.rearmAtEstimatedTokens }), - checkpointReceipts: structuredClone(result.checkpointReceipts), - }, - }; -} diff --git a/rewrite/src/session/local-store.ts b/rewrite/src/session/local-store.ts deleted file mode 100644 index 75ae7882b..000000000 --- a/rewrite/src/session/local-store.ts +++ /dev/null @@ -1,827 +0,0 @@ -import { projectNetworkFacts } from './adopted/network.js'; -/** Canonical filesystem SessionStore. The source conversation log is its sole authority. */ -import crypto from 'node:crypto'; -import { - captureAdoptedInput, - type AdoptedAdmissionContext, - type AdoptedAdmissionReceipt, - type AdoptedSubmissionPort, -} from './adopted/submission.js'; -import fs from 'node:fs'; -import path from 'node:path'; -import type { Item, ModelRef } from '../model/index.js'; -import type { Workspace } from '../tools/index.js'; -import type { - Session, - SessionDecision, - SessionFact, - SessionId, - SessionLog, - SessionState, - SessionStore, - SessionSummary, -} from './index.js'; -import { - CanonicalSessionLog, - auditCanonicalConversation, - loadCanonicalConversation, - normalizedBodyHash, - type CanonicalSessionLog as CanonicalLog, - type SessionBinding, -} from './adopted/conversation-log.js'; -import { SessionIndexService } from './adopted/conversation-index.js'; -import { initialState, newestAdmission, projectLifecycle, statusFromLegacyAudit, withTranscript } from './reducer.js'; - -export type SessionDriver = Pick< - Session, - 'submit' | 'decide' | 'cancel' | 'retry' | 'compact' | 'setModel' | 'subscribe' ->; -export type SessionDriverContext = { - readonly id: SessionId; - readonly root: string; - readonly log: SessionLog; - state(): SessionState; - /** Permanently reject mutations after partial runtime/persistence failure. */ - poison(error: unknown): void; - assertHealthy(): void; - reserveSettings(): () => void; - /** Replace state only after the caller has durably appended the supplied facts. */ - commit(facts: readonly SessionFact[]): Promise; -}; -export interface AdoptedSessionHandle { - readonly driver: SessionDriver; - readonly submission: AdoptedSubmissionPort; - closeNetwork?(): Promise; - shutdown(): Promise; -} -export type SessionStoreOptions = { - readonly root: string; - readonly logger: Parameters[0]['logger']; - readonly defaultWorkspace: Workspace; - readonly defaultModel: ModelRef; - readonly admissionTtlMs?: number; - readonly fileSystem?: Parameters[0]['fileSystem']; - /** Adopted source runtime driver; the store remains the only log writer. */ - readonly driverFactory: (context: SessionDriverContext) => SessionDriver; - readonly adoptedHandleFactory?: ( - context: AdoptedAdmissionContext, - ) => AdoptedSessionHandle | Promise; - readonly adoptedSubmissionFactory?: (context: AdoptedAdmissionContext) => AdoptedSubmissionPort; -}; - -export class LocalSessionStore implements SessionStore { - readonly #options: SessionStoreOptions; - constructor(options: SessionStoreOptions) { - this.#options = options; - } - - async create(workspace: Workspace, model: ModelRef, binding?: SessionBinding): Promise { - fs.mkdirSync(this.#options.root, { recursive: true }); - const id = `ses_${crypto.randomUUID()}` as SessionId; - return this.createWithId(id, workspace, model, binding); - } - - /** Proof/adapter composition keeps the source runtime's already-selected ID. */ - async createWithId( - id: SessionId, - workspace: Workspace, - model: ModelRef, - binding?: SessionBinding, - metadata: Partial = {}, - ): Promise { - fs.mkdirSync(this.#options.root, { recursive: true }); - const log = CanonicalSessionLog.create({ - root: this.#options.root, - id, - metadata: { projectPath: workspace.root, model: model.model, provider: model.provider, ...metadata }, - workspace, - model, - ...(binding ? { binding } : {}), - logger: this.#options.logger, - fileSystem: this.#options.fileSystem, - }); - return (await this.#makeSession(id, log, replayState(id, log, this.#options), false)).session; - } - - async createAdopted( - workspace: Workspace, - model: ModelRef, - binding?: SessionBinding, - ): Promise<{ session: Session; submission: AdoptedSubmissionPort }> { - if (!this.#options.adoptedSubmissionFactory && !this.#options.adoptedHandleFactory) - throw new Error('Adopted submission factory is required'); - if (this.#options.adoptedSubmissionFactory && this.#options.adoptedHandleFactory) - throw new Error('Ambiguous adopted factories'); - fs.mkdirSync(this.#options.root, { recursive: true }); - const id = `ses_${crypto.randomUUID()}` as SessionId; - const log = CanonicalSessionLog.create({ - root: this.#options.root, - id, - metadata: { projectPath: workspace.root, model: model.model, provider: model.provider }, - workspace, - model, - ...(binding ? { binding } : {}), - logger: this.#options.logger, - fileSystem: this.#options.fileSystem, - }); - try { - return (await this.#makeSession(id, log, replayState(id, log, this.#options), true)) as { - session: Session; - submission: AdoptedSubmissionPort; - }; - } catch (error) { - try { - await log.dispose(); - } catch { - // Preserve the original construction failure. - } - throw error; - } - } - - async open(id: SessionId, expectedBinding?: SessionBinding): Promise { - return (await this.#open(id, expectedBinding, false)).session; - } - - async openAdopted( - id: SessionId, - expectedBinding?: SessionBinding, - ): Promise<{ session: Session; submission: AdoptedSubmissionPort }> { - if (!this.#options.adoptedSubmissionFactory && !this.#options.adoptedHandleFactory) - throw new Error('Adopted submission factory is required'); - if (this.#options.adoptedSubmissionFactory && this.#options.adoptedHandleFactory) - throw new Error('Ambiguous adopted factories'); - return this.#open(id, expectedBinding, true) as Promise<{ session: Session; submission: AdoptedSubmissionPort }>; - } - - async #open( - id: SessionId, - expectedBinding: SessionBinding | undefined, - adopted: boolean, - ): Promise<{ session: Session; submission?: AdoptedSubmissionPort }> { - if (expectedBinding) { - const existing = CanonicalSessionLog.readOnly(this.#options.root, id); - const binding = existing.find((entry) => entry.fact.type === 'session_bound')?.fact; - if (binding?.type !== 'session_bound' || !sameBinding(binding.binding, expectedBinding)) { - throw new Error('session binding does not match the expected owner/workspace/grant'); - } - } - // Acquire the existing source writer lock before replay and reconciliation. - const log = CanonicalSessionLog.open({ - root: this.#options.root, - id, - logger: this.#options.logger, - fileSystem: this.#options.fileSystem, - }); - try { - let state = replayState(id, log, this.#options); - if (state.status === 'closed') throw new Error(`session ${id} is closed`); - const facts: SessionFact[] = []; - for (const request of projectNetworkFacts(log.entries().map((entry) => entry.fact))) { - facts.push({ - type: 'session_network_resolved', - version: 1, - requestId: request.requestId, - generation: request.generation, - resolution: { kind: 'recovered', reason: 'process_restarted' }, - }); - } - const pending = state.pendingInteraction; - if (pending) { - const identity = { - turnId: pending.turnId, - interactionId: pending.interaction.interactionId, - revision: pending.interaction.revision, - generation: pending.generation, - }; - facts.push({ type: 'session_interaction_recovered', version: 1, ...identity, reason: 'daemon_restart' }); - const pendingFact = [...log.entries()] - .reverse() - .find( - (entry) => entry.fact.type === 'session_interaction_pending' && entry.fact.turnId === pending.turnId, - )?.fact; - if ( - pendingFact?.type === 'session_interaction_pending' && - pendingFact.settleOnRecovery === 'child_interrupted' - ) { - facts.push({ - type: 'subagent_interrupted', - agentId: pendingFact.child?.agentId ?? pending.interaction.descriptor.agentName, - role: pendingFact.child?.role ?? pending.interaction.descriptor.agentName, - finalText: 'Child interaction interrupted by daemon restart.', - }); - } - facts.push({ type: 'session_interaction_resolved', version: 1, ...identity, outcome: 'cancelled' }); - } - for (const admission of Object.values(state.admissions)) { - if (admission.result) continue; - const started = log - .entries() - .some((entry) => entry.fact.type === 'session_turn_started' && entry.fact.turnId === admission.turnId); - const terminal = [...log.entries()] - .reverse() - .find((entry) => entry.fact.type === 'session_turn_ended' && entry.fact.turnId === admission.turnId)?.fact; - if (admission.disposition === 'queued' && !started && !terminal) { - facts.push({ - type: 'session_admission_settled', - version: 1, - clientRequestId: admission.clientRequestId, - turnId: admission.turnId, - result: 'queue_discarded', - }); - } else if (terminal?.type === 'session_turn_ended') { - const result = - terminal.outcome === 'completed' ? 'accepted' : terminal.outcome === 'cancelled' ? 'aborted' : 'failed'; - facts.push({ - type: 'session_admission_settled', - version: 1, - clientRequestId: admission.clientRequestId, - turnId: admission.turnId, - result, - }); - } else if (!terminal && (admission.disposition === 'started' || started)) { - facts.push({ - type: 'session_admission_settled', - version: 1, - clientRequestId: admission.clientRequestId, - turnId: admission.turnId, - result: 'aborted', - }); - } - } - const priorInterrupt = log - .entries() - .some( - (entry) => - entry.fact.type === 'session_interrupted' && - (state.activeTurnId === null || entry.fact.turnId === state.activeTurnId), - ); - if ( - (state.status === 'running' || state.status === 'awaiting_interaction' || state.status === 'interrupted') && - !priorInterrupt - ) { - facts.push({ - type: 'session_interrupted', - version: 1, - turnId: state.activeTurnId, - reason: 'daemon_restart', - interruptedAt: new Date().toISOString(), - }); - } - if (facts.length) { - await log.append(facts); - state = replayState(id, log, this.#options); - } - return await this.#makeSession(id, log, state, adopted); - } catch (error) { - await log.dispose(); - throw error; - } - } - - async list(cursor?: string): Promise<{ sessions: readonly SessionSummary[]; nextCursor: string | null }> { - const names = fs.existsSync(this.#options.root) - ? fs - .readdirSync(this.#options.root) - .filter((name) => name.endsWith('.jsonl')) - .map((name) => name.slice(0, -6)) - .sort() - : []; - const start = cursor ? Math.max(0, names.findIndex((id) => id === cursor) + 1) : 0; - const page = names.slice(start, start + 100); - const sessions: SessionSummary[] = []; - for (const id of page) { - const entries = CanonicalSessionLog.readOnly(this.#options.root, id); - const state = replayState( - id as SessionId, - { entries: () => entries } as unknown as CanonicalLog, - this.#options, - this.#options.root, - ); - sessions.push({ - id: state.id, - status: state.status, - workspace: state.workspace, - lastSeq: state.lastSeq, - title: state.messages.length - ? String((state.messages.at(-1) as { text?: unknown })?.text ?? '').slice(0, 80) || null - : null, - updatedAt: entries.at(-1)?.at ?? new Date(0).toISOString(), - }); - } - return { sessions, nextCursor: start + page.length < names.length && page.length ? page.at(-1)! : null }; - } - - /** Delete only explicitly closed sessions whose fixed retention deadline has passed. */ - async expireClosed( - now = new Date(), - ): Promise<{ readonly deleted: readonly string[]; readonly errors: readonly { id: string; message: string }[] }> { - const deleted: string[] = []; - const errors: { id: string; message: string }[] = []; - const names = fs.existsSync(this.#options.root) - ? fs.readdirSync(this.#options.root).filter((name) => name.endsWith('.jsonl')) - : []; - for (const name of names) { - const id = name.slice(0, -'.jsonl'.length); - let snapshot: readonly import('./adopted/conversation-log.js').SessionLogEntry[]; - try { - snapshot = CanonicalSessionLog.readOnly(this.#options.root, id); - } catch (error) { - errors.push({ id, message: error instanceof Error ? error.message : String(error) }); - continue; - } - if (!isRetentionEligible(snapshot, now.getTime())) continue; - let locked: CanonicalLog | undefined; - try { - locked = CanonicalSessionLog.open({ - root: this.#options.root, - id, - logger: this.#options.logger, - fileSystem: this.#options.fileSystem, - }); - if (!isRetentionEligible(locked.entries(), now.getTime())) { - await locked.dispose(); - continue; - } - await locked.deleteFiles(); - deleted.push(id); - } catch (error) { - if (locked) { - try { - await locked.dispose(); - } catch { - /* keep the original delete error */ - } - } - errors.push({ id, message: error instanceof Error ? error.message : String(error) }); - } - } - if (deleted.length) { - try { - const result = await new SessionIndexService({ - conversationsDir: this.#options.root, - backend: 'direct', - }).reconcile(); - if (!result.ok) errors.push({ id: '*index*', message: result.error ?? 'session index reconciliation failed' }); - } catch (error) { - errors.push({ id: '*index*', message: error instanceof Error ? error.message : String(error) }); - } - } - return { deleted, errors }; - } - - async #makeSession( - id: SessionId, - log: CanonicalLog, - state: SessionState, - adopted: boolean, - ): Promise<{ session: Session; submission?: AdoptedSubmissionPort }> { - let current = state; - let mutationFailure: unknown; - let constructing = true; - let settingsReserved = false; - let closingLog = false; - const pendingAdmissions = new Map }>(); - type Update = import('./index.js').SessionUpdate; - type Captured = import('./index.js').CapturedSessionUpdate; - const generation = crypto.randomUUID(); - let position = 0; - const listeners = new Set<(update: Captured) => void>(); - const queue: { item: Captured; listeners: readonly ((update: Captured) => void)[] }[] = []; - let delivering = false; - const enqueue = (updates: readonly Update[]) => { - for (const update of updates) { - if (!Number.isSafeInteger(position + 1)) throw new Error('Session position exhausted'); - const item = { position: { generation, position: ++position }, update: structuredClone(update) }; - queue.push({ item, listeners: [...listeners] }); - } - if (delivering) return; - delivering = true; - try { - while (queue.length) { - const { item, listeners: targets } = queue.shift()!; - for (const listener of targets) { - if (!listeners.has(listener)) continue; - try { - const result: unknown = listener(item); - if ( - result != null && - (typeof result === 'object' || typeof result === 'function') && - typeof (result as { then?: unknown }).then === 'function' - ) - void Promise.resolve(result).catch(() => {}); - } catch { - /* Observers cannot undo publication. */ - } - } - } - } finally { - delivering = false; - } - }; - const context: SessionDriverContext = { - id, - root: log.root, - log, - state: () => current, - reserveSettings: () => { - if (settingsReserved || closingLog || mutationFailure !== undefined) - throw new Error('Session mutation is unavailable'); - settingsReserved = true; - return () => { - settingsReserved = false; - }; - }, - poison: (error) => { - mutationFailure ??= error ?? new Error('Session mutation failed'); - }, - assertHealthy: () => { - if (mutationFailure !== undefined) throw mutationFailure; - }, - commit: async (facts) => { - if (mutationFailure !== undefined) throw mutationFailure; - const entries = await log.append(facts); - for (const entry of entries) current = projectLifecycle(current, entry); - const restored = loadCanonicalConversation(id, log.root, 'live'); - current = withTranscript(current, restored, restored.history as unknown as Item[]); - if (!constructing) enqueue(entries.map((entry) => ({ kind: 'fact', entry }))); - if (mutationFailure !== undefined) throw mutationFailure; - return entries; - }, - }; - const receipts = new Map(); - let handleClosed = false; - const adoptedContext: AdoptedAdmissionContext = { - ...context, - admit: async ({ requestId, input }, message) => { - if (mutationFailure !== undefined) throw mutationFailure; - if (settingsReserved || closingLog) throw new Error('Session mutation is in progress'); - if (handleClosed || current.status === 'closed') return { kind: 'rejected', reason: 'closed' }; - const { input: owned, hash } = captureAdoptedInput(input); - const ownedMessage = structuredClone(message); - ownedMessage.text = owned.text; - if (owned.skill) ownedMessage.skill = structuredClone(owned.skill); - else delete ownedMessage.skill; - ownedMessage.submittedTurn = structuredClone(owned); - const pending = pendingAdmissions.get(requestId); - if (pending) { - if (pending.hash !== hash) return { kind: 'rejected', reason: 'request_conflict' }; - await pending.admitted; - return { kind: 'duplicate', turnId: pending.turnId as never }; - } - const prior = newestAdmission(current, requestId, Date.now()); - if (prior) - return prior.normalizedBodyHash === hash - ? { kind: 'duplicate', turnId: prior.turnId } - : { kind: 'rejected', reason: 'request_conflict' }; - const turnId = crypto.randomUUID() as import('../engine/index.js').TurnId; - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + (this.#options.admissionTtlMs ?? 60_000)).toISOString(); - let resolveAdmitted!: () => void; - let rejectAdmitted!: (error: unknown) => void; - const admitted = new Promise((resolve, reject) => { - resolveAdmitted = resolve; - rejectAdmitted = reject; - }); - // Observe rejection even when no duplicate waiter exists. - void admitted.catch(() => {}); - pendingAdmissions.set(requestId, { hash, turnId, admitted }); - try { - const entries = await context.commit([ - { type: 'user_message', message: ownedMessage }, - { - type: 'session_admitted', - version: 1, - clientRequestId: requestId, - normalizedBodyHash: hash, - turnId, - disposition: 'started', - createdAt, - expiresAt, - }, - ]); - const receipt: AdoptedAdmissionReceipt = { requestId, turnId, userMessageRef: entries[0]!.ref, input: owned }; - receipts.set(receipt, 'outstanding'); - resolveAdmitted(); - return { kind: 'admitted', receipt }; - } catch (error) { - rejectAdmitted(error); - throw error; - } finally { - pendingAdmissions.delete(requestId); - } - }, - activate: async (receipt) => { - const admission = current.admissions[receipt.requestId]; - if ( - current.status === 'closed' || - receipts.get(receipt) !== 'outstanding' || - !admission || - admission.turnId !== receipt.turnId || - admission.result || - current.activeTurnId !== null - ) - throw new Error('Invalid or stale adopted admission receipt'); - receipts.set(receipt, 'activating'); - try { - await context.commit([ - { type: 'session_turn_started', version: 1, turnId: receipt.turnId, clientRequestId: receipt.requestId }, - ]); - receipts.set(receipt, 'activated'); - } catch (error) { - receipts.delete(receipt); - throw error; - } - }, - }; - const combined = - adopted && this.#options.adoptedHandleFactory - ? await this.#options.adoptedHandleFactory(adoptedContext) - : undefined; - constructing = false; - const driver = combined?.driver ?? this.#options.driverFactory(context); - const rawSubmission = adopted - ? combined?.submission ?? this.#options.adoptedSubmissionFactory!(adoptedContext) - : undefined; - const submission: AdoptedSubmissionPort | undefined = rawSubmission && { - networkApproval: rawSubmission.networkApproval, - goal: rawSubmission.goal && { - get: rawSubmission.goal.get, - set: (goal, expected) => - handleClosed - ? Promise.reject(new Error('Session handle is closed')) - : rawSubmission.goal!.set(goal, expected), - }, - history: (command) => - handleClosed ? Promise.reject(new Error('Session handle is closed')) : rawSubmission.history(command), - listRewindTargets: () => - handleClosed ? Promise.reject(new Error('Session handle is closed')) : rawSubmission.listRewindTargets(), - submit: (...args) => - handleClosed ? Promise.resolve({ kind: 'rejected', reason: 'closed' }) : rawSubmission.submit(...args), - decide: (command) => { - if (handleClosed) return Promise.reject(new Error('Session handle is closed')); - return rawSubmission.decide(command); - }, - retry: (target) => - handleClosed ? Promise.resolve({ kind: 'rejected', reason: 'closed' }) : rawSubmission.retry(target), - compact: () => (handleClosed ? Promise.reject(new Error('Session handle is closed')) : rawSubmission.compact()), - setRuntimeSettings: (command) => { - if (handleClosed) return Promise.reject(new Error('Session handle is closed')); - return rawSubmission.setRuntimeSettings(command); - }, - interruptFromUser: () => { - if (handleClosed) return Promise.reject(new Error('Session handle is closed')); - return rawSubmission.interruptFromUser(); - }, - }; - let closePromise: Promise | undefined; - const ensureOpen = () => { - if (handleClosed) throw new Error('Session handle is closed'); - }; - // Recovery-only drivers can intentionally omit live capabilities. Their - // canonical facts still flow through the session-owned publication queue. - const unsubscribeDriver = - typeof driver.subscribe === 'function' - ? driver.subscribe(current.lastSeq, (update) => { - if (update.kind === 'live') enqueue([update]); - }) - : () => {}; - const subscribeCombined: Session['subscribe'] = (fromSeq, listener) => { - ensureOpen(); - let stopped = false; - let delivering = false; - let lastSeq = fromSeq; - const pending: Update[] = []; - const drain = () => { - if (delivering) return; - delivering = true; - try { - while (!stopped && pending.length) { - const update = pending.shift()!; - if (update.kind === 'fact') { - if (update.entry.seq <= lastSeq) continue; - lastSeq = update.entry.seq; - } - try { - const result: unknown = listener(update); - if ( - result != null && - (typeof result === 'object' || typeof result === 'function') && - typeof (result as { then?: unknown }).then === 'function' - ) - void Promise.resolve(result).catch(() => {}); - } catch { - /* Observer failure cannot undo durable work. */ - } - } - } finally { - delivering = false; - } - }; - const wrapped = (item: Captured) => { - pending.push(structuredClone(item.update)); - drain(); - }; - listeners.add(wrapped); - pending.push( - ...log - .entries() - .filter((entry) => entry.seq > fromSeq && entry.seq <= current.lastSeq) - .map((entry) => ({ kind: 'fact' as const, entry: structuredClone(entry) })), - ); - drain(); - return () => { - stopped = true; - listeners.delete(wrapped); - }; - }; - const session: Session = { - id, - state: () => current, - submit: async (requestId, items) => { - if (handleClosed || current.status === 'closed') return { kind: 'rejected', reason: 'closed' }; - const hash = normalizedBodyHash(items); - const pending = pendingAdmissions.get(requestId); - if (pending) { - if (pending.hash !== hash) return { kind: 'rejected', reason: 'request_conflict' }; - await pending.admitted; - return { kind: 'duplicate', turnId: pending.turnId as never }; - } - const prior = newestAdmission(current, requestId, Date.now()); - if (prior) - return prior.normalizedBodyHash === hash - ? { kind: 'duplicate', turnId: prior.turnId } - : { kind: 'rejected', reason: 'request_conflict' }; - if (adopted) throw new Error('Use adopted submission.submit for new requests on this session'); - const turnId = crypto.randomUUID(); - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() + (this.#options.admissionTtlMs ?? 60_000)).toISOString(); - const text = items - .map((item) => - item.type === 'user_message' - ? item.content.map((part) => (part.type === 'text' ? part.text : '')).join('') - : '', - ) - .join('\n'); - let resolveAdmitted!: () => void; - let rejectAdmitted!: (error: unknown) => void; - const admitted = new Promise((resolve, reject) => { - resolveAdmitted = resolve; - rejectAdmitted = reject; - }); - pendingAdmissions.set(requestId, { hash, turnId, admitted }); - try { - const commit = context.commit([ - { type: 'user_message', message: { id: crypto.randomUUID(), sender: 'user', text } }, - { - type: 'session_admitted', - version: 1, - clientRequestId: requestId, - normalizedBodyHash: hash, - turnId, - disposition: 'started', - createdAt, - expiresAt, - }, - { type: 'session_turn_started', version: 1, turnId, clientRequestId: requestId }, - ]); - commit.then(resolveAdmitted, rejectAdmitted); - await commit; - } catch (error) { - pendingAdmissions.delete(requestId); - throw error; - } - pendingAdmissions.delete(requestId); - return driver.submit(requestId, items); - }, - decide: (decision: SessionDecision) => { - ensureOpen(); - return driver.decide(decision); - }, - cancel: () => { - ensureOpen(); - return driver.cancel(); - }, - retry: () => { - ensureOpen(); - return driver.retry(); - }, - compact: () => { - ensureOpen(); - return driver.compact(); - }, - setModel: (model) => { - ensureOpen(); - return driver.setModel(model); - }, - subscribe: subscribeCombined, - capture: (listener) => { - ensureOpen(); - const snapshot = structuredClone(current); - const captured = { generation, position }; - const wrapped = (update: Captured) => listener(structuredClone(update)); - listeners.add(wrapped); - return { - state: snapshot, - position: captured, - unsubscribe: () => { - listeners.delete(wrapped); - }, - }; - }, - close: () => { - if (closePromise) return closePromise; - handleClosed = true; - closePromise = (async () => { - let failure: unknown; - try { - await combined?.shutdown(); - } catch (error) { - failure = error; - } - try { - await log.dispose(); - } catch (error) { - failure ??= error; - } - unsubscribeDriver(); - listeners.clear(); - if (failure !== undefined) throw failure; - })(); - return closePromise; - }, - closeLog: async ( - closedAt = new Date().toISOString(), - retentionEligibleAt = new Date(Date.parse(closedAt) + 30 * 86400000).toISOString(), - ) => { - ensureOpen(); - context.assertHealthy(); - if (settingsReserved || closingLog) throw new Error('Session mutation is in progress'); - if (current.status === 'closed') return; - closingLog = true; - try { - await combined?.closeNetwork?.(); - await context.commit([{ type: 'session_closed', version: 1, closedAt, retentionEligibleAt }]); - } finally { - closingLog = false; - } - }, - }; - return { session, ...(submission ? { submission } : {}) }; - } -} - -function sameBinding(actual: SessionBinding | undefined, expected: SessionBinding): boolean { - return ( - !!actual && - actual.ownerUserId === expected.ownerUserId && - actual.workspaceId === expected.workspaceId && - actual.grantVersion === expected.grantVersion && - actual.fingerprint === expected.fingerprint - ); -} - -function isRetentionEligible( - entries: readonly import('./adopted/conversation-log.js').SessionLogEntry[], - now: number, -): boolean { - const close = [...entries].reverse().find((entry) => entry.fact.type === 'session_closed')?.fact; - return close?.type === 'session_closed' && Date.parse(close.retentionEligibleAt) <= now; -} - -function replayState( - id: SessionId, - log: Pick, - options: SessionStoreOptions, - explicitRoot?: string, -): SessionState { - const entries = log.entries(); - const binding = entries.find((entry) => entry.fact.type === 'session_bound')?.fact; - const init = entries.find((entry) => entry.fact.type === 'session_init')?.fact; - const workspace = - binding?.type === 'session_bound' - ? binding.workspace - : { - ...options.defaultWorkspace, - ...(init?.type === 'session_init' && init.projectPath ? { root: init.projectPath } : {}), - }; - const model = - binding?.type === 'session_bound' - ? binding.model - : { - ...options.defaultModel, - ...(init?.type === 'session_init' && init.model ? { model: init.model } : {}), - ...(init?.type === 'session_init' && init.provider ? { provider: init.provider } : {}), - }; - let state = initialState(id, workspace, model); - for (const entry of entries) state = projectLifecycle(state, entry); - const root = explicitRoot ?? (log as CanonicalLog).root; - const restored = loadCanonicalConversation(id, root); - state = withTranscript(state, restored, restored.history as unknown as import('../model/index.js').Item[]); - const audit = auditCanonicalConversation(id, explicitRoot ?? (log as CanonicalLog).root); - const hasLifecycleTurn = entries.some( - (entry) => entry.fact.type === 'session_turn_started' || entry.fact.type === 'session_interrupted', - ); - if (!hasLifecycleTurn) state = statusFromLegacyAudit(state, audit.outcome); - return { ...state, lastSeq: entries.at(-1)?.seq ?? 0 }; -} diff --git a/rewrite/src/session/reducer.ts b/rewrite/src/session/reducer.ts deleted file mode 100644 index 20ee787e5..000000000 --- a/rewrite/src/session/reducer.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { normalizedModelSelection } from './adopted/settings.js'; -import type { Item, ModelRef } from '../model/index.js'; -import type { Workspace } from '../tools/index.js'; -import type { AdmissionRecord, LogEntry, Project, SessionId, SessionState } from './index.js'; -import { projectRetryEligibility } from './adopted/conversation-retry-projection.js'; - -export function initialState(id: SessionId, workspace: Workspace, model: ModelRef): SessionState { - return { - id, - status: 'idle', - workspace, - model, - history: [], - providerHistory: [], - messages: [], - activeTurnId: null, - pendingInteraction: null, - queue: [], - admissions: {}, - retryEligibility: { turn: false, tool: false }, - canRetry: false, - binding: null, - closedAt: null, - lastSeq: 0, - }; -} - -/** Applies only fact-owned lifecycle projection; transcript replay remains adopted from source. */ -export const projectLifecycle: Project = (state, entry: LogEntry) => { - const fact = entry.fact; - const next = { ...state, lastSeq: entry.seq }; - if (state.status === 'closed' && fact.type !== 'session_closed') return next; - - switch (fact.type) { - case 'session_settings_initialized': - return { ...next, model: fact.model }; - case 'settings_changed': - if (fact.key === 'agent.modelSelection') { - const model = normalizedModelSelection(fact.value); - if (model) return { ...next, model }; - } - if (fact.key === 'agent.model' && typeof fact.value === 'string') - return { ...next, model: { ...state.model, model: fact.value } }; - if (fact.key === 'agent.provider' && typeof fact.value === 'string') - return { ...next, model: { ...state.model, provider: fact.value } }; - return next; - case 'session_bound': - return { ...next, workspace: fact.workspace, model: fact.model, binding: fact.binding ?? null }; - case 'session_admitted': { - const record: AdmissionRecord = { - clientRequestId: fact.clientRequestId as never, - normalizedBodyHash: fact.normalizedBodyHash, - turnId: fact.turnId as never, - disposition: fact.disposition, - createdAt: fact.createdAt, - expiresAt: fact.expiresAt, - }; - const admissions = { ...state.admissions, [fact.clientRequestId]: record }; - return { - ...next, - admissions, - queue: - fact.disposition === 'queued' - ? [...state.queue.filter((item) => item.clientRequestId !== record.clientRequestId), record] - : state.queue, - }; - } - case 'session_admission_settled': { - const prior = state.admissions[fact.clientRequestId]; - if (!prior || prior.turnId !== fact.turnId) return next; - const record = { ...prior, result: fact.result }; - return { - ...next, - admissions: { ...state.admissions, [fact.clientRequestId]: record }, - queue: state.queue.filter((item) => item.clientRequestId !== record.clientRequestId), - }; - } - case 'session_turn_started': { - const queue = - fact.clientRequestId === null - ? state.queue - : state.queue.filter((item) => item.clientRequestId !== fact.clientRequestId); - return { - ...next, - status: 'running', - activeTurnId: fact.turnId as never, - pendingInteraction: null, - queue, - canRetry: false, - }; - } - case 'session_interaction_pending': - if (state.activeTurnId && state.activeTurnId !== fact.turnId) return next; - return { - ...next, - status: 'awaiting_interaction', - activeTurnId: fact.turnId as never, - pendingInteraction: { - turnId: fact.turnId as never, - interaction: fact.interaction, - generation: fact.generation, - }, - }; - case 'session_interaction_resolved': { - const pending = state.pendingInteraction; - if ( - !pending || - pending.turnId !== fact.turnId || - pending.interaction.interactionId !== fact.interactionId || - pending.interaction.revision !== fact.revision || - pending.generation !== fact.generation - ) - return next; - return { - ...next, - status: fact.outcome === 'answered' && state.activeTurnId === fact.turnId ? 'running' : state.status, - pendingInteraction: null, - }; - } - case 'session_turn_ended': - if (state.activeTurnId !== fact.turnId) return next; - return { ...next, status: 'idle', activeTurnId: null, pendingInteraction: null }; - case 'session_interrupted': - if (fact.turnId !== null && state.activeTurnId !== fact.turnId) return next; - return { ...next, status: 'interrupted', activeTurnId: null, pendingInteraction: null }; - case 'session_closed': - return { - ...next, - status: 'closed', - activeTurnId: null, - pendingInteraction: null, - queue: [], - retryEligibility: { turn: false, tool: false }, - canRetry: false, - closedAt: fact.closedAt, - }; - default: - return next; - } -}; - -export function withTranscript( - state: SessionState, - transcript: { - readonly history: readonly unknown[]; - readonly messages: readonly unknown[]; - readonly retryEligibility?: { readonly turn: boolean; readonly tool: boolean }; - }, - compatibilityItems: readonly Item[], -): SessionState { - const retryEligibility = transcript.retryEligibility ?? projectRetryEligibility(transcript.history); - const available = state.status === 'closed' ? { turn: false, tool: false } : retryEligibility; - return { - ...state, - retryEligibility: available, - canRetry: available.turn, - history: compatibilityItems, - providerHistory: transcript.history, - messages: transcript.messages, - }; -} - -export function newestAdmission(state: SessionState, requestId: string, now: number): AdmissionRecord | null { - const record = state.admissions[requestId]; - return record && Date.parse(record.expiresAt) > now ? record : null; -} - -/** Old CLI logs use source replay's actual unsettled-turn audit. */ -export function statusFromLegacyAudit( - state: SessionState, - outcome: 'empty' | 'settled' | 'awaiting_approval' | 'interrupted_mid_tool' | 'interrupted_mid_turn', -): SessionState { - if (state.lastSeq === 0 || state.activeTurnId || state.status === 'closed') return state; - return { - ...state, - status: outcome === 'empty' || outcome === 'settled' ? 'idle' : 'interrupted', - }; -} From 5277104568fdf86a6c857cd9a2744ebc0a76a59b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:06:50 +0000 Subject: [PATCH 06/17] refactor(rewrite): delete the adopted engine and the native-tracer loop engine/adopted/ ran the old CLI's AgentClient behind the bridge; engine/loop.ts was the native-tracer fixture's engine. The direct engine (engine/turn*.ts) is the only one production runs. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/complexity-budget.json | 12 - rewrite/scripts/reachability-allowlist.json | 7 - rewrite/src/engine/adopted/conversion.ts | 126 --- rewrite/src/engine/adopted/dependencies.ts | 9 - rewrite/src/engine/adopted/index.ts | 884 ------------------ .../src/engine/adopted/provider-transports.ts | 287 ------ rewrite/src/engine/adopted/settings.ts | 124 --- rewrite/src/engine/adopted/tool-catalog.ts | 33 - rewrite/src/engine/loop.ts | 240 ----- 9 files changed, 1722 deletions(-) delete mode 100644 rewrite/src/engine/adopted/conversion.ts delete mode 100644 rewrite/src/engine/adopted/dependencies.ts delete mode 100644 rewrite/src/engine/adopted/index.ts delete mode 100644 rewrite/src/engine/adopted/provider-transports.ts delete mode 100644 rewrite/src/engine/adopted/settings.ts delete mode 100644 rewrite/src/engine/adopted/tool-catalog.ts delete mode 100644 rewrite/src/engine/loop.ts diff --git a/rewrite/scripts/complexity-budget.json b/rewrite/scripts/complexity-budget.json index fca964560..a961289d0 100644 --- a/rewrite/scripts/complexity-budget.json +++ b/rewrite/scripts/complexity-budget.json @@ -2,18 +2,6 @@ "maxSourceEdges": 521, "maxLines": 500, "fileCeilings": { - "rewrite/src/session/adopted/foreground.ts": { - "lines": 591, - "reason": "Compact command wiring and the compacting interlock share the foreground command object; compact itself lives in compact-command.ts." - }, - "rewrite/src/engine/adopted/index.ts": { - "lines": 884, - "reason": "Post-preparation refresh of the private TurnInput copy is owned by the same engine that validates it; not yet reviewed for a job seam." - }, - "rewrite/src/session/local-store.ts": { - "lines": 827, - "reason": "Not yet reviewed for a job seam; its submission wrapper grows by one forward per session command." - }, "rewrite/src/session/adopted/conversation-log.ts": { "lines": 631, "reason": "Prettier expands the existing SessionFact union by four lines after reconciliation; no behavior or new job added. Keep the fact union with its decoder rather than extract a formatting-only seam." diff --git a/rewrite/scripts/reachability-allowlist.json b/rewrite/scripts/reachability-allowlist.json index a04373942..14a4e61db 100644 --- a/rewrite/scripts/reachability-allowlist.json +++ b/rewrite/scripts/reachability-allowlist.json @@ -1,13 +1,6 @@ { "rewrite/src/config/memory.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/config/scoped-settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/engine/adopted/conversion.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/engine/adopted/dependencies.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/engine/adopted/index.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/engine/adopted/provider-transports.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/engine/adopted/settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/engine/adopted/tool-catalog.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/engine/loop.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/model/scripted.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", "rewrite/src/session/direct-import.ts": "Offline v3 conversation migration (scripts/conversation-migration/migration-command.mjs) imports its emitted module; no CLI command reaches it.", "rewrite/src/tools/notes.ts": "Pending deletion: reached only by the bench-only bridge path (#139)." diff --git a/rewrite/src/engine/adopted/conversion.ts b/rewrite/src/engine/adopted/conversion.ts deleted file mode 100644 index a80069d51..000000000 --- a/rewrite/src/engine/adopted/conversion.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { normalizeApplicationInput } from '../../../../source/services/agent-runtime/application-run-loop.js'; -import type { ProviderInput, ProviderInputItem } from '../../../../source/contracts/provider-input.js'; - -/** Source events can contain raw provider-opaque outputs as well as source history items. */ -export function convertSourceItems(input: ProviderInput | readonly ProviderInputItem[], provider: string): Item[] { - if (typeof input === 'string') return normalizeApplicationInput(input).map((part) => inputToItem(part, provider)); - const entries: readonly ProviderInputItem[] = Array.isArray(input) ? input : [input as ProviderInputItem]; - return entries.flatMap((entry): Item[] => { - if ( - entry.type === 'provider_opaque' && - typeof entry.provider === 'string' && - entry.item && - typeof entry.item === 'object' && - !Array.isArray(entry.item) - ) { - const output = entry as unknown as Extract; - return [ - { - type: 'provider_opaque', - id: newItemId(), - provider: output.provider || provider, - ...(output.sourceProvider ? { sourceProvider: output.sourceProvider } : {}), - item: output.item, - }, - ]; - } - return normalizeApplicationInput([entry]).map((part) => inputToItem(part, provider)); - }); -} - -import type { - StreamedModelTurnInput, - StreamedModelTurnOutput, -} from '../../../../source/contracts/streamed-model-turn.js'; -import type { Item, ItemId, ToolCallId } from '../../model/index.js'; - -let nextItemId = 0; -export const newItemId = (): ItemId => `adopted_${Date.now().toString(36)}_${(nextItemId++).toString(36)}` as ItemId; - -function id(value: string | undefined): ItemId { - return (value || newItemId()) as ItemId; -} - -function parseArguments(raw: string): unknown { - try { - return JSON.parse(raw); - } catch { - return raw; - } -} - -function decodeDataImage(image: string): { mediaType: string; data: string } | undefined { - const match = /^data:([^;,]+);base64,(.*)$/s.exec(image); - return match ? { mediaType: match[1]!, data: match[2]! } : undefined; -} - -export function inputToItem(input: StreamedModelTurnInput, provider: string): Item { - switch (input.type) { - case 'message': { - const canRepresentContent = input.content.every( - (part) => part.type === 'text' || (typeof part.image === 'string' && decodeDataImage(part.image) !== undefined), - ); - if (input.role === 'system' || !canRepresentContent) - return { - type: 'provider_input', - id: newItemId(), - provider, - input: input as unknown as Readonly>, - }; - const content = input.content.map((part) => { - if (part.type === 'text') return { type: 'text' as const, text: part.text }; - const decoded = decodeDataImage(part.image as string)!; - return { type: 'image' as const, ...decoded, ...(part.detail ? { detail: part.detail } : {}) }; - }); - return input.role === 'user' - ? { type: 'user_message', id: newItemId(), content } - : { type: 'assistant_message', id: newItemId(), content }; - } - case 'reasoning': - return { - type: 'reasoning', - id: id(input.id), - ...(input.id !== undefined ? { providerItemId: input.id } : {}), - summary: input.text, - ...(input.providerMetadata ? { providerMetadata: input.providerMetadata } : {}), - }; - case 'tool_call': { - const parsed = parseArguments(input.arguments); - return { - type: 'tool_call', - id: id(input.id), - callId: input.id as ToolCallId, - toolName: input.name, - input: parsed, - rawArguments: input.arguments, - argumentsSnapshot: parsed, - ...(input.providerMetadata ? { providerMetadata: input.providerMetadata } : {}), - ...(input.toolType ? { toolType: input.toolType } : {}), - }; - } - case 'tool_result': - if (typeof input.output !== 'string') - return { - type: 'provider_input', - id: newItemId(), - provider, - input: input as unknown as Readonly>, - }; - return { - type: 'tool_result', - id: newItemId(), - callId: input.id as ToolCallId, - output: input.output, - isError: false, - ...(input.toolType ? { toolType: input.toolType } : {}), - }; - case 'provider_opaque': - return { - type: 'provider_opaque', - id: newItemId(), - provider: input.provider, - ...(input.sourceProvider ? { sourceProvider: input.sourceProvider } : {}), - item: input.item, - }; - } -} diff --git a/rewrite/src/engine/adopted/dependencies.ts b/rewrite/src/engine/adopted/dependencies.ts deleted file mode 100644 index eecdfa593..000000000 --- a/rewrite/src/engine/adopted/dependencies.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { EngineDeps } from '../index.js'; -import type { SourceModelTransports } from './provider-transports.js'; -import type { OriginalToolCatalog } from './tool-catalog.js'; - -/** The historical engine runs source tools itself; it needs no native ToolRegistry. */ -export type AdoptedEngineDeps = Omit & { - readonly models: SourceModelTransports; - readonly tools: OriginalToolCatalog; -}; diff --git a/rewrite/src/engine/adopted/index.ts b/rewrite/src/engine/adopted/index.ts deleted file mode 100644 index 036185257..000000000 --- a/rewrite/src/engine/adopted/index.ts +++ /dev/null @@ -1,884 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { isDeepStrictEqual } from 'node:util'; -import { AgentClient } from '../../../../source/lib/agent-client.js'; -import { isHarnessInvariantError } from '../../../../source/lib/harness-invariant-error.js'; -import { waitForAbortableRead } from '../../../../source/contracts/abort-aware-wait.js'; -import { - ApplicationRunLoop, - normalizeApplicationInput, -} from '../../../../source/services/agent-runtime/application-run-loop.js'; -import type { ProviderInputItem } from '../../../../source/contracts/provider-input.js'; -import type { SteerOutcome } from '../../../../source/services/agent-runtime/application-run-loop.js'; -import type { AgentStream } from '../../../../source/services/agent-stream.js'; -import type { ApplicationRunEvent } from '../../../../source/contracts/application-stream.js'; -import type { Item, ToolCallId } from '../../model/index.js'; -import type { AdoptedEngineDeps } from './dependencies.js'; -import { convertSourceItems } from './conversion.js'; -import type { OriginalToolCatalog } from './tool-catalog.js'; -import { isLegacySettingsPair } from './settings.js'; -import type { - ApprovalDecision, - ApprovalRequest, - CheckpointId, - Engine, - EngineEvent, - TurnCheckpoint, - TurnId, - TurnInput, - TurnRun, -} from '../index.js'; - -type ConversationTurnSession = Parameters[0]; -type ConversationEvent = Parameters[0]>>[0]; -export type AdoptedTurnBridge = ConversationTurnSession & { - getAcceptedHistory(): readonly ProviderInputItem[]; - getAcceptedCompaction?(): { checkpoint: ProviderInputItem; replaced: number; index: number } | undefined; -}; - -/** A failed durability boundary must never be retried as a provider or tool failure. */ -export class FatalPersistenceError extends Error { - readonly code = 'fatal_persistence' as const; - constructor(cause: unknown) { - super('Persistence failed', { cause }); - this.name = 'FatalPersistenceError'; - } -} -export class InvalidAdoptedInputError extends Error { - constructor() { - super('Turn input does not match the current source agent'); - this.name = 'InvalidAdoptedInputError'; - } -} -export const isFatalPersistenceError = (error: unknown): boolean => error instanceof FatalPersistenceError; - -export interface AdoptedTurnControl { - /** Bridge owners must close their live post-execution and child gates when this signal aborts. */ - readonly signal: AbortSignal; - publishSessionEvent(event: ConversationEvent): void; - steer(items: readonly ProviderInputItem[], options?: { id?: string }): Promise; - retractSteer(id: string): boolean; - editSteer(id: string, items: readonly ProviderInputItem[]): boolean; - setAskUserAnswer(callId: string, answer: string): void; - park(input: { - readonly generation: number; - readonly isCurrent: () => boolean; - readonly decisionAddress: unknown; - readonly approvals: readonly ApprovalRequest[]; - readonly pendingCalls: TurnCheckpoint['pendingCalls']; - readonly validatePendingSnapshot: (calls: TurnCheckpoint['pendingCalls']) => void; - readonly validate: (decisions: ReadonlyMap) => void; - }): Promise>; -} -export interface AdoptedTurnRun extends TurnRun { - waitForSettlement(signal?: AbortSignal): Promise; -} -export interface AdoptedEngine extends Engine { - start(input: TurnInput): AdoptedTurnRun; - resume(checkpoint: TurnCheckpoint, decisions: ReadonlyMap): AdoptedTurnRun; - waitForAdmission(signal: AbortSignal): Promise; - registerTurnBridge( - turnId: TurnId, - authority: { readonly generation: number; readonly isCurrent: () => boolean }, - create: (control: AdoptedTurnControl) => AdoptedTurnBridge, - ): () => void; -} - -class Queue implements AsyncIterable { - private values: EngineEvent[] = []; - private wake?: () => void; - private ended = false; - private suspended = false; - constructor(private readonly abandon: () => void) {} - push(value: EngineEvent): void { - if (!this.ended) { - if (value.type === 'turn_ended' && value.outcome.kind === 'suspended') this.suspended = true; - this.values.push(value); - this.wake?.(); - } - } - end(): void { - this.ended = true; - this.wake?.(); - } - async *[Symbol.asyncIterator](): AsyncIterator { - let finished = false; - try { - while (true) { - if (this.values.length) { - yield this.values.shift()!; - continue; - } - if (this.ended) { - finished = true; - return; - } - await new Promise((resolve) => { - this.wake = resolve; - }); - this.wake = undefined; - } - } finally { - if (!finished && !this.suspended) this.abandon(); - } - } -} -type Registration = { - readonly authority: { readonly generation: number; readonly isCurrent: () => boolean }; - readonly create: (control: AdoptedTurnControl) => AdoptedTurnBridge; -}; -type Parked = { - checkpoint: TurnCheckpoint; - readonly generation: number; - readonly decisionAddress: unknown; - current: () => boolean; - approvals: readonly ApprovalRequest[]; - validate: (decisions: ReadonlyMap) => void; - resolve: (decisions: ReadonlyMap) => void; - reject: (error: unknown) => void; - logical: Logical; -}; -type Logical = { - input: TurnInput; - controller: AbortController; - queue: Queue; - parked?: Parked; - settled: Promise; - produced: Item[]; - segment: string; - suspended: boolean; - failure?: unknown; - finished?: boolean; - current?: () => boolean; - calls: Array<{ callId: ToolCallId; toolName: string; input: unknown }>; - modelCalls: number; - compactions: Array<{ checkpoint: ProviderInputItem; replaced: number; index: number }>; - answers: Map; - resolving: boolean; - stopAfterApprovalResolution: boolean; -}; -const aborted = () => new DOMException('The operation was aborted.', 'AbortError'); -async function waitForGate(operation: Promise, signal: AbortSignal): Promise { - void operation.catch(() => undefined); - if (signal.aborted) throw aborted(); - let onAbort: (() => void) | undefined; - try { - await Promise.race([ - operation, - new Promise((_resolve, reject) => { - onAbort = () => reject(aborted()); - signal.addEventListener('abort', onAbort, { once: true }); - if (signal.aborted) onAbort(); - }), - ]); - } finally { - if (onAbort) signal.removeEventListener('abort', onAbort); - } -} -const same = (a: unknown, b: unknown) => isDeepStrictEqual(a, b); -const grants = new Set([ - 'allow-once', - 'allow-remember', - 'unsandboxed-once', - 'allow-folder-session', - 'allow-edit-file-session', - 'allow-edit-folder-session', - 'docker-allow-once', - 'docker-allow-session', - 'docker-allow-project', -]); -function validDecision(value: ApprovalDecision): boolean { - if (!value || typeof value !== 'object') return false; - const fields = Object.keys(value); - if ('stopAfterApprovalResolution' in value && typeof value.stopAfterApprovalResolution !== 'boolean') return false; - const allowed = - value.kind === 'approve' - ? ['kind'] - : value.kind === 'reject' - ? ['kind', 'reason'] - : value.kind === 'answer' - ? ['kind', 'answer'] - : value.kind === 'grant' - ? ['kind', 'grant'] - : []; - if (!allowed.length || fields.some((field) => field !== 'stopAfterApprovalResolution' && !allowed.includes(field))) - return false; - if (value.kind === 'reject' && value.reason !== undefined && typeof value.reason !== 'string') return false; - if (value.kind === 'answer' && typeof value.answer !== 'string') return false; - if (value.kind === 'grant' && !grants.has(value.grant)) return false; - return true; -} -const failure = (message: string): EngineEvent => ({ - type: 'turn_ended', - outcome: { kind: 'failed', error: { kind: 'invalid_request', message, retryable: false } }, -}); - -/** Adapter lifecycle over the real application client. Binding of its dependencies is supplied by the adopted composition. */ -export function createAdoptedEngine(options: { - readonly deps: AdoptedEngineDeps; - readonly clientOptions: Omit< - ConstructorParameters[0], - | 'driverFactory' - | 'modelBinding' - | 'adoptedProviderBinding' - | 'assembleAgent' - | 'selectApplicationAgent' - | 'validateApplicationAgent' - | 'agentOverride' - >; - readonly assembleAgent: NonNullable[0]['assembleAgent']>; - readonly bindSessionClient?: (client: AgentClient) => void; - readonly refreshPreparedInput?: ( - input: TurnInput, - ...args: Parameters[0]['validateApplicationAgent']>> - ) => TurnInput; - readonly validateTurnInput: ( - input: TurnInput, - ...args: Parameters[0]['validateApplicationAgent']>> - ) => void; -}): AdoptedEngine { - const { deps } = options; - if ( - !isLegacySettingsPair(deps.settings, options.clientOptions.deps.settings) || - deps.models.settingsService !== options.clientOptions.deps.settings - ) { - throw new Error('Adopted engine requires one paired scoped settings service'); - } - let selectedInput: TurnInput | undefined; - let initialAgent: - | Parameters[0]['validateApplicationAgent']>> - | undefined; - const client = new AgentClient({ - ...options.clientOptions, - assembleAgent: options.assembleAgent, - validateApplicationAgent: (agent, providerId) => { - if (!selectedInput) throw new Error('No adopted turn is active'); - const logical = active.get(selectedInput.turnId); - if (!logical || logical.modelCalls === 0) { - if (initialAgent && options.refreshPreparedInput) { - selectedInput = options.refreshPreparedInput(selectedInput, agent, providerId); - if (logical) logical.input = selectedInput; - } - options.validateTurnInput(selectedInput, agent, providerId); - initialAgent = [agent, providerId]; - } - }, - modelBinding: { - hasUnsettledReads: () => deps.models.hasUnsettledReads(), - trackDetachedRead: (operation) => deps.models.trackDetachedRead(operation), - providerDefinition: (providerId) => deps.models.providerDefinition(providerId), - scopedModel: (ref, modelDeps) => deps.models.createScoped(ref, modelDeps), - acquireHelper: (ref, modelDeps) => deps.models.acquireHelper(ref, modelDeps), - fetchModels: (providerId, modelDeps) => deps.models.fetchScopedModels(providerId, modelDeps), - modelDefaultReasoningLevel: (providerId, modelId, settings) => - deps.models.modelDefaultReasoningLevel(providerId, modelId, settings), - providerDefaultReasoningLevel: (providerId) => deps.models.providerDefaultReasoningLevel(providerId), - }, - selectApplicationAgent: (agent) => { - if (!selectedInput) throw new Error('No adopted turn is active'); - let tools: ReturnType; - try { - tools = deps.tools.originals(selectedInput.toolNames); - } catch { - throw new InvalidAdoptedInputError(); - } - if (tools.some((tool) => !agent.tools.includes(tool))) throw new InvalidAdoptedInputError(); - return { ...agent, tools: [...tools] }; - }, - adoptedProviderBinding: { - provider: (providerId) => deps.models.providerDefinition(providerId), - model: (provider, model) => deps.models.original({ provider, model }), - independentModel: (provider, model) => deps.models.createIndependent({ provider, model }), - }, - driverFactory: (dependencies) => { - if (deps.models.hasUnsettledReads()) throw new Error('A prior adopted model read has not settled'); - return new ApplicationRunLoop({ - ...dependencies, - abortPendingModelReads: true, - recordBudgetStopCause: true, - onDetachedModelRead: (operation) => deps.models.trackDetachedRead(operation), - }); - }, - }); - const bridges = new Map(); - const active = new Map(); - const parked = new Map(); - const detachCompactionObserver = client.addLocalCheckpointObserver((evidence) => { - const logical = selectedInput && active.get(selectedInput.turnId); - if (logical && !logical.controller.signal.aborted) - logical.compactions.push({ checkpoint: evidence.checkpoint, replaced: evidence.replaced, index: evidence.index }); - }); - let disposed = false; - const disposalController = new AbortController(); - const cancel = (logical: Logical) => { - if (logical.controller.signal.aborted || active.get(logical.input.turnId) !== logical) return; - logical.controller.abort(); - if (logical.parked) { - const entry = logical.parked; - parked.delete(entry.checkpoint.checkpointId!); - logical.parked = undefined; - entry.reject(aborted()); - } - client.abort(); - }; - const queue = (logical: Logical) => - new Queue(() => { - if (!logical.suspended && !logical.finished) cancel(logical); - }); - const view = (logical: Logical): AdoptedTurnRun => ({ - turnId: logical.input.turnId, - events: logical.queue, - cancel: () => cancel(logical), - waitForSettlement: (signal) => (signal ? waitForAbortableRead(logical.settled, signal) : logical.settled), - }); - const invalid = (turnId: TurnId, message: string): AdoptedTurnRun => { - const q = new Queue(() => {}); - q.push(failure(message)); - q.end(); - return { - turnId, - events: q, - cancel() {}, - waitForSettlement: (signal) => (signal?.aborted ? Promise.reject(aborted()) : Promise.resolve()), - }; - }; - const engine: AdoptedEngine = { - async waitForAdmission(signal) { - const combined = AbortSignal.any([signal, disposalController.signal]); - while (true) { - combined.throwIfAborted(); - const runs = [...active.values()]; - if (runs.length) - await waitForAbortableRead( - Promise.allSettled(runs.map((run) => run.settled)).then(() => undefined), - combined, - ); - await deps.models.waitForReadSettlement(combined); - combined.throwIfAborted(); - if (!active.size && !deps.models.hasUnsettledReads()) return; - } - }, - registerTurnBridge(turnId, authority, create) { - if (disposed || bridges.has(turnId) || active.has(turnId)) - throw new Error('Turn bridge already registered or engine disposed'); - const registration = { authority, create }; - bridges.set(turnId, registration); - return () => { - if (bridges.get(turnId) === registration && !active.has(turnId)) bridges.delete(turnId); - }; - }, - start(input) { - try { - const snapshot = JSON.parse(JSON.stringify(input)) as TurnInput; - if (!same(snapshot, input)) return invalid(input.turnId, 'Turn input is not serializable'); - input = snapshot; - } catch { - return invalid(input.turnId, 'Turn input is not serializable'); - } - const registration = bridges.get(input.turnId); - if (deps.models.hasUnsettledReads()) return invalid(input.turnId, 'adopted_model_read_busy'); - if ( - disposed || - !registration || - !registration.authority.isCurrent() || - active.size > 0 || - input.budget.kind !== 'adopted' - ) - return invalid(input.turnId, 'Missing or invalid adopted turn bridge'); - try { - if (new Set(input.toolNames).size !== input.toolNames.length) throw new Error('Duplicate offered tool'); - deps.tools.originals(input.toolNames); - } catch { - return invalid(input.turnId, 'Configured tool is missing from the adopted registry'); - } - const logical = { - input, - controller: new AbortController(), - produced: [], - segment: '', - suspended: false, - calls: [], - modelCalls: 0, - compactions: [], - answers: new Map(), - resolving: false, - stopAfterApprovalResolution: false, - } as unknown as Logical; - logical.queue = queue(logical); - active.set(input.turnId, logical); - const control: AdoptedTurnControl = { - signal: logical.controller.signal, - publishSessionEvent(event) { - if ( - active.get(input.turnId) !== logical || - logical.controller.signal.aborted || - !registration.authority.isCurrent() - ) - return; - if (event.type !== 'retry') return; - const kind = event.errorKind && event.errorKind !== 'unknown' ? event.errorKind : 'provider'; - logical.queue.push({ - type: 'retrying', - attempt: event.attempt, - delayMs: event.delayMs ?? 0, - error: { - kind, - message: event.errorMessage, - retryable: true, - ...(event.retryAfterMs !== undefined ? { retryAfterMs: event.retryAfterMs } : {}), - }, - }); - }, - steer: (items, options) => - active.get(input.turnId) === logical && - registration.authority.isCurrent() && - !logical.controller.signal.aborted - ? client.steer(items, options) - : Promise.resolve('released'), - retractSteer: (id) => - active.get(input.turnId) === logical && - registration.authority.isCurrent() && - !logical.controller.signal.aborted - ? client.retractSteer(id) - : false, - editSteer: (id, items) => - active.get(input.turnId) === logical && - registration.authority.isCurrent() && - !logical.controller.signal.aborted - ? client.editSteer(id, items) - : false, - setAskUserAnswer: (callId, answer) => { - if ( - !logical.resolving || - active.get(input.turnId) !== logical || - logical.controller.signal.aborted || - !registration.authority.isCurrent() || - logical.answers.get(callId as ToolCallId) !== answer - ) - throw new Error('Ask-user answer is stale or does not match the decision'); - logical.answers.delete(callId as ToolCallId); - client.setAskUserAnswer(callId, answer); - }, - park(request) { - if ( - disposed || - active.get(input.turnId) !== logical || - logical.finished || - logical.controller.signal.aborted || - request.generation !== registration.authority.generation || - request.isCurrent !== registration.authority.isCurrent || - !request.isCurrent() || - logical.parked - ) - return Promise.reject(aborted()); - if ( - new Set(request.approvals.map((approval) => approval.callId)).size !== request.approvals.length || - request.approvals.some((approval) => { - const call = request.pendingCalls.find( - (candidate) => candidate.callId === approval.callId && candidate.toolName === approval.toolName, - ); - if (!call) return true; - return !approval.agentId && !logical.calls.some((observed) => same(observed, call)); - }) - ) - return Promise.reject(new InvalidAdoptedInputError()); - try { - request.validatePendingSnapshot(request.pendingCalls); - } catch { - return Promise.reject(new InvalidAdoptedInputError()); - } - const checkpointId = randomUUID() as CheckpointId; - const checkpoint: TurnCheckpoint = { - turnId: input.turnId, - checkpointId, - input: logical.input, - produced: [...logical.produced], - pendingCalls: request.pendingCalls, - modelCalls: logical.modelCalls, - continuation: { kind: 'adopted_live', version: 1, checkpointId }, - }; - let snapshot: TurnCheckpoint; - let approvals: ApprovalRequest[]; - try { - snapshot = JSON.parse(JSON.stringify(checkpoint)) as TurnCheckpoint; - approvals = JSON.parse(JSON.stringify(request.approvals)) as ApprovalRequest[]; - if (!same(checkpoint, snapshot) || !same(request.approvals, approvals)) - throw new Error('Non-serializable checkpoint'); - } catch { - return Promise.reject(new InvalidAdoptedInputError()); - } - return new Promise((resolve, reject) => { - const entry: Parked = { - checkpoint: snapshot, - generation: request.generation, - decisionAddress: request.decisionAddress, - current: request.isCurrent, - approvals, - validate: request.validate, - resolve, - reject, - logical, - }; - logical.parked = entry; - logical.current = request.isCurrent; - parked.set(checkpointId, entry); - logical.suspended = true; - const published = JSON.parse(JSON.stringify(snapshot)) as TurnCheckpoint; - logical.queue.push({ type: 'checkpoint', checkpoint: published }); - logical.queue.push({ - type: 'turn_ended', - outcome: { - kind: 'suspended', - approvals: JSON.parse(JSON.stringify(approvals)) as ApprovalRequest[], - checkpoint: JSON.parse(JSON.stringify(snapshot)) as TurnCheckpoint, - }, - }); - logical.queue.end(); - }); - }, - }; - let bridge: AdoptedTurnBridge; - try { - bridge = registration.create(control); - if (typeof bridge.getAcceptedHistory !== 'function') throw new Error('Missing accepted history capability'); - } catch (error) { - active.delete(input.turnId); - return invalid(input.turnId, String(error)); - } - const emit = (event: ApplicationRunEvent) => { - switch (event.type) { - case 'text_delta': - logical.queue.push({ type: 'text_delta', delta: event.text }); - break; - case 'reasoning_delta': - logical.queue.push({ type: 'reasoning_delta', delta: event.text }); - break; - case 'model_attempt_rollback': - logical.queue.push({ type: 'model_attempt_rollback', reasoningCharacters: event.reasoningCharacters }); - break; - case 'tool_call_dispatched': { - const call = logical.calls.find( - (candidate) => candidate.callId === event.callId && candidate.toolName === event.toolName, - ); - logical.queue.push({ - type: 'tool_started', - callId: event.callId as ToolCallId, - toolName: event.toolName, - input: call?.input, - }); - break; - } - case 'usage_update': - logical.queue.push({ - type: 'usage', - usage: { - inputTokens: event.usage.prompt_tokens ?? 0, - outputTokens: event.usage.completion_tokens ?? 0, - cachedInputTokens: event.usage.cache_read_tokens ?? 0, - }, - }); - break; - case 'item': { - const source = event.item; - if ( - (source.type === 'function_call' || source.type === 'custom_tool_call') && - typeof source.callId === 'string' && - typeof source.name === 'string' - ) { - const raw = source.type === 'custom_tool_call' ? source.input : source.arguments; - logical.calls.push({ - callId: source.callId as ToolCallId, - toolName: source.name, - input: raw, - }); - } - if ( - (source.type === 'function_call_result' || source.type === 'custom_tool_call_output') && - typeof source.callId === 'string' - ) { - const index = logical.calls.findIndex((call) => call.callId === source.callId); - if (index >= 0) logical.calls.splice(index, 1); - logical.queue.push({ - type: 'tool_finished', - callId: source.callId as ToolCallId, - result: { - output: typeof source.output === 'string' ? source.output : JSON.stringify(source.output) ?? '', - isError: source.isError === true, - }, - }); - } - for (const converted of convertSourceItems([source], input.model.provider)) { - logical.produced.push(converted); - logical.queue.push({ type: 'item', item: converted }); - } - break; - } - } - }; - const wrapped: ConversationTurnSession = { - approvalBatch: true, - abortPendingModelReads: true, - recordBudgetStopCause: true, - onDetachedModelRead: (operation) => deps.models.trackDetachedRead(operation), - async prepare(signal) { - if (!registration.authority.isCurrent()) { - cancel(logical); - throw aborted(); - } - const result = await bridge.prepare(signal); - if (!registration.authority.isCurrent()) { - cancel(logical); - throw aborted(); - } - return result; - }, - beginSegment(stream) { - if (!registration.authority.isCurrent() || logical.controller.signal.aborted) { - cancel(logical); - throw aborted(); - } - bridge.beginSegment(stream); - logical.segment = randomUUID(); - logical.queue.push({ type: 'segment_started', segmentId: logical.segment }); - }, - acceptEvent(event) { - if (!registration.authority.isCurrent() || logical.controller.signal.aborted) { - cancel(logical); - throw aborted(); - } - bridge.acceptEvent(event); - emit(event); - }, - onModelDispatch(event) { - if (!registration.authority.isCurrent()) { - cancel(logical); - throw aborted(); - } - if (logical.modelCalls === 0 && initialAgent) options.validateTurnInput(logical.input, ...initialAgent); - bridge.onModelDispatch?.(event); - logical.modelCalls++; - }, - acceptToolDispatch(callId) { - if (!registration.authority.isCurrent() || logical.controller.signal.aborted) { - cancel(logical); - throw aborted(); - } - bridge.acceptToolDispatch(callId); - logical.queue.push({ type: 'tool_dispatched', callId: callId as ToolCallId }); - }, - async beforeToolDispatch(callId: string) { - logical.controller.signal.throwIfAborted(); - if (!registration.authority.isCurrent()) { - cancel(logical); - throw aborted(); - } - if (logical.current && !logical.current()) throw aborted(); - if (bridge.beforeToolDispatch) { - try { - await waitForGate( - Promise.resolve().then(() => bridge.beforeToolDispatch!(callId)), - logical.controller.signal, - ); - } catch (error) { - if (logical.controller.signal.aborted) throw aborted(); - const fatal = isFatalPersistenceError(error) ? error : new FatalPersistenceError(error); - logical.failure = fatal; - cancel(logical); - throw fatal; - } - } - logical.controller.signal.throwIfAborted(); - if (!registration.authority.isCurrent()) { - cancel(logical); - throw aborted(); - } - if (logical.current && !logical.current()) throw aborted(); - }, - isFatalError: (error) => - isFatalPersistenceError(error) || error instanceof InvalidAdoptedInputError || isHarnessInvariantError(error), - acceptSegment(stream) { - if (!registration.authority.isCurrent() || logical.controller.signal.aborted) { - cancel(logical); - throw aborted(); - } - bridge.acceptSegment(stream); - if (!registration.authority.isCurrent()) { - cancel(logical); - throw aborted(); - } - const sourceHistory = bridge.getAcceptedHistory(); - const nativeCompaction = bridge.getAcceptedCompaction?.(); - if (nativeCompaction) logical.compactions.push(nativeCompaction); - const history: Item[] = []; - const compactions: Array<{ summaryId: Item['id']; replaced: number }> = []; - const matched = new Set<(typeof logical.compactions)[number]>(); - for (const [index, sourceItem] of sourceHistory.entries()) { - const converted = convertSourceItems([sourceItem], input.model.provider); - const evidence = logical.compactions.find( - (candidate) => - !matched.has(candidate) && - same(candidate.checkpoint, sourceItem) && - logical.compactions.filter((other) => same(other.checkpoint, sourceItem)).length === 1 && - (candidate.index === index || - sourceHistory.filter((item) => same(candidate.checkpoint, item)).length === 1), - ); - if (evidence && converted.length === 1) { - matched.add(evidence); - compactions.push({ summaryId: converted[0]!.id, replaced: evidence.replaced }); - } - history.push(...converted); - } - logical.compactions = logical.compactions.filter((candidate) => !matched.has(candidate)); - logical.queue.push({ - type: 'segment_committed', - segmentId: logical.segment, - history, - }); - for (const evidence of compactions) logical.queue.push({ type: 'compacted', ...evidence }); - }, - consume: (stream) => bridge.consume(stream), - async interrupted(stream) { - try { - const options = await bridge.interrupted(stream); - return logical.stopAfterApprovalResolution ? { ...options, stopAfterApprovalResolution: true } : options; - } finally { - logical.resolving = false; - logical.answers.clear(); - } - }, - async completed(stream: AgentStream) { - if (!registration.authority.isCurrent() || logical.controller.signal.aborted) { - cancel(logical); - throw aborted(); - } - await bridge.completed(stream); - if (!registration.authority.isCurrent() || logical.controller.signal.aborted) { - cancel(logical); - throw aborted(); - } - logical.finished = true; - logical.queue.push({ - type: 'turn_ended', - outcome: stream.cancelled - ? { kind: 'cancelled' } - : stream.terminalCause === 'budget_exhausted' - ? { kind: 'budget_exhausted' } - : stream.terminalCause - ? { kind: 'failed', error: { kind: 'provider', message: stream.terminalCause, retryable: false } } - : { kind: 'completed' }, - }); - }, - async recover(error) { - const retry = await bridge.recover(error); - if (retry) logical.compactions = []; - if (!retry) logical.failure = error; - return retry; - }, - }; - client.openTurn(); - selectedInput = input; - initialAgent = undefined; - logical.settled = client - .runTurn(wrapped) - .catch((error) => { - logical.failure ??= error; - }) - .finally(() => { - if (!logical.finished) - logical.queue.push( - logical.controller.signal.aborted && !isFatalPersistenceError(logical.failure) - ? { type: 'turn_ended', outcome: { kind: 'cancelled' } } - : logical.failure instanceof InvalidAdoptedInputError - ? failure(logical.failure.message) - : { - type: 'turn_ended', - outcome: { kind: 'failed', error: { kind: 'provider', message: 'Turn failed', retryable: false } }, - }, - ); - logical.queue.end(); - client.closeTurn(); - selectedInput = undefined; - initialAgent = undefined; - active.delete(input.turnId); - bridges.delete(input.turnId); - }); - return view(logical); - }, - resume(checkpoint, decisions) { - const entry = checkpoint.checkpointId && parked.get(checkpoint.checkpointId); - if (!entry) return invalid(checkpoint.turnId, 'checkpoint_unavailable'); - const registration = bridges.get(entry.logical.input.turnId); - if ( - !registration || - registration.authority.generation !== entry.generation || - !same(checkpoint, entry.checkpoint) || - !entry.current() || - entry.logical.controller.signal.aborted - ) - return invalid(checkpoint.turnId, 'Invalid checkpoint'); - const ids = new Set(entry.approvals.map((a) => a.callId)); - if ( - decisions.size !== ids.size || - [...decisions].some( - ([id, decision]) => - !ids.has(id) || - !validDecision(decision) || - (decision.kind === 'answer' && - !entry.approvals.some((approval) => approval.callId === id && approval.toolName === 'ask_user')), - ) - ) - return invalid(checkpoint.turnId, 'Invalid decision batch'); - try { - entry.validate(decisions); - } catch { - return invalid(checkpoint.turnId, 'Invalid decision batch'); - } - for (const approval of entry.approvals) { - const decision = decisions.get(approval.callId); - if (decision?.stopAfterApprovalResolution) entry.logical.stopAfterApprovalResolution = true; - if (!approval.agentId && approval.toolName === 'ask_user' && decision?.kind === 'answer') - entry.logical.answers.set(approval.callId, decision.answer); - } - parked.delete(checkpoint.checkpointId!); - entry.logical.parked = undefined; - entry.logical.suspended = false; - entry.logical.resolving = true; - entry.logical.queue = queue(entry.logical); - entry.resolve(new Map([...decisions].map(([id, decision]) => [id, { ...decision }]))); - return view(entry.logical); - }, - async dispose() { - disposed = true; - disposalController.abort(); - const runs = [...active.values()]; - for (const run of runs) cancel(run); - await Promise.allSettled(runs.map((run) => run.settled)); - bridges.clear(); - detachCompactionObserver(); - client.dispose(); - }, - }; - try { - const result: unknown = options.bindSessionClient?.(client); - if ( - result != null && - (typeof result === 'object' || typeof result === 'function') && - typeof (result as { then?: unknown }).then === 'function' - ) { - void Promise.resolve(result).catch(() => {}); - throw new TypeError('bindSessionClient must be synchronous'); - } - } catch (error) { - try { - detachCompactionObserver(); - } catch { - /* Preserve the binding error. */ - } - try { - client.dispose(); - } catch { - /* Preserve the binding error. */ - } - throw error; - } - return engine; -} diff --git a/rewrite/src/engine/adopted/provider-transports.ts b/rewrite/src/engine/adopted/provider-transports.ts deleted file mode 100644 index 7fbddd36b..000000000 --- a/rewrite/src/engine/adopted/provider-transports.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { ContextCompactionSessionState } from '../../../../source/contracts/streamed-model-turn.js'; -import type { - ILoggingService, - ISettingsService, - ISessionContextService, -} from '../../../../source/services/service-interfaces.js'; -import type { ProviderDefinition, ProviderRegistry } from '../../../../source/providers/registry.js'; -import type { ProviderRequestCapture } from '../../../../source/providers/provider-request-capture.js'; -import type { ProviderRetryInfo } from '../../../../source/providers/retrying-model.js'; -import { waitForAbortableRead } from '../../../../source/contracts/abort-aware-wait.js'; -import { createProviderRegistry, getProvider } from '../../../../source/providers/index.js'; -import { - fetchModels as sourceFetchModels, - getModelDefaultReasoningLevel, - getProviderDefaultReasoningLevel, - type ScopedModelDiscovery, -} from '../../../../source/services/model-service.js'; -import type { StreamedModelTurn } from '../../../../source/contracts/streamed-model-turn.js'; -import type { ModelRef } from '../../model/index.js'; - -export interface SourceModelTransports { - waitForReadSettlement(signal: AbortSignal): Promise; - readonly settingsService: ISettingsService; - hasUnsettledReads(): boolean; - trackDetachedRead(operation: Promise): void; - original(ref: ModelRef): StreamedModelTurn | Promise; - createIndependent(ref: ModelRef): StreamedModelTurn | Promise; - acquireHelper( - ref: ModelRef, - deps: { - readonly settingsService: ISettingsService; - readonly loggingService: ILoggingService; - readonly sessionContextService?: ISessionContextService; - }, - ): { readonly original: StreamedModelTurn | Promise; release(): Promise }; - providerDefinition(providerId: string): ProviderDefinition; - fetchScopedModels( - providerId: string, - deps: { - readonly settingsService: ISettingsService; - readonly loggingService: ILoggingService; - readonly signal?: AbortSignal; - }, - ): Promise; - modelDefaultReasoningLevel( - providerId: string, - modelId: string, - settingsService?: ISettingsService, - ): string | undefined; - providerDefaultReasoningLevel(providerId: string): string | undefined; - createScoped( - ref: ModelRef, - deps: { - readonly settingsService: ISettingsService; - readonly loggingService: ILoggingService; - readonly sessionContextService?: ISessionContextService; - }, - ): StreamedModelTurn | Promise; - dispose(): Promise; -} - -export interface SourceModelTransportsOptions { - readonly settingsService: ISettingsService; - readonly loggingService: ILoggingService; - readonly sessionContextService?: ISessionContextService; - readonly retryAttempts?: number; - readonly requestCapture?: ProviderRequestCapture; - readonly contextCompactionSessionState?: ContextCompactionSessionState; - readonly providerRegistry?: ProviderRegistry; - readonly ownership?: 'owned' | 'borrowed'; - readonly onRetry?: (info?: ProviderRetryInfo) => void; -} - -const registryOptions = new WeakMap(); - -/** Fork the source transport pool with independent lifetime and settings-scoped discovery. */ -export function forkSourceModelTransports( - registry: SourceModelTransports, - settingsService: ISettingsService, -): SourceModelTransports { - const options = registryOptions.get(registry); - if (!options) throw new Error('Expected adopted model registry'); - return createSourceModelTransports({ ...options, settingsService }); -} - -/** Own source transport identities and their deferred disposal for a historical engine graph. */ -export function createSourceModelTransports(options: SourceModelTransportsOptions): SourceModelTransports { - const providerRegistry = options.providerRegistry ?? createProviderRegistry(); - const compactionState = options.contextCompactionSessionState ?? { disabled: false }; - const ownership = options.ownership ?? 'owned'; - const bindings = new Map>(); - const workerIdentities = new Map(); - const helperIdentities = new Map(); - const pendingIdentities = new Set>(); - const closedIdentities = new WeakSet(); - const pendingCloses = new Set>(); - const unsettledReads = new Set>(); - const discoveryContexts = new WeakMap(); - let disposalRequested = false; - const disposalController = new AbortController(); - const assertActive = () => { - if (disposalRequested) throw new Error('Model registry is disposed'); - }; - const closeOnce = (model: StreamedModelTurn) => { - if (closedIdentities.has(model)) return; - closedIdentities.add(model); - const closing = Promise.resolve().then(() => - ( - model as StreamedModelTurn & { - close?: () => unknown; - } - ).close?.(), - ); - pendingCloses.add(closing); - void closing.then( - () => pendingCloses.delete(closing), - () => pendingCloses.delete(closing), - ); - }; - const reconcileOwnership = () => { - if (!disposalRequested || pendingIdentities.size || unsettledReads.size) return; - for (const [model, worker] of workerIdentities) - if (worker.owned && !worker.borrowed && (helperIdentities.get(model) ?? 0) === 0) closeOnce(model); - for (const [model, count] of helperIdentities) if (count === 0 && !workerIdentities.has(model)) closeOnce(model); - }; - const trackIdentity = (operation: Promise, accept: (value: T) => void) => { - pendingIdentities.add(operation); - void operation.then( - (value) => { - pendingIdentities.delete(operation); - accept(value); - reconcileOwnership(); - }, - () => { - pendingIdentities.delete(operation); - reconcileOwnership(); - }, - ); - }; - const providerDefinition = (providerId: string): ProviderDefinition => { - const provider = getProvider(providerId, providerRegistry); - if (!provider) throw new Error(`Provider '${providerId}' is not registered.`); - return provider; - }; - const discoveryFor = (settings: ISettingsService): ScopedModelDiscovery => { - assertActive(); - let context = discoveryContexts.get(settings); - if (!context) { - context = { providerDefinition, cache: new Map() }; - discoveryContexts.set(settings, context); - } - return context; - }; - const makeBinding = ( - ref: ModelRef, - independent: boolean, - scoped?: { - readonly settingsService: ISettingsService; - readonly loggingService: ILoggingService; - readonly sessionContextService?: ISessionContextService; - }, - ): StreamedModelTurn | Promise => { - assertActive(); - const provider = getProvider(ref.provider, providerRegistry); - if (!provider?.createStreamedModel) - throw new Error(`Provider '${ref.provider}' does not expose an application streamed model.`); - const created = provider.createStreamedModel(ref.model, { - settingsService: scoped?.settingsService ?? options.settingsService, - loggingService: scoped?.loggingService ?? options.loggingService, - ...(scoped - ? { ...(scoped.sessionContextService ? { sessionContextService: scoped.sessionContextService } : {}) } - : independent - ? {} - : { - sessionContextService: options.sessionContextService, - onRetry: options.onRetry, - retryAttempts: options.retryAttempts, - requestCapture: options.requestCapture, - contextCompactionSessionState: compactionState, - }), - }); - const assertOpen = (model: StreamedModelTurn) => { - if (closedIdentities.has(model)) throw new Error('Provider returned a released model transport'); - return model; - }; - const original = created instanceof Promise ? created.then(assertOpen) : assertOpen(created); - return original; - }; - const bindingFor = (ref: ModelRef) => { - assertActive(); - const key = `${ref.provider}\u0000${ref.model}`; - const existing = bindings.get(key); - if (existing) return existing; - const binding = makeBinding(ref, false); - bindings.set(key, binding); - trackIdentity(Promise.resolve(binding), (model) => { - const previous = workerIdentities.get(model) ?? { owned: false, borrowed: false }; - workerIdentities.set(model, { - owned: previous.owned || ownership === 'owned', - borrowed: previous.borrowed || ownership === 'borrowed', - }); - }); - if (binding instanceof Promise) - void binding.catch(() => { - if (bindings.get(key) === binding) bindings.delete(key); - }); - return binding; - }; - const registry: SourceModelTransports = { - settingsService: options.settingsService, - async waitForReadSettlement(signal) { - assertActive(); - const combined = AbortSignal.any([signal, disposalController.signal]); - combined.throwIfAborted(); - while (unsettledReads.size) { - await waitForAbortableRead( - Promise.allSettled([...unsettledReads]).then(() => undefined), - combined, - ); - assertActive(); - } - combined.throwIfAborted(); - }, - hasUnsettledReads: () => unsettledReads.size > 0, - trackDetachedRead(operation) { - if (unsettledReads.has(operation)) return; - unsettledReads.add(operation); - void operation.then( - () => { - unsettledReads.delete(operation); - reconcileOwnership(); - }, - () => { - unsettledReads.delete(operation); - reconcileOwnership(); - }, - ); - }, - original: bindingFor, - createIndependent: (ref) => makeBinding(ref, true), - acquireHelper(ref, deps) { - const binding = makeBinding(ref, true, deps); - const operation = Promise.resolve(binding); - let released = false; - let model: StreamedModelTurn | undefined; - trackIdentity(operation, (resolved) => { - model = resolved; - if (!released) helperIdentities.set(resolved, (helperIdentities.get(resolved) ?? 0) + 1); - else if (!helperIdentities.has(resolved)) helperIdentities.set(resolved, 0); - }); - return { - original: binding, - async release() { - if (released) return; - released = true; - if (model) helperIdentities.set(model, Math.max(0, (helperIdentities.get(model) ?? 1) - 1)); - reconcileOwnership(); - await Promise.allSettled([...pendingCloses]); - }, - }; - }, - createScoped: (ref, deps) => { - const binding = makeBinding(ref, true, deps); - trackIdentity(Promise.resolve(binding), (model) => { - const previous = workerIdentities.get(model) ?? { owned: false, borrowed: false }; - workerIdentities.set(model, { ...previous, borrowed: true }); - }); - return binding; - }, - providerDefinition, - fetchScopedModels: (providerId, deps) => - sourceFetchModels({ ...deps, discovery: discoveryFor(deps.settingsService) }, providerId), - modelDefaultReasoningLevel: (providerId, modelId, settings = options.settingsService) => - getModelDefaultReasoningLevel(providerId, modelId, discoveryFor(settings)), - providerDefaultReasoningLevel: (providerId) => - getProviderDefaultReasoningLevel(providerId, discoveryFor(options.settingsService)), - async dispose() { - disposalRequested = true; - disposalController.abort(); - bindings.clear(); - reconcileOwnership(); - await Promise.allSettled([...pendingCloses]); - }, - }; - registryOptions.set(registry, options); - return registry; -} diff --git a/rewrite/src/engine/adopted/settings.ts b/rewrite/src/engine/adopted/settings.ts deleted file mode 100644 index da140a1fe..000000000 --- a/rewrite/src/engine/adopted/settings.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { ISettingsService } from '../../../../source/services/service-interfaces.js'; -import type { SettingsService } from '../../../../source/services/settings/settings-service.js'; -import type { - RuntimeSettingMutation, - RuntimeSettingsSnapshot, -} from '../../../../source/services/settings/session-runtime-settings.js'; -import type { SettingKey, SettingValue, SettingsReader, SettingsScope, SettingsWriter } from '../../config/index.js'; -import { createScopedSettings, type SettingsWriteMode } from '../../config/scoped-settings.js'; - -type LegacySettingsBinding = { - readonly settings: SettingsWriter; - readonly service: ISettingsService; - readonly setRuntimeOverride: (mutation: RuntimeSettingMutation) => void; -}; -export interface LegacySettingsSource { - forSession(scope: SettingsScope, overrides?: ReadonlyMap): SettingsWriter; - forSessionBinding( - scope: SettingsScope, - overrides?: ReadonlyMap, - runtimeInitial?: RuntimeSettingsSnapshot, - ): LegacySettingsBinding; -} -const pairedServices = new WeakMap(); -const bindingOrigins = new WeakMap< - SettingsReader, - { - source: LegacySettingsSource; - scope: SettingsScope; - overrides: ReadonlyMap; - } ->(); - -/** Historical engine identity only; native config has no knowledge of source services. */ -export function isLegacySettingsPair(settings: SettingsReader, service: ISettingsService): boolean { - return pairedServices.get(settings) === service; -} - -/** Rebind one historical session from its original scope, not another session's overlays. */ -export function forkLegacySettingsBinding( - settings: SettingsReader, - service: ISettingsService, - initial: RuntimeSettingsSnapshot, -): LegacySettingsBinding { - const origin = bindingOrigins.get(settings); - if (!origin || pairedServices.get(settings) !== service) throw new Error('Expected paired adopted settings'); - return origin.source.forSessionBinding(origin.scope, origin.overrides, initial); -} - -/** Translates the source engine's settings protocol onto native scoped settings. - * Runtime snapshot/mutation and paired-service identity remain historical concerns. - */ -export function createLegacySettingsSource( - host: SettingsService, - writeMode: SettingsWriteMode = 'persistent', -): LegacySettingsSource { - const source: LegacySettingsSource = { - forSession(scope, overrides) { - return source.forSessionBinding(scope, overrides).settings; - }, - forSessionBinding(scope, overrides = new Map(), runtimeInitial) { - const values = new Map(overrides); - if (runtimeInitial) { - values.set('agent.modelSelection' as SettingKey, runtimeInitial.selection); - values.set('agent.reasoningEffort' as SettingKey, runtimeInitial.reasoningEffort); - values.set('agent.temperature' as SettingKey, runtimeInitial.temperature); - } - const view = createScopedSettings( - { - read: (key) => host.getDynamic(key) as SettingValue | undefined, - write(key, value, mode) { - if (mode === 'runtime') { - host.setDynamic(key, value, { persist: false }); - return; - } - const result = host.setPersistentDynamic(key, value); - if (result.status !== 'saved') throw new Error(`Setting '${key}' was not persisted (${result.reason})`); - }, - watch: (listener) => host.onChange(listener), - }, - scope, - values, - writeMode, - ); - const write = (key: string, value: SettingValue, options?: { persist?: boolean }) => - view.write(key as SettingKey, value, options?.persist === false ? 'runtime' : writeMode); - const service = { - get: view.readPath, - getDynamic: view.readPath, - getSource: (key: string) => { - const origin = view.origin(key); - return origin === 'hidden' ? 'default' : origin === 'override' ? 'cli' : host.getSource(key as never); - }, - set: write, - setDynamic: write, - setPersistent: (key: string, value: SettingValue) => view.write(key as SettingKey, value, 'persistent'), - setPersistentDynamic: (key: string, value: SettingValue) => view.write(key as SettingKey, value, 'persistent'), - onChange: (listener: (key?: string) => void) => - view.observe((keys) => listener(keys.length === 1 ? keys[0] : undefined)), - } as ISettingsService; - pairedServices.set(view.settings, service); - bindingOrigins.set(view.settings, { - source, - scope: { readable: [...scope.readable], writable: [...scope.writable] }, - overrides: new Map([...overrides].map(([key, value]) => [key, structuredClone(value)])), - }); - return { - settings: view.settings, - service, - setRuntimeOverride(mutation) { - if (!runtimeInitial) throw new Error('Not a runtime-owned settings binding'); - const [key, value] = - mutation.kind === 'model' - ? ['agent.modelSelection', mutation.selection] - : mutation.kind === 'reasoning' - ? ['agent.reasoningEffort', mutation.effort] - : ['agent.temperature', mutation.temperature]; - view.replaceOverride(key as SettingKey, value as SettingValue | undefined); - // The source client setter owns the only refresh for this change. - }, - }; - }, - }; - return source; -} diff --git a/rewrite/src/engine/adopted/tool-catalog.ts b/rewrite/src/engine/adopted/tool-catalog.ts deleted file mode 100644 index 01b4ed18b..000000000 --- a/rewrite/src/engine/adopted/tool-catalog.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AnyToolDefinition } from '../../../../source/tools/types.js'; - -/** Source definitions used by the historical engine, not native executable tools. */ -export type OriginalToolCatalog = { - original(name: string): AnyToolDefinition | undefined; - /** Resolve configured offered names in their supplied offering order. */ - originals(names: readonly string[]): readonly AnyToolDefinition[]; - replaceGeneration(definitions: readonly AnyToolDefinition[]): void; -}; - -/** Preserve source identity and offering order; reject duplicate generations atomically. */ -export function createOriginalToolCatalog(definitions: readonly AnyToolDefinition[]): OriginalToolCatalog { - let originals = new Map(); - const replaceGeneration = (next: readonly AnyToolDefinition[]) => { - const nextOriginals = new Map(); - for (const definition of next) { - if (nextOriginals.has(definition.name)) throw new Error(`Duplicate assembled tool name: ${definition.name}`); - nextOriginals.set(definition.name, definition); - } - originals = nextOriginals; - }; - replaceGeneration(definitions); - return { - original: (name) => originals.get(name), - replaceGeneration, - originals: (names) => - names.map((name) => { - const definition = originals.get(name); - if (!definition) throw new Error(`Configured tool "${name}" is missing from the assembled tool registry`); - return definition; - }), - }; -} diff --git a/rewrite/src/engine/loop.ts b/rewrite/src/engine/loop.ts deleted file mode 100644 index c31bb80a4..000000000 --- a/rewrite/src/engine/loop.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { Item, ItemId, ToolCallId } from '../model/index.js'; -import type { ToolContext, ToolResult } from '../tools/index.js'; -import type { - ApprovalDecision, - ApprovalRequest, - CreateEngine, - EngineDeps, - EngineEvent, - TurnCheckpoint, - TurnInput, - TurnRun, -} from './index.js'; - -type PendingCall = TurnCheckpoint['pendingCalls'][number]; - -let nextId = 0; -const itemId = (): ItemId => `item_${Date.now().toString(36)}_e${(nextId++).toString(36)}` as ItemId; - -/** - * The turn loop: call the model, run the tool calls it asks for, repeat. - * - * This compatibility loop is used by the native tracer, not the direct runtime. - * Its EngineEvent contract has no argument-stream event; the direct turn driver - * in turn-model.ts forwards that provider progress on the direct live stream. - * - * Every step boundary emits a `checkpoint`. A tool that needs approval ends - * the run as `suspended`; `resume` picks up from that checkpoint with the - * decisions. Nothing here calls into a UI or writes storage. - */ -export const createEngine: CreateEngine = (deps) => { - const controllers = new Set(); - return { - start: (input) => - run(deps, { turnId: input.turnId, input, produced: [], pendingCalls: [], modelCalls: 0 }, new Map(), controllers), - resume: (checkpoint, decisions) => run(deps, checkpoint, decisions, controllers), - dispose: async () => { - for (const controller of controllers) controller.abort(); - controllers.clear(); - }, - }; -}; - -function run( - deps: EngineDeps, - from: TurnCheckpoint, - decisions: ReadonlyMap, - controllers: Set, -): TurnRun { - const controller = new AbortController(); - controllers.add(controller); - return { - turnId: from.turnId, - events: (async function* () { - try { - yield* loop(deps, from, decisions, controller.signal); - } finally { - controllers.delete(controller); - } - })(), - cancel: () => controller.abort(), - }; -} - -async function* loop( - deps: EngineDeps, - from: TurnCheckpoint, - decisions: ReadonlyMap, - signal: AbortSignal, -): AsyncGenerator { - const { input } = from; - if (input.budget.kind === 'adopted') { - yield { - type: 'turn_ended', - outcome: { - kind: 'failed', - error: { kind: 'invalid_request', message: 'Adopted budget requires the adopted engine', retryable: false }, - }, - }; - return; - } - const produced: Item[] = [...from.produced]; - let pending: PendingCall[] = [...from.pendingCalls]; - let modelCalls = from.modelCalls; - let continuation = from.continuation; - const checkpoint = (): TurnCheckpoint => ({ - turnId: input.turnId, - input, - produced: [...produced], - pendingCalls: [...pending], - modelCalls, - ...(continuation === undefined ? {} : { continuation }), - }); - - while (true) { - if (signal.aborted) return yield { type: 'turn_ended', outcome: { kind: 'cancelled' } }; - - if (pending.length > 0) { - const ask = await askFor(deps, input, pending, decisions, signal); - if (signal.aborted) return yield { type: 'turn_ended', outcome: { kind: 'cancelled' } }; - if (ask.length > 0) { - return yield { type: 'turn_ended', outcome: { kind: 'suspended', approvals: ask, checkpoint: checkpoint() } }; - } - for (const call of [...pending]) { - yield { type: 'tool_started', callId: call.callId, toolName: call.toolName, input: call.input }; - // Progress is buffered until the tool returns; live streaming needs a channel, not a generator. - const progress: EngineEvent[] = []; - const result = await executeCall(deps, input, call, decisions.get(call.callId), signal, (chunk) => { - progress.push({ type: 'tool_progress', callId: call.callId, chunk }); - }); - if (signal.aborted) return yield { type: 'turn_ended', outcome: { kind: 'cancelled' } }; - yield* progress; - yield { type: 'tool_finished', callId: call.callId, result }; - const item: Item = { - type: 'tool_result', - id: itemId(), - callId: call.callId, - output: result.output, - isError: result.isError, - ...(result.providerOutput ? { providerOutput: result.providerOutput } : {}), - }; - produced.push(item); - pending = pending.filter((candidate) => candidate.callId !== call.callId); - yield { type: 'item', item }; - yield { type: 'checkpoint', checkpoint: checkpoint() }; - } - } - - if (modelCalls >= input.budget.maxModelCalls) { - return yield { type: 'turn_ended', outcome: { kind: 'budget_exhausted' } }; - } - - const model = deps.models.resolve(input.model); - const tools = input.toolNames.flatMap((name) => { - const tool = deps.tools.get(name); - return tool ? [tool.spec] : []; - }); - modelCalls += 1; - try { - for await (const event of model.stream( - { - model: input.model, - instructions: input.instructions, - input: [...input.history, ...input.newItems, ...produced], - tools, - ...(input.reasoning ? { reasoning: input.reasoning } : {}), - ...(continuation === undefined ? {} : { continuation }), - }, - signal, - )) { - switch (event.type) { - case 'item': - produced.push(event.item); - if (event.item.type === 'tool_call') { - pending.push({ callId: event.item.callId, toolName: event.item.toolName, input: event.item.input }); - } - yield { type: 'item', item: event.item }; - break; - case 'done': - continuation = event.continuation; - break; - case 'codex_quota': - case 'tool_call_streaming_delta': - // Not part of the tracer's compatibility EngineEvent contract. - break; - default: - yield event; - } - } - } catch (error) { - if (signal.aborted) return yield { type: 'turn_ended', outcome: { kind: 'cancelled' } }; - const message = error instanceof Error ? error.message : String(error); - return yield { - type: 'turn_ended', - outcome: { kind: 'failed', error: { kind: 'provider', message, retryable: false } }, - }; - } - - if (pending.length === 0) return yield { type: 'turn_ended', outcome: { kind: 'completed' } }; - yield { type: 'checkpoint', checkpoint: checkpoint() }; - } -} - -function toolContext(deps: EngineDeps, input: TurnInput): Omit { - return { - workspace: input.workspace, - settings: deps.settings, - runNested: async () => { - throw new Error('Nested runs are unavailable'); - }, - }; -} - -async function askFor( - deps: EngineDeps, - input: TurnInput, - pending: readonly PendingCall[], - decisions: ReadonlyMap, - signal: AbortSignal, -): Promise { - const ctx = { ...toolContext(deps, input), signal, progress: () => {} }; - const requests: ApprovalRequest[] = []; - for (const call of pending) { - if (decisions.has(call.callId)) continue; - const tool = deps.tools.get(call.toolName); - if (!tool) continue; - let requirement; - try { - requirement = await tool.approval(tool.parse(call.input), ctx); - } catch { - continue; - } - if (requirement.kind === 'ask') requests.push({ callId: call.callId, toolName: call.toolName, requirement }); - } - return requests; -} - -async function executeCall( - deps: EngineDeps, - input: TurnInput, - call: PendingCall, - decision: ApprovalDecision | undefined, - signal: AbortSignal, - progress: (chunk: string) => void, -): Promise { - const tool = deps.tools.get(call.toolName); - if (!tool) return { output: `unknown tool "${call.toolName}"`, isError: true }; - if (decision?.kind === 'reject') { - return { output: `the user rejected this call${decision.reason ? `: ${decision.reason}` : ''}`, isError: true }; - } - const ctx: ToolContext = { ...toolContext(deps, input), signal, progress }; - try { - const parsed = tool.parse(call.input); - const requirement = await tool.approval(parsed, ctx); - if (signal.aborted) return { output: 'Operation aborted', isError: true }; - if (requirement.kind === 'deny') return { output: `denied: ${requirement.reason}`, isError: true }; - return await tool.execute(parsed, ctx); - } catch (error) { - return { output: error instanceof Error ? error.message : String(error), isError: true }; - } -} From e3e27db08588df63888207edbab14c6e123c78f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:06:51 +0000 Subject: [PATCH 07/17] refactor(rewrite): delete scoped settings, the scripted model and notes tool config/scoped-settings.ts, config/memory.ts, model/scripted.ts and tools/notes.ts were reached only by deleted benches and the tracer fixture. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/reachability-allowlist.json | 6 +- rewrite/src/config/memory.ts | 10 - rewrite/src/config/scoped-settings.ts | 196 -------------------- rewrite/src/model/scripted.ts | 68 ------- rewrite/src/tools/notes.ts | 67 ------- 5 files changed, 1 insertion(+), 346 deletions(-) delete mode 100644 rewrite/src/config/memory.ts delete mode 100644 rewrite/src/config/scoped-settings.ts delete mode 100644 rewrite/src/model/scripted.ts delete mode 100644 rewrite/src/tools/notes.ts diff --git a/rewrite/scripts/reachability-allowlist.json b/rewrite/scripts/reachability-allowlist.json index 14a4e61db..033a463fe 100644 --- a/rewrite/scripts/reachability-allowlist.json +++ b/rewrite/scripts/reachability-allowlist.json @@ -1,7 +1,3 @@ { - "rewrite/src/config/memory.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/config/scoped-settings.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/model/scripted.ts": "Pending deletion: reached only by the bench-only bridge path (#139).", - "rewrite/src/session/direct-import.ts": "Offline v3 conversation migration (scripts/conversation-migration/migration-command.mjs) imports its emitted module; no CLI command reaches it.", - "rewrite/src/tools/notes.ts": "Pending deletion: reached only by the bench-only bridge path (#139)." + "rewrite/src/session/direct-import.ts": "Offline v3 conversation migration (scripts/conversation-migration/migration-command.mjs) imports its emitted module; no CLI command reaches it." } diff --git a/rewrite/src/config/memory.ts b/rewrite/src/config/memory.ts deleted file mode 100644 index fe7b2fa29..000000000 --- a/rewrite/src/config/memory.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SettingKey, SettingValue, SettingsChange, SettingsReader } from './index.js'; - -/** Fixed in-memory settings. Enough for the tracer; no persistence or scoping. */ -export function memorySettings(values: Readonly> = {}): SettingsReader { - const map = new Map(Object.entries(values)); - return { - get: (key: SettingKey) => map.get(key), - watch: (_listener: (change: SettingsChange) => void) => () => {}, - }; -} diff --git a/rewrite/src/config/scoped-settings.ts b/rewrite/src/config/scoped-settings.ts deleted file mode 100644 index ea858b9ce..000000000 --- a/rewrite/src/config/scoped-settings.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { isDeepStrictEqual } from 'node:util'; -import type { SettingKey, SettingValue, SettingsChange, SettingsScope, SettingsWriter } from './index.js'; - -export type SettingsWriteMode = 'persistent' | 'runtime'; - -/** Storage operations only: the owner validates/persists writes and throws on refusal. */ -export interface SettingsStorage { - read(key: string): SettingValue | undefined; - write(key: SettingKey, value: SettingValue, mode: SettingsWriteMode): void; - watch(listener: () => void): () => void; -} - -/** A copied capability scope over storage, with isolated overlays and passive observers. - * Exact-key reads implement SettingsWriter; path reads project authorized descendants. - * Silent overlay replacement is reserved for an owner that performs its own refresh. - */ -export function createScopedSettings( - storage: SettingsStorage, - scope: SettingsScope, - overrides: ReadonlyMap = new Map(), - writeMode: SettingsWriteMode = 'persistent', -) { - const readable = new Set(scope.readable); - const writable = new Set(scope.writable); - // Each session owns its own copy. A caller can safely reuse or mutate its - // input map after this point without changing another session's view. - const sessionOverrides = new Map( - [...overrides].map(([key, value]) => [key, snapshot(value)]), - ); - const watchers = new Set<{ - readonly listener: (change: SettingsChange) => void; - readonly lastSeen: Map; - }>(); - const pathWatchers = new Set<{ - readonly listener: (keys: readonly SettingKey[]) => void; - readonly lastSeen: Map; - }>(); - - const beneath = (child: string, parent: string) => child.startsWith(`${parent}.`); - const directlyAuthorized = (key: string) => [...readable].some((allowed) => key === allowed || beneath(key, allowed)); - const sourceGet = (key: string): SettingValue | undefined => { - const authorized = directlyAuthorized(key); - const descendants = [...readable].filter((allowed) => beneath(allowed, key)); - if (!authorized && descendants.length === 0) return undefined; - let value: unknown = authorized ? snapshot(storage.read(key) as SettingValue | undefined) : undefined; - const ancestors = [...sessionOverrides.entries()] - .filter(([path]) => key === path || beneath(key, path)) - .sort(([left], [right]) => left.length - right.length); - for (const [path, override] of ancestors) value = nestedValue(override, key.slice(path.length).replace(/^\./, '')); - if (!authorized) { - value = {}; - for (const path of descendants) { - const child = sourceGet(path); - if (child !== undefined) value = withNestedValue(value, path.slice(key.length + 1), child); - } - } else { - const children = [...sessionOverrides.entries()] - .filter(([path]) => beneath(path, key) && directlyAuthorized(path)) - .sort(([left], [right]) => left.length - right.length); - for (const [path, override] of children) value = withNestedValue(value, path.slice(key.length + 1), override); - } - return snapshot(value as SettingValue | undefined); - }; - const get = (key: SettingKey): SettingValue | undefined => (readable.has(key) ? sourceGet(key) : undefined); - - const notify = (watcher: { - readonly listener: (change: SettingsChange) => void; - readonly lastSeen: Map; - }): void => { - for (const key of readable) { - const previous = watcher.lastSeen.get(key); - const next = snapshot(get(key)); - if (isDeepStrictEqual(previous, next)) continue; - watcher.lastSeen.set(key, next); - try { - watcher.listener({ key, previous, next }); - } catch { - /* Settings observers are passive. */ - } - } - }; - - const watch = (listener: (change: SettingsChange) => void): (() => void) => { - const lastSeen = new Map(); - for (const key of readable) lastSeen.set(key, snapshot(get(key))); - const watcher = { listener, lastSeen }; - watchers.add(watcher); - const unsubscribeService = storage.watch(() => notify(watcher)); - return () => { - watchers.delete(watcher); - unsubscribeService(); - }; - }; - - const notifyPaths = (watcher: { - readonly listener: (keys: readonly SettingKey[]) => void; - readonly lastSeen: Map; - }): void => { - const changed: SettingKey[] = []; - for (const key of readable) { - const next = snapshot(get(key)); - if (isDeepStrictEqual(watcher.lastSeen.get(key), next)) continue; - watcher.lastSeen.set(key, next); - changed.push(key); - } - if (changed.length) { - try { - watcher.listener(changed); - } catch { - /* Path observers are passive. */ - } - } - }; - const observe = (listener: (keys: readonly SettingKey[]) => void): (() => void) => { - const lastSeen = new Map(); - for (const key of readable) lastSeen.set(key, snapshot(get(key))); - const watcher = { listener, lastSeen }; - pathWatchers.add(watcher); - const unsubscribe = storage.watch(() => notifyPaths(watcher)); - return () => { - pathWatchers.delete(watcher); - unsubscribe(); - }; - }; - - const setSync = (key: SettingKey, value: SettingValue, mode: SettingsWriteMode = writeMode): void => { - if (!writable.has(key)) throw new Error(`Setting '${key}' is not writable in this session`); - if ([...sessionOverrides.keys()].some((path) => path === key || beneath(key, path))) { - sessionOverrides.set(key, snapshot(value)!); - for (const watcher of watchers) notify(watcher); - for (const watcher of pathWatchers) notifyPaths(watcher); - return; - } - - storage.write(key, value, mode); - }; - - const settings: SettingsWriter = { - get, - watch, - async set(key, value) { - setSync(key, value); - }, - }; - return { - settings, - readPath: sourceGet, - write: setSync, - observe, - origin(key: string): 'hidden' | 'override' | 'storage' { - if (!directlyAuthorized(key) && ![...readable].some((path) => beneath(path, key))) return 'hidden'; - return [...sessionOverrides.keys()].some( - (path) => path === key || beneath(key, path) || (beneath(path, key) && directlyAuthorized(path)), - ) - ? 'override' - : 'storage'; - }, - replaceOverride(key: SettingKey, value: SettingValue | undefined): void { - sessionOverrides.set(key, snapshot(value)); - }, - }; -} - -function snapshot(value: SettingValue | undefined): SettingValue | undefined { - return value !== undefined && typeof value === 'object' && value !== null ? structuredClone(value) : value; -} - -function nestedValue(value: unknown, path: string): unknown { - if (!path) return value; - return path - .split('.') - .reduce( - (current, part) => - current && typeof current === 'object' ? (current as Record)[part] : undefined, - value, - ); -} - -function withNestedValue(value: unknown, path: string, next: unknown): unknown { - const copy: Record = - value && typeof value === 'object' && !Array.isArray(value) - ? (structuredClone(value) as Record) - : {}; - const parts = path.split('.'); - let cursor = copy; - for (const part of parts.slice(0, -1)) { - const existing = cursor[part]; - cursor[part] = - existing && typeof existing === 'object' && !Array.isArray(existing) - ? { ...(existing as Record) } - : {}; - cursor = cursor[part] as Record; - } - cursor[parts.at(-1)!] = structuredClone(next); - return copy; -} diff --git a/rewrite/src/model/scripted.ts b/rewrite/src/model/scripted.ts deleted file mode 100644 index 0344ec84a..000000000 --- a/rewrite/src/model/scripted.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Item, ItemId, Model, ModelEvent, ModelRef, ModelRegistry, ModelRequest, ToolCallId } from './index.js'; - -let nextId = 0; -const itemId = (): ItemId => `item_${Date.now().toString(36)}_${(nextId++).toString(36)}` as ItemId; - -function lastUserText(input: readonly Item[]): string { - for (let index = input.length - 1; index >= 0; index -= 1) { - const item = input[index]!; - if (item.type === 'user_message') { - return item.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); - } - } - return ''; -} - -/** - * A deterministic stand-in for a provider. After a tool result it summarises - * the result; otherwise "read " or "write : " in the latest - * user message becomes a tool call, and anything else is echoed back. - */ -export function scriptedModel(ref: ModelRef = { provider: 'scripted', model: 'tracer' }): Model { - return { - ref, - contextWindow: 100_000, - async *stream(request: ModelRequest, _signal, onDispatched): AsyncIterable { - onDispatched?.(); - const last = request.input.at(-1); - if (last?.type === 'tool_result') { - const text = last.isError ? `The tool failed: ${last.output}` : `Done. Tool said: ${last.output}`; - yield { type: 'text_delta', delta: text }; - yield { type: 'item', item: { type: 'assistant_message', id: itemId(), content: [{ type: 'text', text }] } }; - yield { type: 'done', stopReason: 'end_turn' }; - return; - } - - const prompt = lastUserText(request.input).trim(); - const read = /^read (\S+)$/.exec(prompt); - const write = /^write (\S+): (.+)$/.exec(prompt); - if (read || write) { - const callId = `call_${itemId()}` as ToolCallId; - const item: Item = read - ? { type: 'tool_call', id: itemId(), callId, toolName: 'read_note', input: { name: read[1] } } - : { - type: 'tool_call', - id: itemId(), - callId, - toolName: 'write_note', - input: { name: write![1], text: write![2] }, - }; - yield { type: 'item', item }; - yield { type: 'done', stopReason: 'tool_use' }; - return; - } - - const text = `You said: ${prompt}`; - yield { type: 'text_delta', delta: text }; - yield { type: 'item', item: { type: 'assistant_message', id: itemId(), content: [{ type: 'text', text }] } }; - yield { type: 'done', stopReason: 'end_turn' }; - }, - }; -} - -export function singleModelRegistry(model: Model): ModelRegistry { - return { - resolve: () => model, - list: () => [model.ref], - }; -} diff --git a/rewrite/src/tools/notes.ts b/rewrite/src/tools/notes.ts deleted file mode 100644 index a7d833f0a..000000000 --- a/rewrite/src/tools/notes.ts +++ /dev/null @@ -1,67 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { Tool, ToolContext, ToolRegistry } from './index.js'; - -type ReadInput = { readonly name: string }; -type WriteInput = { readonly name: string; readonly text: string }; - -function noteName(raw: unknown): string { - const name = (raw as { name?: unknown } | null)?.name; - if (typeof name !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) - throw new Error('name must be 1-64 letters, digits, _ or -'); - return name; -} - -const notePath = (ctx: ToolContext, name: string): string => path.join(ctx.workspace.root, `${name}.txt`); - -/** Read-only, so it runs without approval. */ -export const readNote: Tool = { - spec: { - name: 'read_note', - description: 'Read a note from the workspace.', - inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, - }, - parse: (raw) => ({ name: noteName(raw) }), - approval: () => ({ kind: 'none' }), - async execute(input, ctx) { - try { - return { output: await fs.readFile(notePath(ctx, input.name), 'utf8'), isError: false }; - } catch { - return { output: `note "${input.name}" not found`, isError: true }; - } - }, -}; - -/** Writes a file, so it asks first. */ -export const writeNote: Tool = { - spec: { - name: 'write_note', - description: 'Write a note to the workspace.', - inputSchema: { - type: 'object', - properties: { name: { type: 'string' }, text: { type: 'string' } }, - required: ['name', 'text'], - }, - }, - parse(raw) { - const text = (raw as { text?: unknown } | null)?.text; - if (typeof text !== 'string') throw new Error('text must be a string'); - return { name: noteName(raw), text }; - }, - approval: (input, ctx) => - ctx.workspace.access === 'read' - ? { kind: 'deny', reason: 'workspace is read-only' } - : { kind: 'ask', summary: `Write note "${input.name}"`, risk: 'medium', detail: input.text }, - async execute(input, ctx) { - await fs.writeFile(notePath(ctx, input.name), input.text, 'utf8'); - return { output: `wrote ${input.text.length} characters to "${input.name}"`, isError: false }; - }, -}; - -export function toolRegistry(tools: readonly Tool[]): ToolRegistry { - const byName = new Map(tools.map((tool) => [tool.spec.name, tool as Tool])); - return { - get: (name) => byName.get(name), - available: () => [...byName.values()], - }; -} From 1ddd943370997c2d3cc31c4b2d3771ccb08bb518 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:09:13 +0000 Subject: [PATCH 08/17] refactor(rewrite): delete app/run.ts; withGenerationGuard moves to app/generation-guard.ts run() and prepareResume were the pre-direct headless runner; cli.ts launches headless runs through direct-launch-headless.ts. withGenerationGuard is live (both direct runtimes wrap their model registry with it), so it moves first. RunRequest/RunHandle fold into DirectHeadlessRequest/Handle, and the M1 renderTurn renderer and HeadlessEvent go with their only caller. The log-reader exception for app/run.ts in check-layers.mjs is retired. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/check-layers.mjs | 6 +- rewrite/src/app/cli.ts | 2 +- rewrite/src/app/direct-application-runtime.ts | 2 +- rewrite/src/app/direct-config-runtime.ts | 2 +- rewrite/src/app/direct-launch-headless.ts | 2 +- rewrite/src/app/direct-launch-types.ts | 19 +- rewrite/src/app/generation-guard.ts | 55 ++++ rewrite/src/app/run-types.ts | 19 -- rewrite/src/app/run.ts | 280 ------------------ rewrite/src/clients/headless/index.ts | 10 +- rewrite/src/clients/headless/render.ts | 56 ---- rewrite/src/clients/headless/types.ts | 15 - 12 files changed, 79 insertions(+), 389 deletions(-) create mode 100644 rewrite/src/app/generation-guard.ts delete mode 100644 rewrite/src/app/run-types.ts delete mode 100644 rewrite/src/app/run.ts delete mode 100644 rewrite/src/clients/headless/render.ts diff --git a/rewrite/scripts/check-layers.mjs b/rewrite/scripts/check-layers.mjs index fd401d672..9f031d4df 100644 --- a/rewrite/scripts/check-layers.mjs +++ b/rewrite/scripts/check-layers.mjs @@ -51,7 +51,7 @@ const DIRECT_SOURCE_IMPORTERS = new Set([ 'app/direct-subagent-callbacks.ts', 'app/direct-verifier-workspace.ts', 'app/direct-verifier.ts', - 'app/run.ts', + 'app/generation-guard.ts', 'app/source-tools.ts', 'app/tool-contract.ts', 'app/tool-surface.ts', @@ -412,11 +412,9 @@ for (const entry of files(rewriteSrc)) { // so a metadata call never re-parses a log the session already holds in memory. const LOG_READERS = /\b(readDirectLogLineages?|readFactLines\w*|readFacts|discoverDirectLogs|readDirectLogHeaderLine|readDirectResumeTitle|readDirectResumeUpdatedAt)\b/; -// app/run.ts is the non-direct headless runner: it owns the plain-log file it writes itself (session/log.ts) and replays it for approvals. -const LOG_READER_EXCEPTIONS = new Set(['app/run.ts']); for (const file of files(rewriteSrc)) { const relative = path.relative(rewriteSrc, file).split(path.sep).join('/'); - if (!/^(app|clients)\//.test(relative) || LOG_READER_EXCEPTIONS.has(relative)) continue; + if (!/^(app|clients)\//.test(relative)) continue; if (LOG_READERS.test(fs.readFileSync(file, 'utf8'))) problems.add(`${relative}: app/ and clients/ may not read session logs; ask the session`); } diff --git a/rewrite/src/app/cli.ts b/rewrite/src/app/cli.ts index 351d728f1..c64a8abdd 100644 --- a/rewrite/src/app/cli.ts +++ b/rewrite/src/app/cli.ts @@ -95,7 +95,7 @@ function conversationsDirectory(): string { /** * Refuses any existing filename outside the `conv_` namespace. It enumerates * names only: no old file is opened or parsed. A missing directory is allowed; - * `run()` creates it after this check. + * the session store creates it when it writes the first conversation. */ async function checkConversationsDirectory(directory: string): Promise { let entries: string[]; diff --git a/rewrite/src/app/direct-application-runtime.ts b/rewrite/src/app/direct-application-runtime.ts index 825fd12d1..731c0ca7e 100644 --- a/rewrite/src/app/direct-application-runtime.ts +++ b/rewrite/src/app/direct-application-runtime.ts @@ -34,7 +34,7 @@ import { readDirectInputPreviewConfiguration } from './direct-input-preview.js'; import { createDirectSessionMemory, directMainMemoryEnabled } from './direct-memory.js'; import { runPolicy } from './direct-application-policy.js'; import { createDirectVerificationOperations } from './direct-verifier.js'; -import { withGenerationGuard } from './run.js'; +import { withGenerationGuard } from './generation-guard.js'; import { createAppSettings } from './settings.js'; import { createNativeSearchTool } from './native-search.js'; import type { AgentSurface, ProfileSelection, ToolSurfaceOptions } from './tool-contract.js'; diff --git a/rewrite/src/app/direct-config-runtime.ts b/rewrite/src/app/direct-config-runtime.ts index c3e114ea1..6a5b6d597 100644 --- a/rewrite/src/app/direct-config-runtime.ts +++ b/rewrite/src/app/direct-config-runtime.ts @@ -19,7 +19,7 @@ import { } from './direct-config-settings.js'; import { readDirectInputPreviewConfiguration } from './direct-input-preview.js'; import { createDirectVerificationOperations } from './direct-verifier.js'; -import { withGenerationGuard } from './run.js'; +import { withGenerationGuard } from './generation-guard.js'; import type { AgentSurface, ProfileSelection, ToolSurfaceOptions } from './tool-contract.js'; export type DirectConfiguredGeneration = { diff --git a/rewrite/src/app/direct-launch-headless.ts b/rewrite/src/app/direct-launch-headless.ts index 9ed1aa34d..0f7746fa6 100644 --- a/rewrite/src/app/direct-launch-headless.ts +++ b/rewrite/src/app/direct-launch-headless.ts @@ -1,4 +1,4 @@ -/** One headless run as a client of the direct host. `cli.ts` owns signals, as it does for any `RunHandle`. */ +/** One headless run as a client of the direct host. `cli.ts` owns signals, through the returned handle. */ import fs from 'node:fs'; import { openDirectApplicationMcp } from './direct-application-mcp.js'; import type { DirectMcp } from './direct-mcp.js'; diff --git a/rewrite/src/app/direct-launch-types.ts b/rewrite/src/app/direct-launch-types.ts index 923eedab7..a46148b54 100644 --- a/rewrite/src/app/direct-launch-types.ts +++ b/rewrite/src/app/direct-launch-types.ts @@ -2,7 +2,7 @@ import type { AcpArgs } from '../../../source/acp-v2/serve-args.js'; import type { ServeArgs } from '../../../source/gateway/serve-args.js'; import type { DurableGoal } from '../session/direct-goal.js'; -import type { RunHandle, RunRequest } from './run-types.js'; +import type { HeadlessIo } from '../clients/headless/index.js'; import type { ProfileSelection } from './tool-contract.js'; /** @@ -26,7 +26,16 @@ export type DirectSshRequest = { }; /** A headless run. Output modes live here because only the host client reads them. */ -export type DirectHeadlessRequest = RunRequest & { +export type DirectHeadlessRequest = { + readonly prompt: string; + readonly conversationsDir: string; + readonly selection: ProfileSelection; + readonly io: HeadlessIo; + readonly autoApprove?: boolean; + /** Exact direct `conv_`; absent starts a new session. */ + readonly resumeId?: string; + /** True only for an explicit --profile/--lite, which overrides the resumed profile. */ + readonly profileExplicit?: boolean; readonly output?: 'plain' | 'json'; /** Reasoning deltas go to stderr, never stdout. */ readonly showReasoning?: boolean; @@ -45,7 +54,11 @@ export type DirectHeadlessRequest = RunRequest & { export type DirectHeadlessResult = { readonly kind: 'completed' | 'cancelled' | 'failed' | 'context_paused' | 'blocked' | 'budget_paused'; }; -export type DirectHeadlessHandle = Omit & { readonly result: Promise }; +export type DirectHeadlessHandle = { + readonly result: Promise; + turnActive(): boolean; + cancel(): void; +}; export type DirectControlSocketRequest = { /** Already checked with `isControlSocketName`; defaults to the pid in `cli.ts`. */ diff --git a/rewrite/src/app/generation-guard.ts b/rewrite/src/app/generation-guard.ts new file mode 100644 index 000000000..7994eb621 --- /dev/null +++ b/rewrite/src/app/generation-guard.ts @@ -0,0 +1,55 @@ +/** Per-request generation limits wrapped around every model the direct application resolves. */ +import { GenerationGuard, GenerationStreamDeadlines } from '../../../source/services/agent-runtime/generation-guard.js'; +import type { Model, ModelEvent, ModelRegistry, ModelRequest } from '../model/index.js'; +import type { ProfileSelection } from './agent-surface.js'; + +/** + * The old run loop's per-request limits: a stalled stream and oversized text end + * the turn as a failure, with the same settings and messages. Above the provider, + * so a trip is not retried. Text passes `observeText` before it can be shown. + */ +export function withGenerationGuard(models: ModelRegistry, { settings }: ProfileSelection): ModelRegistry { + const stream = async function* ( + model: Model, + request: ModelRequest, + signal: AbortSignal, + onDispatched?: () => void, + ): AsyncIterable { + const maxCharacters = settings.get('agent.maxStreamOutputChars'); + const guard = new GenerationGuard({ + maxOutputCharacters: maxCharacters, + maxTextCharacters: maxCharacters, + maxReasoningCharacters: maxCharacters, + requestDeadlineMs: settings.get('agent.maxModelRequestDurationMs'), + maxStreamIdleMs: settings.get('agent.maxModelStreamIdleMs'), + }); + const stop = new AbortController(); + const deadlines = new GenerationStreamDeadlines( + { totalMs: guard.requestDeadlineMs, idleMs: guard.maxStreamIdleMs }, + () => stop.abort(), + () => guard.progress, + ); + const events = model.stream(request, AbortSignal.any([signal, stop.signal]), onDispatched)[Symbol.asyncIterator](); + try { + for (;;) { + const next = await deadlines.wait(events.next()); + if (next.done) return; + deadlines.recordActivity(); + if (next.value.type === 'text_delta') guard.observeText(next.value.delta); + if (next.value.type === 'reasoning_delta') guard.observeReasoning(next.value.delta); + yield next.value; + } + } finally { + deadlines.dispose(); + stop.abort(); + void events.return?.()?.catch(() => undefined); + } + }; + return { + list: () => models.list(), + resolve(ref) { + const model = models.resolve(ref); + return { ...model, stream: (request, signal, onDispatched) => stream(model, request, signal, onDispatched) }; + }, + }; +} diff --git a/rewrite/src/app/run-types.ts b/rewrite/src/app/run-types.ts deleted file mode 100644 index 13324f1a4..000000000 --- a/rewrite/src/app/run-types.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** App/client contract: exact direct resume only; no storage or engine authority. */ -import type { HeadlessIo } from '../clients/headless/index.js'; -import type { ProfileSelection } from './tool-contract.js'; -export type RunRequest = { - readonly prompt: string; - readonly conversationsDir: string; - readonly selection: ProfileSelection; - readonly io: HeadlessIo; - readonly autoApprove?: boolean; - readonly resumeId?: string; - /** CLI sets true only for explicit --profile/--lite, to override resumed profile. */ - readonly profileExplicit?: boolean; -}; -export type RunResult = { readonly kind: 'completed' | 'cancelled' | 'failed' }; -export type RunHandle = { - readonly result: Promise; - turnActive(): boolean; - cancel(): void; -}; diff --git a/rewrite/src/app/run.ts b/rewrite/src/app/run.ts deleted file mode 100644 index 47f9b625b..000000000 --- a/rewrite/src/app/run.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** Direct headless composition: native Session owns durability and effect release. */ -import { randomUUID } from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { GenerationGuard, GenerationStreamDeadlines } from '../../../source/services/agent-runtime/generation-guard.js'; -import { getProfileMetadata } from '../../../source/services/profiles/active-profile.js'; -import { renderTurn } from '../clients/headless/index.js'; -import type { HeadlessEvent } from '../clients/headless/index.js'; -import type { InteractionDecision, TurnEvent } from '../engine/index.js'; -import type { Item, ItemId, Model, ModelEvent, ModelRef, ModelRegistry, ModelRequest } from '../model/index.js'; -import { createProviderRegistry } from '../model/provider.js'; -import { createConversation, openConversation } from '../session/conversation.js'; -import type { Conversation, Turn } from '../session/conversation.js'; -import type { Fact, SessionStartedFact } from '../session/facts.js'; -import { createLog, openLog, readFacts } from '../session/log.js'; -import type { ConversationLog } from '../session/log.js'; -import { replay } from '../session/replay.js'; -import { loadSurface } from './agent-surface.js'; -import type { AgentSurface, ProfileSelection } from './agent-surface.js'; -import type { RunHandle, RunRequest, RunResult } from './run-types.js'; -export type { RunHandle, RunRequest, RunResult } from './run-types.js'; - -// Keep the client structural: neither engine nor Session is imported there. -type Renderable = T; -export type RenderedEvent = Renderable; - -/** Starts one run; result settles only after the Session has drained its engine. */ -export function run(request: RunRequest): RunHandle { - let turn: Turn | null = null; - let active = false; - const stopped = new AbortController(); - - const execute = async (): Promise => { - const { selection, io, prompt, conversationsDir } = request; - let conversation: Conversation | null = null; - let log: ConversationLog | null = null; - let surface: AgentSurface | null = null; - let policyFailed = false; - try { - // Refusal/metadata/workspace validation precedes assembly, writable open, - // directory creation, and every possible provider or tool operation. - const resumed = prepareResume(request); - surface = loadSurface(selection, { autoApprove: request.autoApprove ?? false, interactive: false }); - // Resume restoration precedes this announcement; CLI must not print the - // pre-restoration profile or announce a second time. - io.stderr.write(`Profile: ${surface.profileName}\n`); - const { notice, profileName: _profileName, runtime, decideInteraction, ...settings } = surface; - const model = selection.settings.get('agent.modelSelection') as ModelRef; - const id = resumed?.meta.id ?? `conv_${randomUUID()}`; - const models = withGenerationGuard(createProviderRegistry(selection), selection); - const deps = { - models, - tools: runtime.tools, - toolContext: runtime.toolContext, - maxParallelToolCalls: runtime.maxParallelToolCalls, - }; - if (resumed) { - log = openLog(resumed.path); - conversation = openConversation({ ...deps, log, facts: resumed.facts }); - } else { - const profile = getProfileMetadata(String(selection.settings.get('app.activeProfileId'))); - fs.mkdirSync(conversationsDir, { recursive: true }); - log = createLog(conversationsDir, id); - conversation = createConversation({ - ...deps, - log, - meta: { - id, - workspace: fs.realpathSync(process.cwd()), - model, - ...(settings.reasoning !== undefined ? { reasoning: settings.reasoning } : {}), - profile: { - id: profile.activeProfileId, - version: profile.activeProfileVersion, - digest: profile.activeProfileDigest, - }, - appVersion: appVersion(), - }, - }); - } - const input: Item = { - type: 'user_message', - id: randomUUID() as ItemId, - content: [{ type: 'text', text: prompt }], - }; - const context = { - sessionId: id, - sessionStartedAt: resumed?.startedAt ?? new Date().toISOString(), - promptCacheKey: id, - }; - const owner = conversation; - const acceptedLog = log; - const { kind } = await selection.sessionContext.runWithContext(context, async () => { - // startTurn may start work eagerly: creation and all subsequent policy / - // provider work must inherit this same session AsyncLocalStorage scope. - const started = owner.run({ ...settings, model, input: [input], ...(notice ? { notice } : {}) }); - turn = started; - active = true; - if (stopped.signal.aborted) started.cancel(); - async function* events(): AsyncGenerator { - for await (const event of started.events) { - yield event; - if (event.type !== 'interaction_required' || stopped.signal.aborted) continue; - try { - const history = replay(readFacts(acceptedLog.path)).history; - const decision = await untilCancelled(decideInteraction(event.request, history), stopped.signal); - if (decision) { - const answer = started.answer(event.request.requestId, decision); - if (answer.kind === 'failed') throw new Error(answer.error.message); - } - } catch (error) { - policyFailed = true; - io.stderr.write(`error ${error instanceof Error ? error.message : String(error)}\n`); - started.cancel(); - } - } - } - return renderTurn(events(), io); - }); - return { kind: !policyFailed && (kind === 'completed' || kind === 'cancelled') ? kind : 'failed' }; - } catch (error) { - io.stderr.write(`error ${error instanceof Error ? error.message : String(error)}\n`); - return { kind: 'failed' }; - } finally { - try { - if (conversation) await conversation.close(); - else log?.close(); - } catch { - // The durable terminal (or write failure) has already decided the result. - } finally { - active = false; - try { - await surface?.runtime.dispose?.(); - } catch { - /* Do not mask the durable result. */ - } - } - } - }; - return { - result: execute(), - turnActive: () => active, - cancel() { - stopped.abort(); - turn?.cancel(); - }, - }; -} - -const DIRECT_ID = /^conv_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -type Resumed = { - readonly path: string; - readonly facts: readonly Fact[]; - readonly meta: SessionStartedFact; - readonly startedAt: string; -}; - -/** Exact direct resume only. This function never opens a writer or changes data. */ -function prepareResume({ resumeId, conversationsDir, selection, profileExplicit }: RunRequest): Resumed | null { - if (resumeId !== undefined && !DIRECT_ID.test(resumeId)) - throw new Error('resume requires an exact direct conversation ID'); - // Check in app as well as CLI: programmatic callers have the same isolation - // contract. No old index, legacy file contents, or symlink target is read. - try { - const incompatible = fs - .readdirSync(conversationsDir, { withFileTypes: true }) - .find((entry) => !entry.isFile() || !entry.name.endsWith('.jsonl') || !DIRECT_ID.test(entry.name.slice(0, -6))); - if (incompatible) - throw new Error( - `Refusing conversations directory containing a non-direct entry: ${JSON.stringify(incompatible.name)}`, - ); - } catch (error) { - if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; - } - if (resumeId === undefined) return null; - const logPath = path.join(conversationsDir, `${resumeId}.jsonl`); - const facts = readFacts(logPath); - const meta = facts[0]; - if (meta?.type !== 'session_started' || meta.id !== resumeId) - throw new Error('resume conversation metadata does not match its ID'); - if (fs.realpathSync(meta.workspace) !== fs.realpathSync(process.cwd())) - throw new Error('resume conversation belongs to a different workspace'); - replay(facts); - // Metadata is not a current-request activation notice. Surface construction - // runs after these non-persistent overrides and primes only the new notice. - if (!profileExplicit) selection.settings.set('app.activeProfileId', meta.profile.id, { persist: false }); - selection.settings.set('agent.modelSelection', meta.model, { persist: false }); - selection.settings.set('agent.reasoningEffort', meta.reasoning ?? 'default', { persist: false }); - const firstLine = fs.readFileSync(logPath, 'utf8').split('\n', 1)[0]!; - const startedAt = String((JSON.parse(firstLine) as { at: string }).at); - return { path: logPath, facts, meta, startedAt }; -} - -/** Cancellation stops waiting on advisory policy without detaching tool cleanup. */ -async function untilCancelled( - operation: Promise, - signal: AbortSignal, -): Promise { - if (signal.aborted) { - void operation.catch(() => undefined); - return null; - } - let onAbort: () => void = () => {}; - const cancelled = new Promise((resolve) => { - onAbort = () => resolve(null); - signal.addEventListener('abort', onAbort, { once: true }); - }); - try { - return await Promise.race([operation, cancelled]); - } finally { - signal.removeEventListener('abort', onAbort); - } -} - -/** - * The old run loop's per-request limits: a stalled stream and oversized text end - * the turn as a failure, with the same settings and messages. Above the provider, - * so a trip is not retried. Text passes `observeText` before it can be shown. - */ -export function withGenerationGuard(models: ModelRegistry, { settings }: ProfileSelection): ModelRegistry { - const stream = async function* ( - model: Model, - request: ModelRequest, - signal: AbortSignal, - onDispatched?: () => void, - ): AsyncIterable { - const maxCharacters = settings.get('agent.maxStreamOutputChars'); - const guard = new GenerationGuard({ - maxOutputCharacters: maxCharacters, - maxTextCharacters: maxCharacters, - maxReasoningCharacters: maxCharacters, - requestDeadlineMs: settings.get('agent.maxModelRequestDurationMs'), - maxStreamIdleMs: settings.get('agent.maxModelStreamIdleMs'), - }); - const stop = new AbortController(); - const deadlines = new GenerationStreamDeadlines( - { totalMs: guard.requestDeadlineMs, idleMs: guard.maxStreamIdleMs }, - () => stop.abort(), - () => guard.progress, - ); - const events = model.stream(request, AbortSignal.any([signal, stop.signal]), onDispatched)[Symbol.asyncIterator](); - try { - for (;;) { - const next = await deadlines.wait(events.next()); - if (next.done) return; - deadlines.recordActivity(); - if (next.value.type === 'text_delta') guard.observeText(next.value.delta); - if (next.value.type === 'reasoning_delta') guard.observeReasoning(next.value.delta); - yield next.value; - } - } finally { - deadlines.dispose(); - stop.abort(); - void events.return?.()?.catch(() => undefined); - } - }; - return { - list: () => models.list(), - resolve(ref) { - const model = models.resolve(ref); - return { ...model, stream: (request, signal, onDispatched) => stream(model, request, signal, onDispatched) }; - }, - }; -} - -function appVersion(): string { - let directory = path.dirname(fileURLToPath(import.meta.url)); - for (;;) { - try { - const pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')); - if (pkg.name === '@qduc/term2') return String(pkg.version); - } catch { - // No package.json here; keep walking up. - } - const parent = path.dirname(directory); - if (parent === directory) return 'unknown'; - directory = parent; - } -} diff --git a/rewrite/src/clients/headless/index.ts b/rewrite/src/clients/headless/index.ts index 80afae8da..2ac2453dd 100644 --- a/rewrite/src/clients/headless/index.ts +++ b/rewrite/src/clients/headless/index.ts @@ -1,21 +1,15 @@ /** - * Headless client. - * - * `runDirectHeadless` is the M5 path: one prompt over `DirectConnection`, with - * the old non-interactive plain/JSON output, approval and completion rules. - * `renderTurn` remains for the current app/run engine-event path until the app - * integration owner switches it to the direct connection. + * Headless client: `runDirectHeadless` sends one prompt over `DirectConnection`, + * with the old non-interactive plain/JSON output, approval and completion rules. */ export { formatToolSummary, interactionLine, rejectionLine, toolStartLine } from './format.js'; export { decideHeadlessApproval, runDirectHeadless } from './direct.js'; -export { renderTurn } from './render.js'; export type { DirectHeadlessOptions, DirectHeadlessOutcome, DirectHeadlessResult, HeadlessApprovalDecider, HeadlessDecision, - HeadlessEvent, HeadlessIo, HeadlessOutput, } from './types.js'; diff --git a/rewrite/src/clients/headless/render.ts b/rewrite/src/clients/headless/render.ts deleted file mode 100644 index aa3ec7cdc..000000000 --- a/rewrite/src/clients/headless/render.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * M1 engine-event renderer retained for the current app/run path until the app - * integration owner switches headless runs to the direct connection. - */ -import type { HeadlessEvent, HeadlessIo } from './types.js'; - -/** - * Streams text deltas as they arrive. Only completed turns get a trailing - * newline; failure diagnostics go to stderr, while cancelled and failed - * partial output stays unterminated. - */ -export async function renderTurn( - events: AsyncIterable, - io: HeadlessIo, -): Promise<{ readonly kind: string }> { - for await (const event of events) { - if (event.type === 'text_delta' && 'delta' in event && typeof event.delta === 'string') { - io.stdout.write(event.delta); - continue; - } - if (event.type === 'tool_start_requested' && 'toolName' in event && typeof event.toolName === 'string') { - io.stderr.write(`Tool: ${event.toolName}\n`); - continue; - } - if ( - event.type === 'interaction_required' && - 'request' in event && - event.request && - typeof event.request === 'object' - ) { - const request = event.request as { - readonly kind?: unknown; - readonly toolName?: unknown; - readonly summary?: unknown; - }; - const kind = request.kind === 'question' ? 'Question' : 'Approval'; - const toolName = typeof request.toolName === 'string' ? ` (${request.toolName})` : ''; - const summary = typeof request.summary === 'string' ? `: ${request.summary}` : ''; - io.stderr.write(`${kind} required${toolName}${summary}\n`); - continue; - } - if (event.type === 'turn_ended' && 'result' in event) { - const result = event.result; - if (result.kind === 'completed') { - io.stdout.write('\n'); - } else if (result.kind === 'failed') { - io.stderr.write(`error ${result.error?.message ?? 'Unknown error.'}\n`); - } - return result; - } - } - - const result = { kind: 'failed', error: { message: 'Turn ended without a terminal event.' } }; - io.stderr.write(`error ${result.error.message}\n`); - return result; -} diff --git a/rewrite/src/clients/headless/types.ts b/rewrite/src/clients/headless/types.ts index 53ede688e..790b7daff 100644 --- a/rewrite/src/clients/headless/types.ts +++ b/rewrite/src/clients/headless/types.ts @@ -7,21 +7,6 @@ import type { WireSubmission, } from '../../protocol/direct-wire.js'; -/** - * Renders one M1 turn. Structural types only, because clients may not import - * session/ or engine/. - * - * `{ type: string }` keeps the union open, so a discriminant check does not - * narrow it; use `'delta' in event` or `'result' in event`. - */ -export type HeadlessEvent = - | { readonly type: 'text_delta'; readonly delta: string } - | { - readonly type: 'turn_ended'; - readonly result: { readonly kind: string; readonly error?: { readonly message: string } }; - } - | { readonly type: string }; - export type HeadlessIo = { readonly stdout: { write(text: string): unknown }; readonly stderr: { write(text: string): unknown }; From 10437412cc6222fb5289c48c95fc8053540ee005 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:16:30 +0000 Subject: [PATCH 09/17] refactor(rewrite): delete the agent-wire v1 SSE translator encodeCanonicalEvent and canonicalEventStream encoded accepted events as agent-wire v1; no route serves that format. canonicalCandidate stays: the gated agent-wire v2 encoder (server-wire-v2.ts) builds on it. The v2 wire itself stays behind AGENT_WIRE_V2_RUNTIME_AVAILABLE=false because #80 (web/server) is deferred, not dropped, and keeps its existing work. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/src/host/server-events.ts | 28 +--------------------------- rewrite/src/host/server-sse.ts | 19 +------------------ 2 files changed, 2 insertions(+), 45 deletions(-) diff --git a/rewrite/src/host/server-events.ts b/rewrite/src/host/server-events.ts index a88d3a0fb..7f59b4d60 100644 --- a/rewrite/src/host/server-events.ts +++ b/rewrite/src/host/server-events.ts @@ -1,8 +1,7 @@ -/** Pure pinned agent-wire encoding of accepted direct events. No cursor, timestamp, terminal text +/** Pure agent-wire candidates for accepted direct events, encoded by the gated v2 wire. No cursor, timestamp, terminal text * or tool identity is synthesized here. The caller supplies only authoritative replay-cut context * when a direct payload does not itself carry the immutable data needed by the pinned DTO. */ -import type { AgentEventEnvelope } from '@qduc/agent-wire'; import type { WireAdmission, WireEvent } from '../protocol/direct-wire.js'; import { legacyCommandMessages } from '../protocol/direct-json.js'; import type { ServerInteractions } from './server-interactions.js'; @@ -19,31 +18,6 @@ export type { CanonicalEventCandidate, CanonicalEventContext } from './server-ev type Candidate = CanonicalEventCandidate; -/** Returns at most one numeric-ID envelope per accepted boundary. Non-public projection updates - * return null. Missing accepted evidence or unsupported consumer semantics throws - * instead of silently omitting a lifecycle boundary or lying to a retained BFF. - */ -export function encodeCanonicalEvent( - event: WireEvent, - interactions: ServerInteractions, - context: CanonicalEventContext = {}, -): AgentEventEnvelope | null { - const candidate = canonicalCandidate(event, interactions, context); - if (!candidate) return null; - if (event.seq === null || !Number.isSafeInteger(event.seq) || event.seq < 1) - return missing('canonical events require an accepted durable cursor'); - if (!event.occurredAt || /[\u0000-\u001f\u007f]/u.test(event.occurredAt)) - return missing('canonical events require the original accepted timestamp'); - return { - schemaVersion: 1, - id: event.seq, - sessionId: event.sessionId, - type: candidate.type, - occurredAt: event.occurredAt, - payload: candidate.payload, - }; -} - export function canonicalCandidate( event: WireEvent, interactions: ServerInteractions, diff --git a/rewrite/src/host/server-sse.ts b/rewrite/src/host/server-sse.ts index b217c59da..6f545151c 100644 --- a/rewrite/src/host/server-sse.ts +++ b/rewrite/src/host/server-sse.ts @@ -2,7 +2,6 @@ * supplied direct connection supports Session captureFrom; null cursors have no ID. The gateway's * historical agent-wire envelope still needs enrichment; x-term2-event-format is explicit. */ -import type { AgentEventEnvelope } from '@qduc/agent-wire'; import type { DirectConnection, DirectReplayConnection } from './in-process-direct-types.js'; import type { WireEvent } from '../protocol/direct-wire.js'; import type { AgentWireV2Envelope } from './server-wire-v2.js'; @@ -15,7 +14,7 @@ const DRAIN_TIMEOUT_MS = 10_000; const MAX_BUFFERED_EVENTS = 256; type StreamFrame = { readonly id: number | null; readonly event?: 'accepted' | 'live'; readonly data: unknown }; type StreamEncoding = { - readonly format: 'direct-wire-v1' | 'agent-wire-v1' | 'agent-wire-v2'; + readonly format: 'direct-wire-v1' | 'agent-wire-v2'; /** V2 accepted frames retain their real accepted seq even when replay is unavailable. */ readonly acceptedIds?: boolean; readonly snapshot: boolean; @@ -29,22 +28,6 @@ export function directEventStream(connection: DirectConnection | DirectReplayCon encode: (event) => ({ id: event.seq, data: event }), }); } -/** Canonical SSE consumes only the atomic accepted tail, never a current snapshot before history. - * Encoding is supplied by the server's semantic adapter; this transport owns no domain projection. - */ -export function canonicalEventStream( - connection: DirectReplayConnection, - encode: (event: WireEvent) => AgentEventEnvelope | null, -): ServerResult { - return eventStream(connection, { - format: 'agent-wire-v1', - snapshot: false, - encode(event) { - const envelope = encode(event); - return envelope ? { id: envelope.id, data: envelope } : null; - }, - }); -} export function agentWireV2EventStream( connection: DirectConnection | DirectReplayConnection, encode: (event: WireEvent) => AgentWireV2Envelope | null, From c58f591e8f089031b5ce1527193e631af867ac02 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:16:32 +0000 Subject: [PATCH 10/17] refactor(rewrite): trim the layer index files to the production contracts engine/, host/, protocol/, session/ and config/ index.ts still declared the bridge's Engine, Host, Connection, Projection, Session and SessionStore contracts, which nothing in production implements or calls. They go, and each header now describes the direct-* files a message actually passes through. With the bridge Host gone, the adopted session-fact union is used only by the offline v3 migration, so session/adopted/conversation-log.ts keeps just validateSessionFact and its types; its CanonicalSessionLog writer and loaders go. protocol/ and session/ index.ts are imported by nothing and are allowlisted as the layer maps the README's fresh-reader check reads. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/complexity-budget.json | 7 +- rewrite/scripts/reachability-allowlist.json | 6 +- rewrite/src/config/index.ts | 15 - rewrite/src/engine/index.ts | 143 +------ rewrite/src/host/index.ts | 64 +--- rewrite/src/protocol/index.ts | 113 +----- .../src/session/adopted/conversation-log.ts | 360 +----------------- rewrite/src/session/adopted/network.ts | 29 +- rewrite/src/session/index.ts | 152 +------- 9 files changed, 70 insertions(+), 819 deletions(-) diff --git a/rewrite/scripts/complexity-budget.json b/rewrite/scripts/complexity-budget.json index a961289d0..ad551f816 100644 --- a/rewrite/scripts/complexity-budget.json +++ b/rewrite/scripts/complexity-budget.json @@ -1,12 +1,7 @@ { "maxSourceEdges": 521, "maxLines": 500, - "fileCeilings": { - "rewrite/src/session/adopted/conversation-log.ts": { - "lines": 631, - "reason": "Prettier expands the existing SessionFact union by four lines after reconciliation; no behavior or new job added. Keep the fact union with its decoder rather than extract a formatting-only seam." - } - }, + "fileCeilings": {}, "messagePath": { "maxHops": 14, "maxFiles": 14 diff --git a/rewrite/scripts/reachability-allowlist.json b/rewrite/scripts/reachability-allowlist.json index 033a463fe..3e6cc00c8 100644 --- a/rewrite/scripts/reachability-allowlist.json +++ b/rewrite/scripts/reachability-allowlist.json @@ -1,3 +1,7 @@ { - "rewrite/src/session/direct-import.ts": "Offline v3 conversation migration (scripts/conversation-migration/migration-command.mjs) imports its emitted module; no CLI command reaches it." + "rewrite/src/protocol/index.ts": "Layer map: the README's fresh-reader check reads each layer's index.ts. Production imports the layer's files directly, so nothing imports this index.", + "rewrite/src/session/adopted/conversation-log.ts": "The offline v3 conversation migration (scripts/conversation-migration/v3-input.mjs) imports validateSessionFact from its emitted module; production no longer writes adopted facts.", + "rewrite/src/session/adopted/network.ts": "Network facts validated by session/adopted/conversation-log.ts for the v3 migration.", + "rewrite/src/session/direct-import.ts": "Offline v3 conversation migration (scripts/conversation-migration/migration-command.mjs) imports its emitted module; no CLI command reaches it.", + "rewrite/src/session/index.ts": "Layer map: the README's fresh-reader check reads each layer's index.ts. Production imports the layer's files directly, so nothing imports this index." } diff --git a/rewrite/src/config/index.ts b/rewrite/src/config/index.ts index 095cb8d2f..864930e91 100644 --- a/rewrite/src/config/index.ts +++ b/rewrite/src/config/index.ts @@ -27,18 +27,3 @@ export interface SettingsReader { /** Returns an unsubscribe function. */ watch(listener: (change: SettingsChange) => void): () => void; } - -export interface SettingsWriter extends SettingsReader { - set(key: SettingKey, value: SettingValue): Promise; -} - -/** Narrows settings to an allowlist, e.g. what a remote client may see or change. */ -export type SettingsScope = { - readonly readable: readonly SettingKey[]; - readonly writable: readonly SettingKey[]; -}; - -export interface SettingsSource { - /** Settings for one session: host defaults, then profile, then session overrides. */ - forSession(scope: SettingsScope, overrides?: ReadonlyMap): SettingsWriter; -} diff --git a/rewrite/src/engine/index.ts b/rewrite/src/engine/index.ts index d5049689e..de94b67f3 100644 --- a/rewrite/src/engine/index.ts +++ b/rewrite/src/engine/index.ts @@ -1,145 +1,20 @@ /** * engine/ — the turn loop and nothing else. * - * Takes history, tools and a model and runs one turn, emitting one event - * stream. When a tool needs approval it suspends and returns a serializable - * continuation instead of calling up into a UI. Owns compaction. A subagent is - * a nested engine run whose events are wrapped in `subagent`. + * `startTurn` (`turn.ts`) runs one turn from a `TurnSpec`: it calls the model + * (`turn-model.ts`), dispatches the tools the model asks for (`turn-tools.ts`) + * and emits `TurnEvent`s on a `TurnHandle`. It never writes the log. Session + * accepts each event and only then lets the turn advance, so a crash can lose + * at most the unaccepted tail. When a tool needs approval or an answer, the + * turn emits an interaction request and waits in memory for the session to + * answer it; it does not call up into a UI. `startCompaction` and the + * `direct-recovery*` files own compaction and recovery from model failures. + * A subagent is a nested turn run through the same loop. * Depends on: model, tools, config. */ -import type { - Item, - ItemId, - ModelError, - ModelRef, - ModelRegistry, - ReasoningEffort, - ToolCallId, - Usage, -} from '../model/index.js'; -import type { ApprovalRequirement, NestedRunBudget, ToolRegistry, ToolResult, Workspace } from '../tools/index.js'; -import type { SettingsReader } from '../config/index.js'; - export type TurnId = string & { readonly __brand: 'TurnId' }; -export type AgentId = string & { readonly __brand: 'AgentId' }; -export type CheckpointId = string & { readonly __brand: 'CheckpointId' }; - -export type TurnBudget = NestedRunBudget; - -export type TurnInput = { - readonly turnId: TurnId; - readonly history: readonly Item[]; - /** Appended to history at the start of the turn. Empty for a retry. */ - readonly newItems: readonly Item[]; - readonly instructions: string; - readonly model: ModelRef; - readonly reasoning?: ReasoningEffort; - /** Names, not live tools: a checkpoint embeds this input and must stay plain data. */ - readonly toolNames: readonly string[]; - readonly workspace: Workspace; - readonly budget: TurnBudget; -}; - -/** - * Plain audit data. Adopted checkpoints release only a live parked turn; - * they do not provide crash recovery. - */ -export type TurnCheckpoint = { - readonly turnId: TurnId; - readonly checkpointId?: CheckpointId; - readonly input: TurnInput; - /** Items the turn has produced so far. */ - readonly produced: readonly Item[]; - /** Tool calls the model asked for that have not produced a result yet. */ - readonly pendingCalls: readonly { readonly callId: ToolCallId; readonly toolName: string; readonly input: unknown }[]; - readonly modelCalls: number; - readonly continuation?: unknown; -}; - -export type ApprovalRequest = { - readonly callId: ToolCallId; - readonly toolName: string; - readonly requirement: Extract; - /** Set when the request comes from a nested agent. */ - readonly agentId?: AgentId; -}; - -export type ApprovalGrant = - | 'allow-once' - | 'allow-remember' - | 'unsandboxed-once' - | 'allow-folder-session' - | 'allow-edit-file-session' - | 'allow-edit-folder-session' - | 'docker-allow-once' - | 'docker-allow-session' - | 'docker-allow-project'; -export type ApprovalDecision = ( - | { readonly kind: 'approve' } - | { readonly kind: 'reject'; readonly reason?: string } - | { readonly kind: 'answer'; readonly answer: string } - | { readonly kind: 'grant'; readonly grant: ApprovalGrant } -) & { readonly stopAfterApprovalResolution?: boolean }; - -export type TurnOutcome = - | { readonly kind: 'completed' } - | { readonly kind: 'suspended'; readonly approvals: readonly ApprovalRequest[]; readonly checkpoint: TurnCheckpoint } - | { readonly kind: 'cancelled' } - | { readonly kind: 'budget_exhausted' } - | { readonly kind: 'failed'; readonly error: ModelError }; - -export type EngineEvent = - | { readonly type: 'segment_started'; readonly segmentId: string } - | { readonly type: 'segment_committed'; readonly segmentId: string; readonly history: readonly Item[] } - | { readonly type: 'tool_dispatched'; readonly callId: ToolCallId } - | { readonly type: 'model_attempt_rollback'; readonly reasoningCharacters: number } - | { readonly type: 'text_delta'; readonly delta: string } - | { readonly type: 'reasoning_delta'; readonly delta: string } - /** A finished history item. The session log stores these; deltas are transient. */ - | { readonly type: 'item'; readonly item: Item } - | { readonly type: 'tool_started'; readonly callId: ToolCallId; readonly toolName: string; readonly input: unknown } - | { readonly type: 'tool_progress'; readonly callId: ToolCallId; readonly chunk: string } - | { readonly type: 'tool_finished'; readonly callId: ToolCallId; readonly result: ToolResult } - /** Audit evidence at a turn boundary; persistence does not guarantee resumption. */ - | { readonly type: 'checkpoint'; readonly checkpoint: TurnCheckpoint } - | { readonly type: 'usage'; readonly usage: Usage } - | { readonly type: 'retrying'; readonly attempt: number; readonly delayMs: number; readonly error: ModelError } - | { readonly type: 'compacted'; readonly summaryId: ItemId; readonly replaced: number } - | { readonly type: 'subagent'; readonly agentId: AgentId; readonly role: string; readonly event: EngineEvent } - | { readonly type: 'turn_ended'; readonly outcome: TurnOutcome }; - -/** A view of a turn. Its last event is `turn_ended`; suspended views retain cancellation authority. */ -export interface TurnRun { - readonly turnId: TurnId; - readonly events: AsyncIterable; - cancel(): void; -} - -export interface Engine { - start(input: TurnInput): TurnRun; - /** Continue a suspended turn. Adopted runs require the original live parked turn. */ - resume(checkpoint: TurnCheckpoint, decisions: ReadonlyMap): TurnRun; - dispose(): Promise; -} - -/** Live dependencies are injected once, so turn inputs and checkpoints stay serializable. */ -export type EngineDeps = { - readonly models: ModelRegistry; - readonly tools: ToolRegistry; - readonly settings: SettingsReader; -}; - -export type CreateEngine = (deps: EngineDeps) => Engine; - -export interface Compactor { - /** Returns a shorter history with `summary` items standing in for older spans. */ - compact(history: readonly Item[], model: ModelRef, signal: AbortSignal): Promise; - shouldCompact(history: readonly Item[], model: ModelRef): boolean; -} -// Everything above serves the bridge and goes when it does. The turn surface -// below is the direct path, and its types stand alone for that reason. export type { AcceptedTurnEvent, ExecutionId, diff --git a/rewrite/src/host/index.ts b/rewrite/src/host/index.ts index b59f97952..864873d20 100644 --- a/rewrite/src/host/index.ts +++ b/rewrite/src/host/index.ts @@ -1,17 +1,17 @@ /** * host/ — transport and access, nothing else. * - * Hands clients a `Connection` to a session. The in-process host is what - * the TUI uses. A server host, not built yet, will expose the same contract - * over HTTP for the gateway's web client, ACP and Discord. Auth, pairing and - * workspace admission live here; turn logic does not. + * Hands clients a `DirectConnection` to one session. The in-process host + * (`in-process-direct.ts`, contracts in `in-process-direct-types.ts`) owns the + * session store, sends each wire command to `protocol/direct-commands.ts` and + * fans the projected wire events out to every attached client. The TUI, the + * headless client, ACP and the control socket all connect through it. The HTTP + * server (`server.ts` and its `server-*` files) exposes the same host to the + * gateway's web client with auth, pairing and workspace admission; it never + * drives a turn or writes conversation state itself. * Depends on: protocol, session, config. */ -import type { WireCommand, WireCommandResult, WireEvent, WireSnapshot } from '../protocol/index.js'; -import type { SessionStore } from '../session/index.js'; -import type { SettingsSource } from '../config/index.js'; - export type Principal = { readonly userId: string; /** 'local' for the in-process host, otherwise how the caller authenticated. */ @@ -22,51 +22,3 @@ export type WorkspaceGrant = { readonly workspaceId: string; readonly access: 'read' | 'read_write'; }; - -export interface Connection { - readonly sessionId: string; - readonly snapshot: WireSnapshot; - /** Events after `snapshot.seq`, then live. Ends when the connection closes. */ - readonly events: AsyncIterable; - send(command: WireCommand): Promise; - close(): void; -} - -export interface Host { - listSessions( - principal: Principal, - cursor?: string, - ): Promise<{ - readonly sessions: readonly { readonly id: string; readonly title: string | null; readonly updatedAt: string }[]; - readonly nextCursor: string | null; - }>; - createSession(principal: Principal, grant: WorkspaceGrant): Promise; - /** `fromSeq` lets a reconnecting client skip what it already has. */ - connect(principal: Principal, sessionId: string, fromSeq?: number): Promise; -} - -export type HostDeps = { - readonly sessions: SessionStore; - readonly settings: SettingsSource; - /** Maps a granted workspace id to its root; this is where admission checks belong. */ - resolveWorkspace(grant: WorkspaceGrant): string; - readonly defaultModel: Parameters[1]; -}; - -export type InProcessHostFactory = (deps: HostDeps) => Host; - -export type ServerHostOptions = { - readonly listen: { readonly host: string; readonly port: number }; - readonly allowedWorkspaces: readonly { - readonly id: string; - readonly root: string; - readonly access: 'read' | 'read_write'; - }[]; -}; - -export interface ServerHost extends Host { - start(): Promise<{ readonly url: string }>; - stop(): Promise; -} - -export type ServerHostFactory = (deps: HostDeps, options: ServerHostOptions) => ServerHost; diff --git a/rewrite/src/protocol/index.ts b/rewrite/src/protocol/index.ts index 31ec55727..a4b8218b2 100644 --- a/rewrite/src/protocol/index.ts +++ b/rewrite/src/protocol/index.ts @@ -2,106 +2,29 @@ * protocol/ — the one public wire format. * * Every client, local or remote, sees sessions only through these types. - * `toWire` is the single projection from session updates to public events: - * it bounds sizes and strips private fields (paths, credentials, opaque - * provider state). Adding an engine event means touching this file once. + * Two files carry a message across this layer: + * + * - Inbound, `direct-commands.ts` is the only wire-to-domain translation: it + * turns a client's `WireCommand` into one call on the live `DirectSession`. + * - Outbound, `direct-projection.ts` turns each accepted session update into + * `WireEvent`s once, folding them with `direct-reducer.ts` into the snapshot a + * newly attached client renders from. It bounds sizes and strips private + * fields (paths, credentials, opaque provider state). + * + * `direct-wire.ts` defines the format; `direct-mapping.md` records how each + * event maps to the old CLI's output. Clients import these files directly; this + * index names the layer's public surface. * Depends on: session (and the types it re-exports). */ -import type { SessionState, SessionStatus, SessionUpdate } from '../session/index.js'; - -export const PROTOCOL_VERSION = 1; - -export type WireEventType = - | 'turn_started' - | 'text_delta' - | 'reasoning_delta' - | 'message' - | 'tool_started' - | 'tool_progress' - | 'tool_finished' - | 'interaction_opened' - | 'interaction_resolved' - | 'usage' - | 'retrying' - | 'compacted' - | 'subagent' - | 'status_changed' - | 'turn_ended'; - -export type WireEvent = { - readonly sessionId: string; - /** Log sequence for persisted facts; null for live-only events such as deltas. */ - readonly seq: number | null; - readonly turnId: string | null; - readonly type: WireEventType; - readonly payload: Readonly>; -}; - -export type WireMessage = { - readonly id: string; - readonly role: 'user' | 'assistant' | 'tool'; - readonly text: string; - readonly toolName?: string; - readonly isError?: boolean; -}; - -export type WireInteraction = { - readonly interactionId: string; - readonly approvals: readonly { - readonly callId: string; - readonly toolName: string; - readonly summary: string; - readonly risk?: 'low' | 'medium' | 'high'; - readonly agentId?: string; - }[]; -}; - -/** What a client needs to render a session from scratch before streaming events. */ -export type WireSnapshot = { - readonly protocolVersion: typeof PROTOCOL_VERSION; - readonly sessionId: string; - readonly status: SessionStatus; - readonly model: { readonly provider: string; readonly model: string }; - readonly messages: readonly WireMessage[]; - readonly pendingInteraction: WireInteraction | null; - readonly canRetry: boolean; - readonly seq: number; -}; - -export type WireCommand = - | { - readonly type: 'submit'; - readonly requestId: string; - readonly text: string; - readonly attachments?: readonly unknown[]; - } - | { - readonly type: 'decide'; - readonly interactionId: string; - readonly decisions: readonly { readonly callId: string; readonly approve: boolean; readonly reason?: string }[]; - } - | { readonly type: 'cancel' } - | { readonly type: 'retry' } - | { readonly type: 'compact' } - | { readonly type: 'set_model'; readonly provider: string; readonly model: string }; - -export type WireCommandResult = - | { readonly ok: true; readonly turnId?: string; readonly queuedPosition?: number } - | { readonly ok: false; readonly code: string; readonly message: string }; - -export interface Projection { - /** Returns null for updates clients never see. */ - toWire(sessionId: string, update: SessionUpdate): WireEvent | null; - snapshot(sessionId: string, state: SessionState): WireSnapshot; -} - -// Direct runtime types: explicitly separate from the bridge vocabulary above. export { DIRECT_PROTOCOL_VERSION } from './direct-wire.js'; export type { - WireCommand as DirectWireCommand, WireCommandResult as DirectWireCommandResult, + WireCommand as DirectWireCommand, + WireCommandResult as DirectWireCommandResult, WireConfigureRequest as DirectWireConfigureRequest, - WireEvent as DirectWireEvent, WireSnapshot as DirectWireSnapshot, - WireMessage as DirectWireMessage, WireInteraction as DirectWireInteraction, + WireEvent as DirectWireEvent, + WireSnapshot as DirectWireSnapshot, + WireMessage as DirectWireMessage, + WireInteraction as DirectWireInteraction, } from './direct-wire.js'; export type { DirectCommandCapabilities } from './direct-capabilities.js'; diff --git a/rewrite/src/session/adopted/conversation-log.ts b/rewrite/src/session/adopted/conversation-log.ts index d2249488d..38ecefd44 100644 --- a/rewrite/src/session/adopted/conversation-log.ts +++ b/rewrite/src/session/adopted/conversation-log.ts @@ -1,52 +1,23 @@ -import { ModelSelectionSchema } from '../../../../source/services/settings/model-selection.js'; +/** + * The adopted session-fact union and its validator. Production no longer + * writes these facts; the offline v3 conversation migration + * (scripts/conversation-migration/v3-input.mjs) imports `validateSessionFact` + * from the emitted module to check old canonical logs before converting them. + */ import { validateSettingsBaseline, - validateMarkedSettings, type SessionSettingsInitialized, } from '../../../../source/services/settings/session-runtime-settings.js'; -import { projectNetworkFacts, validateNetworkFact, type NetworkFact } from './network.js'; -/** - * Adopt the source conversation JSONL record boundary for the new session - * layer. The canonical file remains the only durable session authority; - * source replay, identity resolution and delta merge stay in source/. - */ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; +import { validateNetworkFact, type NetworkFact } from './network.js'; import type { ModelRef } from '../../model/index.js'; import type { Workspace } from '../../tools/index.js'; import { sanitizePendingInteractionDto, type PendingInteractionDto, } from '../../../../source/gateway/interaction-protocol.js'; -import { - SIDECAR_EVENT_TYPES, - type LogEvent, - type LogEnvelope, - type PersistedLogEvent, - type SessionInitEvent, -} from '../../../../source/services/logging/conversation-log-events.js'; -import { - createConversationLogWriter, - type AtomicConversationLogWriter, -} from '../../../../source/services/logging/conversation-log-writer.js'; -import { - loadConversationUnscopedForIndex, - readConversationLogEnvelopesFromDirectory, - type RestoredState, -} from '../../../../source/services/conversation/conversation-persistence.js'; -import { - replayEvents, - replayEventsWithRetryTarget, -} from '../../../../source/services/conversation/conversation-replay.js'; -import { auditSessionLog } from '../../../../source/services/conversation/session-audit.js'; -import { - decodeLogEnvelope, - type PersistedLogEnvelope, -} from '../../../../source/services/conversation/conversation-decoder.js'; -import type { SessionLog } from '../index.js'; +import type { LogEvent } from '../../../../source/services/logging/conversation-log-events.js'; +import { decodeLogEnvelope } from '../../../../source/services/conversation/conversation-decoder.js'; -export type ExistingConversationLogEvent = LogEvent; export type { PendingInteractionDto }; export type SessionBinding = { readonly ownerUserId: string; @@ -138,21 +109,8 @@ export type SessionLifecycleFact = readonly retentionEligibleAt: string; }; -export type SessionFact = - | ExistingConversationLogEvent - | SessionLifecycleFact - | SessionSettingsInitialized - | NetworkFact; +export type SessionFact = LogEvent | SessionLifecycleFact | SessionSettingsInitialized | NetworkFact; /** Unknown and truncated legacy events are retained verbatim at the decoder boundary. */ -export type StoredSessionFact = SessionFact; -export type SessionEventReference = { readonly logId: string; readonly eventId: string }; -export type SessionLogEntry = { - readonly seq: number; - readonly at: string; - readonly fact: StoredSessionFact; - readonly ref: SessionEventReference; -}; - const LIFECYCLE_TYPES = new Set([ 'session_settings_initialized', 'session_network_requested', @@ -331,301 +289,3 @@ export function validateSessionFact(value: unknown): asserts value is SessionFac return; } } - -function validateStoredEntry(entry: PersistedLogEnvelope): SessionLogEntry { - const fact = entry.event as unknown; - if (isObject(fact) && typeof fact['type'] === 'string' && LIFECYCLE_TYPES.has(fact['type'])) - validateSessionFact(fact); - const logId = entry.logId; - const eventId = entry.eventId; - if (!NON_EMPTY(logId) || !NON_EMPTY(eventId)) - throw new Error(`conversation event ${entry.seq} has no stable identity`); - return { seq: entry.seq, at: entry.ts, fact: fact as StoredSessionFact, ref: { logId, eventId } }; -} - -export type CanonicalSessionLogOptions = { - readonly root: string; - readonly id: string; - readonly metadata: Partial> & { readonly createdAt?: string }; - readonly workspace: Workspace; - readonly model: ModelRef; - readonly binding?: SessionBinding; - readonly logger: Parameters[0]['logger']; - readonly fileSystem?: Parameters[0]['fileSystem']; -}; -export type CanonicalSessionLogOpenOptions = Pick; - -/** One canonical JSONL log, with the source writer's lock/liveness rules. */ -export class CanonicalSessionLog implements SessionLog { - readonly id: string; - readonly root: string; - readonly #writer: AtomicConversationLogWriter; - #entries: SessionLogEntry[]; - #appendTail: Promise = Promise.resolve(); - - private constructor(id: string, root: string, writer: AtomicConversationLogWriter, entries: SessionLogEntry[]) { - this.id = id; - this.root = root; - this.#writer = writer; - this.#entries = entries; - } - - static create(options: CanonicalSessionLogOptions): CanonicalSessionLog { - const { root, id } = resolveSafeLogPath(options.root, options.id); - fs.mkdirSync(root, { recursive: true }); - assertCanonicalFile(root, id, false); - if (fs.existsSync(path.join(root, `${id}.jsonl`))) throw new Error(`conversation ${id} already exists`); - const writer = createConversationLogWriter({ - sessionId: id, - dir: root, - logger: options.logger, - saveLast: () => undefined, - fileSystem: options.fileSystem, - }); - const init: Omit = { - ...options.metadata, - id, - createdAt: options.metadata.createdAt ?? new Date().toISOString(), - }; - const bindingFact: SessionLifecycleFact = { - type: 'session_bound', - version: 1, - workspace: options.workspace, - model: options.model, - ...(options.binding ? { binding: options.binding } : {}), - }; - validateSessionFact(bindingFact); - try { - writer.initBatch(init, [bindingFact]); - const entries = loadEntries(id, root); - return new CanonicalSessionLog(id, root, writer, entries); - } catch (error) { - void writer.close(); - throw error; - } - } - - static open(options: CanonicalSessionLogOpenOptions): CanonicalSessionLog { - const { root, id } = resolveSafeLogPath(options.root, options.id); - assertCanonicalFile(root, id, true); - const entries = loadEntries(id, root); - const init = entries.find((entry) => entry.fact.type === 'session_init')?.fact as SessionInitEvent | undefined; - if (!init) throw new Error(`conversation ${id} has no session_init record`); - const writer = createConversationLogWriter({ - sessionId: id, - dir: root, - logger: options.logger, - saveLast: () => undefined, - fileSystem: options.fileSystem, - }); - writer.openExisting({ ...init, id }); - try { - // Re-read after acquiring the writer lock; the first read was only to - // obtain source metadata and cannot supply a writable projection. - return new CanonicalSessionLog(id, root, writer, loadEntries(id, root)); - } catch (error) { - void writer.close(); - throw error; - } - } - - /** Read-only canonical load for list projections; it never takes a lock or repairs a tail. */ - static readOnly(root: string, id: string): readonly SessionLogEntry[] { - const safe = resolveSafeLogPath(root, id); - assertCanonicalFile(safe.root, id, true); - return loadEntries(id, safe.root); - } - - get lastSeq(): number { - return this.#entries.at(-1)?.seq ?? 0; - } - entries(): readonly SessionLogEntry[] { - return this.#entries; - } - - async append(facts: readonly SessionFact[]): Promise { - const operation = this.#appendTail.then(() => this.#appendNow(facts)); - this.#appendTail = operation.then( - () => undefined, - () => undefined, - ); - return operation; - } - - async #appendNow(facts: readonly SessionFact[]): Promise { - if (facts.length === 0) return []; - for (const fact of facts) { - validateSessionFact(fact); - if (fact.type === 'session_bound' && this.#entries.some((entry) => entry.fact.type === 'session_bound')) { - throw new Error('session_bound is immutable once written'); - } - } - validatePendingRevisionSequence([...this.#entries.map((entry) => entry.fact), ...facts]); - validateSettingsSequence([...this.#entries.map((entry) => entry.fact), ...facts]); - projectNetworkFacts([...this.#entries.map((entry) => entry.fact), ...facts]); - const previousSeq = this.lastSeq; - const sidecars = facts.filter((fact) => SIDECAR_EVENT_TYPES.has(fact.type as LogEvent['type'])); - for (const fact of sidecars) this.#writer.append(fact as LogEvent); - const durable = facts.filter((fact) => !SIDECAR_EVENT_TYPES.has(fact.type as LogEvent['type'])); - if (durable.length) await this.#writer.appendBatch(durable); - this.#entries = loadEntries(this.id, this.root); - return this.#entries.filter((entry) => entry.seq > previousSeq); - } - - async *read(fromSeq = 0): AsyncIterable { - await this.#appendTail; - for (const entry of this.#entries) if (entry.seq > fromSeq) yield entry; - } - - async flush(): Promise { - await this.#appendTail; - await this.#writer.flush(); - } - async dispose(): Promise { - await this.#appendTail; - await this.#writer.close(); - } - async deleteFiles(): Promise { - await this.#appendTail; - await this.#writer.deleteSessionFiles(); - } -} - -function resolveSafeLogPath(inputRoot: string, id: string): { root: string; id: string; file: string } { - if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id)) throw new Error('invalid session id'); - const root = path.resolve(inputRoot); - const file = path.resolve(root, `${id}.jsonl`); - if (path.dirname(file) !== root) throw new Error('session path escapes conversation directory'); - return { root, id, file }; -} - -function assertCanonicalFile(root: string, id: string, mustExist: boolean): void { - const { file } = resolveSafeLogPath(root, id); - if (!fs.existsSync(root)) { - if (mustExist) throw new Error(`conversation directory does not exist: ${root}`); - return; - } - const rootReal = fs.realpathSync(root); - if (path.resolve(rootReal) !== path.resolve(root)) - throw new Error('conversation root must be a canonical directory path'); - try { - const stat = fs.lstatSync(file); - if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('session path is not a regular file'); - if (path.dirname(fs.realpathSync(file)) !== rootReal) - throw new Error('session path escapes conversation directory'); - } catch (error) { - if (mustExist || !(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; - } -} - -function loadEntries(id: string, root: string): SessionLogEntry[] { - const envelopes = readConversationLogEnvelopesFromDirectory(id, root); - if (!envelopes) throw new Error(`conversation ${id} could not be read`); - const entries = envelopes.map(validateStoredEntry); - if (entries.filter((entry) => entry.fact.type === 'session_bound').length > 1) { - throw new Error(`conversation ${id} has more than one immutable session_bound fact`); - } - validatePendingRevisionSequence(entries.map((entry) => entry.fact)); - validateSettingsSequence(entries.map((entry) => entry.fact)); - projectNetworkFacts(entries.map((entry) => entry.fact)); - return entries; -} - -function validateSettingsSequence(facts: readonly StoredSessionFact[]): void { - let bound = false; - let baseline = false; - let model: { provider: string; model: string } | undefined; - for (const fact of facts) { - if (fact.type === 'session_bound') { - bound = true; - model = fact.model; - } - if (fact.type === 'settings_changed') { - if (Object.hasOwn(fact, 'sessionMutation')) { - validateMarkedSettings(fact as unknown as Record); - if (!baseline) throw new Error('settings mutation before baseline'); - } - if (fact.key === 'agent.modelSelection') { - const parsed = ModelSelectionSchema.safeParse(fact.value); - if (parsed.success) model = parsed.data; - } - if (fact.key === 'agent.model' && typeof fact.value === 'string' && model) - model = { ...model, model: fact.value }; - if (fact.key === 'agent.provider' && typeof fact.value === 'string' && model) - model = { ...model, provider: fact.value }; - } - if (fact.type === 'session_settings_initialized') { - validateSettingsBaseline(fact as unknown as Record); - if (!bound || baseline || !model || fact.model.provider !== model.provider || fact.model.model !== model.model) - throw new Error('invalid session settings baseline order or model'); - baseline = true; - } - } -} - -function validatePendingRevisionSequence(facts: readonly StoredSessionFact[]): void { - const revisions = new Map(); - for (const fact of facts) { - if (fact.type !== 'session_interaction_pending') continue; - const key = JSON.stringify([fact.turnId, fact.interaction.interactionId, fact.generation]); - const previous = revisions.get(key); - if (previous !== undefined && fact.interaction.revision <= previous) { - throw new Error('session interaction pending revision must increase for its turn, identity, and generation'); - } - revisions.set(key, fact.interaction.revision); - } -} - -/** Source's existing decoder/replay path, including provider-native item preservation. */ -export function loadCanonicalConversation( - id: string, - root: string, - finalization: 'live' | 'reopened' = 'reopened', -): RestoredState { - if (finalization === 'live') { - const envelopes = readConversationLogEnvelopesFromDirectory(id, root); - if (!envelopes) throw new Error(`conversation ${id} could not be replayed`); - return replayEvents(envelopes, { finalization }); - } - const result = loadConversationUnscopedForIndex(id, root); - if (result.status === 'loaded' || result.status === 'project_mismatch') return result.conversation; - if (result.status === 'unreadable') { - throw new Error(`conversation ${id} could not be replayed: ${String(result.error)}`); - } - throw new Error(`conversation ${id} is unavailable (${result.status})`); -} - -/** Identity is derived by the same canonical replay that validates retry references. */ -export function loadCanonicalRetryTarget(id: string, root: string, finalization: 'live' | 'reopened' = 'live') { - const envelopes = readConversationLogEnvelopesFromDirectory(id, root); - if (!envelopes) throw new Error(`conversation ${id} could not be replayed`); - return replayEventsWithRetryTarget(envelopes, { finalization }).retryTarget; -} - -/** Source's established interrupted-turn audit; it is observational and never resumes work. */ -export function auditCanonicalConversation(id: string, root: string) { - const envelopes = readConversationLogEnvelopesFromDirectory(id, root); - if (!envelopes) throw new Error(`conversation ${id} could not be audited`); - return auditSessionLog(envelopes); -} - -export function normalizedBodyHash(value: unknown): string { - const canonical = (input: unknown): unknown => { - if (Array.isArray(input)) return input.map(canonical); - if (isObject(input)) { - return Object.fromEntries( - Object.keys(input) - .sort() - .map((key) => [key, canonical(input[key])]), - ); - } - return input; - }; - return crypto - .createHash('sha256') - .update(JSON.stringify(canonical(value))) - .digest('hex'); -} - -export type AtomicSessionEnvelope = LogEnvelope; -export type ReadableSourceFact = LogEvent | PersistedLogEvent; diff --git a/rewrite/src/session/adopted/network.ts b/rewrite/src/session/adopted/network.ts index ab07bef01..96dece8c1 100644 --- a/rewrite/src/session/adopted/network.ts +++ b/rewrite/src/session/adopted/network.ts @@ -1,5 +1,4 @@ -/** Private Session-owned network facts and ordered projection. */ -import { validateCompactionSequence } from '../../../../source/services/conversation/conversation-decoder.js'; +/** Network-approval facts of the adopted session log, validated for the v3 migration. */ import { isSandboxNetworkHost, isSandboxNetworkPort, @@ -77,29 +76,3 @@ export function validateNetworkFact(value: Record): void { return; throw new Error('invalid network resolution'); } - -/** Reject impossible history rather than silently making an orphan actionable. */ -export function projectNetworkFacts(facts: readonly { readonly type: string }[]): readonly NetworkRequestFact[] { - validateCompactionSequence(facts, { requireSessionBound: true }); - const seen = new Set(); - const pending: NetworkRequestFact[] = []; - for (const raw of facts) { - if (raw.type === 'context_compaction_recorded') { - if (pending.length > 0) throw new Error('context_compaction_recorded while network approval is pending'); - } else if (raw.type === 'session_network_requested') { - const fact = raw as NetworkRequestFact; - if (seen.has(fact.requestId)) throw new Error('duplicate network request id'); - seen.add(fact.requestId); - pending.push(fact); - } else if (raw.type === 'session_network_resolved') { - const fact = raw as NetworkResolutionFact; - const index = pending.findIndex( - (request) => request.requestId === fact.requestId && request.generation === fact.generation, - ); - if (index < 0 || (fact.resolution.kind === 'answered' && index !== 0)) - throw new Error('invalid network resolution order or identity'); - pending.splice(index, 1); - } - } - return pending; -} diff --git a/rewrite/src/session/index.ts b/rewrite/src/session/index.ts index 325fafe0c..e1de5a2c8 100644 --- a/rewrite/src/session/index.ts +++ b/rewrite/src/session/index.ts @@ -1,140 +1,24 @@ /** - * session/ — one canonical conversation log and replay-derived session state. - * Native engine checkpoints are never a restart authority. Provider history - * and transcript decoding are adopted from source/services/conversation. - * The fact types are defined in adopted/conversation-log.ts and re-exported - * here: most of them are the old CLI's log events, and only adopted files may - * import source/. + * session/ — one durable conversation: an append-only log and the state + * replayed from it. + * + * `direct-store.ts` creates and opens sessions in the native conversations + * directory and hands the host a `DirectSession` (`direct-session.ts`), the + * live facade over one `Conversation` (`conversation.ts`). Only the + * conversation writes facts. A submitted message is admitted to the log, then + * `conversation-turn.ts` starts the engine turn and is the sole reader of its + * events: it appends each accepted item, answers interactions, and records the + * terminal outcome. `facts.ts` is the log's vocabulary, `log.ts` and + * `direct-rolling-log.ts` write it, and `replay.ts` folds it back into state. + * + * When a tool needs approval, the turn stays parked in memory and the log holds + * the pending interaction. A process that dies there reopens the turn as + * interrupted; nothing resumes on its own. + * + * The host contracts below are what the host and app import. + * Depends on: engine, model, tools, config. */ -import type { Item, ModelRef } from '../model/index.js'; -import type { Workspace } from '../tools/index.js'; -import type { EngineEvent, TurnId } from '../engine/index.js'; -import type { ToolCallId } from '../model/index.js'; -import type { - PendingInteractionDto, - SessionBinding, - SessionEventReference, - SessionFact, - SessionLifecycleFact, - SessionLogEntry, -} from './adopted/conversation-log.js'; - -export type { PendingInteractionDto, SessionBinding, SessionEventReference, SessionFact, SessionLifecycleFact }; -export type SessionId = string & { readonly __brand: 'SessionId' }; -export type ClientRequestId = string & { readonly __brand: 'ClientRequestId' }; -export type InteractionId = string & { readonly __brand: 'InteractionId' }; -export type LogEntry = SessionLogEntry; - -export type SessionStatus = 'idle' | 'running' | 'awaiting_interaction' | 'interrupted' | 'closed'; - -export type PendingInteraction = { - readonly turnId: TurnId; - readonly interaction: PendingInteractionDto; - readonly generation: string; -}; - -export type AdmissionRecord = { - readonly clientRequestId: ClientRequestId; - readonly normalizedBodyHash: string; - readonly turnId: TurnId; - readonly disposition: 'started' | 'queued'; - readonly createdAt: string; - readonly expiresAt: string; - readonly result?: 'accepted' | 'failed' | 'aborted' | 'queue_discarded'; -}; - -export type SessionState = { - readonly id: SessionId; - readonly status: SessionStatus; - readonly workspace: Workspace; - readonly model: ModelRef; - /** Compatibility/UI view. Exact provider items are retained in providerHistory. */ - readonly history: readonly Item[]; - /** Complete adopted provider transcript, including opaque provider-native items. */ - readonly providerHistory: readonly unknown[]; - readonly messages: readonly unknown[]; - readonly activeTurnId: TurnId | null; - readonly pendingInteraction: PendingInteraction | null; - readonly queue: readonly AdmissionRecord[]; - readonly admissions: Readonly>; - /** Retry eligibility supplied by the adopted ConversationService policy. */ - readonly retryEligibility: { readonly turn: boolean; readonly tool: boolean }; - /** Compatibility alias for retryEligibility.turn. */ - readonly canRetry: boolean; - readonly binding: SessionBinding | null; - readonly closedAt: string | null; - readonly lastSeq: number; -}; - -/** The log owns writes and supplies stable event references for rollback facts. */ -export interface SessionLog { - append(facts: readonly SessionFact[]): Promise; - read(fromSeq?: number): AsyncIterable; - flush(): Promise; - /** Releases the writer lock; this is not a logical session close. */ - dispose(): Promise; - readonly lastSeq: number; -} - -export type Project = (state: SessionState, entry: LogEntry) => SessionState; - -export type SessionUpdate = - | { readonly kind: 'fact'; readonly entry: LogEntry } - | { readonly kind: 'live'; readonly turnId: TurnId; readonly event: EngineEvent }; - -export type SessionPosition = { readonly generation: string; readonly position: number }; -export type CapturedSessionUpdate = { readonly position: SessionPosition; readonly update: SessionUpdate }; -export type SessionCapture = { - readonly state: SessionState; - readonly position: SessionPosition; - readonly unsubscribe: () => void; -}; - -export type Admission = - | { readonly kind: 'started'; readonly turnId: TurnId } - | { readonly kind: 'queued'; readonly position: number } - | { readonly kind: 'duplicate'; readonly turnId: TurnId | null } - | { readonly kind: 'rejected'; readonly reason: 'queue_full' | 'closed' | 'request_conflict' }; - -export type SessionDecision = { - readonly turnId: TurnId; - readonly interactionId: InteractionId; - readonly revision: number; - readonly generation: string; - readonly decisions: readonly { readonly callId: ToolCallId; readonly approve: boolean; readonly reason?: string }[]; -}; - -export interface Session { - readonly id: SessionId; - state(): SessionState; - submit(requestId: ClientRequestId, items: readonly Item[]): Promise; - decide(decision: SessionDecision): Promise; - cancel(): Promise; - retry(): Promise; - compact(): Promise; - setModel(model: ModelRef): Promise; - subscribe(fromSeq: number, listener: (update: SessionUpdate) => void): () => void; - capture(listener: (update: CapturedSessionUpdate) => void): SessionCapture; - /** Disposes the live handle while retaining the conversation for later open. */ - close(): Promise; - /** Writes session_closed with one fixed retention deadline. */ - closeLog(closedAt?: string, retentionEligibleAt?: string): Promise; -} - -export type SessionSummary = Pick & { - readonly title: string | null; - readonly updatedAt: string; -}; - -export interface SessionStore { - create(workspace: Workspace, model: ModelRef, binding?: SessionBinding): Promise; - /** Replays the canonical log and durably marks an unfinished prior process interrupted before returning. */ - open(id: SessionId, expectedBinding?: SessionBinding): Promise; - list(cursor?: string): Promise<{ readonly sessions: readonly SessionSummary[]; readonly nextCursor: string | null }>; -} - -// New-path host contracts, deliberately distinct from the bridge Session above. export type { DirectAdmission, DirectBinding, From ba56bbfe31d4767c20b16c018b5b264eb1c8f4d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:16:35 +0000 Subject: [PATCH 11/17] refactor(rewrite): delete dead helpers and the app-side publish seam prepareDirectHistoryRetry/Rewind, isMessageBody, DIRECT_LEGACY_JSON_FINAL_WINDOW_AVAILABLE, directAcpWorkspaces and the workspace authority's reresolve/admitRoot have no callers. The direct store forwarded publishTask/Branch/Notice to optional app-supplied publishers that no app code provides; DirectConversationRuntime now excludes them and the store publishes to the session alone. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/src/app/direct-host-config-access.ts | 25 ----- .../src/app/direct-host-config-workspace.ts | 47 --------- rewrite/src/app/direct-host-config.ts | 7 +- rewrite/src/engine/direct-recovery-history.ts | 59 +---------- rewrite/src/host/server-validation.ts | 12 --- rewrite/src/protocol/direct-json.ts | 98 ++++++++++++++----- rewrite/src/protocol/direct-mapping.md | 6 +- rewrite/src/session/direct-store.ts | 19 ++-- rewrite/src/session/direct-types.ts | 1 + 9 files changed, 84 insertions(+), 190 deletions(-) diff --git a/rewrite/src/app/direct-host-config-access.ts b/rewrite/src/app/direct-host-config-access.ts index e405b49fa..513c1e866 100644 --- a/rewrite/src/app/direct-host-config-access.ts +++ b/rewrite/src/app/direct-host-config-access.ts @@ -198,28 +198,3 @@ function parseLine(line: string): FactLine | undefined { return undefined; } } - -/** ACP admits only an exact canonical grant root for this principal. Tool approval stays off. */ -export function directAcpWorkspaces(options: { - readonly authority: DirectWorkspaceAuthority; - readonly principal: Principal; - readonly directory: string; -}): { - authorize(cwd: string): Promise; - cwd(sessionId: string): Promise; - readonly toolPosture: { readonly autoApprove: false; readonly allowUnsandboxed: false }; -} { - return { - async authorize(cwd) { - return options.authority.admitRoot(options.principal, cwd); - }, - async cwd(sessionId) { - const started = sessionStart(options.directory, sessionId); - if (!started.binding || started.binding.client !== options.principal.userId) throw forbidden(); - const grant = options.authority.admitRoot(options.principal, started.root); - if (grant.workspaceId !== started.binding.id) throw forbidden(); - return options.authority.resolveWorkspace(options.principal, grant).workspace.root; - }, - toolPosture: { autoApprove: false, allowUnsandboxed: false }, - }; -} diff --git a/rewrite/src/app/direct-host-config-workspace.ts b/rewrite/src/app/direct-host-config-workspace.ts index e2522319f..d7cde5a1c 100644 --- a/rewrite/src/app/direct-host-config-workspace.ts +++ b/rewrite/src/app/direct-host-config-workspace.ts @@ -158,42 +158,6 @@ export class DirectWorkspaceAuthority { }; } - /** - * Open/list re-check. Stored access is never replaced by a wider grant. - * A narrower current grant fails instead of silently downgrading a writable session. - */ - reresolve( - principal: Principal, - stored: { root: string; access: WorkspaceAccess; workspaceId?: string }, - ): WorkspaceAuthorityResult { - const owner = requirePrincipal(principal); - if (stored.access !== 'read' && stored.access !== 'read_write') { - throw new DirectHostConfigError('workspace_authority_revoked', 'authority'); - } - const root = canonicalizeUse(stored.root); - const record = stored.workspaceId ? this.#owned(principal, stored.workspaceId) : this.#onlyRoot(owner, root); - if (record.root !== root) throw new DirectHostConfigError('workspace_grant_mismatch', 'authority'); - if (rank(record.access) < rank(stored.access)) throw new DirectHostConfigError('write_not_allowed', 'authority'); - const access: WorkspaceAccess = stored.access === 'read' ? 'read' : record.access; - this.#assertBoundary(root, access, 'authority'); - return { - workspace: { root, access, target: 'local' }, - workspaceId: record.workspaceId, - grantVersion: record.grantVersion, - }; - } - - /** ACP/host admission of an exact canonical root. Access comes from the grant, not the caller. */ - admitRoot(principal: Principal, root: string): WorkspaceGrant { - const canonical = canonicalizeUse(root); - const record = this.#byRoot(canonical); - if (!record || record.ownerUserId !== requirePrincipal(principal)) { - throw new DirectHostConfigError('workspace_authority_revoked', 'authority'); - } - this.#assertBoundary(canonical, record.access, 'authority'); - return { workspaceId: record.workspaceId, access: record.access }; - } - /** Store callback has only a path. Ambiguous or missing roots fail closed. */ resolveStoredRoot(root: string): Workspace { const canonical = canonicalizeUse(root); @@ -219,13 +183,6 @@ export class DirectWorkspaceAuthority { return record; } - #onlyRoot(owner: string, root: string): GrantRecord { - const record = this.#byRoot(root); - if (!record || record.ownerUserId !== owner) - throw new DirectHostConfigError('workspace_authority_revoked', 'authority'); - return record; - } - #byRoot(root: string): GrantRecord | undefined { return [...this.#grants.values()].find((grant) => grant.root === root); } @@ -295,7 +252,3 @@ function selection(record: GrantRecord): CandidateSelection { }, }; } - -function rank(access: WorkspaceAccess): number { - return access === 'read_write' ? 2 : 1; -} diff --git a/rewrite/src/app/direct-host-config.ts b/rewrite/src/app/direct-host-config.ts index 218ac4030..dd90e3b0f 100644 --- a/rewrite/src/app/direct-host-config.ts +++ b/rewrite/src/app/direct-host-config.ts @@ -50,10 +50,5 @@ export function createDirectProductPorts(options: { settingsDir?: string } = {}) ...createDirectAccountPorts(service), }; } -export { - bindDirectSessionAccess, - directAcpWorkspaces, - directServeToolPosture, - sessionStart, -} from './direct-host-config-access.js'; +export { bindDirectSessionAccess, directServeToolPosture } from './direct-host-config-access.js'; export type { DirectBoundSessionStore } from './direct-host-config-access.js'; diff --git a/rewrite/src/engine/direct-recovery-history.ts b/rewrite/src/engine/direct-recovery-history.ts index 30bf61035..4e6040754 100644 --- a/rewrite/src/engine/direct-recovery-history.ts +++ b/rewrite/src/engine/direct-recovery-history.ts @@ -1,60 +1,5 @@ -/** Transcript edits are proposals; only Session may commit them. */ -import type { Item, ItemId } from '../model/index.js'; - -export type DirectHistoryEdit = - | { readonly kind: 'unavailable'; readonly reason: 'no_anchor' | 'unsettled_tools' | 'incomplete_tool_round' } - | { - readonly kind: 'replace'; - readonly history: readonly Item[]; - readonly removed: readonly Item[]; - readonly anchor: ItemId; - readonly disableChaining: true; - }; - -/** - * Retry-turn regenerates the latest genuine user turn, even if it completed. - * Retry-tool keeps the last tool output itself and removes only later items; - * neither operation executes a tool or rolls back physical effects. Session - * must retain execution audit facts independently of the edited transcript. - */ -export function prepareDirectHistoryRetry(input: { - readonly history: readonly Item[]; - readonly target: 'turn' | 'tool'; - readonly executionsSettled: boolean; -}): DirectHistoryEdit { - if (!input.executionsSettled) return { kind: 'unavailable', reason: 'unsettled_tools' }; - const type = input.target === 'turn' ? 'user_message' : 'tool_result'; - for (let index = input.history.length - 1; index >= 0; index--) { - if (input.history[index]!.type === type) return editAfter(input.history, index); - } - return { kind: 'unavailable', reason: 'no_anchor' }; -} - -/** Rewind to an existing inclusive ItemId; no file restore or tool execution. */ -export function prepareDirectHistoryRewind(input: { - readonly history: readonly Item[]; - readonly anchor: ItemId; - readonly executionsSettled: boolean; -}): DirectHistoryEdit { - if (!input.executionsSettled) return { kind: 'unavailable', reason: 'unsettled_tools' }; - return editAfter( - input.history, - input.history.findIndex((item) => item.id === input.anchor), - ); -} - -function editAfter(history: readonly Item[], index: number): DirectHistoryEdit { - if (index < 0) return { kind: 'unavailable', reason: 'no_anchor' }; - const retained = history.slice(0, index + 1); - if (!hasCompleteDirectToolPairs(retained)) return { kind: 'unavailable', reason: 'incomplete_tool_round' }; - return { - kind: 'replace', - history: retained, - removed: history.slice(index + 1), - anchor: history[index]!.id, - disableChaining: true, - }; -} +/** Tool call/result pairing checks for recovery and history edits. */ +import type { Item } from '../model/index.js'; /** Check occurrence-local call/result pairing without assuming globally unique provider ids. */ export function hasCompleteDirectToolPairs(history: readonly Item[]): boolean { diff --git a/rewrite/src/host/server-validation.ts b/rewrite/src/host/server-validation.ts index a618edad8..8ba961ce8 100644 --- a/rewrite/src/host/server-validation.ts +++ b/rewrite/src/host/server-validation.ts @@ -182,18 +182,6 @@ export function hasAttachments(body: unknown): boolean { ); } -export function isMessageBody(body: unknown): body is { text: string; clientRequestId: string } { - if (!body || typeof body !== 'object' || Array.isArray(body)) return false; - const value = body as Record; - return ( - Object.keys(value).every((key) => key === 'text' || key === 'clientRequestId') && - typeof value.text === 'string' && - value.text.length > 0 && - value.text.length <= 128_000 && - isOpaqueId(value.clientRequestId) - ); -} - export function isTurnBody(body: unknown): body is { turnId: string } { return ( !!body && diff --git a/rewrite/src/protocol/direct-json.ts b/rewrite/src/protocol/direct-json.ts index e6aecb5de..88b7a3589 100644 --- a/rewrite/src/protocol/direct-json.ts +++ b/rewrite/src/protocol/direct-json.ts @@ -5,18 +5,24 @@ import type { CommandMessage } from '../../../source/tools/types.js'; import type { ModelRequestCost } from '../../../source/services/cost/model-cost.js'; import { getModelPricing } from '../../../source/services/cost/pricing.js'; -import type { CommandMessageEvent, CostUpdateEvent, FinalResponseEvent, ReasoningDeltaEvent, RetryEvent, RunBudgetEventNotice, TextDeltaEvent, ToolCallStreamingDeltaEvent, ToolDispatchedEvent, ToolStartedEvent, UsageUpdateEvent } from '../../../source/services/conversation/conversation-events.js'; +import type { + CommandMessageEvent, + CostUpdateEvent, + FinalResponseEvent, + ReasoningDeltaEvent, + RetryEvent, + RunBudgetEventNotice, + TextDeltaEvent, + ToolCallStreamingDeltaEvent, + ToolDispatchedEvent, + ToolStartedEvent, + UsageUpdateEvent, +} from '../../../source/services/conversation/conversation-events.js'; import type { WireBudgetEvidence, WireEvent, WireFinalWindow, WireUsage } from './direct-wire.js'; // Retained presentation helpers cross the source boundary here, never in clients. export { formatGoalClosedByCheck } from '../../../source/services/conversation/durable-goal.js'; -/** Static negative capability for the complete old ConversationEvent schema. - * The bounded accepted final window is exposed separately on WireTerminal; - * it does not supply providerHistory or the remaining legacy event families. - */ -export const DIRECT_LEGACY_JSON_FINAL_WINDOW_AVAILABLE = false; - /** Copy the actual text chunk. fullText is supplied only by a caller that has * observed this message's stream from its first delta; no transcript is treated * as a complete prefix. */ @@ -31,21 +37,32 @@ export function legacyTextDelta( * delta. No fullText: native transients do not supply a complete replayable prefix. * Ownership remains on the wire envelope; this helper creates no block identity. */ -export function legacyReasoningDelta(event: Extract): ReasoningDeltaEvent { +export function legacyReasoningDelta( + event: Extract, +): ReasoningDeltaEvent { return { type: 'reasoning_delta', delta: event.payload.delta }; } /** A planned call is the old tool_started boundary. The source DTO requires a * durable tool call ID, so a missing wire call ID is unavailable, not synthesized. */ -export function legacyToolStarted(event: Extract): ToolStartedEvent | undefined { +export function legacyToolStarted( + event: Extract, +): ToolStartedEvent | undefined { const { tool } = event.payload; return tool.callId === undefined ? undefined - : { type: 'tool_started', toolCallId: tool.callId, toolName: tool.toolName, arguments: structuredClone(tool.input) }; + : { + type: 'tool_started', + toolCallId: tool.callId, + toolName: tool.toolName, + arguments: structuredClone(tool.input), + }; } /** Physical start is the old tool_dispatched boundary; same exact ID requirement. */ -export function legacyToolDispatched(event: Extract): ToolDispatchedEvent | undefined { +export function legacyToolDispatched( + event: Extract, +): ToolDispatchedEvent | undefined { const { tool } = event.payload; return tool.callId === undefined ? undefined @@ -53,10 +70,21 @@ export function legacyToolDispatched(event: Extract>([ - 'hallucination', 'parsing_error', 'behavior', 'flex_service_tier', 'upstream', 'conversation_state', 'connection_interrupted', + 'hallucination', + 'parsing_error', + 'behavior', + 'flex_service_tier', + 'upstream', + 'conversation_state', + 'connection_interrupted', ]); const RETRY_ERROR_KINDS = new Set>([ - 'network', 'provider', 'rate_limit', 'authentication', 'cancelled', 'unknown', + 'network', + 'provider', + 'rate_limit', + 'authentication', + 'cancelled', + 'unknown', ]); /** Only emit the source retry DTO when the wire actually carries its required @@ -99,7 +127,7 @@ export function legacyFinalResponse( requestId: (nativeId: string) => string = (nativeId) => nativeId, ): LegacyFinalResponse { const record = window.record; - const commandMessages = record.commandMessages.filter(message => !emittedCommandMessageIds.has(message.id)); + const commandMessages = record.commandMessages.filter((message) => !emittedCommandMessageIds.has(message.id)); // The old final DTO carried provider-facing result items and completed text, // not the durable tool_call admission items. Build a provider result view only // when the accepted window proves no private/unrepresentable provider content @@ -132,10 +160,12 @@ export function legacyFinalResponse( type: 'final', finalText: record.finalText, ...(record.reasoningText !== undefined ? { reasoningText: record.reasoningText } : {}), - ...(commandMessages.length > 0 ? { commandMessages: commandMessages.map(message => structuredClone(message)) } : {}), + ...(commandMessages.length > 0 + ? { commandMessages: commandMessages.map((message) => structuredClone(message)) } + : {}), ...(record.usage !== undefined ? { usage: legacyUsage(record.usage) } : {}), ...(record.costRecords.length > 0 - ? { costRecords: record.costRecords.map(cost => legacyCostRecord(cost, requestId(cost.requestId))) } + ? { costRecords: record.costRecords.map((cost) => legacyCostRecord(cost, requestId(cost.requestId))) } : {}), ...(turnItems.length > 0 ? { turnItems } : {}), ...(record.terminalCause !== undefined ? { terminalCause: record.terminalCause } : {}), @@ -222,14 +252,17 @@ export function legacyCommandMessages( const messages = Array.isArray(display) ? display : display && typeof display === 'object' && 'messages' in display - ? display.messages - : undefined; + ? display.messages + : undefined; if (!Array.isArray(messages)) return []; - return messages.filter(isCommandMessage).map(message => { + return messages.filter(isCommandMessage).map((message) => { options.onSourceId?.(message.id); return { type: 'command_message', - message: options.providerCallId === true ? legacyCommandMessage(message, event.payload.callId) : structuredClone(message), + message: + options.providerCallId === true + ? legacyCommandMessage(message, event.payload.callId) + : structuredClone(message), }; }); } @@ -240,7 +273,8 @@ export function legacyCommandMessages( * foreign tool's ID). */ function legacyCommandMessage(message: CommandMessage, providerCallId: string | undefined): CommandMessage { const copy = structuredClone(message); - if (providerCallId === undefined || typeof copy.callId !== 'string' || !copy.callId.startsWith('direct:')) return copy; + if (providerCallId === undefined || typeof copy.callId !== 'string' || !copy.callId.startsWith('direct:')) + return copy; const prefix = `${copy.callId}-`; return { ...copy, @@ -253,17 +287,27 @@ function legacyCommandMessage(message: CommandMessage, providerCallId: string | function isCommandMessage(value: unknown): value is CommandMessage { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const message = value as Record; - return typeof message.id === 'string' && message.sender === 'command' - && typeof message.command === 'string' && typeof message.output === 'string' - && ['pending', 'running', 'completed', 'failed', 'aborted', 'unknown'].some(status => status === message.status) - && ['success', 'isApprovalRejection', 'autoApprovedByLlm'].every(key => message[key] === undefined || typeof message[key] === 'boolean') - && ['failureReason', 'toolName', 'callId'].every(key => message[key] === undefined || typeof message[key] === 'string'); + return ( + typeof message.id === 'string' && + message.sender === 'command' && + typeof message.command === 'string' && + typeof message.output === 'string' && + ['pending', 'running', 'completed', 'failed', 'aborted', 'unknown'].some((status) => status === message.status) && + ['success', 'isApprovalRejection', 'autoApprovedByLlm'].every( + (key) => message[key] === undefined || typeof message[key] === 'boolean', + ) && + ['failureReason', 'toolName', 'callId'].every( + (key) => message[key] === undefined || typeof message[key] === 'string', + ) + ); } /** Emit only the old actual count/name DTO. Producing turn/step/call/branch scope * stays in the wire envelope; no raw delta or finalized call is reconstructed. */ -export function legacyToolCallStreamingDelta(event: Extract): ToolCallStreamingDeltaEvent { +export function legacyToolCallStreamingDelta( + event: Extract, +): ToolCallStreamingDeltaEvent { return { type: 'tool_call_streaming_delta', ...(event.payload.toolName !== undefined ? { toolName: event.payload.toolName } : {}), diff --git a/rewrite/src/protocol/direct-mapping.md b/rewrite/src/protocol/direct-mapping.md index 5c012d3f4..dac571664 100644 --- a/rewrite/src/protocol/direct-mapping.md +++ b/rewrite/src/protocol/direct-mapping.md @@ -498,9 +498,9 @@ provider; no price or charge is invented. Zero cache counters are omitted from legacy usage. Coverage limits remain documented here rather than emitted as an extra field that old consumers did not define. -`DIRECT_LEGACY_JSON_FINAL_WINDOW_AVAILABLE` remains false as a declaration about -the complete legacy ConversationEvent/private-history schema, not the bounded -terminal window. Headless consumes the final window and exact live slices. +The direct JSON output does not reproduce the complete legacy +ConversationEvent/private-history schema; it carries only the bounded terminal +window. Headless consumes the final window and exact live slices. In log version 2, `recoverDirectFinalWindow` projects each root terminal window read-only from its accepted fact prefix when a preceding `turn_started` exists. This is current-format projection, not `derivedFromAcceptedFacts`/`legacyDerived` diff --git a/rewrite/src/session/direct-store.ts b/rewrite/src/session/direct-store.ts index a9dbbdb30..923f35302 100644 --- a/rewrite/src/session/direct-store.ts +++ b/rewrite/src/session/direct-store.ts @@ -35,7 +35,8 @@ export type DirectRuntimeContext = { readonly configuration?: DirectConfigurationState; }; export type DirectConversationRuntime = { - readonly deps: Omit; + /** The store supplies the log and the live publishers itself; app supplies neither. */ + readonly deps: Omit; readonly request: Omit; readonly profile: SessionStartedFact['profile']; readonly appVersion: string; @@ -214,18 +215,10 @@ export function createDirectSessionStore(options: DirectSessionStoreOptions): Di const liveDeps = { ...ownedRuntime.deps, rotationBoundary: () => rollingLog.head(), - publishTask: (event: import('./direct-background-types.js').DirectBackgroundProgress) => { - ownedRuntime.deps.publishTask?.(event); - publish(event); - }, - publishBranch: (event: import('./facade-types.js').DirectBranchTransient) => { - ownedRuntime.deps.publishBranch?.(event); - publish(event); - }, - publishNotice: (notice: import('./direct-memory.js').DirectAutomaticMemoryNotice) => { - ownedRuntime.deps.publishNotice?.(notice); - publish({ type: 'automatic_memory_notice', notice }); - }, + publishTask: (event: import('./direct-background-types.js').DirectBackgroundProgress) => publish(event), + publishBranch: (event: import('./facade-types.js').DirectBranchTransient) => publish(event), + publishNotice: (notice: import('./direct-memory.js').DirectAutomaticMemoryNotice) => + publish({ type: 'automatic_memory_notice', notice }), }; if (context.metadata) return openConversation({ ...liveDeps, log: observedLog, facts: initial }); return createConversation({ diff --git a/rewrite/src/session/direct-types.ts b/rewrite/src/session/direct-types.ts index 67f72c6d7..86edc512c 100644 --- a/rewrite/src/session/direct-types.ts +++ b/rewrite/src/session/direct-types.ts @@ -116,6 +116,7 @@ export type ConversationDeps = TurnDeps & { /** Store-owned physical boundary, never supplied by app as write authority. */ readonly rotationBoundary?: () => import('./direct-rolling-log.js').DirectLogBoundary; readonly authorizeNested?: import('./direct-branches.js').DirectNestedAuthorizer; + /** Store-owned live publishers: they forward transients to the session's subscribers, never to the log. */ readonly publishNotice?: (notice: import('./direct-memory.js').DirectAutomaticMemoryNotice) => void; readonly publishBranch?: (event: import('./facade-types.js').DirectBranchTransient) => void; readonly publishTask?: (event: import('./direct-background-types.js').DirectBackgroundProgress) => void; From 46ebec54960014ceba58d75ad54bfe470093b08c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:23:17 +0000 Subject: [PATCH 12/17] fix(rewrite): help no longer marks dispatched subcommands unavailable --help starred serve, acp and discord as 'Not available in the direct executable yet', though all three dispatch, and no option carries a refusal any more. The footnote now prints only while some option is refused. The --profile line takes legacy's wording (bare [default] ...; standard/full/lite = bare), which matches canonicalProfileId. cli.ts loses comments claiming --fork and --ssh are still refused. /effort reads VALID_REASONING_EFFORTS instead of a third hand-kept copy; its messages are unchanged. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/src/app/cli-args.ts | 15 +++++++++------ rewrite/src/app/cli.ts | 4 ---- rewrite/src/app/direct-launch-tui-commands.ts | 7 ++++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/rewrite/src/app/cli-args.ts b/rewrite/src/app/cli-args.ts index 6011ded1a..93ca05e05 100644 --- a/rewrite/src/app/cli-args.ts +++ b/rewrite/src/app/cli-args.ts @@ -92,7 +92,7 @@ const FLAGS: readonly Flag[] = [ opt( ['--profile'], ' ', - 'Select a profile (full, bare, plan, pair, mentor, orchestrator; lite = bare)', + 'Select a profile (bare [default], plan, pair, mentor, orchestrator; standard/full/lite = bare)', undefined, 'required', ), @@ -168,6 +168,10 @@ https://github.com/qduc/term2/blob/main/docs/reference/discord-bridge.md Candidate: live Discord/mobile validation is still outstanding. `; +// Printed only while some option is still refused, so the marker never outlives the refusal. +const refusalNote = FLAGS.some((flag) => flag.refusal) + ? '\n* Not available in the direct executable yet. These fail with an error; they never start the current CLI.' + : ''; const optionLines = FLAGS.map((flag) => ` ${flag.label.padEnd(37)}${flag.help}${flag.refusal ? ' *' : ''}`).join('\n'); export const HELP = `Usage: @@ -180,17 +184,17 @@ Options: ${optionLines} Subcommands: - term2 serve --local-owner [--state-dir ] [--socket ] * + term2 serve --local-owner [--state-dir ] [--socket ] [--listen --tls-cert --tls-key ] [--allow-remote] [--pairing] [--bff-key =]... [--workspace-root ]... [--issuer ] [--audience ] [--allow-write] Run the local web gateway until SIGINT/SIGTERM. - term2 acp [--provider ] [--model ] [--effort ] * + term2 acp [--provider ] [--model ] [--effort ] Serve the Agent Client Protocol v2 over stdio. Stdout carries protocol traffic only. term2 control list [--status] | status [--json] | get [--json] | submit|steer --id | interrupt | wait-turn --message-id Talk to a live control socket. - term2 discord [config.json] | --save-token [config.json] | --help * + term2 discord [config.json] | --save-token [config.json] | --help Run the Discord bridge. --help prints its reference. Settings: @@ -198,8 +202,7 @@ Settings: The direct CLI supports one prompt per invocation. --resume [conversation-id|last] selects a native conversation; bare --resume selects the last one for this workspace. Use --resume ls (or list) to browse. -A non-interactive resume requires a follow-up prompt. -* Not available in the direct executable yet. These fail with an error; they never start the current CLI. +A non-interactive resume requires a follow-up prompt.${refusalNote} `; /** A usage error the current CLI reports with exit 2 (its argument parser's convention). Other parse errors stay exit 1. */ diff --git a/rewrite/src/app/cli.ts b/rewrite/src/app/cli.ts index c64a8abdd..af8e3594d 100644 --- a/rewrite/src/app/cli.ts +++ b/rewrite/src/app/cli.ts @@ -301,7 +301,6 @@ async function runHeadless(prompt: string, options: SessionOptions): Promise { lite: options.lite, ...(options.resumeId === null ? {} : { resumeId: options.resumeId }), ...(controlSocket === undefined ? {} : { controlSocket }), - // Still refused in cli-args.ts until the tool surface can run a remote workspace. ...(options.ssh === null ? {} : { ssh: options.ssh }), - // --fork is still refused in cli-args.ts until the fork operation is on the candidate. ...(options.fork ? { fork: true } : {}), ...(options.goal === null ? {} : { goal: options.goal }), }; diff --git a/rewrite/src/app/direct-launch-tui-commands.ts b/rewrite/src/app/direct-launch-tui-commands.ts index 75d3d8556..d83e80995 100644 --- a/rewrite/src/app/direct-launch-tui-commands.ts +++ b/rewrite/src/app/direct-launch-tui-commands.ts @@ -1,4 +1,5 @@ import { formatModelUsageBreakdown, summarizeModelUsage } from '../../../source/services/cost/model-cost.js'; +import { VALID_REASONING_EFFORTS } from '../../../source/services/models/reasoning-effort.js'; import { resetSession } from '../clients/tui/commands.js'; import { loadProviderItems } from '../../../source/providers/provider-service.js'; /** @@ -161,10 +162,10 @@ export function createDirectTuiCommands(options: DirectTuiCommandOptions): TuiIn const effort = async (connection: TuiConnection, value: string | undefined): Promise => { if (!value) return { - message: 'Use /effort default|none|minimal|low|medium|high|xhigh.', + message: `Use /effort ${VALID_REASONING_EFFORTS.join('|')}.`, }; - if (!['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(value)) - throw new Error(`Unknown effort '${value}'. Use: default, none, minimal, low, medium, high, xhigh.`); + if (!(VALID_REASONING_EFFORTS as readonly string[]).includes(value)) + throw new Error(`Unknown effort '${value}'. Use: ${VALID_REASONING_EFFORTS.join(', ')}.`); return configure( connection, { reasoning: value as NonNullable }, From f16f40c3ccb4b824d630ccd86af8ef7abbd3da33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:23:19 +0000 Subject: [PATCH 13/17] docs(rewrite): describe the production direct-* path README: the layer table, rules, 'How a message travels' and 'Where an approval waits' now follow the direct-* files a headless prompt passes through (the path hops.mts measures); the native-tracer and C2-proof sections and bridge-only checks go; 'Owner scope' says dropped means no parity work, and that MCP, ACP, profiles and the sandbox are still wired. Layer READMEs, INTEGRATION docs and file headers drop references to the bridge Session/Host, LocalSessionStore, app/run.ts and scoped settings; app/README names the three reasoning-effort validators. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- .../approvals-and-sandbox/guard-ledger.md | 2 +- rewrite/README.md | 304 ++++++++---------- rewrite/src/app/README.md | 32 +- rewrite/src/clients/acp/README.md | 2 +- rewrite/src/clients/headless/INTEGRATION.md | 14 +- rewrite/src/clients/headless/direct.ts | 2 +- rewrite/src/config/README.md | 26 +- rewrite/src/engine/INTEGRATION.md | 7 - rewrite/src/host/in-process-direct-types.ts | 2 +- rewrite/src/model/README.md | 2 - rewrite/src/protocol/direct-mapping.md | 3 +- rewrite/src/protocol/direct-wire.ts | 4 +- rewrite/src/session/VALIDATION-REPLAY.md | 12 - rewrite/src/session/facade-types.ts | 2 +- 14 files changed, 167 insertions(+), 247 deletions(-) diff --git a/docs/plans/approvals-and-sandbox/guard-ledger.md b/docs/plans/approvals-and-sandbox/guard-ledger.md index 3c7bb1b26..5dfb40748 100644 --- a/docs/plans/approvals-and-sandbox/guard-ledger.md +++ b/docs/plans/approvals-and-sandbox/guard-ledger.md @@ -55,7 +55,7 @@ fail-closed parsing, no global/Standard additions and no subagent grant. ## Direct headless path generation limits (2026-10-31) -Owner: `withGenerationGuard` in `rewrite/src/app/run.ts`, reusing the existing +Owner: `withGenerationGuard` in `rewrite/src/app/generation-guard.ts`, reusing the existing `GenerationGuard` and `GenerationStreamDeadlines` unchanged. Recovery owner: the user, through the failed turn and a later follow-up. Scope: requests on the M1 direct executable only; the old run loop keeps its own instance. Class: inactivity diff --git a/rewrite/README.md b/rewrite/README.md index 5dd569b97..81e737660 100644 --- a/rewrite/README.md +++ b/rewrite/README.md @@ -1,51 +1,46 @@ -# term2 rewrite skeleton +# term2-next (rewrite) -This folder holds the interfaces for a restructured term2 and its production -composition. The earlier native-engine example lives in a separate fixture -directory so its toy facts and restart behavior cannot define canonical -session persistence. It is separate from `source/`, and nothing in `source/` -imports it. +This folder holds term2-next: a restructured term2 whose production entry is +`src/app/cli.ts`, built to `rewrite/dist/rewrite/src/app/cli.js`. It is separate +from `source/` (legacy term2, which remains the product spec), and nothing in +`source/` imports it. ## Layers Each folder in `src/` is one layer, named after its job. A layer may only use the layers listed after its name, so dependencies always point down. -Most layers also have an `adopted/` folder. Code there wraps the existing -`source/` implementation behind the layer's interface, which is how the port -reuses source implementations while the layers are filled in. Native `app/` -composes production directly; historical source-agent assembly is bench-only, -and Session owns the remaining historical handle lifetime. - -| Layer | Job | May depend on | -| ----------- | ----------------------------------------------------------- | ------------------------------ | -| `clients/` | Render the protocol (TUI, headless, ACP, Discord) | host, protocol | -| `host/` | Transport and access: in-process or HTTP server, auth | protocol, session, config | -| `protocol/` | The one public event and command format, one projection | session and everything below | -| `session/` | One durable conversation: append-only log + one reducer | engine, model, tools, config | -| `engine/` | The turn loop: suspends for approval, checkpoints, compacts | model, tools, config | -| `tools/` | Schema, approval policy and execute function per tool | model, config | -| `model/` | Providers behind one streaming interface, item format | nothing | -| `config/` | Settings, scoped per session | nothing | -| `app/` | Composition root: the only place concrete layers are wired | everything | +Production files are the `direct-*`, `turn*`, `conversation*` and `server-*` +families plus the layer's `index.ts`. `session/adopted/` is the one adopted +folder left: it keeps the old session-fact validator for the offline v3 +conversation migration and is not reached from the CLI. + +| Layer | Job | May depend on | +| ----------- | ------------------------------------------------------------- | ------------------------------ | +| `clients/` | Render the protocol (TUI, headless, ACP, control, Discord) | host, protocol | +| `host/` | Transport and access: in-process host, HTTP server, auth | protocol, session, config | +| `protocol/` | The one public event and command format, one projection | session and everything below | +| `session/` | One durable conversation: append-only log and its replay | engine, model, tools, config | +| `engine/` | The turn loop: waits for answers, compacts, recovers | model, tools, config | +| `tools/` | Tool contracts: schema, approval policy and execute function | model, config | +| `model/` | Providers behind one streaming interface, item format | nothing | +| `config/` | Settings reader contract | nothing | +| `app/` | Composition root: the only place concrete layers are wired | everything | ## Rules that make it different from `source/` -- **One log.** `session/` stores a single append-only log of `SessionFact`s. - History, status, the pending approval, the queue and retry eligibility are - all projections of it, so there is no second journal to keep in sync. The - fact types live in `session/adopted/conversation-log.ts`, because most of - them are the old CLI's conversation-log events and only adopted files may - import `source/`. -- **One projection.** `protocol/` turns session updates into `WireEvent`s - once. The TUI reaches it through the in-process host. The gateway's web - client, ACP and Discord will reach the same protocol through an HTTP server - host that is not built yet, so no client keeps its own event translator. -- **The engine suspends instead of calling up.** An approval ends the turn run - with a serializable `TurnCheckpoint`. The native tracer fixture may inspect - that value, but canonical sessions never resume native checkpoints after a - process restart. In production the adopted engine keeps the old run loop - parked in memory under that checkpoint, so a decision continues it. +- **One log.** A session stores a single append-only JSONL log of native facts + (`session/facts.ts`). History, status, the pending interaction, the queue and + retry eligibility are all replayed from it (`session/replay.ts`), so there is + no second journal to keep in sync. +- **One projection.** `protocol/direct-projection.ts` turns session updates + into wire events once per session, and every client attached to that session + reads the same events, so no client keeps its own event translator. +- **The engine waits instead of calling up.** An approval or question is an + `interaction_required` event; the turn waits in memory for the session to + answer it, and the session alone writes the pending interaction to the log. + A process that dies while a turn waits reopens that turn as interrupted; + nothing resumes on its own. - **Names are jobs.** No layer is called service, adapter or orchestrator. ## Shared JSON transport contract @@ -58,67 +53,53 @@ their existing exports for callers. Moving the implementation does not change which values validate; in particular finite numbers and shared acyclic objects remain accepted, while ancestor cycles are rejected. -## Native tracer fixture - -The pre-C2 native engine example is isolated under `bench/native-tracer/` with -fixture-local session facts. It calls the fixture Session API directly and is -not evidence for the canonical conversation log or C2 acceptance. It verifies -that approval and in-flight tool work reopen as interrupted, stale decisions -fail, reopening adds no duplicate facts, and opening does not resume the model -or execute a tool. Compile it separately with -`rewrite/tsconfig.native-tracer.json`. - -What building it changed in the interfaces: - -- `TurnInput` held live `Tool` objects and a settings reader, so a - `TurnCheckpoint` could not be persisted. It now carries tool names and the - workspace, and live dependencies go in once through `EngineDeps`. -- The host built user `Item`s from wire commands, which the layer check - rejected (`host/` may not import `model/`). Inbound command handling moved - to `protocol/commands.ts`, so the protocol owns both directions. -- `HostDeps` gained `resolveWorkspace` and `defaultModel`; creating a session - needs both and the host had neither. - -Known gaps in the fixture, left out on purpose: no input queue (a busy session refuses), no -compaction, progress events are buffered until a tool returns, and each -checkpoint stores the full turn input. - ## How a message travels -This section describes the adopted bridge path. The direct headless program is -described under the layer guard below, and starts at `src/app/cli.ts`. - -1. A client turns the user's text into a `submit` `WireCommand` and sends it on - its host `Connection`. -2. `protocol/commands.ts` (`applyCommand`) turns that command into - `Session.submit(requestId, items)`. -3. The session (`session/local-store.ts`, with the turn driver in - `session/adopted/foreground.ts`) appends the admission facts, then calls - `Engine.start`. -4. The retained historical engine in `engine/adopted/` runs the old CLI's - `AgentClient` and run loop from `source/`, which call the provider through - `engine/adopted/provider-transports.ts`. The pool retains source transport - ownership, helper references and deferred close across detached reads; scoped - settings forks have independent lifetime and discovery. Its historical history - conversion stays in `engine/adopted/conversion.ts`. -5. Each fact the session appends, and each live engine event, reaches - subscribers as a `SessionUpdate`. The in-process host passes every update - through `Projection.toWire` (`protocol/projection.ts`) before the client - sees it. - -**Where an approval waits.** When a tool needs approval, the engine ends the -turn run as suspended with a `TurnCheckpoint`, and the old run loop stays -parked in memory. The turn's interaction owner -(`session/adopted/turn-interaction.ts`) appends `session_interaction_pending`, -and the session status becomes `awaiting_interaction`. The client answers with a -`decide` command. `applyCommand` adds the pending interaction's revision and -generation from session state and calls `Session.decide`, which resumes the -parked loop. If the process dies while it waits, reopening the session ends -that turn as interrupted; nothing resumes on its own. +This is the path `bench/hops.mts` measures for one headless prompt, from the +client's submit to the provider's HTTP request. The TUI, ACP and the control +socket attach to the same host the same way. + +1. `app/cli.ts` parses the command line and hands a headless run to + `app/direct-launch-headless.ts`. It resolves the workspace (local or SSH), + connects the configured MCP servers, builds the direct application + (`app/direct-application.ts`, which owns the in-process host), creates or + connects to the session through that host, and starts the headless client + on the resulting connection. +2. The client (`clients/headless/direct.ts`) sends a `submit` `WireCommand` on + its `DirectConnection` (`host/in-process-direct.ts`). +3. The host passes the command to `protocol/direct-commands.ts` + (`applyDirectCommand`), the only wire-to-domain translation, which calls + `submit` on the live `DirectSession` (`session/direct-session.ts`). +4. The session's `Conversation` (`session/conversation.ts`) appends the + admission facts and starts an engine turn with `startTurn` + (`engine/turn.ts`). `session/conversation-turn.ts` is the only reader of that + turn's events: it appends each event's facts to the log and then calls + `accept(token)`, and the turn does not advance past an event until it is + accepted. +5. `engine/turn-model.ts` asks for the model through the registry the app + built (`app/direct-application-models.ts`, wrapped by + `app/generation-guard.ts`). `model/provider.ts` streams from the source + provider in `source/providers/`, retrying through `model/retry.ts`. +6. On the way back, each accepted fact and live delta reaches the host as a + `DirectSessionUpdate`. `host/in-process-direct-attachment.ts` keeps one + `DirectProjection` per session (`protocol/direct-projection.ts`); it turns + the update into `WireEvent`s, which every connection to that session + receives in order. + +**Where an approval waits.** When a tool needs approval, the engine emits +`interaction_required` and waits in memory. `conversation-turn.ts` appends the +pending interaction, so the session status and the client's snapshot show it. +The client replies with an `answer` command, which `direct-commands.ts` turns +into an answer on the session, and `conversation-turn.ts` passes it to the +waiting turn with `TurnHandle.answer`. Headless decides with the policy in +`clients/headless/direct-approvals.ts`: without `--auto-approve` it denies; +with it, it allows everything except medium- or high-risk shell commands. The +TUI asks the user. If the process dies while it waits, reopening the +session ends that turn as interrupted. ## Keeping it readable -The point of this rewrite is that a person can follow it. Two checks in CI, +The point of this rewrite is that a person can follow it. Three checks in CI, with budgets in `scripts/complexity-budget.json`, and one review step keep that measurable: @@ -127,11 +108,16 @@ measurable: job, not a target: split along a real seam, or, when a file reads better whole, give it a ceiling with a one-line reason. When something gets smaller, the check prints the new number to record. -- `bench/hops.mts` sends one message and follows its async chain from Session - submit to the provider call. It fails when the path gets longer (hops, where - consecutive frames in one file count once) or touches more files. Today the - path is 29 hops through 7 files. Most of those hops are callbacks bouncing - between `source/lib/agent-client.ts` and `application-run-loop.ts`. +- `scripts/check-reachability.mjs` walks every import from `src/app/cli.ts` and + fails when a file under `src/` is never reached, so dead code cannot hide in + the layers. A file kept on purpose goes in + `scripts/reachability-allowlist.json` with the reason it stays. +- `bench/hops.mts` runs the real entry in-process against a local fake + provider, sends one headless prompt, and records the chain of files active + when the provider's HTTP request goes out, starting at the client's submit. + It fails when that chain gets longer (hops) or touches more files. Today it is + 14 hops through 14 files, one pass down the layers: clients, host, protocol, + session, engine, app, model, then the source provider. - At the end of each phase, a fresh reader gets only this README and each layer's `index.ts`, and explains how a message travels and where an approval waits. Whatever they get wrong gets fixed in the structure or the docs before @@ -154,20 +140,18 @@ plan" doc. provider request and the workspace files against `parity/goldens/`. Goldens are recorded only from the old architecture. -The layer guard follows imports from `src//adopted/` into `source/` and -checks every reached source layer against the table above. Existing dependency +The layer guard follows imports from `src/session/adopted/` and the direct +files named in `check-layers.mjs` into `source/`, and checks every reached +source layer against the table above. Existing dependency edges within `source/` that cross those rules are recorded one edge per line in `scripts/source-edge-allowlist.json`; the guard rejects newly added edges and prints stale entries for cleanup. It also reports reached source files missing -from the map. Adopted adapters and the direct path's short named list in -`check-layers.mjs` may import `source/`, and `source/` cannot import from -`rewrite/`. +from the map. Only `session/adopted/` and that named list may import +`source/`, and `source/` cannot import from `rewrite/`. Concrete source-tool assembly lives in `app/source-tools.ts`: it binds source run-code to a concrete nested approval owner and session access/policy state. -The native `tools/` contracts do not construct those upper-layer owners. Host -configuration types use the existing Session facade rather than importing -model/tool types directly. +The native `tools/` contracts do not construct those upper-layer owners. Source mappings describe jobs, not directory names: the input React context is client state, provider-management policy is model-owned, the plan-mode @@ -181,11 +165,12 @@ grandfathered, not evidence that clients or shared helpers have unrestricted model/config/Session access. Every reached target's closure is still checked; new source edges and reverse `source/` → `rewrite/` imports remain prohibited. -The direct headless path is a separate program, -`node rewrite/dist/rewrite/src/app/cli.js ""`, with no fallback to the -old engine. It stores native conversation logs in its own directory and does -not read old conversations. M1 is text-only: a model tool call fails the turn. -`rewrite/bench/m1-direct.mjs` covers it. +The direct CLI is a separate program, +`node rewrite/dist/rewrite/src/app/cli.js [""]`, with no fallback to the +old engine. It stores native conversation logs in its own directory +(`conversations-next`) and does not read old conversations; the offline +`scripts/migrate-conversations.mjs` copies v3 conversations into native logs. +`rewrite/bench/m1-direct.mjs` covers the headless path. For direct HTTP session configuration, the BFF's `session_update` assertion binds only `sessionId`, not `workspaceId`; session lookup is separate, and the gateway @@ -204,29 +189,24 @@ TERM2_TEST_CLI=/cli.js pnpm test:integration # CLI-level suite a These use the root project's TypeScript options and dependencies, so run `pnpm install` at the repository root first. The build keeps repository-relative -paths beneath `rewrite/dist/`; adopted imports therefore resolve to the emitted +paths beneath `rewrite/dist/`; imports of `source/` therefore resolve to the emitted `source/` tree beside `rewrite/`. ```sh pnpm exec tsc -p rewrite/tsconfig.json # production typecheck node rewrite/scripts/check-layers.mjs # production dependency direction +node rewrite/scripts/check-reachability.mjs # every src file is reached from app/cli.ts node rewrite/scripts/check-complexity.mjs # size budgets only shrink node --import tsx rewrite/bench/hops.mts # one message's path stays short node rewrite/scripts/build.mjs # production build -pnpm exec tsx rewrite/bench/c1-adopted-approval.mts # real adopted approval and canonical replay -pnpm exec tsx rewrite/bench/c1-adopted-kill.mts # parked approval process-kill recovery -pnpm exec tsc -p rewrite/tsconfig.native-tracer.json && node rewrite/bench-dist/rewrite/bench/native-tracer/tracer.js +node rewrite/bench/m1-direct.mjs # headless behaviour against the built CLI ``` -Capability-dependent adopted benches need an explicit profile rather than the -application default. In `c3a-goal.mts`, proposals select Pair because Bare omits -`user-interaction`; an unoffered `propose_goal` returns `Unknown tool` and never -opens the approval the bench waits for. The other goal modes retain default -Bare coverage. Its settings directory is isolated too: `disableFilePersistence` -disables writes, not loading existing settings. -The `c3a-interactions.mts` ask-navigation/cancellation modes likewise select Pair -and isolate settings; shell-approval modes stay on default Bare. An unoffered -`ask_user` produces the same unknown-tool result, not a pending interaction. +A profile-dependent probe needs an explicit profile rather than the +application default: Bare omits `user-interaction`, so an unoffered +`propose_goal` or `ask_user` returns `Unknown tool` and never opens the +interaction the probe waits for. Isolate the probe's settings directory too: +`disableFilePersistence` disables writes, not loading existing settings. The build script emits the TypeScript tree and copies the runtime assets just as the root build does: prompts come from `source/prompts/` and provider presets @@ -237,33 +217,6 @@ emitted `.cjs` through a URL, not a static import, so transitive compilation alone misses it. Without that entry, built `run_code` workers fail at startup before executing any tool, even though the CLI and provider turn can complete. -## C2 canonical-log proof - -The bounded C2 entry composes the existing source CLI/non-interactive runner -with `LocalSessionStore`; it is not the production CLI. Build the proof graph, -copy the source runtime assets beside it, then run old-writer/new-session resume -parity and the real approval-kill process proof: - -```sh -pnpm exec tsc -p rewrite/tsconfig.c2-proof.json -cp -r source/prompts rewrite/bench-dist/source/ -mkdir -p rewrite/bench-dist/source/providers -cp source/providers/provider-presets.json rewrite/bench-dist/source/providers/ -node rewrite/parity/run.mjs check --cli dist/cli.js --first-cli dist/cli.js --resumed-cli rewrite/bench-dist/rewrite/bench/c2-proof-cli.js resume-follow-up -node rewrite/bench/c2-approval-kill.mjs -node rewrite/bench/c2-answer-kill.mjs -node rewrite/bench/c2-storage-failure.mjs -node rewrite/bench/c2-boundary-failure.mjs -``` - -The proof CLI persists source materialized events and lifecycle boundaries -through the C2 store. Its approval-kill barrier exists only in the bench -composition. It does not cover the later C1 pre-tool-dispatch durability gate. -The fault matrix distinguishes failures before write, after admission, and at -pending/final event boundaries. A failed fsync may leave a complete atomic -terminal batch when the write reached the kernel first; the caller still -receives failure and the transaction is never partially projected. - ## Owner scope: dropped and deferred features Owner decisions on 2026-11-01 narrow current parity work to everyday Bare use @@ -272,32 +225,45 @@ mentor-only catalog entries needed by Bare remain. Shared or ambiguous settings and dependencies stay until their consumers are traced. These scope decisions are not permission to delete code or change the legacy CLI before the flip. -- **MCP servers — dropped.** A future return can reuse - `app/direct-mcp-source.ts` and `app/tool-surface.ts`, but needs the source MCP - manager, catalog binding, connection lifecycle and approval policy together. +"Dropped" below means no further parity work: the inventory marks the feature +`dropped`, and no scenario has to prove it. It does not mean term2-next lacks +the code. Each dropped feature is still wired in production, as noted, and +behaves as it does today. + +- **MCP servers — dropped from parity work, still wired.** The headless run + (`app/direct-launch-headless.ts`) and the TUI (`app/direct-launch-tui.tsx`) + connect the configured servers through `app/direct-application-mcp.ts` and + `app/direct-mcp.ts`, which drive the source MCP manager, and offer their tools + through `app/tool-surface.ts`. Fixing gaps against legacy would need the + catalog binding, connection lifecycle and approval policy together. - **Plan, pair, mentor and orchestrator profiles/modes, and - `run_agent_workflow` — dropped.** Reintegration would go through - `source/services/profiles/` and the offered tool surface, with profile prompts, - settings and workflow execution dependencies. Bare, `--lite`, subagent - `ask_orchestrator` and the retained mentor catalog are not dropped. -- **ACP — dropped.** `clients/acp/` remains the adapter seam, depending on session - attachment, rendering and permission/question mapping; it is not just a CLI - subcommand to re-enable. -- **Sandbox execution — dropped.** This crosses execution: a return needs shell - policy in `source/utils/shell/sandbox/`, approval rules and sandboxed code-host - lifecycle together, not a new isolated adapter. Shared settings remain. + `run_agent_workflow` — dropped from parity work.** `--profile` still selects + any of them through `source/services/profiles/`, and the TUI's `/plan`, + `/mentor` and `/orchestrator` commands still switch them; parity for their prompts, settings and workflow execution + is not being checked. Bare, `--lite`, subagent `ask_orchestrator` and the + retained mentor catalog are kept. +- **ACP — dropped from parity work, still wired.** `term2 acp` dispatches to + `app/direct-launch-acp.ts`, with `clients/acp/` as the adapter for session + attachment, rendering and permission/question mapping. +- **Sandbox execution — dropped from parity work, still present.** The TUI's + `/sandbox` command and the tool surface's `allowUnsandboxed` posture + (`app/tool-surface.ts`) remain, and `term2 serve` and `term2 acp` warn when + the sandbox is unavailable. A real return needs shell policy in + `source/utils/shell/sandbox/`, approval rules and the sandboxed code-host + lifecycle together. Shared settings remain. - **Web server/browser and canonical v2 wire — deferred, not dropped.** Retain candidate server/browser code, branches and the vendored wire artifact; gates - remain closed. Web is the milestone after kept-core M6 and before the default - flip, so there must be no gap where neither CLI serves it. The existing - `host/server` and browser/protocol seams still need the canonical gate, + remain closed (`AGENT_WIRE_V2_RUNTIME_AVAILABLE` is `false`). Web is the + milestone after kept-core M6 and before the default flip, so there must be no + gap where neither CLI serves it. The existing `host/server*` and + browser/protocol seams still need the canonical gate, branch/tool/approval browser paths, reconnect replay limits and aggregate cost contract established. Reconnect replay is protocol-demanding, not a UI-only plug-in. Run the existing real-browser plain-text turn exercise once at M6's end, not per commit; no web repair work is authorized now. Deferred inventory entries remain incomplete and block the default flip, just like `todo`. -The traced direct control/Discord runtime uses the in-process Host and direct +The traced direct control/Discord runtime uses the in-process host and direct protocol plus shared `source/services/control-socket/` and `source/services/discord/` modules, not the HTTP server or ACP backend. The CLI still statically imports `source/gateway/serve-args.ts` and diff --git a/rewrite/src/app/README.md b/rewrite/src/app/README.md index b4e5febd6..7df4f9deb 100644 --- a/rewrite/src/app/README.md +++ b/rewrite/src/app/README.md @@ -24,11 +24,8 @@ may reuse the already-resolved logger; canonical conversation persistence is separate and remains enabled. This is startup policy, not a runtime toggle. `createAppSettings` constructs only the concrete `SettingsService` through -`createDirectSettingsService`; it does not allocate the historical scoped-service -facade. The settings owner retains its directory, logger, and persistence policies. -Historical bench composition uses `createBenchSettings` in `rewrite/bench/source-app.mts` separately. Its engine -boundary translates source settings onto the source-independent config scope and -owns paired-service identity and runtime forks; native startup does not use it. +`createDirectSettingsService`. The settings owner retains its directory, logger, +and persistence policies. `createDirectRuntimes` in `direct-application-runtime.ts` builds session-owned settings and tool generations. `createDirectConfigSettings` in @@ -36,8 +33,11 @@ settings and tool generations. `createDirectConfigSettings` in snapshot; it does not persist selection changes. Its `prepareDirectSessionConfigurationCommand` validates HTTP selection intent, retains request identity and expected revision, and rejects unknown or empty -mutations. Reasoning validation uses the output object of the source agent -settings preprocess schema, not a second hand-maintained enum. +mutations. Reasoning effort has three validators, each reading a source-owned +list rather than its own copy: HTTP configuration uses the output object of the +source agent settings preprocess schema, while the `--reasoning` flag +(`cli.ts`) and the TUI's `/effort` command (`direct-launch-tui-commands.ts`) +check against `VALID_REASONING_EFFORTS`. `DirectGlobalConfiguration` in `direct-host-config-settings.ts` implements the host's public `SettingsProjection` rather than maintaining another projection @@ -255,22 +255,8 @@ and watch buffers do not survive process loss; unfinished recovered tasks become interrupted, not reattached subprocesses. Sandbox post-execution pauses are not introduced by this bridge. -## Historical bench composition +## Control socket and `/settings` -The production app has no historical agent/engine/session assembly facade. -`rewrite/bench/source-app.mts` builds each source-agent generation once and -publishes its exact final tool definitions into the stable -`engine/adopted/tool-catalog.ts` catalog. There is no second per-generation -catalog. Native tools still use `source-tools.ts` for parsing, approval, -execution and display. - -`session/adopted/session-handle.ts` owns historical graph lifetime, canonical -restore, settings/model forks, network and goal authorities, and ordered cleanup. -Its caller supplies one synchronous engine assembler through Session-owned -contracts; it never imports source app assembly or bench implementation upward. -This preserves historical bench coverage without keeping an app compatibility -facade or routing native startup through the old engine. - -The direct CLI supports the standalone `control` client and `--control-socket=` interactive startup. Both use the private `XDG_RUNTIME_DIR` advertisement and the same socket protocol; the help must not mark control as unavailable. A socket is opt-in, not started by a normal launch. +The direct CLI supports the standalone `control` client and `--control-socket=` interactive startup. Both use the private `XDG_RUNTIME_DIR` advertisement and the same socket protocol; the help must not mark control, or any other dispatched subcommand, as unavailable. A socket is opt-in, not started by a normal launch. The direct `/settings` command presents a category index and three-item category pages (`/settings [page]`) with descriptions and redacted secret values instead of dumping all settings into the unscrollable notice. Existing `/settings [value]` and reset routes remain the editing interface and retain their session/restart restrictions. diff --git a/rewrite/src/clients/acp/README.md b/rewrite/src/clients/acp/README.md index 38862beee..61b63f38f 100644 --- a/rewrite/src/clients/acp/README.md +++ b/rewrite/src/clients/acp/README.md @@ -3,7 +3,7 @@ This subtree implements experimental ACP v2 against the published direct protocol through `82d37ded91705090bc6b253301c7411ac5d5e389` (including `77bca90b`/`5451b43c`) and native keyed-command Session `f14c9baa`. Imports target -`protocol/direct-wire.ts` and `host/in-process-direct-types.ts`, not bridge types. +`protocol/direct-wire.ts` and `host/in-process-direct-types.ts`. Shared owner commits were inspected read-only, not copied/cherry-picked into this worker's older base. This is implementation, not M6 acceptance. diff --git a/rewrite/src/clients/headless/INTEGRATION.md b/rewrite/src/clients/headless/INTEGRATION.md index 0a08b1e91..fea85cdd0 100644 --- a/rewrite/src/clients/headless/INTEGRATION.md +++ b/rewrite/src/clients/headless/INTEGRATION.md @@ -5,8 +5,7 @@ aligned with the updated contract commit `0a27e134` (typed delivery, structured questions/answers, base64 images, admission identity, cursor replay and nested output). It consumes only `DirectConnection` from `host/in-process-direct-types.ts` and `protocol/direct-wire.ts`; it never -imports Session, Engine, adopted code or the bridge runtime, and never opens -conversation storage. +imports Session, Engine or adopted code, and never opens conversation storage. ## App wiring @@ -156,12 +155,11 @@ app can map SIGINT/SIGTERM to their signal exit codes rather than ordinary error ## Remaining shared contract gaps -- **Host assembly:** this candidate has the `DirectHost`/`DirectConnection` - types but no `createDirectInProcessHost`/direct store wiring. The host must - deliver the attach snapshot and ordered live events atomically, ack commands - without implying completion, and dispose sessions on shutdown. The app - integration owner switches `app/run.ts`/`app/cli.ts` to this client once that - exists. +- **Host assembly:** `app/direct-launch-headless.ts` runs every headless + prompt through this client on a connection from `createDirectInProcessHost` + (`host/in-process-direct.ts`). The host delivers the attach snapshot and + ordered live events atomically, acks commands without implying completion, + and disposes sessions on shutdown. - **`--json` schema:** the direct client emits `session_start`, exact live slices (`text_delta`, `reasoning_delta`, `tool_call_streaming_delta`, `tool_started`/`tool_dispatched` when the wire call ID is present, `retry` diff --git a/rewrite/src/clients/headless/direct.ts b/rewrite/src/clients/headless/direct.ts index 161aae0cb..395801924 100644 --- a/rewrite/src/clients/headless/direct.ts +++ b/rewrite/src/clients/headless/direct.ts @@ -4,7 +4,7 @@ * Command acknowledgement is admission, never completion. The run stays on the * ordered event stream until the submitted turn's `turn_ended`, and every * failure path states what happened instead of reporting success. It never - * opens storage, imports Session/Engine, or calls the bridge runtime. + * opens storage or imports Session/Engine. */ import { randomUUID } from 'node:crypto'; import type { DirectConnection } from '../../host/in-process-direct-types.js'; diff --git a/rewrite/src/config/README.md b/rewrite/src/config/README.md index a4ff21986..98146a407 100644 --- a/rewrite/src/config/README.md +++ b/rewrite/src/config/README.md @@ -1,18 +1,12 @@ -# Scoped settings +# Settings reader contract -`createScopedSettings` implements the config layer's `SettingsWriter` contract over -storage operations, without importing a source service, runtime settings schema, -or historical engine identity. The storage owner validates writes and throws when -a requested persistent write was not saved. +`index.ts` defines the one settings contract layers below `app/` see: +`SettingsReader`, with exact-key `get` and a `watch` that reports individual +changes, over branded `SettingKey`s and plain `SettingValue`s. It imports no +source service or settings schema. -Each view copies its readable/writable allowlists and override values. Exact-key -`settings.get` is distinct from `readPath`, which projects authorized descendants -for callers reading a parent object. Returned objects are snapshots. `settings.watch` -reports individual changes; `observe` reports a batch of changed readable keys. -Observers are passive, suppress unchanged values, and return an unsubscribe. - -`origin` distinguishes hidden, overlay, and storage-backed reads without naming a -source-service provenance enum. `replaceOverride` is a silent overlay update for -an owner that performs its own accepted-runtime refresh; ordinary writes notify -subscribers. Runtime model/reasoning/temperature translation and source-service -pair identity remain in the historical engine boundary, not in config. +Production settings are the source `SettingsService`, which `app/` constructs +(`app/settings.ts`, `createAppSettings`) and owns, with its directory, logger and +persistence policies. Tools read settings through `ToolContext.settings`; +`app/tool-surface.ts` builds that reader over the session's settings service, so +a tool sees the session's settings without importing the service. diff --git a/rewrite/src/engine/INTEGRATION.md b/rewrite/src/engine/INTEGRATION.md index cf1980e3c..924822111 100644 --- a/rewrite/src/engine/INTEGRATION.md +++ b/rewrite/src/engine/INTEGRATION.md @@ -22,13 +22,6 @@ When adding another execution-launch entry point, reuse the selected-start polic at that entry point, not just the body-liveness check or the root model-loop stop. Checking only “no next model request” misses a sibling's late nested launch. -## Historical adopted boundary - -The historical adopted boundary no longer contains a duplicate native-style -nested adapter. Live source nesting remains owned by the source runtime; native -nesting remains unchanged. - - ## Background launch ordering Session's `DirectBackgroundTasks` holds newly admitted background bodies with a diff --git a/rewrite/src/host/in-process-direct-types.ts b/rewrite/src/host/in-process-direct-types.ts index cd97dc1c0..180a4caf1 100644 --- a/rewrite/src/host/in-process-direct-types.ts +++ b/rewrite/src/host/in-process-direct-types.ts @@ -1,4 +1,4 @@ -/** Direct in-process host boundary; the bridge Host remains isolated in index.ts. */ +/** Direct in-process host boundary. `Principal` and `WorkspaceGrant` live in index.ts. */ import type { DirectSession, DirectSessionState, DirectSessionStore } from '../session/facade-types.js'; import type { Principal, WorkspaceGrant } from './index.js'; import type { WireCommand, WireCommandResult, WireEvent, WireSnapshot } from '../protocol/direct-wire.js'; diff --git a/rewrite/src/model/README.md b/rewrite/src/model/README.md index 459f22651..8727c2f2f 100644 --- a/rewrite/src/model/README.md +++ b/rewrite/src/model/README.md @@ -3,8 +3,6 @@ `createProviderRegistry` in `provider.ts` implements the native `ModelRegistry` contract from `index.ts` using registered source streamed-model factories. It owns request/item translation; the engine owns recovery admission and history. -The historical engine owns its source transport pool and history conversion separately; -its transport lifetime differs from this native registry. ## Flex service tier diff --git a/rewrite/src/protocol/direct-mapping.md b/rewrite/src/protocol/direct-mapping.md index dac571664..cc4c61f2f 100644 --- a/rewrite/src/protocol/direct-mapping.md +++ b/rewrite/src/protocol/direct-mapping.md @@ -1,8 +1,7 @@ # Direct public mapping and remaining producer contracts Scope: direct `direct-wire.ts`, `DirectProjection`, `reduceWireEvent` and -`host/in-process-direct*`. The old unprefixed protocol/index exports still serve -the isolated bridge. This document describes implementation, not runtime acceptance. +`host/in-process-direct*`. This document describes implementation, not runtime acceptance. The earlier pre-M6 verification hold no longer applies. The native-v2 unit has focused persistence, generated-property, build/type/lint, independent-review and fenced CLI evidence; see `m6-native-format-final.md` in the coordinator task records. diff --git a/rewrite/src/protocol/direct-wire.ts b/rewrite/src/protocol/direct-wire.ts index 396cfbdb4..95e3290ab 100644 --- a/rewrite/src/protocol/direct-wire.ts +++ b/rewrite/src/protocol/direct-wire.ts @@ -1,6 +1,4 @@ -/** Direct public protocol. No engine tokens, provider history or live callbacks cross this boundary. - * The unprefixed exports in index.ts still serve the isolated bridge, not this runtime. - */ +/** Direct public protocol. No engine tokens, provider history or live callbacks cross this boundary. */ import type { ModelRequestCost } from '../engine/direct-recovery-budget.js'; import type { DirectFinalWindow } from '../session/direct-final.js'; diff --git a/rewrite/src/session/VALIDATION-REPLAY.md b/rewrite/src/session/VALIDATION-REPLAY.md index 6c17df457..00ea15548 100644 --- a/rewrite/src/session/VALIDATION-REPLAY.md +++ b/rewrite/src/session/VALIDATION-REPLAY.md @@ -30,15 +30,3 @@ preceding snapshot makes `admissionBoundaries` reject rewind/retry commands live and after reopen. Retry-turn keeps the snapshot and human together; rewind removes the human but retains the preceding snapshot, consistent with the retained memory comparison state so the next unchanged admission does not lose context. - -## Historical source-session handle - -`adopted/session-handle.ts#createLegacySessionHandleFactory` composes the historical -source-backed handle admitted by `LocalSessionStore`. It owns canonical restore, -paired settings/model forks, source runtime binding, foreground/network/goal -authorities and shutdown ordering. The `assembleEngine` callback is supplied by -the caller through `LegacyEngineOptions` / `LegacyEngineAssembly`; Session never -imports application-agent construction. Engine options, runtime options and -assembly are synchronous, and the bound client must exist before construction -returns. Failure preserves the original construction error while attempting -owned cleanup. This boundary does not assemble native direct Sessions. diff --git a/rewrite/src/session/facade-types.ts b/rewrite/src/session/facade-types.ts index dff58e38c..0130fd724 100644 --- a/rewrite/src/session/facade-types.ts +++ b/rewrite/src/session/facade-types.ts @@ -1,4 +1,4 @@ -/** Direct host boundary. These domain contracts do not invoke the bridge Session. +/** Direct host boundary: the session contracts the host and app use. * Admission is not completion; capture is an atomic snapshot/subscription cut. * M3/M4 extend this facade when their actual domain capabilities land. */ From 8395b1bfc90a88d9f28a3a933d9fcb8ca071a3bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:25:56 +0000 Subject: [PATCH 14/17] refactor(rewrite): one owner for each duplicated table - CREDENTIALS: the account commands and the server's settings projection kept the same provider-to-setting table; it lives in app/credential-settings.ts (providerId was always the key). - LOCAL_ONLY (SSH adapter) and REMOTE_UNAVAILABLE (tool surface) were the same five tools; direct-remote-tools.ts exports SSH_LOCAL_ONLY_TOOLS. - warnIfSandboxUnavailable: acp and serve share app/sandbox-warning.ts; the lines they print are unchanged. - --version reads source's installationVersion, the resolver session metadata already used, instead of a second package.json walk in cli.ts. Also drops four unused type imports. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/check-layers.mjs | 1 + rewrite/src/app/cli-account-commands.ts | 11 ++------- rewrite/src/app/cli.ts | 23 ++---------------- rewrite/src/app/credential-settings.ts | 10 ++++++++ .../src/app/direct-host-config-settings.ts | 24 +++++++------------ rewrite/src/app/direct-launch-acp.ts | 21 ++-------------- rewrite/src/app/direct-launch-serve.ts | 21 ++-------------- rewrite/src/app/direct-remote-tools.ts | 6 ++--- rewrite/src/app/sandbox-warning.ts | 23 ++++++++++++++++++ rewrite/src/app/tool-surface.ts | 13 +++------- rewrite/src/engine/turn-model-compaction.ts | 2 +- rewrite/src/session/direct-capabilities.ts | 2 +- rewrite/src/session/direct-session.ts | 2 +- 13 files changed, 60 insertions(+), 99 deletions(-) create mode 100644 rewrite/src/app/credential-settings.ts create mode 100644 rewrite/src/app/sandbox-warning.ts diff --git a/rewrite/scripts/check-layers.mjs b/rewrite/scripts/check-layers.mjs index 9f031d4df..651237404 100644 --- a/rewrite/scripts/check-layers.mjs +++ b/rewrite/scripts/check-layers.mjs @@ -52,6 +52,7 @@ const DIRECT_SOURCE_IMPORTERS = new Set([ 'app/direct-verifier-workspace.ts', 'app/direct-verifier.ts', 'app/generation-guard.ts', + 'app/sandbox-warning.ts', 'app/source-tools.ts', 'app/tool-contract.ts', 'app/tool-surface.ts', diff --git a/rewrite/src/app/cli-account-commands.ts b/rewrite/src/app/cli-account-commands.ts index 60daba3d3..c7455e49b 100644 --- a/rewrite/src/app/cli-account-commands.ts +++ b/rewrite/src/app/cli-account-commands.ts @@ -23,6 +23,7 @@ import type { DirectOAuthBackend, DirectProviderBackend, } from './direct-host-config-settings.js'; +import { CREDENTIAL_SETTINGS } from './credential-settings.js'; const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -87,14 +88,6 @@ export async function runListModelsCommand(search: string | null, refresh: boole return outcome.exitCode; } -const CREDENTIALS: Record = { - openai: { settingKey: 'agent.openai.apiKey', required: true }, - openrouter: { settingKey: 'agent.openrouter.apiKey', required: true }, - tavily: { settingKey: 'webSearch.tavily.apiKey', required: true }, - exa: { settingKey: 'webSearch.exa.apiKey', required: true }, - firecrawl: { settingKey: 'webSearch.firecrawl.apiKey', required: true }, -}; - const silentLog: ILoggingService = { info() {}, warn() {}, @@ -153,7 +146,7 @@ export function createDirectAccountPorts(service: ProductSettings): { type ProductSettings = ReturnType['service']; function describeCredential(service: ProductSettings, providerId: string): CredentialDescription { - const definition = CREDENTIALS[providerId]; + const definition = CREDENTIAL_SETTINGS[providerId]; const source = definition ? service.getSource(definition.settingKey) : undefined; if (source === 'env' || String(source) === 'environment') return { configured: true, required: definition?.required ?? false, source: 'environment' }; diff --git a/rewrite/src/app/cli.ts b/rewrite/src/app/cli.ts index af8e3594d..16ab437f9 100644 --- a/rewrite/src/app/cli.ts +++ b/rewrite/src/app/cli.ts @@ -2,13 +2,12 @@ import '../../../source/env-setup.js'; import { readdir } from 'node:fs/promises'; -import { readFileSync } from 'node:fs'; import { stdin, stdout, stderr } from 'node:process'; import { homedir } from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import envPaths from 'env-paths'; import { getAllProviders, getProviderIds } from '../../../source/providers/index.js'; +import { installationVersion } from '../../../source/providers/fetch/logging-middleware.js'; import { LoggingService } from '../../../source/services/logging/logging-service.js'; import { VALID_REASONING_EFFORTS, @@ -59,24 +58,6 @@ function refused(refusal: Refusal): Error { return new Error(`${refusal.flag} is not available in the direct executable yet: ${refusal.reason}.`); } -function packageVersion(): string { - let directory = path.dirname(fileURLToPath(import.meta.url)); - for (;;) { - try { - const manifest = JSON.parse(readFileSync(path.join(directory, 'package.json'), 'utf8')) as { - name?: unknown; - version?: unknown; - }; - if (manifest.name === '@qduc/term2' && typeof manifest.version === 'string') return manifest.version; - } catch { - // Keep searching parent directories; the built CLI is nested under rewrite/dist. - } - const parent = path.dirname(directory); - if (parent === directory) return 'unknown'; - directory = parent; - } -} - /** * When the home-directory guard applies, as in the current CLI: non-lite, not a * resume, not SSH, and (without a prompt, or with --auto-approve for a prompt). @@ -402,7 +383,7 @@ async function main(argv: readonly string[]): Promise { stdout.write(HELP); return 0; case 'version': - stdout.write(`${packageVersion()}\n`); + stdout.write(`${installationVersion}\n`); return 0; case 'discord-help': stdout.write(DISCORD_HELP); diff --git a/rewrite/src/app/credential-settings.ts b/rewrite/src/app/credential-settings.ts new file mode 100644 index 000000000..544fba829 --- /dev/null +++ b/rewrite/src/app/credential-settings.ts @@ -0,0 +1,10 @@ +/** The setting that stores each API-key credential, keyed by provider id. Read by the account commands and the server's settings projection. */ +export const CREDENTIAL_SETTINGS: Readonly< + Record +> = { + openai: { settingKey: 'agent.openai.apiKey', required: true }, + openrouter: { settingKey: 'agent.openrouter.apiKey', required: true }, + tavily: { settingKey: 'webSearch.tavily.apiKey', required: true }, + exa: { settingKey: 'webSearch.exa.apiKey', required: true }, + firecrawl: { settingKey: 'webSearch.firecrawl.apiKey', required: true }, +}; diff --git a/rewrite/src/app/direct-host-config-settings.ts b/rewrite/src/app/direct-host-config-settings.ts index b31e02ae3..bdd49bb79 100644 --- a/rewrite/src/app/direct-host-config-settings.ts +++ b/rewrite/src/app/direct-host-config-settings.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import type { SettingsProjection } from '../host/server-contracts.js'; import { DirectHostConfigError } from './direct-host-config-error.js'; +import { CREDENTIAL_SETTINGS } from './credential-settings.js'; const SAFE_KEYS = [ 'agent.modelSelection', @@ -39,13 +40,6 @@ const SESSION_SCOPED = new Set([ 'app.planMode', 'app.orchestratorMode', ]); -const CREDENTIALS: Record = { - openai: { settingKey: 'agent.openai.apiKey', providerId: 'openai', required: true }, - openrouter: { settingKey: 'agent.openrouter.apiKey', providerId: 'openrouter', required: true }, - tavily: { settingKey: 'webSearch.tavily.apiKey', providerId: 'tavily', required: true }, - exa: { settingKey: 'webSearch.exa.apiKey', providerId: 'exa', required: true }, - firecrawl: { settingKey: 'webSearch.firecrawl.apiKey', providerId: 'firecrawl', required: true }, -}; export type CredentialSource = 'setting' | 'stored' | 'environment' | 'token-file' | 'local' | 'external' | 'missing'; export type SecretFreeCredential = { @@ -132,27 +126,27 @@ export class DirectGlobalConfiguration { } async setCredential(id: string, value: string): Promise<{ status: 'saved'; configured: boolean; source: string }> { - const definition = CREDENTIALS[id]; + const definition = CREDENTIAL_SETTINGS[id]; if (!definition || typeof value !== 'string' || value.length === 0) throw new DirectHostConfigError('settings_not_allowed', 'config'); - const before = this.#credential(definition.providerId, definition.required); + const before = this.#credential(id, definition.required); if (before.source === 'environment') throw new DirectHostConfigError('settings_not_allowed', 'config'); const result = this.settings.setPersistent(definition.settingKey, value); if (result?.status !== 'saved') throw new DirectHostConfigError('not_persisted', 'config'); - const after = this.#credential(definition.providerId, definition.required); + const after = this.#credential(id, definition.required); return { status: 'saved', configured: after.configured, source: after.source }; } async deleteCredential( id: string, ): Promise<{ status: 'deleted' | 'unchanged'; configured: boolean; source?: 'environment' }> { - const definition = CREDENTIALS[id]; + const definition = CREDENTIAL_SETTINGS[id]; if (!definition) throw new DirectHostConfigError('settings_not_allowed', 'config'); - const before = this.#credential(definition.providerId, definition.required); + const before = this.#credential(id, definition.required); if (before.source === 'environment') return { status: 'unchanged', configured: true, source: 'environment' }; const result = this.settings.reset(definition.settingKey); if (result?.status !== 'saved') throw new DirectHostConfigError('not_persisted', 'config'); - const after = this.#credential(definition.providerId, definition.required); + const after = this.#credential(id, definition.required); return { status: 'deleted', configured: after.configured, @@ -247,8 +241,8 @@ export class DirectGlobalConfiguration { }; } const credentials: Record = {}; - for (const [id, definition] of Object.entries(CREDENTIALS)) - credentials[id] = this.#credential(definition.providerId, definition.required); + for (const [id, definition] of Object.entries(CREDENTIAL_SETTINGS)) + credentials[id] = this.#credential(id, definition.required); let providers: SettingsProjection['settings']['providers']; try { providers = this.providers.list().map((provider) => ({ diff --git a/rewrite/src/app/direct-launch-acp.ts b/rewrite/src/app/direct-launch-acp.ts index 6d0e6e97b..a10ca1852 100644 --- a/rewrite/src/app/direct-launch-acp.ts +++ b/rewrite/src/app/direct-launch-acp.ts @@ -7,11 +7,11 @@ import fs from 'node:fs'; import { getAllProviders, getProviderIds } from '../../../source/providers/index.js'; import { installationVersion } from '../../../source/providers/fetch/logging-middleware.js'; import { resolveModelFlag } from '../../../source/services/models/model-resolution.js'; -import { getDefaultShellSandboxRunner } from '../../../source/utils/shell/sandbox/shell-sandbox-runner.js'; import { createAcpBackend, createStdioAcpIo, serveAcp, type AcpIo, type AcpWorkspaces } from '../clients/acp/index.js'; import { createDirectApplication, type DirectApplication } from './direct-application.js'; import { createLocalAuthority, LOCAL_PRINCIPAL, localGrant } from './direct-application-workspaces.js'; import type { DirectLaunchRequest } from './direct-launch-types.js'; +import { warnIfSandboxUnavailable } from './sandbox-warning.js'; type AcpRequest = Extract; @@ -65,23 +65,6 @@ async function applyModelFlags({ args, selection }: AcpRequest, io: AcpIo): Prom return true; } -async function warnIfSandboxUnavailable(io: AcpIo): Promise { - const warn = (type: string, reason?: string): void => - io.writeStderr( - `term2 acp: warning: shell sandbox unavailable: ${type}${ - reason ? ` (${reason})` : '' - }; shell tool calls will be refused`, - ); - try { - const availability = await getDefaultShellSandboxRunner().availability(); - if (availability.type !== 'available') { - warn(availability.type, 'reason' in availability ? availability.reason : undefined); - } - } catch (error) { - warn('probe_failed', messageOf(error)); - } -} - /** An ACP client names its cwd per session; any real directory is admitted, as today. */ function acpWorkspaces(app: DirectApplication): AcpWorkspaces { return { @@ -95,7 +78,7 @@ function acpWorkspaces(app: DirectApplication): AcpWorkspaces { export async function launchAcp(request: AcpRequest): Promise { const io = createStdioAcpIo(); if (!(await applyModelFlags(request, io))) return 1; - await warnIfSandboxUnavailable(io); + await warnIfSandboxUnavailable('acp', (line) => io.writeStderr(line)); const { conversationsDir, selection } = request; const app = await createDirectApplication({ diff --git a/rewrite/src/app/direct-launch-serve.ts b/rewrite/src/app/direct-launch-serve.ts index 72a06a8ec..904f1b050 100644 --- a/rewrite/src/app/direct-launch-serve.ts +++ b/rewrite/src/app/direct-launch-serve.ts @@ -10,7 +10,6 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'no import path from 'node:path'; import { getProfileLabel } from '../../../source/services/profiles/labels.js'; import { defaultServeStateDir } from '../../../source/gateway/serve-args.js'; -import { getDefaultShellSandboxRunner } from '../../../source/utils/shell/sandbox/shell-sandbox-runner.js'; import { createDirectServerHost, createServerAuthentication } from '../host/server.js'; import { SqliteReplayLedger } from '../host/server-auth.js'; import type { Principal } from '../host/in-process-direct-types.js'; @@ -28,6 +27,7 @@ import { DirectWorkspaceAuthority, } from './direct-host-config.js'; import type { DirectLaunchRequest } from './direct-launch-types.js'; +import { warnIfSandboxUnavailable } from './sandbox-warning.js'; type ServeRequest = Extract; @@ -84,23 +84,6 @@ function transportOf({ transport }: ServeRequest['args']): ServerTransportOption }; } -async function warnIfSandboxUnavailable(): Promise { - const warn = (type: string, reason?: string): void => { - const detail = reason ? ` (${reason})` : ''; - process.stderr.write( - `term2 serve: warning: shell sandbox unavailable: ${type}${detail}; shell tool calls will be refused\n`, - ); - }; - try { - const availability = await getDefaultShellSandboxRunner().availability(); - if (availability.type !== 'available') { - warn(availability.type, 'reason' in availability ? availability.reason : undefined); - } - } catch (error) { - warn('probe_failed', messageOf(error)); - } -} - /** * Only the local owner reaches a session at all, so a bad assertion subject fails * closed. Every session records its owner and grant, and that record, not its @@ -210,7 +193,7 @@ export async function launchServe({ args, selection }: ServeRequest): Promise process.stderr.write(`${line}\n`)); // Grants and globals live here, in this state directory, and never in a conversation. const grants = new DirectWorkspaceAuthority({ diff --git a/rewrite/src/app/direct-remote-tools.ts b/rewrite/src/app/direct-remote-tools.ts index e723c9e44..48d84fce3 100644 --- a/rewrite/src/app/direct-remote-tools.ts +++ b/rewrite/src/app/direct-remote-tools.ts @@ -7,8 +7,8 @@ import type { Workspace } from '../tools/index.js'; type Scope = { readonly access: 'read' | 'write' | 'shell' | 'none'; readonly signal: AbortSignal }; const READERS = new Set(['read_file', 'grep', 'glob', 'run_code']); const WRITERS = new Set(['create_file', 'search_replace', 'apply_patch']); -/** These source leaves use local fs/index/worktree facilities, not SSH. */ -const LOCAL_ONLY = new Set([ +/** These source leaves use local fs/index/worktree facilities, not SSH, so an SSH tool surface does not offer them. */ +export const SSH_LOCAL_ONLY_TOOLS: ReadonlySet = new Set([ 'read_code_outline', 'code_context_search', 'enter_worktree', @@ -66,7 +66,7 @@ export class DirectRemoteTools implements ISSHService { async runTool(toolName: string, signal: AbortSignal, operation: () => Promise): Promise { this.#assertConnected(); signal.throwIfAborted(); - if (LOCAL_ONLY.has(toolName)) throw new Error(`${toolName} has no direct SSH implementation.`); + if (SSH_LOCAL_ONLY_TOOLS.has(toolName)) throw new Error(`${toolName} has no direct SSH implementation.`); const access = READERS.has(toolName) ? 'read' : WRITERS.has(toolName) diff --git a/rewrite/src/app/sandbox-warning.ts b/rewrite/src/app/sandbox-warning.ts new file mode 100644 index 000000000..307afa97b --- /dev/null +++ b/rewrite/src/app/sandbox-warning.ts @@ -0,0 +1,23 @@ +/** The startup warning `term2 acp` and `term2 serve` print when the shell sandbox cannot run. */ +import { getDefaultShellSandboxRunner } from '../../../source/utils/shell/sandbox/shell-sandbox-runner.js'; + +/** Writes one line (without its newline) through `writeLine` when the sandbox is unavailable or its probe fails. */ +export async function warnIfSandboxUnavailable( + command: 'acp' | 'serve', + writeLine: (line: string) => void, +): Promise { + const warn = (type: string, reason?: string): void => + writeLine( + `term2 ${command}: warning: shell sandbox unavailable: ${type}${ + reason ? ` (${reason})` : '' + }; shell tool calls will be refused`, + ); + try { + const availability = await getDefaultShellSandboxRunner().availability(); + if (availability.type !== 'available') { + warn(availability.type, 'reason' in availability ? availability.reason : undefined); + } + } catch (error) { + warn('probe_failed', error instanceof Error ? error.message : String(error)); + } +} diff --git a/rewrite/src/app/tool-surface.ts b/rewrite/src/app/tool-surface.ts index 66b23cac9..49a6c1ffb 100644 --- a/rewrite/src/app/tool-surface.ts +++ b/rewrite/src/app/tool-surface.ts @@ -27,6 +27,7 @@ import { createDirectSubagentTools } from './direct-subagent-tools.js'; import { createDirectShellTools } from './direct-shell-tools.js'; import { createSourceToolRegistry, executeSourceChild, sourceToolSpec } from './source-tools.js'; import type { AgentSurface, ProfileSelection, ToolSurfaceOptions } from './tool-contract.js'; +import { SSH_LOCAL_ONLY_TOOLS } from './direct-remote-tools.js'; type ToolInterceptor = Parameters[0]['addToolInterceptor']>[0]; @@ -104,7 +105,7 @@ export function createToolSurface(selection: ProfileSelection, options: ToolSurf }, withAnswer: (answer, operation) => answers.run(answer, operation), targetInvocation, - remoteUnavailable: REMOTE_UNAVAILABLE, + remoteUnavailable: SSH_LOCAL_ONLY_TOOLS, }); const shell = createDirectShellTools(); const allowBackgroundShell = options.interactive === true && !remote; @@ -163,7 +164,7 @@ export function createToolSurface(selection: ProfileSelection, options: ToolSurf { selection: modelSelection, reasoningEffort: settings.get('agent.reasoningEffort') }, deps, ); - const offered = remote ? agent.tools.filter((tool) => !REMOTE_UNAVAILABLE.has(tool.name)) : agent.tools; + const offered = remote ? agent.tools.filter((tool) => !SSH_LOCAL_ONLY_TOOLS.has(tool.name)) : agent.tools; const registry = createSourceToolRegistry(offered, { policies, settings, @@ -279,14 +280,6 @@ export function createToolSurface(selection: ProfileSelection, options: ToolSurf }; } -const REMOTE_UNAVAILABLE = new Set([ - 'read_code_outline', - 'code_context_search', - 'enter_worktree', - 'exit_worktree', - 'activate_skill', -]); - /** Snapshot authority before building tools; an SSH adapter must match a current grant exactly. */ function surfaceWorkspace(workspace: Workspace | undefined, remote: ToolSurfaceOptions['remote']): Workspace { if (remote && !workspace) throw new Error('Direct SSH tool surface requires an explicit authorized workspace'); diff --git a/rewrite/src/engine/turn-model-compaction.ts b/rewrite/src/engine/turn-model-compaction.ts index 4e4a6bb24..623eb7a8c 100644 --- a/rewrite/src/engine/turn-model-compaction.ts +++ b/rewrite/src/engine/turn-model-compaction.ts @@ -4,7 +4,7 @@ import { performance } from 'node:perf_hooks'; import type { Item, Model, ModelError, ModelEvent, ModelRequest, Usage } from '../model/index.js'; import type { DirectCompactionResult, DirectCompactionRequest } from './direct-recovery-compaction.js'; import { compactDirectHistory, estimateDirectContext } from './direct-recovery-compaction.js'; -import type { DirectManualCompactionOutcome, TurnCompactionEvent, TurnDeps, TurnSpec } from './direct-types.js'; +import type { TurnCompactionEvent, TurnDeps, TurnSpec } from './direct-types.js'; import { NativeBudgetExhausted, type TurnBudgetControl } from './turn-budget.js'; import { TurnEvents, cancelled } from './turn-events.js'; diff --git a/rewrite/src/session/direct-capabilities.ts b/rewrite/src/session/direct-capabilities.ts index 4a63b2e8c..983ac3af8 100644 --- a/rewrite/src/session/direct-capabilities.ts +++ b/rewrite/src/session/direct-capabilities.ts @@ -10,7 +10,7 @@ import { type DirectContextState, type DirectContextFact, } from './direct-context.js'; -import { createDirectGoalCheckTool, createDirectProposeGoalTool, type DirectGoalState } from './direct-goal.js'; +import { createDirectGoalCheckTool, createDirectProposeGoalTool } from './direct-goal.js'; import { createDirectGoalStopPolicy, type DirectGoalHistoryItem } from './direct-goal-stop.js'; import { createDirectGoalVerifier } from './direct-goal-verification.js'; import type { DirectMemoryCapability, DirectAutomaticMemoryNotice } from './direct-memory.js'; diff --git a/rewrite/src/session/direct-session.ts b/rewrite/src/session/direct-session.ts index 4ad441640..07cb1ba2e 100644 --- a/rewrite/src/session/direct-session.ts +++ b/rewrite/src/session/direct-session.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { directPreviewLifecycle } from './direct-preview.js'; import type { Item, ItemId } from '../model/index.js'; -import type { InteractionDecision, InteractionRequestId, TurnId } from '../engine/index.js'; +import type { TurnId } from '../engine/index.js'; import type { ModelError, ModelRef } from '../model/index.js'; import type { Workspace } from '../tools/index.js'; import type { Conversation, Turn, TurnRequest } from './direct-types.js'; From 556837e547ce5881f98ff261f83e9b44724f9475 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:27:28 +0000 Subject: [PATCH 15/17] refactor(rewrite): name things by what they do - createAppSettings wrapped createDirectSettingsService, which only called new SettingsService(options). One function, createSettingsService in app/settings.ts, now returns the service itself. - loadSurface was an alias of createToolSurface; app/agent-surface.ts goes and callers use createToolSurface. - DirectOrchestratorQuestions becomes DirectTaskQuestions (session/direct-task-questions.ts): it holds the one pending question each background task asked its parent, recorded as task_question_* facts. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/scripts/check-layers.mjs | 1 + rewrite/src/app/README.md | 4 ++-- rewrite/src/app/agent-surface.ts | 9 --------- rewrite/src/app/cli-account-commands.ts | 6 +++--- rewrite/src/app/cli.ts | 15 ++++++--------- rewrite/src/app/direct-application-runtime.ts | 10 +++++----- rewrite/src/app/direct-config-runtime.ts | 4 ++-- rewrite/src/app/direct-config-settings.ts | 8 -------- rewrite/src/app/direct-host-config.ts | 4 ++-- rewrite/src/app/generation-guard.ts | 2 +- rewrite/src/app/settings.ts | 12 ++++++------ rewrite/src/config/README.md | 2 +- rewrite/src/session/direct-background.ts | 6 +++--- ...ator-questions.ts => direct-task-questions.ts} | 8 +++++--- 14 files changed, 37 insertions(+), 54 deletions(-) delete mode 100644 rewrite/src/app/agent-surface.ts rename rewrite/src/session/{direct-orchestrator-questions.ts => direct-task-questions.ts} (90%) diff --git a/rewrite/scripts/check-layers.mjs b/rewrite/scripts/check-layers.mjs index 651237404..dce91938c 100644 --- a/rewrite/scripts/check-layers.mjs +++ b/rewrite/scripts/check-layers.mjs @@ -53,6 +53,7 @@ const DIRECT_SOURCE_IMPORTERS = new Set([ 'app/direct-verifier.ts', 'app/generation-guard.ts', 'app/sandbox-warning.ts', + 'app/settings.ts', 'app/source-tools.ts', 'app/tool-contract.ts', 'app/tool-surface.ts', diff --git a/rewrite/src/app/README.md b/rewrite/src/app/README.md index 7df4f9deb..57ea1bc4b 100644 --- a/rewrite/src/app/README.md +++ b/rewrite/src/app/README.md @@ -23,8 +23,8 @@ traffic store fixes its enabled state at construction. Session runtime bindings may reuse the already-resolved logger; canonical conversation persistence is separate and remains enabled. This is startup policy, not a runtime toggle. -`createAppSettings` constructs only the concrete `SettingsService` through -`createDirectSettingsService`. The settings owner retains its directory, logger, +`createSettingsService` (`settings.ts`) constructs the concrete +`SettingsService`. The settings owner retains its directory, logger, and persistence policies. `createDirectRuntimes` in `direct-application-runtime.ts` builds session-owned diff --git a/rewrite/src/app/agent-surface.ts b/rewrite/src/app/agent-surface.ts deleted file mode 100644 index 1e8f164b0..000000000 --- a/rewrite/src/app/agent-surface.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Direct app assembly. Tool/policy construction lives at its single source boundary. */ -import { createToolSurface } from './tool-surface.js'; -import type { AgentSurface, ProfileSelection, ToolSurfaceOptions } from './tool-contract.js'; -export type { AgentSurface, ProfileSelection, ToolSurfaceOptions } from './tool-contract.js'; - -/** Build the offered surface and its matching execution/policy capabilities together. */ -export function loadSurface(selection: ProfileSelection, options?: ToolSurfaceOptions): AgentSurface { - return createToolSurface(selection, options); -} diff --git a/rewrite/src/app/cli-account-commands.ts b/rewrite/src/app/cli-account-commands.ts index c7455e49b..b656c6c2a 100644 --- a/rewrite/src/app/cli-account-commands.ts +++ b/rewrite/src/app/cli-account-commands.ts @@ -2,7 +2,7 @@ import { stdin, stdout, stderr } from 'node:process'; import { SessionContextService } from '../../../source/services/session/session-context-service.js'; import { buildEnvOverrides } from '../../../source/services/settings/settings-service.js'; -import { createAppSettings } from './settings.js'; +import { createSettingsService, type SettingsService } from './settings.js'; import type { ILoggingService } from '../../../source/services/service-interfaces.js'; import { getProviderIds } from '../../../source/providers/index.js'; @@ -71,7 +71,7 @@ export async function runLogin(provider: 'grok' | 'codex'): Promise { /** List the source model catalogue with its persisted app settings and diagnostics. */ export async function runListModelsCommand(search: string | null, refresh: boolean): Promise { const { runListModels } = await import('../../../source/services/models/model-listing.js'); - const { service: listingSettings } = createAppSettings({ + const listingSettings = createSettingsService({ env: buildEnvOverrides(), loggingServiceOptions: { sessionContextService: new SessionContextService() }, }); @@ -143,7 +143,7 @@ export function createDirectAccountPorts(service: ProductSettings): { }; } -type ProductSettings = ReturnType['service']; +type ProductSettings = SettingsService; function describeCredential(service: ProductSettings, providerId: string): CredentialDescription { const definition = CREDENTIAL_SETTINGS[providerId]; diff --git a/rewrite/src/app/cli.ts b/rewrite/src/app/cli.ts index 16ab437f9..ea1ffd672 100644 --- a/rewrite/src/app/cli.ts +++ b/rewrite/src/app/cli.ts @@ -27,7 +27,7 @@ import type { DirectLaunchBase } from './direct-launch-types.js'; import type { InteractivePickerRequest } from '../../../source/services/models/model-resolution.js'; import { HARNESS_IDLE_ENV } from '../../../source/lib/harness-input-idle.js'; import { isControlSocketName } from '../../../source/services/control-socket/control-socket.js'; -import { createAppSettings } from './settings.js'; +import { createSettingsService, type SettingsService } from './settings.js'; import { runLogin, runListModelsCommand } from './cli-account-commands.js'; import { listNativeConversations, resolveNativeResume } from './cli-conversations.js'; import { @@ -92,10 +92,7 @@ async function checkConversationsDirectory(directory: string): Promise { } } -function chooseProfile( - options: Pick, - settings: ReturnType['service'], -): string { +function chooseProfile(options: Pick, settings: SettingsService): string { const currentProfile = String(settings.get('app.activeProfileId')); let profileId: string; if (options.profile !== null) { @@ -164,7 +161,7 @@ async function runWithSignals(request: DirectHeadlessRequest): Promise<{ */ async function applyModelOverrides( options: SessionOptions, - settings: ReturnType['service'], + settings: SettingsService, logger: LoggingService, pickerEligible: boolean, ): Promise { @@ -260,7 +257,7 @@ async function runHeadless(prompt: string, options: SessionOptions): Promise { await checkConversationsDirectory(directory); const sessionContext = new SessionContextService(); - const { service: settings } = createAppSettings({ + const settings = createSettingsService({ env: buildEnvOverrides(), loggingServiceOptions: { sessionContextService: sessionContext }, }); @@ -357,7 +354,7 @@ async function launcherBase(): Promise(sessionId: string, operation: () => Promise): Promise; }; -export type DirectSessionSettings = ReturnType['service']; +export type DirectSessionSettings = SettingsService; /** The settings a new session continues from, carried through the store's creation call. */ const continuedSettings = new AsyncLocalStorage(); @@ -101,7 +101,7 @@ function sessionSettings( ): DirectSessionSettings { const continued = continuedSettings.getStore(); const defaults = continued ?? selection.settings; - const { service: settings } = createAppSettings({ + const settings = createSettingsService({ env: buildEnvOverrides(), disableFilePersistence: true, loggingService: selection.logger, @@ -205,7 +205,7 @@ export function createDirectRuntimes( // The Session owns memory, so the surface must not offer it a second time. It // borrows SSH because the connection outlives this tool graph and this session. // The prompt may still describe that real native capability without source tools. - const surface = loadSurface( + const surface = createToolSurface( selection, borrowDirectConfigSurfaceOptions({ ...surfaceOptions, diff --git a/rewrite/src/app/direct-config-runtime.ts b/rewrite/src/app/direct-config-runtime.ts index 6a5b6d597..ee80c92a3 100644 --- a/rewrite/src/app/direct-config-runtime.ts +++ b/rewrite/src/app/direct-config-runtime.ts @@ -5,7 +5,7 @@ import { createProviderRegistry } from '../model/provider.js'; import type { DirectContextCapabilities } from '../session/direct-capabilities.js'; import type { DirectAcceptedConfiguration } from '../session/direct-command-types.js'; import type { DirectConversationRuntime, DirectRuntimeContext } from '../session/direct-store.js'; -import { loadSurface } from './agent-surface.js'; +import { createToolSurface } from './tool-surface.js'; import { withSessionScope, type SessionScope } from './direct-application-models.js'; import { createNestedAuthorizer } from './direct-application-nested.js'; import { runPolicy } from './direct-application-policy.js'; @@ -70,7 +70,7 @@ export async function prepareDirectConfiguration( const selection: ProfileSelection = { ...options.selection, settings }; const context: DirectRuntimeContext = { ...options.context, model }; const profile = getProfileMetadata(String(settings.get('app.activeProfileId'))); - const surface = loadSurface( + const surface = createToolSurface( selection, borrowDirectConfigSurfaceOptions({ ...options.surfaceOptions, diff --git a/rewrite/src/app/direct-config-settings.ts b/rewrite/src/app/direct-config-settings.ts index c330c6620..e3394187e 100644 --- a/rewrite/src/app/direct-config-settings.ts +++ b/rewrite/src/app/direct-config-settings.ts @@ -1,6 +1,5 @@ /** Isolated, source-schema-validated settings for one prepared runtime generation. */ import { z } from 'zod'; -import { SettingsService } from '../../../source/services/settings/settings-service.js'; import { getProfileMetadata } from '../../../source/services/profiles/active-profile.js'; import { canonicalProfileId, legacyModeFromProfileId } from '../../../source/services/profiles/legacy-adapter.js'; import { @@ -14,13 +13,6 @@ import { getProvider } from '../../../source/providers/index.js'; import type { ISettingsService } from '../../../source/services/service-interfaces.js'; import type { DirectCommandConfiguration } from '../session/direct-command-types.js'; -export type DirectSettingsOptions = NonNullable[0]>; - -/** Construct the settings owner without allocating a historical scoped-service facade. */ -export function createDirectSettingsService(options: DirectSettingsOptions = {}): SettingsService { - return new SettingsService(options); -} - /** App preparation accepts selection intent. Native/protocol ingress must retain * both fields; effective accepted configuration remains source-resolved values. */ export type DirectAppConfiguration = Omit & { diff --git a/rewrite/src/app/direct-host-config.ts b/rewrite/src/app/direct-host-config.ts index dd90e3b0f..2d9d30efe 100644 --- a/rewrite/src/app/direct-host-config.ts +++ b/rewrite/src/app/direct-host-config.ts @@ -30,7 +30,7 @@ export type { } from './direct-host-config-workspace.js'; export { DirectGlobalConfiguration } from './direct-host-config-settings.js'; export type { SettingsProjection } from './direct-host-config-settings.js'; -import { createAppSettings } from './settings.js'; +import { createSettingsService } from './settings.js'; import { createDirectAccountPorts } from './cli-account-commands.js'; import type { DirectSettingsBackend } from './direct-host-config-settings.js'; @@ -38,7 +38,7 @@ import type { DirectSettingsBackend } from './direct-host-config-settings.js'; export function createDirectProductPorts(options: { settingsDir?: string } = {}): { readonly settings: DirectSettingsBackend; } & ReturnType { - const { service } = createAppSettings(options.settingsDir ? { settingsDir: options.settingsDir } : {}); + const service = createSettingsService(options.settingsDir ? { settingsDir: options.settingsDir } : {}); return { settings: { get: (key) => service.getDynamic(key), diff --git a/rewrite/src/app/generation-guard.ts b/rewrite/src/app/generation-guard.ts index 7994eb621..e8b27c2df 100644 --- a/rewrite/src/app/generation-guard.ts +++ b/rewrite/src/app/generation-guard.ts @@ -1,7 +1,7 @@ /** Per-request generation limits wrapped around every model the direct application resolves. */ import { GenerationGuard, GenerationStreamDeadlines } from '../../../source/services/agent-runtime/generation-guard.js'; import type { Model, ModelEvent, ModelRegistry, ModelRequest } from '../model/index.js'; -import type { ProfileSelection } from './agent-surface.js'; +import type { ProfileSelection } from './tool-contract.js'; /** * The old run loop's per-request limits: a stalled stream and oversized text end diff --git a/rewrite/src/app/settings.ts b/rewrite/src/app/settings.ts index b383699b7..33407dab8 100644 --- a/rewrite/src/app/settings.ts +++ b/rewrite/src/app/settings.ts @@ -1,9 +1,9 @@ -import { createDirectSettingsService, type DirectSettingsOptions } from './direct-config-settings.js'; +/** The app's one settings owner: the source `SettingsService`. Sessions build their own runtime generations from it. */ +import { SettingsService } from '../../../source/services/settings/settings-service.js'; -export type AppSettingsOptions = DirectSettingsOptions; -export type AppSettings = { readonly service: ReturnType }; +export type AppSettingsOptions = NonNullable[0]>; +export type { SettingsService }; -/** Builds only the concrete settings owner; native sessions own their runtime generations. */ -export function createAppSettings(options: AppSettingsOptions = {}): AppSettings { - return { service: createDirectSettingsService(options) }; +export function createSettingsService(options: AppSettingsOptions = {}): SettingsService { + return new SettingsService(options); } diff --git a/rewrite/src/config/README.md b/rewrite/src/config/README.md index 98146a407..6466a49a6 100644 --- a/rewrite/src/config/README.md +++ b/rewrite/src/config/README.md @@ -6,7 +6,7 @@ changes, over branded `SettingKey`s and plain `SettingValue`s. It imports no source service or settings schema. Production settings are the source `SettingsService`, which `app/` constructs -(`app/settings.ts`, `createAppSettings`) and owns, with its directory, logger and +(`app/settings.ts`, `createSettingsService`) and owns, with its directory, logger and persistence policies. Tools read settings through `ToolContext.settings`; `app/tool-surface.ts` builds that reader over the session's settings service, so a tool sees the session's settings without importing the service. diff --git a/rewrite/src/session/direct-background.ts b/rewrite/src/session/direct-background.ts index 27de3bdbd..c6cfbc995 100644 --- a/rewrite/src/session/direct-background.ts +++ b/rewrite/src/session/direct-background.ts @@ -4,7 +4,7 @@ import type { ModelError } from '../model/index.js'; import type { InteractionDecision, InteractionRequestId, SubagentMessageResult } from '../tools/index.js'; import type { AnswerResult } from './direct-types.js'; import { DirectBackgroundInteractions } from './direct-background-interactions.js'; -import { DirectOrchestratorQuestions } from './direct-orchestrator-questions.js'; +import { DirectTaskQuestions } from './direct-task-questions.js'; import { directTaskAbortError, directTaskError } from './direct-background-types.js'; import type { DirectBackgroundFact, @@ -52,7 +52,7 @@ export class DirectBackgroundTasks { readonly #now: () => number; readonly #entries = new Map(); readonly #interactions: DirectBackgroundInteractions; - readonly #questions: DirectOrchestratorQuestions; + readonly #questions: DirectTaskQuestions; #failure?: ModelError; #closed = false; @@ -65,7 +65,7 @@ export class DirectBackgroundTasks { this.#sink = options.sink; this.#now = options.now ?? Date.now; this.#interactions = new DirectBackgroundInteractions((fact) => this.#accept(fact)); - this.#questions = new DirectOrchestratorQuestions((fact) => this.#accept(fact), this.#now); + this.#questions = new DirectTaskQuestions((fact) => this.#accept(fact), this.#now); } /** Admit before invoking the body. Values/outcomes must be serializable Session data. */ diff --git a/rewrite/src/session/direct-orchestrator-questions.ts b/rewrite/src/session/direct-task-questions.ts similarity index 90% rename from rewrite/src/session/direct-orchestrator-questions.ts rename to rewrite/src/session/direct-task-questions.ts index 0e12cb477..881d23e3a 100644 --- a/rewrite/src/session/direct-orchestrator-questions.ts +++ b/rewrite/src/session/direct-task-questions.ts @@ -8,9 +8,11 @@ type Pending = DirectOrchestratorQuestion & { readonly reject: (error: Error) => void; }; -/** Session-owned live continuations, separate from user interactions. Every release - * follows accepted closure; recovery closes facts, never rehydrates these promises. */ -export class DirectOrchestratorQuestions { +/** One pending question per background task: a child's `ask_orchestrator` call waits + * here until its parent replies to that message, or the task stops. Separate from user + * interactions. Every release follows the accepted `task_question_closed` fact; recovery + * closes facts, never rehydrates these promises. */ +export class DirectTaskQuestions { readonly #pending = new Map(); constructor(private readonly accept: (fact: DirectBackgroundFact) => void, private readonly now: () => number) {} From d738d35814c38cc316bca1eef40b63fa94b902a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:30:30 +0000 Subject: [PATCH 16/17] docs(rewrite): fix what the fresh-reader check got wrong A context-free reader given only the README and the layer index files traced the message path and the approval wait correctly, and flagged: a wrong claim that production is the direct-*/turn*/conversation*/server-* families; a hop count with no list (and 'down the layers' while app/ sits below engine/); config/index.ts still saying 'settings and profiles'; host/index.ts omitting the attachment that owns the projection and not saying the web wire is gated; 'reopened' vs 'ended' for an interrupted turn. Each is corrected. Refs #139 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- rewrite/README.md | 23 ++++++++++++++++------- rewrite/src/config/index.ts | 9 +++++---- rewrite/src/host/index.ts | 19 ++++++++++++------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/rewrite/README.md b/rewrite/README.md index 81e737660..af4d3cf94 100644 --- a/rewrite/README.md +++ b/rewrite/README.md @@ -10,10 +10,13 @@ from `source/` (legacy term2, which remains the product spec), and nothing in Each folder in `src/` is one layer, named after its job. A layer may only use the layers listed after its name, so dependencies always point down. -Production files are the `direct-*`, `turn*`, `conversation*` and `server-*` -families plus the layer's `index.ts`. `session/adopted/` is the one adopted -folder left: it keeps the old session-fact validator for the offline v3 -conversation migration and is not reached from the CLI. +Every file under `src/` is reached from `src/app/cli.ts`; the reachability +check below enforces it, and its allowlist names the five exceptions with +reasons. `session/adopted/` is the one adopted folder left: it keeps the old +session-fact validator for the offline v3 conversation migration and is not +reached from the CLI. "May depend on" governs imports between these layers; +which `src/` files may also import `source/` is a separate, named list in +`scripts/check-layers.mjs`. | Layer | Job | May depend on | | ----------- | ------------------------------------------------------------- | ------------------------------ | @@ -95,7 +98,7 @@ waiting turn with `TurnHandle.answer`. Headless decides with the policy in `clients/headless/direct-approvals.ts`: without `--auto-approve` it denies; with it, it allows everything except medium- or high-risk shell commands. The TUI asks the user. If the process dies while it waits, reopening the -session ends that turn as interrupted. +session records that turn as interrupted; nothing resumes on its own. ## Keeping it readable @@ -116,8 +119,14 @@ measurable: provider, sends one headless prompt, and records the chain of files active when the provider's HTTP request goes out, starting at the client's submit. It fails when that chain gets longer (hops) or touches more files. Today it is - 14 hops through 14 files, one pass down the layers: clients, host, protocol, - session, engine, app, model, then the source provider. + 14 hops through 14 files, each layer once: `clients/headless/direct.ts`, + `host/in-process-direct.ts`, `protocol/direct-commands.ts`, + `session/direct-session.ts`, `session/conversation-turn.ts`, `engine/turn.ts`, + `engine/turn-model.ts`, `app/generation-guard.ts`, + `app/direct-application-models.ts`, `model/provider.ts`, and four + `source/providers/` files. `app/` appears below `engine/` because the engine + calls the model registry the app injected, not because `engine/` imports + `app/`. - At the end of each phase, a fresh reader gets only this README and each layer's `index.ts`, and explains how a message travels and where an approval waits. Whatever they get wrong gets fixed in the structure or the docs before diff --git a/rewrite/src/config/index.ts b/rewrite/src/config/index.ts index 864930e91..027bb2f5d 100644 --- a/rewrite/src/config/index.ts +++ b/rewrite/src/config/index.ts @@ -1,9 +1,10 @@ /** - * config/ — settings and profiles. + * config/ — the settings reader contract. * - * Sits beside the layer stack: any layer may read it, and it depends on no - * other layer. Layers receive a scoped `SettingsReader` instead of reaching - * for a global, so a session can run with settings isolated from its host. + * Any layer may read it, and it depends on no other layer. Tools receive a + * `SettingsReader` in their `ToolContext` instead of reaching for a global, so + * a session runs with its own settings. The concrete settings service (and + * profiles) are source's, constructed in `app/settings.ts`; see README.md. */ export type SettingKey = string & { readonly __brand: 'SettingKey' }; diff --git a/rewrite/src/host/index.ts b/rewrite/src/host/index.ts index 864873d20..f5ca7ca55 100644 --- a/rewrite/src/host/index.ts +++ b/rewrite/src/host/index.ts @@ -2,13 +2,18 @@ * host/ — transport and access, nothing else. * * Hands clients a `DirectConnection` to one session. The in-process host - * (`in-process-direct.ts`, contracts in `in-process-direct-types.ts`) owns the - * session store, sends each wire command to `protocol/direct-commands.ts` and - * fans the projected wire events out to every attached client. The TUI, the - * headless client, ACP and the control socket all connect through it. The HTTP - * server (`server.ts` and its `server-*` files) exposes the same host to the - * gateway's web client with auth, pairing and workspace admission; it never - * drives a turn or writes conversation state itself. + * (`in-process-direct.ts`, contracts in `in-process-direct-types.ts`) holds the + * session store the app built with `session/direct-store.ts` and disposes it on + * shutdown. It sends each wire command to `protocol/direct-commands.ts`. + * `in-process-direct-attachment.ts` keeps one projection per session and fans + * its wire events out, in order, to every connection to that session. The TUI, + * the headless client, ACP and the control socket all connect through it. + * + * The HTTP server (`server.ts` and its `server-*` files, started by + * `term2 serve`) exposes the same host to the gateway's web client with auth, + * pairing and workspace admission; it never drives a turn or writes + * conversation state itself. Web parity is deferred (#80): the agent-wire v2 + * format in `server-wire-v2.ts` stays gated off. * Depends on: protocol, session, config. */ From fd861daa4d02665165cc814b41de48a0309ad366 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:33:13 +0000 Subject: [PATCH 17/17] docs(testing): record the direct-settings e2e teardown race Reproduced on unmodified main (3 of 6 isolated runs) while verifying #139. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JqbJB9USbeW5UuNMFVsDas --- .agents/skills/testing/SKILL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md index 651829dd2..fd31f43ba 100644 --- a/.agents/skills/testing/SKILL.md +++ b/.agents/skills/testing/SKILL.md @@ -92,6 +92,15 @@ and `68ef50ff` (local-only evidence), not a result file to resume appending. cause from that alone. A separate fixed harness defect missed an already captured HTTP request when no later PTY event woke its waiter; test HTTP-only wakeup and an unmet-predicate timeout, not just terminal output. +- **Direct settings e2e teardown:** `source/direct-settings.e2e.test.ts` fails + intermittently with `ENOTEMPTY: directory not empty, rmdir + '/term2-tool-rows-e2e-*/home'` (or `home/.local`) and no assertion + failure. It reproduced on unmodified `main` `d7bf2ff` in 3 of 6 isolated runs + (2026-11-04). `afterEach` calls `session.dispose()`, which kills the PTY + without awaiting child exit, then `rmSync`s the root while the child may still + write under HOME; `maxRetries` does not cover it. Treat the signature as this + teardown race, not a product regression; a likely fix (untested) is to await child exit + before removing the root. - **Build/host isolation:** suites that replace `dist` in one worktree must run sequentially; overlapping builds produced transient missing-module errors. Missing `dist/cli.js` is setup failure, not behavioral red proof. Child-process