diff --git a/docs/architecture.md b/docs/architecture.md index e13fc8f..964c2c2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -367,17 +367,21 @@ scripts/ pack.mjs, publish-tag.sh ## Indexed native sessions and isolated ACP completion Kimi Code's profile uses wire protocol 1.5 for CLI 2.4.1 and Desktop 1.2.1 on Windows. -Both local clients use the same Kimi profile and managed plugin. The profile declares the native workspace/session -layout; `runtime/native-facts` owns bounded filesystem reads and refuses linked, replaced, malformed or partial -records. The driver opens a logical turn before the host admits its native turn. `app/native-turn` binds the two -using the captured byte/file boundary, then closes only the matched main-user terminal. `Stop` is an observer -hint. A detached native monitor catches terminal writes after the last hook; recovery reads those records before -orphan abort reporting. Turn number, session epoch and owner guards keep a late observer with its original turn. -Background checks remain unknown until matching native task metadata settles them. Other hosts retain their -existing transcript/tool-result adapters; Kimi subagents and concurrent prompts are outside this v1 contract. - -The optional ModelRunner `capture` port records the selected alias, provider, upstream model, effort and a -credential/config binding before recall returns. The `acp` runner revalidates that binding before creating one +Both local clients use the same Kimi profile and managed plugin. Kimi uses the same late-terminal mechanism as +ZCode (`turnEnd.statusRecord` with `confirmCompletion`): only the host's own terminal status admits a turn's +evidence. Its status record is the session log rather than a database: the profile declares the workspace/session +layout and a decoder, and `runtime/session-log` reads a bounded head and tail of the JSONL log, refusing linked, +replaced, malformed or partial records. Because Kimi names a turn only once it runs, its `TurnStarted` hook maps to +the canonical `turnStart` event, which binds the open turn's id when the announced prompt is the one the turn opened +with, and schedules the shared turn monitor; `Stop`, `StopFailure`, `Interrupt` and `Notification` schedule it again. +A row carries the turn's `record` and `answer` from the log (hook tool output is truncated), and the monitor, the next +prompt's `closeUnfinishedTurn`, the session end and `recoverNativeTurns` judge the turn on it. A background check +still running when the turn ended keeps the row `pending`: the turn is not verified until its task metadata settles +the check. Kimi subagents and concurrent prompts are outside this v1 contract. + +The `acp` ModelRunner follows the `configStamp` + `turnEnd.modelOf` contract: the stamp hashes the configuration and +every credential it names, and the turn's route (alias, upstream model, protocol, effort) comes from the log's model +requests. The runner checks the stamp and route again before creating one private session and sending one prompt. It uses a private HOME and a selected-only TOML config; the credential is passed in an environment variable. The private agent has no tools/subagents, project hooks, user plugins or MCP servers. ACP success, the native/provider terminal, exact route and native process closure must agree. A separate @@ -388,12 +392,10 @@ after native exit or stop. It reports proof separately from native output. Scrat the job does not contain processes created by an external broker outside the descendant tree. When a captured route is unavailable or has changed, preflight preserves the candidate for seven days from the -first block. The person can retry after restoring the original binding. A repeat block keeps the original -deadline. A verified turn with a missing or unmatched binding is cleared on the first distill without -calling the model or Proxy. A new verified turn can capture its own binding. Ordinary model and protocol failures -keep the existing retry policy. +first block. The person can retry after restoring the original configuration and account. A repeat block keeps the +original deadline. Ordinary model and protocol failures keep the existing retry policy. The `kimi.com` managed route reads the current access token and device identity from the exact native profile. -It binds both to the snapshot, preserves the native provider family separately from its transport, and passes +Both are part of the stamp; it preserves the native provider family separately from its transport, and passes only the access token in the private child's environment. It does not manage login or refresh. Draft publication still uses the shared explicit command and Proxy contract. Other regions, clients and native acceptance on each target OS are separate gates, not inferred from fixture tests. diff --git a/hosts/README.md b/hosts/README.md index 4ff9fbf..1a8462c 100644 --- a/hosts/README.md +++ b/hosts/README.md @@ -73,10 +73,10 @@ root manifest is `kimi.plugin.json`; `scripts/install-kimi.mjs` stages a version (`KIMI_CODE_HOME/evolver`) stay outside the package; unregistering retains managed bytes. Other client types, subagents, parallel prompts and other OAuth regions require separate native acceptance. -Kimi's `UserPromptSubmit` returns `{ message }` to the model. Tool hooks observe native evidence and never promise -inline failed-check context. Stop is a hint rather than a terminal verdict: wire/task records reconcile lifecycle -and verification, with dead-owner recovery when a session has no end event. Session ids include the native-home -scope. Commands use `node /bin/command.mjs kimi ` from a terminal, without prompt interception. +Kimi's `UserPromptSubmit` returns `{ message }` to the model. Like ZCode it waits for the native terminal record +before using completed work, here read from the session log (`wire.jsonl` and task metadata): `TurnStarted` binds the +turn's native id and starts the shared turn monitor, Stop and the other lifecycle hooks only restart it, and dead-owner +recovery closes a turn whose session has no end event. Session ids include the native-home scope. Commands use `node /bin/command.mjs kimi ` from a terminal, without prompt interception. Distillation captures provider/model/account identity from the native session and uses a private ACP profile with no user/project hooks, plugins, MCP or skills; missing or changed route identity keeps the candidate for retry. Windows packed hooks have passed with a loopback fixture provider/Proxy, including native turn-zero evidence, diff --git a/hosts/kimi/dist/README.md b/hosts/kimi/dist/README.md index 1ca7802..b89805f 100644 --- a/hosts/kimi/dist/README.md +++ b/hosts/kimi/dist/README.md @@ -68,14 +68,15 @@ Evolver's Proxy/Hub account are separate. Verification keeps native task exit codes when available. Foreground Bash results without an exit code need a supported passing summary; silent output alone cannot -verify work. Pending background checks block distillation, including recovery. +verify work. A turn whose background check still runs when it ends stays pending; it +is verified only once the check's task record settles it before the turn is closed. Private distillation profiles exclude user/project hooks, plugins, MCP servers and skills. The selected credential goes to the native process through its environment. For `kimi.com` OAuth, the private profile contains the selected provider, model and device ID; it has no OAuth token file or refresh credential. Expired or changed -credentials keep the range blocked. A range can use only one captured provider -and account binding. Start a new session to accumulate work under a new binding. +credentials keep the range blocked. A range can use only one captured provider, +model and configuration. Start a new session to accumulate work under a new one. On Windows, ACP completion uses a Job Object with kill-on-close. It starts the native CLI inside the job and waits for the job to empty before deleting scratch, diff --git a/hosts/kimi/dist/kimi.plugin.json b/hosts/kimi/dist/kimi.plugin.json index 7797d76..eaaf6f0 100644 --- a/hosts/kimi/dist/kimi.plugin.json +++ b/hosts/kimi/dist/kimi.plugin.json @@ -9,13 +9,10 @@ { "event": "UserPromptSubmit", "command": "node bin/hook.mjs kimi UserPromptSubmit", "timeout": 10 }, { "event": "TurnStarted", "command": "node bin/hook.mjs kimi TurnStarted", "timeout": 3 }, { "event": "PreToolUse", "command": "node bin/hook.mjs kimi PreToolUse", "timeout": 3 }, - { "event": "PostToolUse", "command": "node bin/hook.mjs kimi PostToolUse", "timeout": 3 }, - { "event": "PostToolUseFailure", "command": "node bin/hook.mjs kimi PostToolUseFailure", "timeout": 3 }, { "event": "Stop", "command": "node bin/hook.mjs kimi Stop", "timeout": 3 }, { "event": "StopFailure", "command": "node bin/hook.mjs kimi StopFailure", "timeout": 3 }, { "event": "Interrupt", "command": "node bin/hook.mjs kimi Interrupt", "timeout": 3 }, { "event": "SessionEnd", "command": "node bin/hook.mjs kimi SessionEnd", "timeout": 3 }, - { "event": "TaskStarted", "command": "node bin/hook.mjs kimi TaskStarted", "timeout": 3 }, { "event": "Notification", "command": "node bin/hook.mjs kimi Notification", "timeout": 3 } ] } diff --git a/hosts/kimi/evidence.js b/hosts/kimi/evidence.js index 1fc1f36..6ff4cfd 100644 --- a/hosts/kimi/evidence.js +++ b/hosts/kimi/evidence.js @@ -3,26 +3,16 @@ import { isAbsolute, resolve } from 'node:path'; -const factsOf = (context) => context?.nativeFacts?.status === 'ok' && context.nativeFacts.schemaSupported - ? context.nativeFacts - : null; - -export function editedFilesOf(payload, context = {}) { - const facts = factsOf(context); - if (payload.hook_event_name !== 'PreToolUse' || !['Write', 'Edit'].includes(payload.tool_name) - || facts?.toolTurnId == null) return []; +export function editedFilesOf(payload) { + if (payload.hook_event_name !== 'PreToolUse' || !['Write', 'Edit'].includes(payload.tool_name)) return []; const file = payload.tool_input?.path; if (typeof file !== 'string' || !file || file.includes('\0')) return []; - return isAbsolute(file) - ? [file] - : typeof payload.cwd === 'string' && isAbsolute(payload.cwd) ? [resolve(payload.cwd, file)] : []; - + if (isAbsolute(file)) return [file]; + return typeof payload.cwd === 'string' && isAbsolute(payload.cwd) ? [resolve(payload.cwd, file)] : []; } export const kimiEvidence = Object.freeze({ - // Kimi truncates hook tool_output. Only correlated native wire/task results can settle evidence. + // Kimi truncates hook tool_output: a turn's evidence is read from its session log once the turn ended. toolNote: () => null, editedFiles: editedFilesOf, - turnStart: (payload, context = {}) => context.nativeFacts?.mark ?? null, - turnRecord: (payload, context = {}) => factsOf(context)?.record ?? null, }); diff --git a/hosts/kimi/model.js b/hosts/kimi/model.js index 9a033ad..efd7165 100644 --- a/hosts/kimi/model.js +++ b/hosts/kimi/model.js @@ -42,6 +42,38 @@ function managedAccess(provider, providerId, endpoint, env) { }; } +function endpointOf(provider) { + try { + const url = new URL(provider.base_url); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + return null; + } + return url.href.replace(/\/$/, ''); + } catch { + return null; + } +} + +function credentialOf(provider, providerId, endpoint, env) { + if (provider.oauth !== undefined) return managedAccess(provider, providerId, endpoint, env); + const key = text(provider.api_key); + const keyEnv = text(provider.api_key_env); + if (Boolean(key) === Boolean(keyEnv) || (keyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv))) return null; + const value = key ?? text(env[keyEnv]); + if (!value || /[\x00-\x1f\x7f]/.test(value)) return null; + return { kind: 'api-key', ...(keyEnv ? { env: keyEnv } : { value }) }; +} + +/** Every credential the configuration names, so a captured route's stamp changes with any account it could reach. */ +export function credentialsOf(config, env = {}) { + if (!object(config?.providers)) return []; + return Object.entries(config.providers).flatMap(([providerId, provider]) => { + const endpoint = object(provider) ? endpointOf(provider) : null; + const credential = endpoint && credentialOf(provider, providerId, endpoint, env); + return credential ? [credential] : []; + }); +} + /** Keep the selected account and transport; never fall back to another credential or endpoint. */ export function selectRoute(config, hint, env = {}) { const alias = text(hint?.alias); @@ -65,28 +97,9 @@ export function selectRoute(config, hint, env = {}) { && (!Number.isSafeInteger(model.max_output_size) || model.max_output_size < 1)) return null; if (model.adaptive_thinking !== undefined && typeof model.adaptive_thinking !== 'boolean') return null; if (model.support_efforts !== undefined && !strings(model.support_efforts)) return null; - let endpoint; - try { - const url = new URL(provider.base_url); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { - return null; - } - endpoint = url.href.replace(/\/$/, ''); - } catch { - return null; - } - let credential; - if (provider.oauth !== undefined) { - credential = managedAccess(provider, providerId, endpoint, env); - if (!credential) return null; - } else { - const key = text(provider.api_key); - const keyEnv = text(provider.api_key_env); - if (Boolean(key) === Boolean(keyEnv) || (keyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv))) return null; - const value = key ?? text(env[keyEnv]); - if (!value || /[\x00-\x1f\x7f]/.test(value)) return null; - credential = { kind: 'api-key', ...(keyEnv ? { env: keyEnv } : { value }) }; - } + const endpoint = endpointOf(provider); + const credential = endpoint && credentialOf(provider, providerId, endpoint, env); + if (!credential) return null; const thinking = supportsThinking(model); const effort = text(hint?.effort) ?? (thinking ? null : 'off'); // Native models without named levels expose the binary thinking toggle. @@ -134,6 +147,18 @@ export function prepareRoute(selected, { home, credentialEnv }) { }; } +/** + * The route a turn ran on, from its session log's model requests (`row.model`), when the configuration stamp it began + * with (`started`) is the one it ended with (`closed`). + */ +export function modelOf(row, started, closed) { + if (!started || !closed) return { unavailable: 'credential_missing' }; + if (started !== closed) return { unavailable: 'route_changed' }; + const { alias, model, protocol, effort } = row?.model ?? {}; + if (![alias, model, protocol].every(text)) return { unavailable: 'route_unresolved' }; + return { selection: { alias, model, protocol, effort: text(effort) }, configDigest: started }; +} + /** ACP's end_turn alone is insufficient: cross-check the owned private session's native evidence. */ export function completionFacts(records = []) { const main = records.filter((record) => record.agentId === 'main'); @@ -190,6 +215,7 @@ export const headlessKimi = Object.freeze({ parseConfig: parse, serializeConfig: stringify, select: selectRoute, + credentials: credentialsOf, }, prepare: prepareRoute, sessionConfig: (snapshot, selected) => supportsThinking(selected.model) diff --git a/hosts/kimi/paths.js b/hosts/kimi/paths.js index cfc048e..20d058e 100644 --- a/hosts/kimi/paths.js +++ b/hosts/kimi/paths.js @@ -4,17 +4,29 @@ import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; +const SCOPE = /^[a-f0-9]{64}$/; +const NATIVE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + export const kimiHomeOf = (env = {}) => resolve( env.KIMI_CODE_HOME || join(env.HOME || env.USERPROFILE || homedir(), '.kimi-code'), ); export const stateDirOf = (env = {}) => join(kimiHomeOf(env), 'evolver'); export const nativeSessionIdOf = (payload) => typeof payload.session_id === 'string' - && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(payload.session_id) ? payload.session_id : null; + && NATIVE_ID.test(payload.session_id) ? payload.session_id : null; export const projectDirOf = (payload) => typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null; + +/** The plugin's session id: the Kimi home it was read from (`recordScope`, a pseudonym) and Kimi's own session id. */ export const sessionIdOf = (payload, context = {}) => { const id = nativeSessionIdOf(payload); - return id && typeof context.nativeScope === 'string' && /^[a-f0-9]{64}$/.test(context.nativeScope) - ? JSON.stringify([context.nativeScope, id]) - : null; - + return id && SCOPE.test(context.recordScope ?? '') ? JSON.stringify([context.recordScope, id]) : null; }; + +/** Kimi's session id in a plugin session id of the Kimi home `scope`; null for another home's. */ +export function nativeSessionOf(sessionId, scope) { + try { + const [owner, id] = JSON.parse(sessionId); + return owner === scope && NATIVE_ID.test(id ?? '') ? id : null; + } catch { + return null; + } +} diff --git a/hosts/kimi/payload.js b/hosts/kimi/payload.js index 86090be..abe8adf 100644 --- a/hosts/kimi/payload.js +++ b/hosts/kimi/payload.js @@ -5,7 +5,6 @@ import { nativeTurnIdOf } from './transcript.js'; import { projectDirOf, sessionIdOf } from './paths.js'; const text = (value) => typeof value === 'string' ? value : ''; -const toolEvents = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']); const localClients = new Set(['kimi_code_cli', 'kimi_code_desktop']); export const kimiPayload = Object.freeze({ @@ -13,16 +12,12 @@ export const kimiPayload = Object.freeze({ projectDir: projectDirOf, prompt: (payload) => Array.isArray(payload.prompt) ? payload.prompt.filter((part) => part?.type === 'text').map((part) => text(part.text)).join('\n').trim() - : '', - turnId: (payload, context = {}) => { - if (payload.hook_event_name === 'UserPromptSubmit') return null; - if (toolEvents.has(payload.hook_event_name)) return nativeTurnIdOf(context.nativeFacts?.toolTurnId); - return nativeTurnIdOf(payload.turn_id) ?? nativeTurnIdOf(context.nativeFacts?.bind?.turnId); - }, + : text(payload.prompt).trim(), + turnId: (payload) => payload.hook_event_name === 'UserPromptSubmit' ? null : nativeTurnIdOf(payload.turn_id), isIgnored: (payload) => payload.is_steer === true || Boolean(payload.agent_id && payload.agent_id !== 'main') || (payload.origin_kind !== undefined && payload.origin_kind !== 'user') || !localClients.has(payload.client_type), - // SessionStart.model is a global default, not the selected native profile binding. - model: (payload, context = {}) => context.nativeFacts?.modelHint ?? null, + // SessionStart.model is a global default, not the model a turn ran on; that is read from the session log. + model: () => null, }); diff --git a/hosts/kimi/profile.js b/hosts/kimi/profile.js index 8824e55..aaee768 100644 --- a/hosts/kimi/profile.js +++ b/hosts/kimi/profile.js @@ -6,44 +6,42 @@ import { join } from 'node:path'; import { defineProfile } from '../../lib/agent/profile.js'; import { EVENTS } from '../../lib/agent/events.js'; import { kimiEvidence } from './evidence.js'; -import { headlessKimi } from './model.js'; +import { headlessKimi, modelOf } from './model.js'; import { kimiPayload } from './payload.js'; -import { kimiHomeOf, nativeSessionIdOf, projectDirOf, stateDirOf } from './paths.js'; -import { decodeNativeFacts, taskIdsOf } from './transcript.js'; +import { kimiHomeOf, nativeSessionOf, stateDirOf } from './paths.js'; +import { taskIdsOf, turnStatusOf } from './transcript.js'; const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); const workspacesOf = (index) => index?.version === 1 && isObject(index.workspaces) ? Object.entries(index.workspaces).map(([id, workspace]) => [id, workspace?.root]) : null; const promptReply = (message, event) => message && event === 'UserPromptSubmit' ? { message } : {}; -const reasons = { completed: 'answer', failed: 'error', cancelled: 'aborted', interrupted: 'aborted' }; -const terminalOf = (context) => context?.nativeFacts?.status === 'ok' && context.nativeFacts.schemaSupported - ? context.nativeFacts.terminal - : null; + +const sessionLog = Object.freeze({ + root: kimiHomeOf, workspaceIndex: 'workspaces.json', workspacesOf, nativeSessionOf, + sessionDir: (workspaceId, sessionId) => join('sessions', workspaceId, sessionId), + logFile: 'agents/main/wire.jsonl', + tasksDir: (sessionDir) => join(sessionDir, 'agents', 'main', 'tasks'), + taskIds: taskIdsOf, decode: turnStatusOf, +}); export default defineProfile({ name: 'kimi', label: 'Kimi Code', events: { SessionStart: EVENTS.SESSION_START, UserPromptSubmit: EVENTS.PROMPT, - PreToolUse: EVENTS.BEFORE_EDIT, PostToolUse: EVENTS.TOOL_RESULT, PostToolUseFailure: EVENTS.TOOL_RESULT, + // Kimi names a turn only once it runs, and writes its end to the session log, sometimes after the last hook. + TurnStarted: EVENTS.TURN_START, Stop: EVENTS.TURN_START, StopFailure: EVENTS.TURN_START, Interrupt: EVENTS.TURN_START, + Notification: EVENTS.TURN_START, + PreToolUse: EVENTS.BEFORE_EDIT, SessionEnd: EVENTS.SESSION_END, }, payload: kimiPayload, - native: { - events: ['TurnStarted', 'Stop', 'StopFailure', 'Interrupt', 'TaskStarted', 'Notification'], - root: kimiHomeOf, workspaceIndex: 'workspaces.json', workspacesOf, - sessionDir: (workspaceId, sessionId) => join('sessions', workspaceId, sessionId), - - logFile: 'agents/main/wire.jsonl', sessionId: nativeSessionIdOf, projectDir: projectDirOf, - reference: (payload) => ({ session_id: nativeSessionIdOf(payload), cwd: projectDirOf(payload) }), - tasksRoot: (env, sessionDir) => join(sessionDir, 'agents', 'main', 'tasks'), - taskIds: taskIdsOf, decode: decodeNativeFacts, - }, evidence: kimiEvidence, reply: { context: promptReply, notice: promptReply, toolResultContext: false }, commands: { entry: 'cli-only', parse: () => null }, + modelRecord: sessionLog, turnEnd: { - reason: (payload, context) => reasons[terminalOf(context)?.reason] ?? 'unknown', - answer: (payload, context) => terminalOf(context)?.answer ?? '', + modelOf, + statusRecord: { ...sessionLog, confirmCompletion: true }, unclosed: { settleMs: 0, as: 'error' }, }, budgets: { prompt: 6_000, sessionEnd: 2_000 }, diff --git a/hosts/kimi/transcript.js b/hosts/kimi/transcript.js index 2ee5c61..360e694 100644 --- a/hosts/kimi/transcript.js +++ b/hosts/kimi/transcript.js @@ -21,9 +21,12 @@ const validResult = (result) => result && typeof result === 'object' && typeof r const pathOf = (input, cwd) => text(input?.path) && (isAbsolute(input.path) ? input.path : text(cwd) && isAbsolute(cwd) ? resolve(cwd, input.path) : null); -/** Only discovers fixed task IDs; caller chooses a bounded file under the known session root. */ -export function taskIdsOf(records = [], payload = {}) { - const ids = [taskId(payload.task_id)]; +// The newest turns' tasks are the ones a turn still waiting for its end can name; older ones are past their end. +const MAX_TASKS = 64; + +/** Only discovers fixed task IDs; the reader chooses a bounded file under the known session root. */ +export function taskIdsOf(records = []) { + const ids = []; for (const record of records.filter(main)) { const event = record.type === 'context.append_loop_event' ? record.event : null; if (event?.type === 'tool.call' && event.name === 'TaskOutput') ids.push(taskId(event.args?.task_id)); @@ -31,7 +34,8 @@ export function taskIdsOf(records = [], payload = {}) { ids.push(taskId(/^task_id:\s*([A-Za-z0-9_-]+)(?:\r?\n|$)/.exec(text(event.result?.output))?.[1])); } } - return [...new Set(ids.filter(Boolean))]; + const newestLast = [...new Set(ids.filter(Boolean).reverse())].reverse(); + return newestLast.slice(-MAX_TASKS); } function callsOf(records, turnId) { @@ -118,105 +122,46 @@ function conflictingIdentity(records) { return false; } -function admissionOf(prompt, payload) { - if (payload.hook_event_name !== 'TurnStarted' || payload.origin_kind !== 'user' - || nativeTurnIdOf(payload.turn_id) !== nativeTurnIdOf(prompt.turnId) - || (payload.prompt_id !== undefined && payload.prompt_id !== prompt.promptId) - || !Number.isSafeInteger(prompt.time) || !Array.isArray(prompt.input)) return null; - const parts = prompt.input - .filter((part) => part?.type === 'text' && part.meta?.source !== 'user prompt submit hook') - .map((part) => text(part.text)); - if (!parts.length || !text(payload.prompt).trim() || parts.join('').trim() !== text(payload.prompt).trim()) { - return null; - } - return { prompt: parts.join('\n').trim(), time: prompt.time }; -} +const STATUSES = { completed: 'completed', failed: 'failed', cancelled: 'aborted', interrupted: 'aborted' }; -/** Decode only the main agent's supported native wire; native turn zero is a real identity. */ -export function decodeNativeFacts(records = [], tasks = [], payload = {}, context = {}) { - const schema = records.find((record) => record.type === 'metadata')?.protocol_version ?? context.schemaVersion; - const schemaSupported = schema === '1.5'; - const binding = records.findLast((record) => record.type === 'profile.bind' && main(record)); - const modelHint = binding && text(binding.modelAlias) - ? { alias: binding.modelAlias, effort: text(binding.thinkingEffort) || null } - : context.modelHint ?? null; - const empty = { - status: schemaSupported ? 'ok' : 'unsupported', - schemaSupported, - schemaVersion: schemaSupported ? schema : null, - bind: null, terminal: null, modelHint, record: null, notes: [], toolTurnId: null, +function routeOf(turn) { + const requests = turn.filter(main).filter((item) => item.type === 'llm.request'); + const routes = new Set(requests.map((item) => JSON.stringify([item.modelAlias, item.model, item.provider, item.thinkingEffort]))); + const request = requests.at(-1); + if (routes.size !== 1 || !text(request?.modelAlias)) return null; + return { + alias: request.modelAlias, model: text(request.model) || null, protocol: text(request.provider) || null, + effort: text(request.thinkingEffort) || null, }; - if (!schemaSupported) return empty; - const boundTurn = nativeTurnIdOf(context.turnId); - const wanted = boundTurn ?? nativeTurnIdOf(payload.turn_id); - const prompts = records.filter(promptOf).filter((record) => wanted !== null - ? nativeTurnIdOf(record.turnId) === wanted - : context.after ? record.$offset >= context.after.offset : true); - const unique = new Map(prompts.map((record) => [`${nativeTurnIdOf(record.turnId)}:${record.promptId}`, record])); - if (unique.size !== 1 || (payload.hook_event_name === 'UserPromptSubmit' && wanted === null && !context.after)) { - return empty; - } - const prompt = [...unique.values()][0]; - const turnId = nativeTurnIdOf(prompt.turnId); - const start = records.indexOf(prompt); - const turnBinding = records.slice(0, start + 1).findLast((record) => record.type === 'profile.bind' && main(record)); - const boundModelHint = turnBinding && text(turnBinding.modelAlias) - ? { alias: turnBinding.modelAlias, effort: text(turnBinding.thinkingEffort) || null } - : context.modelHint ?? null; - const next = records.findIndex( - (record, index) => index > start && promptOf(record) && nativeTurnIdOf(record.turnId) !== turnId, - ); +} + +/** + * A turn's status row from the main agent's supported wire: `accepted` while it runs, its terminal status once it + * ended, `pending` while a background check it started still runs, and its record, answer and model route from the log + * alone (hook tool output is truncated). Null for no unique user turn of that id, contradictory records, or a turn + * that runs through the part of a long log left unread. Native turn zero is a real identity. + */ +export function turnStatusOf(records = [], { turnId: wanted, tasks = [], projectDir, unread = null } = {}) { + const turnId = nativeTurnIdOf(wanted); + if (turnId === null || records.find((record) => record.type === 'metadata')?.protocol_version !== '1.5') return null; + const prompts = records.filter(promptOf).filter((record) => nativeTurnIdOf(record.turnId) === turnId); + if (new Set(prompts.map((record) => record.promptId)).size !== 1) return null; + const start = records.indexOf(prompts[0]); + const next = records.findIndex((record, index) => index > start && promptOf(record) && nativeTurnIdOf(record.turnId) !== turnId); + const crossesUnread = unread && prompts[0].$offset < unread[1] && (next === -1 || records[next].$offset > unread[0]); + if (crossesUnread) return null; const turn = records.slice(start, next === -1 ? undefined : next); const isTerminal = (item) => item.type === 'turn.ended' && main(item) && nativeTurnIdOf(item.turnId) === turnId; - let ended = turn.findLast(isTerminal); - // A bound turn can finish late. Keep the untagged model requests within its original prompt boundary. - if (boundTurn !== null && next !== -1) { - for (const item of records.slice(next)) { - if (!isTerminal(item)) continue; - if ((ended && ended.reason !== item.reason) || (item.reason === 'completed' && item.error)) { - return { ...empty, status: 'partial' }; - } - ended ??= item; - } - } - if (conflictingIdentity(turn) || (ended?.reason === 'completed' && ended.error)) { - return { ...empty, status: 'partial' }; - } - const notes = notesOf(turn, tasks, turnId, payload.cwd ?? context.projectDir); - const pendingVerification = notes.some((note) => note.running && isVerificationCommand(note.command)); - // The indexed wire gives one note per call, so it can retain the order of unfinished checks locally. + const ended = records.slice(start).filter(isTerminal); + if (new Set(ended.map((item) => item.reason)).size > 1 || conflictingIdentity(turn) + || ended.some((item) => item.reason === 'completed' && item.error)) return null; + const notes = notesOf(turn, tasks, turnId, projectDir); const record = turnRecordOf(notes.map( (note) => note.running ? { ...note, running: false, exit: null, output: '', failed: null } : note, )); record.answer = answerOf(turn, turnId); - const requests = turn.filter(main).filter((item) => item.type === 'llm.request'); - const request = requests.at(-1); - const routes = new Set( - requests.map((item) => JSON.stringify([item.modelAlias, item.model, item.provider, item.thinkingEffort])), - ); - const terminalHint = routes.size === 1 && request && text(request.modelAlias) - ? { - alias: request.modelAlias, - effort: text(request.thinkingEffort) || null, - model: text(request.model) || null, - protocol: text(request.provider) || null, - } - : null; - const terminal = ended && ['completed', 'failed', 'cancelled', 'interrupted'].includes(ended.reason) - ? { turnId, reason: ended.reason, answer: record.answer, modelHint: terminalHint } - : null; - const hookCalls = records.filter(main).filter((item) => item.type === 'context.append_loop_event' - && item.event?.type === 'tool.call' && item.event.toolCallId === payload.tool_call_id); - const hookTurns = [...new Set(hookCalls.map((item) => nativeTurnIdOf(item.event.turnId)).filter((id) => id !== null))]; - const toolTurnId = hookTurns.length === 1 - ? hookTurns[0] - : payload.hook_event_name === 'PreToolUse' && nativeTurnIdOf(context.turnId) !== null ? turnId : null; - const admission = prompts.length === 1 && next === -1 ? admissionOf(prompt, payload) : null; - return { - ...empty, - modelHint: boundModelHint, - bind: { turnId, promptId: prompt.promptId, origin: 'user', mark: prompt.$offset ?? null }, - ...(admission && { admission }), terminal, record, notes, pendingVerification, toolTurnId, - }; - + const terminal = STATUSES[ended[0]?.reason]; + const checking = notes.some((note) => note.running && isVerificationCommand(note.command)); + const status = !terminal ? 'accepted' : terminal === 'completed' && checking ? 'pending' : terminal; + return { status, record, answer: record.answer, model: routeOf(turn) }; } diff --git a/lib/agent/driver.js b/lib/agent/driver.js index dcaba68..943e2ac 100644 --- a/lib/agent/driver.js +++ b/lib/agent/driver.js @@ -4,9 +4,8 @@ import { distillText } from '../app/commands/distill.js'; import { commandNamed, runCommand } from '../app/commands/index.js'; import { distillSession, postAgentGene } from '../app/distill-session.js'; -import { adoptStrategy, lastTurnOf, promptSubmitted, takeWaitingStrategy } from '../app/prompt.js'; +import { adoptStrategy, lastTurnOf, promptSubmitted, takeWaitingStrategy, turnStarted } from '../app/prompt.js'; import { sessionEnded } from '../app/session-end.js'; -import { turnObservationOf } from '../app/native-turn.js'; import { isNewOwner, sessionStarted } from '../app/session-start.js'; import { recallWhenStuck } from '../app/stuck-recall.js'; import { beforeEdit, toolFinished } from '../app/tool-result.js'; @@ -50,14 +49,13 @@ async function commandAnswer(profile, ports, at, typed, options = {}) { } const HANDLERS = { - async [EVENTS.SESSION_START]({ profile, ports, hostEvent, payload, at, observation }) { - await observation?.recover(); - const trackOwner = !observation || profile.sessionStart === 'owner-change'; - const notice = await sessionStarted(ports, { ...at, trackOwner }); + async [EVENTS.SESSION_START]({ profile, ports, hostEvent, payload, at }) { + // Only a host whose new session is a new owner reads the owner here; finding it can take seconds on Windows. + const notice = await sessionStarted(ports, { ...at, trackOwner: profile.sessionStart === 'owner-change' }); return notice ? profile.reply.notice(notice, hostEvent, payload) : profile.reply.empty(hostEvent, payload); }, - async [EVENTS.PROMPT]({ profile, ports, hostEvent, payload, context, at, waitLeft, options, observation }) { + async [EVENTS.PROMPT]({ profile, ports, hostEvent, payload, context, at, waitLeft, options }) { const { sessionId, projectDir } = at; const prompt = profile.payload.prompt(payload, context); const isNewSession = profile.sessionStart === 'owner-change' && (await isNewOwner(ports, sessionId)); @@ -78,15 +76,20 @@ const HANDLERS = { const keepLate = typeof profile.reply.late === 'function'; const hostTurn = profile.payload.hostTurn(payload, context); const holdForToolResult = profile.reply.strategyAt === STRATEGY_AT.TOOL_RESULT; - const attach = await observation?.capturePrompt(at, payload, { ...context, prompt }); const { turn, sessionEpoch, injection, late, skipped } = await promptSubmitted(ports, { sessionId, prompt, projectDir, turnId, turnStart, hostTurn, isCommand: Boolean(typed), waitMs: waitLeft(), keepLate, holdForToolResult }); if (skipped) options.onError?.(skipped); - await attach?.({ turn, sessionEpoch }); if (profile.turnEnd.statusRecord && turnId) await scheduleTurn(ports, at, { turn, turnId, sessionEpoch }); const answer = profile.reply.context(joined(notice, await checkpointOf(), injection), hostEvent, payload); return late ? profile.reply.late(answer, late, hostEvent, payload) : answer; }, + async [EVENTS.TURN_START]({ profile, ports, hostEvent, payload, context, at }) { + const turnId = profile.payload.turnId(payload, context); + const started = turnId && await turnStarted(ports, { sessionId: at.sessionId, turnId, prompt: profile.payload.prompt(payload, context) }); + if (started && profile.turnEnd.statusRecord) await scheduleTurn(ports, at, started); + return profile.reply.empty(hostEvent, payload); + }, + async [EVENTS.TOOL_RESULT]({ profile, ports, hostEvent, payload, context, at, waitLeft }) { const note = profile.evidence.toolNote(payload, context); const turnId = profile.payload.turnId(payload, context); @@ -98,7 +101,7 @@ const HANDLERS = { } const waiting = await takeWaitingStrategy(ports, { sessionId: at.sessionId, turnId }); const reachesModel = profile.reply.toolResultContext || profile.reply.strategyAt === STRATEGY_AT.TOOL_RESULT; - const stuck = note && reachesModel ? await recallWhenStuck(ports, { sessionId: at.sessionId, projectDir: at.projectDir, turnId, note, waitMs: waitLeft('toolResult') }) : ''; + const stuck = note && reachesModel ? await recallWhenStuck(ports, { sessionId: at.sessionId, turnId, note, projectDir: at.projectDir, waitMs: waitLeft('toolResult') }) : ''; const strategy = joined(waiting, stuck); return strategy ? profile.reply.context(strategy, hostEvent, payload) : profile.reply.empty(hostEvent, payload); }, @@ -165,10 +168,9 @@ const TRANSCRIPT_HEAD_BYTES = 64 * 1024; * hook answers within the host's timeout. */ export function createDriver({ profile, ports, transcripts, env = process.env, lifetime, startedAt = Date.now() }) { - const observation = turnObservationOf(ports, profile.native?.events); const contextOf = (payload, hostEvent) => { const file = profile.payload.transcriptPath(payload); - return { env, hostEvent, nativeScope: observation?.scope, transcriptSize: () => transcripts.size(file), transcriptSince: (offset) => transcripts.readSince(file, offset), transcriptHead: () => transcripts.head(file, TRANSCRIPT_HEAD_BYTES), modelRecord: key => ports.modelRecord?.(key) }; + return { env, hostEvent, recordScope: ports.turnStatus?.scope, transcriptSize: () => transcripts.size(file), transcriptSince: (offset) => transcripts.readSince(file, offset), transcriptHead: () => transcripts.head(file, TRANSCRIPT_HEAD_BYTES), modelRecord: key => ports.modelRecord?.(key) }; }; return { /** @@ -189,15 +191,13 @@ export function createDriver({ profile, ports, transcripts, env = process.env, l try { const event = profile.events[hostEvent]; const context = contextOf(payload, hostEvent); - if ((!event && !observation?.observes(hostEvent)) || profile.payload.isIgnored(payload, context)) return profile.reply.empty(hostEvent, payload); + if (!event || profile.payload.isIgnored(payload, context)) return profile.reply.empty(hostEvent, payload); const sessionId = profile.payload.sessionId(payload, context); if (!sessionId) return profile.reply.empty(hostEvent, payload); const at = { sessionId, projectDir: profile.payload.projectDir(payload, context) }; - if (observation) context.nativeFacts = await observation.beforeEvent(at, payload, { prompt: event === EVENTS.PROMPT, onError: options.onError }); - if (!event) return profile.reply.empty(hostEvent, payload); if (VERSION_EVENTS.has(event)) ports.client?.rememberAgentVersion(profile.payload.agentVersion(payload, context)); const waitLeft = (budgetOf = 'prompt') => waitBudgetOf(profile.budgets, budgetOf, { lifetime, startedAt }); - return await HANDLERS[event]({ profile, ports, hostEvent, payload, context, at, waitLeft, startedAt, options, observation }); + return await HANDLERS[event]({ profile, ports, hostEvent, payload, context, at, waitLeft, startedAt, options }); } catch (error) { options.onError?.(error); return profile.reply.empty(hostEvent, payload); diff --git a/lib/agent/events.js b/lib/agent/events.js index 9454fdc..3c7ae39 100644 --- a/lib/agent/events.js +++ b/lib/agent/events.js @@ -5,6 +5,7 @@ export const EVENTS = Object.freeze({ SESSION_START: 'sessionStart', PROMPT: 'prompt', + TURN_START: 'turnStart', TOOL_RESULT: 'toolResult', BEFORE_EDIT: 'beforeEdit', TURN_END: 'turnEnd', @@ -23,6 +24,7 @@ export const COMMAND_ENTRIES = Object.freeze({ INTERCEPT: 'intercept', INLINE_SCRIPT: 'inline-script', PROMPT: 'prompt', + TURN_START: 'turnStart', TOOL: 'tool', CLI_ONLY: 'cli-only', }); diff --git a/lib/agent/profile.js b/lib/agent/profile.js index ef86081..0490ab7 100644 --- a/lib/agent/profile.js +++ b/lib/agent/profile.js @@ -57,10 +57,13 @@ function merged(layers) { return result; } -const NATIVE_READERS = ['root', 'sessionId', 'projectDir', 'workspacesOf', 'sessionDir', 'decode', 'taskIds', 'tasksRoot', 'reference']; -const isNativeDeclaration = (native) => NATIVE_READERS.every((key) => typeof native?.[key] === 'function') - && [native.workspaceIndex, native.logFile].every((file) => /^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*$/.test(file ?? '')) - && Array.isArray(native.events) && native.events.every((event) => typeof event === 'string' && event); +const LOG_READERS = ['root', 'nativeSessionOf', 'workspacesOf', 'sessionDir', 'decode']; +const RELATIVE_FILE = /^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*$/; +const isSqlRecord = (record) => typeof record?.file === 'function' && typeof record.query === 'string' && record.logFile === undefined; +const isLogRecord = (record) => record?.query === undefined && LOG_READERS.every((key) => typeof record?.[key] === 'function') + && [record.workspaceIndex, record.logFile].every((file) => RELATIVE_FILE.test(file ?? '')); +const isRecord = (record) => isSqlRecord(record) || isLogRecord(record); +const RECORD_SHAPES = 'file(env) and a fixed query, or an indexed session log'; function problemsOf(profile) { const problems = []; @@ -87,11 +90,10 @@ function problemsOf(profile) { if (Object.values(profile.budgets).some((budget) => !Number.isFinite(budget))) problems.push('budgets must be milliseconds'); if (!isFunction(profile.stateDir)) problems.push('stateDir must be a function of the environment'); if (profile.settingsFile !== undefined && !isFunction(profile.settingsFile)) problems.push('settingsFile must be a function of the environment'); - if (profile.modelRecord !== undefined && (!isFunction(profile.modelRecord.file) || typeof profile.modelRecord.query !== 'string')) problems.push('modelRecord requires file(env) and a fixed query'); - if (profile.turnEnd.statusRecord !== undefined && (!isFunction(profile.turnEnd.statusRecord.file) || typeof profile.turnEnd.statusRecord.query !== 'string')) problems.push('turnEnd.statusRecord requires file(env) and a fixed query'); + if (profile.modelRecord !== undefined && !isRecord(profile.modelRecord)) problems.push(`modelRecord requires ${RECORD_SHAPES}`); + if (profile.turnEnd.statusRecord !== undefined && !isRecord(profile.turnEnd.statusRecord)) problems.push(`turnEnd.statusRecord requires ${RECORD_SHAPES}`); if (profile.commands.background !== undefined && !isFunction(profile.commands.background)) problems.push('commands.background must select admitted command names'); if (!Array.isArray(profile.legacy)) problems.push('legacy must be a list of leftovers to adopt'); - if (profile.native !== undefined && !isNativeDeclaration(profile.native)) problems.push('native requires indexed-session readers, observation events and a fixed workspaceIndex and logFile'); return problems; } diff --git a/lib/app/background.js b/lib/app/background.js index 952dc34..e87697a 100644 --- a/lib/app/background.js +++ b/lib/app/background.js @@ -4,7 +4,6 @@ import { runCommand } from './commands/index.js'; import { deliverReports, recordReports } from './deliver-reports.js'; import { armDistillWakeUp, distillDue, distillsInBackground, dropExpiredLeftovers } from './distill-session.js'; -import { reconcileNativeTurn } from './native-turn.js'; import { TASKS } from './ports.js'; import { recoverNativeTurns } from './session-start.js'; import { turnEnded } from './turn-end.js'; @@ -15,7 +14,7 @@ function cancellable(ports, signal) { ...ports, scheduler: { ...ports.scheduler, signal: combined(ports.scheduler.signal) }, proxy: { ...ports.proxy, request: (method, path, body, options = {}) => ports.proxy.request(method, path, body, { ...options, signal: combined(options.signal) }) }, - model: ports.model && { ...ports.model, complete: (model, prompt, other) => ports.model.complete(model, prompt, combined(other)) }, + model: ports.model && { complete: (model, prompt, other) => ports.model.complete(model, prompt, combined(other)) }, }; } @@ -25,8 +24,6 @@ const WORK = { [TASKS.RECOVER_TURNS]: recoverNativeTurns, command: (ports, id) => ports.commandJobs?.run(id, (at, signal) => runCommand(cancellable(ports, signal), { ...at, signal })), turn: (ports, payload) => ports.turnMonitor?.(JSON.parse(payload), (closed) => turnEnded(ports, closed)), - [TASKS.NATIVE]: (ports, payload) => ports.observeTurn?.(JSON.parse(payload), - (at) => reconcileNativeTurn(ports, at), (closed) => turnEnded(ports, closed)), // Without a model outside the conversation a leftover cannot be distilled here; trying would only use up its tries // before the agent's own model, asked in its session, gets to write the Gene. A person who turned background // distillation off still distills on demand. diff --git a/lib/app/distill-run.js b/lib/app/distill-run.js index 226ad3f..32e5372 100644 --- a/lib/app/distill-run.js +++ b/lib/app/distill-run.js @@ -8,7 +8,7 @@ import { irProblemOf, irReplyOf } from '../distill/ir.js'; import { irPromptOf } from '../distill/ir-prompt.js'; import { defaultKindOf } from '../distill/kinds.js'; import { withOutcome } from '../distill/outcomes.js'; -import { isCurrentRange, rangeOf, rangeRouteProblem } from '../distill/range.js'; +import { isCurrentRange } from '../distill/range.js'; import { sessionRecordOf } from '../distill/record.js'; import { distillBodiesOf, reusedAssetIdsOf } from '../distill/request.js'; import { blockedModelReason, distillResultOf, isDiscarded, postedOutcomeOf } from '../distill/result.js'; @@ -47,43 +47,31 @@ async function keptForCalibration(ports, claimed, { gate, kind, session }) { await ports.corpus?.update((corpus) => withCorpusEntry(corpus, entry)); } -export const usesCapturedRoute = (ports, range) => Boolean(ports.native || ports.model?.capture || range.native || range.model?.binding); +const isCurrent = async ({ candidates }, { sessionId, range, exact }) => isCurrentRange(await candidates.read(), sessionId, range, { exact }); -export function blockedRangeOf(ports, range) { - const reason = rangeRouteProblem(range, { requireCapturedRoute: Boolean(ports.native) }) - ?? (!range.model && usesCapturedRoute(ports, range) ? 'route_not_captured' : null); - if (!reason) return null; - return reason === 'route_not_captured' && range.turns.length === 1 && !range.routeConflict - ? { status: 'route_not_captured' } - : { status: 'blocked', reason }; -} - -function sourceRangeOf(range) { - return { at: range.at, changedAt: range.changedAt, turns: range.turns.map(({ turn, sessionEpoch, at, correction }) => ({ - turn, sessionEpoch: sessionEpoch ?? range.sessionEpoch, at, ...(correction && { corrected: true }), - })) }; +/** + * The turn whose later correction would invalidate a draft of `range`: its last. One corrected already when the range + * was read cannot be corrected again, and its correction mark does not concern the draft. + */ +function draftSourceOf(range) { + const last = range.turns.at(-1); + return { at: range.at, turn: last.turn, sessionEpoch: last.sessionEpoch ?? range.sessionEpoch, ...(last.correction && { sawCorrection: true }) }; } +/** Whether a correction of a turn `draft` was distilled from came after it was (see `draftSourceOf`). */ export async function draftWasCorrected({ draftCorrections }, draft) { - const sources = draftSourcesOf(draft).filter((source) => source && !source.corrected && !source.sawCorrection); - return (await Promise.all(sources.map(({ turn, sessionEpoch }) => draftCorrections.has(draft.sessionId, turn, sessionEpoch)))).some(Boolean); -} - -async function isCurrent(ports, { sessionId, range, exact }) { - const pending = await ports.candidates.read(); - if (!isCurrentRange(pending, sessionId, range, { exact })) return false; - const current = rangeOf(pending, sessionId); - if (blockedRangeOf(ports, current)) return false; - return current.turns.length !== range.turns.length - || JSON.stringify([current.project, current.model]) === JSON.stringify([range.project, range.model]); + const sources = draftSourcesOf(draft).filter((source) => !source.corrected && !source.sawCorrection); + const marks = await Promise.all(sources.map(({ turn, sessionEpoch }) => draftCorrections.has(draft.sessionId, turn, sessionEpoch))); + return marks.some(Boolean); } -const wasCorrected = (ports, { sessionId, range }) => draftWasCorrected(ports, { sessionId, sourceRange: sourceRangeOf(range) }); +const isStale = async (ports, claimed) => !(await isCurrent(ports, claimed)) + || draftWasCorrected(ports, { sessionId: claimed.sessionId, sourceCandidate: draftSourceOf(claimed.range) }); // A correction may land between any two steps: the draft is written unconfirmed, the range checked again, and only // then confirmed; a range that changed takes its draft back, and one it cannot take back stays unpublishable. async function savedDraft(ports, claimed, result) { - const draft = { ...result.draft, sourceRange: sourceRangeOf(claimed.range) }; + const draft = { ...result.draft, sourceCandidate: draftSourceOf(claimed.range) }; const missed = (status) => ({ status, title: result.title }); const written = await ports.drafts.update((list) => withDraft(list, { ...draft, pendingCandidateCheck: true })); if (await isCurrent(ports, claimed)) { @@ -103,31 +91,28 @@ async function savedDraft(ports, claimed, result) { // What the Proxy stored stands; any other answer for work that changed while it was asked for is discarded. async function keptResult(ports, claimed, result) { if (result.status === 'stored') return result; - if (!(await isCurrent(ports, claimed)) || await wasCorrected(ports, claimed)) return { status: 'dropped', title: result.title }; + if (await isStale(ports, claimed)) return { status: 'dropped', title: result.title }; return result.draft ? savedDraft(ports, claimed, result) : result; } // A session file that cannot be read costs the distillation its lineage, never the distillation itself. -async function reusedAssetIdsIn(ports, { sessionId, range }) { +async function reusedAssetIdsIn(ports, { sessionId, range }, session) { try { - return reusedAssetIdsOf(sessionOf(await ports.sessions.read(sessionId)), range); + return reusedAssetIdsOf(sessionOf(await ports.sessions.read(sessionId)), { ...range, turns: session.turns }); } catch { return []; } } async function posted(ports, claimed, session, { irs, usage, signal }) { - const reusedAssetIds = await reusedAssetIdsIn(ports, claimed); + const reusedAssetIds = await reusedAssetIdsIn(ports, claimed, session); const bodies = distillBodiesOf({ platform: ports.identity.agent, sessionId: taskSessionId(ports.identity, claimed.sessionId), trigger: claimed.trigger, session, irs, usage, reusedAssetIds }); const results = []; for (const body of bodies) { // The range watch polls; a correction since the last look (while a refused IR was logged, or during the previous - // request) must stop the next request before it is sent. - const started = await ports.draftCorrections.withSession(claimed.sessionId, async () => { - if (signal?.aborted || !(await isCurrent(ports, claimed)) || await wasCorrected(ports, claimed)) return null; - // Hold the correction lock through dispatch. Corrections can still proceed while the Proxy answers. - return { response: ports.proxy.request('POST', '/conversation/distill', body, { timeoutMs: DISTILL_TIMEOUT_MS, signal }) }; - }, signal); + // request) must stop the next request before it is sent. The correction lock is held through dispatch only. + const started = await ports.draftCorrections.withSession(claimed.sessionId, async () => (signal?.aborted || await isStale(ports, claimed) + ? null : { response: ports.proxy.request('POST', '/conversation/distill', body, { timeoutMs: DISTILL_TIMEOUT_MS, signal }) }), signal); if (!started) { results.push(await logged(ports, claimed, { status: 'dropped', title: body.ir.title })); break; @@ -145,7 +130,7 @@ async function posted(ports, claimed, session, { irs, usage, signal }) { * could never be accepted is refused here. Every outcome is logged. */ export async function postedReply(ports, claimed, distillation, { text, usage = null, signal }) { - if (!(await isCurrent(ports, claimed)) || await wasCorrected(ports, claimed)) return { status: 'dropped' }; + if (await isStale(ports, claimed)) return { status: 'dropped' }; const reply = irReplyOf(text); if (!reply) return { ...(await logged(ports, claimed, { status: 'failed', reason: 'unparsed' })), status: 'unparsed', ...(usage && { usage }) }; if (reply.skip || reply.irs.length === 0) return { ...(await logged(ports, claimed, { status: 'declined', reason: reply.skip ?? 'no IR' })), ...(usage && { usage }) }; @@ -164,7 +149,7 @@ function watchRange(ports, claimed) { const controller = new AbortController(); const timer = setInterval(async () => { try { - if (!(await isCurrent(ports, claimed)) || await wasCorrected(ports, claimed)) controller.abort(); + if (await isStale(ports, claimed)) controller.abort(); } catch { // Retry a failed read; the fresh check before posting must still succeed. } @@ -179,14 +164,20 @@ async function asked(ports, claimed, prompt, { signal, watched }) { } catch (error) { if (watched.aborted) return { outcome: await logged(ports, claimed, { status: 'dropped' }) }; if (ports.scheduler?.signal?.aborted) return { outcome: { status: 'stopped' } }; - const blocked = usesCapturedRoute(ports, claimed.range) && blockedModelReason(error); + const blocked = ports.model.capture && blockedModelReason(error); if (blocked) return { outcome: await logged(ports, claimed, { status: 'blocked', reason: blocked }) }; - const detail = usesCapturedRoute(ports, claimed.range) ? 'Captured model completion failed.' : withoutSecrets(String(error?.message ?? error)); - const failure = await logged(ports, claimed, { status: 'failed', reason: 'model_failed', error: detail }); + const failure = await logged(ports, claimed, { status: 'failed', reason: 'model_failed', error: withoutSecrets(String(error?.message ?? error)) }); return { outcome: { ...failure, status: 'model_failed' } }; } } +// A model that captures the route each session used must not stand in the current route for a range that kept none. +async function withoutRoute(ports, claimed) { + if (!ports.model.capture) return { status: 'no_model' }; + await logged(ports, claimed, { status: 'failed', reason: 'route_not_captured' }); + return { status: 'route_not_captured' }; +} + /** * Distills a claimed range (`{ sessionId, range, trigger }`): below the gate there is no model call, only a logged * `below_threshold` (once per amount of work: a range held below it logs nothing again); otherwise the range is kept @@ -194,30 +185,25 @@ async function asked(ports, claimed, prompt, { signal, watched }) { * `blocked` when the session's model route is unavailable; `dropped` when a turn it read was corrected meanwhile. */ export async function distillClaimed(ports, claimed, { focus = '', explicit = false } = {}) { - const blocked = blockedRangeOf(ports, claimed.range); - if (blocked) return logged(ports, claimed, blocked); - if (!(await isCurrent(ports, claimed)) || await wasCorrected(ports, claimed)) return { status: 'dropped' }; + if (await isStale(ports, claimed)) return { status: 'dropped' }; const distillation = distillationOf(claimed.range, { explicit, focus }); const { gate } = distillation; if (!gate.passed && claimed.range.held) return { status: 'below_threshold', reason: gate.reason, detail: gateDetailOf(gate) }; await keptForCalibration(ports, claimed, distillation); if (!gate.passed) return logged(ports, claimed, { status: 'below_threshold', reason: gate.reason, detail: gateDetailOf(gate) }); - if (!ports.model || !claimed.range.model) return { status: 'no_model' }; - if (!(await isCurrent(ports, claimed)) || await wasCorrected(ports, claimed)) return { status: 'dropped' }; + if (!ports.model) return { status: 'no_model' }; + if (!claimed.range.model) return withoutRoute(ports, claimed); const watch = watchRange(ports, claimed); const stopping = ports.scheduler?.signal; const signal = AbortSignal.any([AbortSignal.timeout(DISTILL_TIMEOUT_MS), watch.signal, ...(stopping ? [stopping] : [])]); try { const { reply, outcome } = await asked(ports, claimed, distillation.prompt, { signal, watched: watch.signal }); if (outcome) return outcome; - if (stopping?.aborted) { - if (usesCapturedRoute(ports, claimed.range)) return { status: 'stopped' }; - // An SDK that already answered during shutdown keeps its retry wait, without dispatching the IR. - return logged(ports, claimed, { status: 'failed', reason: 'proxy_cancelled' }); - } + if (stopping?.aborted) return { status: 'stopped' }; if (watch.signal.aborted || !(await isCurrent(ports, claimed))) return logged(ports, claimed, { status: 'dropped' }); return await postedReply(ports, claimed, distillation, { text: reply.answer, usage: reply.usage, signal }); } catch (error) { + // The Proxy request carries the same signal: a correction or shutdown during it aborts the request. if (watch.signal.aborted) return { status: 'dropped' }; if (stopping?.aborted) return { status: 'stopped' }; throw error; diff --git a/lib/app/distill-session.js b/lib/app/distill-session.js index 4c1bc3d..e56b6d6 100644 --- a/lib/app/distill-session.js +++ b/lib/app/distill-session.js @@ -5,7 +5,7 @@ import { sameDraftSource } from '../distill.js'; import { irReplyOf } from '../distill/ir.js'; import { claimedDue, claimedOwn, hasDueRange, nextDueAt, rangeOf, rangeSessionOf, settledRange, TRIGGERS, withoutExpiredLeftovers } from '../distill/range.js'; import { isDiscarded, isSettled, needsAgentModel, postedOutcomeOf } from '../distill/result.js'; -import { DISTILL_TIMEOUT_MS, blockedRangeOf, distillClaimed, distillationOf, draftWasCorrected, postedReply, usesCapturedRoute } from './distill-run.js'; +import { DISTILL_TIMEOUT_MS, distillClaimed, distillationOf, draftWasCorrected, postedReply } from './distill-run.js'; import { TASKS } from './ports.js'; export { DISTILL_TIMEOUT_MS, draftWasCorrected }; @@ -14,10 +14,10 @@ export { DISTILL_TIMEOUT_MS, draftWasCorrected }; // its own time. export const MIN_WAKE_DELAY_MS = 60_000; -async function claim({ candidates, owner, native, clock }, claimOf) { +async function claim({ candidates, owner, clock }, claimOf) { let claimed = null; await candidates.update((pending) => { - const options = { pid: owner.self(), isGone: owner.isGone, requireCompletedChecks: Boolean(native), now: clock.now() }; + const options = { pid: owner.self(), isGone: owner.isGone, now: clock.now() }; const pruned = withoutExpiredLeftovers(pending, { ...options, blockedOnly: true }); const result = claimOf(pruned ?? pending, options); claimed = result.claimed; @@ -37,10 +37,11 @@ function settlementOf(outcome, { trigger, explicit, countModelFailures }) { if (outcome.status === 'blocked') return { blocked: outcome.reason }; if (outcome.status === 'below_threshold') return explicit ? {} : trigger === TRIGGERS.SESSION_END ? { consume: true } : { hold: true }; if (isSettled(outcome)) return { consume: true }; - return { counted: countModelFailures || !needsAgentModel(outcome), blocked: null }; + return { counted: countModelFailures || !needsAgentModel(outcome) }; } // A run its process stopped for shutdown only gives its claim back: the next start tries the range again. +// One that threw gives its claim back too, so this live process can try the range again. async function run(ports, claimed, { focus = '', explicit = false, countModelFailures = true } = {}) { let outcome; try { @@ -91,7 +92,7 @@ export async function distillSession(ports, { sessionId: asked, focus = '', anyS await armDistillWakeUp(ports); if (!needsAgentModel(outcome)) return (await confirmDraftOutcomes(ports, [outcome]))[0]; const range = rangeOf(await ports.candidates.read(), sessionId); - if (!range || usesCapturedRoute(ports, range)) return outcome; + if (!range) return outcome; const { gate, prompt } = distillationOf(range, { explicit: true, focus }); return gate.passed ? { ...outcome, agentGene: { writtenFor: ports.candidates.referenceOf(sessionId, range), prompt } } : outcome; } @@ -112,9 +113,7 @@ export const distillsInBackground = ({ model, settings }) => Boolean(model) && s */ export async function armDistillWakeUp(ports) { const now = ports.clock.now(); - const at = nextDueAt(await ports.candidates.read(), { - now, isGone: ports.owner.isGone, requireCompletedChecks: Boolean(ports.native), includeDistillation: distillsInBackground(ports), - }); + const at = nextDueAt(await ports.candidates.read(), { now, isGone: ports.owner.isGone, includeDistillation: distillsInBackground(ports) }); if (at !== null) ports.scheduler.wakeAt(at > now ? at : now + MIN_WAKE_DELAY_MS, [TASKS.DISTILL]); } @@ -123,9 +122,7 @@ export async function armDistillWakeUp(ports) { * session that never ends still gets its draft; a distillation started here arms the next wake-up once it ran. */ export async function scheduleDueDistillation(ports, alongside = []) { - const isDue = distillsInBackground(ports) && hasDueRange(await ports.candidates.read(), { - now: ports.clock.now(), isGone: ports.owner.isGone, requireCompletedChecks: Boolean(ports.native), - }); + const isDue = distillsInBackground(ports) && hasDueRange(await ports.candidates.read(), { now: ports.clock.now(), isGone: ports.owner.isGone }); const tasks = [...alongside, ...(isDue ? [TASKS.DISTILL] : [])]; if (tasks.length > 0) ports.scheduler.schedule(tasks); if (!isDue) await armDistillWakeUp(ports); @@ -148,16 +145,12 @@ export async function postAgentGene(ports, { sessionId: asked, text, writtenFor, if (!claimed) return rangeOf(await ports.candidates.read(), sessionId) ? { status: 'busy' } : { status: 'nothing' }; let outcome; try { - if (ports.candidates.referenceOf(sessionId, claimed.range) !== writtenFor) return (outcome = { status: 'dropped' }); - const blocked = blockedRangeOf(ports, claimed.range); - if (blocked) return (outcome = blocked); - if (usesCapturedRoute(ports, claimed.range)) return (outcome = { status: 'dropped' }); const distillation = distillationOf(claimed.range, { explicit: true }); - if (!distillation.gate.passed) return (outcome = { status: 'dropped' }); - outcome = await postedReply(ports, { ...claimed, trigger: TRIGGERS.SESSION_END, exact: true }, distillation, { text, signal: AbortSignal.timeout(DISTILL_TIMEOUT_MS) }); + outcome = ports.candidates.referenceOf(sessionId, claimed.range) === writtenFor && distillation.gate.passed + ? await postedReply(ports, { ...claimed, trigger: TRIGGERS.SESSION_END, exact: true }, distillation, { text, signal: AbortSignal.timeout(DISTILL_TIMEOUT_MS) }) + : { status: 'dropped' }; } finally { - const decision = outcome?.status === 'blocked' ? { blocked: outcome.reason } : isSettled(outcome) ? { consume: true } : {}; - await settle(ports, claimed, decision); + await settle(ports, claimed, isSettled(outcome) ? { consume: true } : {}); await armDistillWakeUp(ports); } return (await confirmDraftOutcomes(ports, [outcome]))[0]; diff --git a/lib/app/native-turn.js b/lib/app/native-turn.js deleted file mode 100644 index 58bc1e1..0000000 --- a/lib/app/native-turn.js +++ /dev/null @@ -1,384 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 EvoMap - -import { hasUnknownChecks, verdictOf } from '../checks.js'; -import { rangeOf, withPendingNativeCheck, withoutPendingNativeCheck, withoutWorkTurn } from '../distill/range.js'; -import { isCorrected, isSettling, observedNativeTurn, sessionOf } from '../session/state.js'; -import { finishDeferredNativeTurn, isNativeWorkTurn, turnEnded } from './turn-end.js'; -import { scheduleDueDistillation } from './distill-session.js'; -import { TASKS } from './ports.js'; - -const reasons = { completed: 'answer', failed: 'error', cancelled: 'aborted', interrupted: 'aborted' }; -const same = (left, right) => JSON.stringify(left) === JSON.stringify(right); -const sameTurn = (left, right) => left.turn === right.turn - && left.pid === right.pid - && left.sessionEpoch === right.sessionEpoch; -const supersedesCheck = (current, check) => current.sessionEpoch !== check.sessionEpoch || current.turn > check.turn; -const turnIdOf = (value) => typeof value === 'string' && value ? value : null; -const cursorOf = (facts) => ({ mark: facts.mark, schemaVersion: facts.schemaVersion, modelHint: facts.modelHint }); - -async function promptDigest(prompt) { - const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(prompt))); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); -} - -/** Optional observation of host records whose terminal can arrive after the last hook. */ -export function turnObservationOf(ports, events = []) { - const { native, candidates, sessions, owner, scheduler } = ports; - if (!native) return null; - return { - scope: native.scope, - observes: (event) => events.includes(event), - recover: () => recoverNativeTurns(ports), - async beforeEvent(at, payload, { prompt, onError }) { - const deferred = (await sessions.read(at.sessionId)).deferredNativeTurns?.at(-1); - if (deferred) { - // A failed write leaves the older turn available to its worker or recovery. - await reconcileNativeTurn(ports, { ...at, turn: deferred.turn, sessionEpoch: deferred.sessionEpoch }) - .catch((error) => onError?.(error)); - } - const reconciled = await reconcileNativeTurn(ports, { ...at, payload }); - if (!prompt) return reconciled.facts; - const cursor = (await sessions.read(at.sessionId)).nativeCursor; - return readFacts(ports, payload, null, cursor); - }, - async capturePrompt(at, payload, { nativeFacts: facts, prompt }) { - const mark = facts?.status === 'ok' ? facts.mark ?? null : null; - const admission = !mark && prompt - ? { time: ports.clock.now(), digest: await promptDigest(prompt), mark: facts?.mark ?? null } - : null; - let model = null; - // No binding means no model request. - try { - model = await ports.model?.capture?.(facts?.modelHint); - } catch {} - return async ({ turn, sessionEpoch }) => { - const pending = rangeOf(await candidates.read(), at.sessionId)?.pendingNativeCheck; - const attached = await sessions.update(at.sessionId, (stored) => { - if (!stored.open || stored.turn !== turn || stored.sessionEpoch !== sessionEpoch - || stored.pid !== owner.current()) return undefined; - return { - ...stored, - native: { - turn, mark, ...(admission && { admission }), turnId: null, model, - modelHint: facts?.modelHint ?? null, - schemaVersion: facts?.schemaVersion ?? null, - payload: native.reference(payload), - }, - }; - }); - if (attached) { - if (pending && supersedesCheck(attached, pending)) await releasePendingCheck(ports, at.sessionId, pending); - scheduler.schedule([`${TASKS.NATIVE}:${JSON.stringify({ ...at, turn, sessionEpoch })}`]); - } - }; - }, - }; -} - -async function releasePendingCheck(ports, sessionId, turn) { - const changed = await ports.candidates.update((pending) => withoutPendingNativeCheck(pending, sessionId, turn)); - if (changed) await scheduleDueDistillation(ports); -} - -async function holdPendingCheck(ports, sessionId, turn) { - const changed = await ports.candidates.update( - (pending) => withPendingNativeCheck(pending, sessionId, turn, ports.clock.now()), - ); - if (!changed) return; - const current = sessionOf(await ports.sessions.read(sessionId)); - if (!sameTurn(current, turn) || (current.native?.settled && !current.native.pending)) { - await releasePendingCheck(ports, sessionId, turn); - } -} - -const provisionalWorkTurn = (range, turn, sessionEpoch) => range?.turns.find((work) => work.turn === turn - && work.sessionEpoch === sessionEpoch && work.finalizationPending === true); - -async function activateWorkTurn(ports, sessionId, turn, sessionEpoch) { - const { candidates, sessions } = ports; - const expected = provisionalWorkTurn(rangeOf(await candidates.read(), sessionId), turn, sessionEpoch); - if (!expected) return; - const session = sessionOf(await sessions.read(sessionId)); - const observed = observedNativeTurn(session, turn); - const committed = observed?.native.settled && !observed.native.pending && observed.native.candidateMade; - const invalidated = session.sessionEpoch !== expected.sessionEpoch || isCorrected(session, turn); - if (!invalidated - && (!(committed || (!observed && session.turn > turn)) - || expected.steps.some((step) => step.kind === 'check' && verdictOf(step) === 'unknown'))) return; - let activated = false; - await candidates.update((pending) => { - const range = rangeOf(pending, sessionId); - const work = provisionalWorkTurn(range, turn, sessionEpoch); - if (invalidated && work) return withoutWorkTurn(pending, sessionId, turn, { sessionEpoch, now: ports.clock.now() }); - if (!same(work, expected)) return undefined; - const { finalizationPending, ...ready } = work; - activated = true; - const turns = range.turns.map((row) => row === work ? ready : row); - return { - ...pending, - [sessionId]: { ...range, turns, ...(session.native?.sessionEnded && !session.open && { closed: true }) }, - }; - }); - if (activated) await scheduleDueDistillation(ports); -} - -async function readFacts(ports, payload, native, cursor) { - if (!ports.native?.read || !payload) return null; - try { - return await ports.native.read(payload, { - after: native?.mark ?? native?.admission?.mark ?? cursor?.mark, - turnId: native?.turnId ?? undefined, - modelHint: native?.modelHint ?? cursor?.modelHint, - schemaVersion: native?.schemaVersion ?? cursor?.schemaVersion, - }); - } catch { - return { status: 'unreadable' }; - } -} - -function capturedModel(native, terminal) { - const captured = native.model; - const actual = terminal.modelHint; - if (!captured || !actual - || ['alias', 'model', 'protocol'].some((key) => !actual[key] || captured[key] !== actual[key]) - || (captured.effort ?? null) !== (actual.effort ?? null)) return null; - // A changed current config cannot replace the verified turn's captured account. Keep that binding for a retry - // after restoration; the runner checks it again before any native model request. - return captured; -} - -// A claim can move into the deferred slot while its original observer is running. -function updateDeferred(session, expected, change) { - const saved = observedNativeTurn(session, expected.turn); - if (session.turn <= expected.turn || !saved || !sameTurn(saved, expected) - || saved.native.turnId !== expected.native.turnId || saved.native.promptId !== expected.native.promptId - || !same(saved.native.mark, expected.native.mark)) return undefined; - const next = change(saved); - if (next === undefined) return undefined; - return { - ...session, - deferredNativeTurns: session.deferredNativeTurns.flatMap((item) => item === saved ? (next ? [next] : []) : [item]), - }; -} - -async function reconcileDeferredTurn(ports, sessionId, opened, suppliedFacts) { - const { native } = opened; - const facts = suppliedFacts ?? await readFacts(ports, native.payload, native); - const result = { facts, native, ended: null, waiting: true }; - const terminal = facts?.terminal; - if (facts?.status !== 'ok' || !facts.schemaSupported || facts.bind?.origin !== 'user' - || facts.bind.turnId !== native.turnId || facts.bind.promptId !== native.promptId - || facts.mark?.fileId !== native.mark.fileId || !Number.isSafeInteger(facts.bind.mark) - || facts.bind.mark < native.mark.offset || terminal?.turnId !== native.turnId - || !reasons[terminal.reason] || !facts.record) return result; - if (terminal.reason === 'completed' && (facts.pendingVerification || hasUnknownChecks(facts.record))) { - return { ...result, waiting: facts.pendingVerification === true }; - } - - const claimant = ports.owner.self(); - if (!Number.isSafeInteger(claimant) || claimant <= 0) return result; - const update = (change) => ports.sessions.update(sessionId, (current) => updateDeferred(current, opened, change)); - const claimed = await update((saved) => saved.native.finalizingPid && !ports.owner.isGone(saved.native.finalizingPid) - ? undefined : { ...saved, native: { ...saved.native, finalizingPid: claimant } }); - if (!claimed) return result; - try { - const ended = await finishDeferredNativeTurn(ports, sessionId, opened, { - reason: reasons[terminal.reason], - record: { ...facts.record, answer: String(terminal.answer || facts.record.answer || '').trim() }, - model: capturedModel(native, terminal), - }); - const settled = await update((saved) => saved.native.finalizingPid === claimant ? null : undefined); - if (settled) await activateWorkTurn(ports, sessionId, opened.turn, opened.sessionEpoch); - return { ...result, ended, waiting: false }; - } finally { - await update((saved) => saved.native.finalizingPid === claimant - ? { ...saved, native: { ...saved.native, finalizingPid: null } } : undefined); - } -} - -/** Reconcile only a previously opened logical turn with its matched native main-user identity and terminal. */ -export async function reconcileNativeTurn(ports, at = {}, suppliedFacts) { - const { sessionId, projectDir, payload, turn } = at; - const stored = sessionOf(await ports.sessions.read(sessionId)); - if (Object.hasOwn(at, 'sessionEpoch') && at.sessionEpoch !== stored.sessionEpoch) { - return { facts: null, native: null, ended: null, waiting: false }; - } - if (turn !== undefined && turn !== stored.turn) { - const deferred = observedNativeTurn(stored, turn); - return deferred - ? reconcileDeferredTurn(ports, sessionId, deferred, suppliedFacts) - : { facts: null, native: null, ended: null, waiting: false }; - } - const native = stored.native; - const facts = suppliedFacts ?? await readFacts(ports, payload ?? native?.payload, native, stored.nativeCursor); - const result = { - facts, - native: native ?? null, - ended: null, - waiting: Boolean(native && stored.turn === native.turn && (!native.settled || native.pending)), - }; - if (!native || stored.turn !== native.turn || (turn !== undefined && turn !== native.turn)) { - return { ...result, waiting: false }; - } - if (facts?.status !== 'ok' || !facts.schemaSupported || facts.bind?.origin !== 'user') return result; - const id = turnIdOf(facts.bind.turnId); - if (!id || !Number.isSafeInteger(facts.bind.mark) || !facts.mark?.fileId - || (native.turnId != null && native.turnId !== id) - || (native.promptId && native.promptId !== facts.bind.promptId)) return result; - - const anchor = native.mark ?? native.admission?.mark ?? stored.nativeCursor?.mark; - if (anchor && (facts.mark.fileId !== anchor.fileId || facts.bind.mark < anchor.offset)) return result; - if (!native.mark) { - // Without an earlier wire mark, only a fresh native start matching the submitted prompt can establish identity. - const start = facts.admission; - const pid = ports.owner.current(); - if (!stored.open || !Number.isSafeInteger(pid) || pid <= 0 || stored.pid !== pid - || native.turnId != null || !native.admission - || facts.bind.mark < 0 || !Number.isSafeInteger(facts.mark.offset) || facts.bind.mark >= facts.mark.offset - || !start || start.time <= native.admission.time || stored.turnIds.includes(id) - || await promptDigest(start.prompt) !== native.admission.digest) return result; - } - - let bound = null; - await ports.sessions.update(sessionId, (currentStored) => { - const current = sessionOf(currentStored); - const state = current.native; - if (!sameTurn(current, stored) || state?.turn !== native.turn || !same(state.payload, native.payload) - || !same(state.mark, native.mark) || (state.turnId != null && state.turnId !== id) - || (state.promptId && state.promptId !== facts.bind.promptId)) return undefined; - if (!native.mark - && (!current.open || !same(state.admission, native.admission) - || !same(current.nativeCursor, stored.nativeCursor))) return undefined; - const { admission, ...previous } = state; - bound = { - ...current, - ...(!current.open && !current.prompt && state.sessionEnded && { prompt: state.prompt || '' }), - turnId: id, - turnIds: [...current.turnIds.filter((known) => known !== id), id].slice(-20), - native: { - ...previous, - mark: state.mark ?? { fileId: facts.mark.fileId, offset: facts.bind.mark }, - turnId: id, - promptId: facts.bind.promptId, - schemaVersion: facts.schemaVersion, - modelHint: facts.modelHint ?? state.modelHint, - }, - }; - return bound; - }); - if (!bound) return { ...result, waiting: false }; - result.native = bound.native; - const terminal = facts.terminal; - const terminalMatches = terminal && terminal.turnId === id && reasons[terminal.reason] && facts.record; - if (bound.native.settled && !bound.native.pending) { - if (!terminalMatches) return result; - await releasePendingCheck(ports, sessionId, stored); - await activateWorkTurn(ports, sessionId, stored.turn, stored.sessionEpoch); - return { ...result, waiting: false }; - } - if (!terminalMatches) { - if (!terminal && facts.pendingVerification === true) await holdPendingCheck(ports, sessionId, stored); - return result; - } - const claimant = ports.owner.self(); - let claimed = null; - await ports.sessions.update(sessionId, (currentStored) => { - const current = sessionOf(currentStored); - const state = current.native; - if (!Number.isSafeInteger(claimant) || claimant <= 0 || !sameTurn(current, stored) - || state?.turnId !== id || state.promptId !== facts.bind.promptId || (state.settled && !state.pending) - || (!current.open && isSettling(current, ports.clock.now())) - || (state.finalizingPid && !ports.owner.isGone(state.finalizingPid))) return undefined; - claimed = { ...current, native: { ...state, settled: false, finalizingPid: claimant } }; - return claimed; - }); - if (!claimed) return result; - try { - const model = capturedModel(claimed.native, terminal); - const pending = terminal.reason === 'completed' - && (facts.pendingVerification === true || hasUnknownChecks(facts.record)); - // Unknown results block verification, but only unfinished native calls need polling. - const waiting = pending && facts.pendingVerification === true; - if (waiting) await holdPendingCheck(ports, sessionId, stored); - const ended = await turnEnded(ports, { - sessionId, projectDir: native.payload?.cwd ?? projectDir, turnId: id, - expected: { turn: stored.turn, pid: stored.pid, sessionEpoch: stored.sessionEpoch }, - allowNewCandidate: !pending && !claimed.native.candidateMade, - finalizationPending: true, - reason: reasons[terminal.reason], answer: terminal.answer, model, - recordOf: () => facts.record, - }); - const eligible = !pending && isNativeWorkTurn(reasons[terminal.reason], facts.record); - const settled = await ports.sessions.update(sessionId, (currentStored) => { - const current = sessionOf(currentStored); - if (!sameTurn(current, stored)) { - // The older work may have lost the write race to a newer candidate. Its deferred observer can retry it. - if (!ended || pending || (eligible && !ended.isCandidate)) return undefined; - return updateDeferred(current, claimed, (saved) => saved.native.finalizingPid === claimant ? null : undefined); - } - if (current.native?.turnId !== id || current.native.finalizingPid !== claimant - || current.open || isSettling(current, ports.clock.now())) return undefined; - return { - ...current, - native: { - ...current.native, - settled: true, - pending, - candidateMade: Boolean(current.native.candidateMade || eligible), - }, - nativeCursor: cursorOf(facts), - }; - }); - if (settled && !waiting) await releasePendingCheck(ports, sessionId, stored); - await activateWorkTurn(ports, sessionId, stored.turn, stored.sessionEpoch); - return { facts, native: claimed.native, ended, waiting }; - } finally { - await ports.sessions.update(sessionId, (currentStored) => { - const current = sessionOf(currentStored); - if (!sameTurn(current, stored)) { - return updateDeferred(current, claimed, (saved) => saved.native.finalizingPid === claimant - ? { ...saved, native: { ...saved.native, finalizingPid: null } } : undefined); - } - if (current.native?.turnId !== id || current.native.finalizingPid !== claimant) return undefined; - return { ...current, native: { ...current.native, finalizingPid: null } }; - }); - } -} - -/** Only inspect sessions already in the plugin's state; the native reader never discovers arbitrary history. */ -export async function recoverNativeTurns(ports) { - if (!ports.native?.read) return []; - const pending = await ports.candidates.read(); - const known = []; - let scanError; - try { - await ports.sessions.updateEach((stored, sessionId) => { - const range = rangeOf(pending, sessionId); - const provisional = range?.turns - .filter((work) => work.sessionEpoch === stored.sessionEpoch && work.finalizationPending) ?? []; - const check = range?.pendingNativeCheck; - // A worker can die after writing a barrier but before confirming that the observed turn is still current. - const superseded = check && supersedesCheck(stored, check) ? check : null; - const deferred = stored.deferredNativeTurns?.filter((entry) => entry.sessionEpoch === stored.sessionEpoch) ?? []; - if ((stored?.native && stored.turn === stored.native.turn) - || provisional.length > 0 || superseded || deferred.length > 0) { - known.push({ sessionId, turn: stored.turn, sessionEpoch: stored.sessionEpoch, provisional, superseded, deferred }); - } - }); - } catch (error) { - scanError = error; - } - - const results = []; - for (const at of known) { - for (const { turn, sessionEpoch } of at.deferred) { - results.push(await reconcileNativeTurn(ports, { sessionId: at.sessionId, turn, sessionEpoch })); - } - results.push(await reconcileNativeTurn(ports, at)); - if (at.superseded) await releasePendingCheck(ports, at.sessionId, at.superseded); - for (const work of at.provisional) await activateWorkTurn(ports, at.sessionId, work.turn, work.sessionEpoch); - } - if (scanError) throw scanError; - return results; -} diff --git a/lib/app/ports.js b/lib/app/ports.js index e5307d0..6aef5c0 100644 --- a/lib/app/ports.js +++ b/lib/app/ports.js @@ -56,12 +56,6 @@ * * @typedef {object} ModelRunner The agent's model, run outside the conversation; absent when it has none. * @property {(model: any, prompt: string, signal: AbortSignal) => Promise<{ answer: string, usage?: object }>} complete - * @property {(hint: object|null) => object} [capture] A no-secret route/account binding before the turn requests a model. - * - * @typedef {object} NativeFacts Read-only, bounded host records; an unsuccessful read supplies no verdict. - * @property {string} scope Pseudonymous profile identity; filesystem paths never become Hub task IDs. - * @property {(payload: object) => object} reference Minimal lookup data safe to keep in local session state. - * @property {(payload: object, trusted?: object) => object} read * * @typedef {object} OwnerProbe The process a session belongs to, this process, and whether a recorded one has exited. * @property {() => number|null} current @@ -134,8 +128,6 @@ * @property {RetentionStore} retention * @property {Proxy} proxy * @property {ModelRunner|null} model - * @property {NativeFacts|null} [native] - * @property {(at: object, reconcile: Function, onClosed: Function) => Promise} [observeTurn] * @property {(sessionId: string, opened: object) => object} [modelForTurn] Captures a route confirmed by the host's terminal record. * @property {OwnerProbe} owner * @property {ProjectProbe} project @@ -146,8 +138,10 @@ * @property {EvolverCli} cli * @property {SignIn} signIn * @property {Settings} settings - * @property {(sessionId: string, turnId: string) => { status: string }|null} [turnStatus] + * @property {((sessionId: string, turnId: string) => { status: string, record?: object, answer?: string, model?: object }|null) & { scope?: string }} [turnStatus] + * A turn's status in the host's own store; a host whose evidence is only there (a session log) adds its `record` and + * `answer`. `scope` names the store a session id was read from. * @property {boolean} [confirmTurnCompletion] Only native terminal status can admit observed evidence. */ -export const TASKS = Object.freeze({ ENGINE: 'engine', DRAIN: 'drain', DISTILL: 'distill', RECOVER_TURNS: 'recover-turns', NATIVE: 'native' }); +export const TASKS = Object.freeze({ ENGINE: 'engine', DRAIN: 'drain', DISTILL: 'distill', RECOVER_TURNS: 'recover-turns' }); diff --git a/lib/app/prompt.js b/lib/app/prompt.js index 5d13b07..6487c92 100644 --- a/lib/app/prompt.js +++ b/lib/app/prompt.js @@ -6,7 +6,7 @@ import { rangeOf, withCorrection, withSessionActivity } from '../distill/range.j import { correctionTextOf } from '../distill/work-turn.js'; import { promptTextOf, recallStrategy, taskTextOf } from '../recall.js'; import { correctionReportsOf, followUpLabelOf } from '../session/follow-ups.js'; -import { correctedSession, joinedWaiting, listedIdsOf, openedTurn, sessionOf, withInjection, withShown, withTurnHead, withWaiting, withWithheld } from '../session/state.js'; +import { boundTurn, joinedWaiting, listedIdsOf, openedTurn, sessionOf, withInjection, withShown, withTurnHead, withWaiting, withWithheld } from '../session/state.js'; import { scheduleDueDistillation } from './distill-session.js'; import { recallRequestOf } from './recall-request.js'; import { TASKS } from './ports.js'; @@ -16,55 +16,54 @@ export const CLOSING = 'Apply them where they fit.'; const RECIPE_MARGIN_MS = 300; const LATE_RECIPE_WAIT_MS = 6_000; -// Older session state may lack an epoch; its work turn or newest draft can still identify the correction. +// An older plugin version ending the session drops its epoch; the range holding the corrected turn, or its newest +// draft, keeps it. async function sourceEpochOf({ candidates, drafts }, sessionId, turn) { const saved = await drafts.read(); const ofSession = (Array.isArray(saved) ? saved : []).filter((draft) => draft?.sessionId === sessionId); const range = rangeOf(await candidates.read(), sessionId); const work = range?.turns.find((read) => read.turn === turn); - const fromRange = work && { at: work.at ?? range.at, turn, sessionEpoch: work.sessionEpoch ?? range.sessionEpoch }; - const sources = [fromRange, ...ofSession.flatMap(draftSourcesOf)]; + const ofRange = work ? [{ at: range.at, turn, sessionEpoch: work.sessionEpoch ?? range.sessionEpoch }] : []; + const sources = [...ofRange, ...ofSession.flatMap(draftSourcesOf)]; return sources.filter((source) => source?.turn === turn && source.sessionEpoch !== undefined).sort((left, right) => right.at - left.at)[0]?.sessionEpoch; } -async function correctedTurnOf(ports, sessionId, session, prompt) { - const { turn } = session; +async function markedCorrection(ports, sessionId, { turn, sessionEpoch }) { if (turn === 0) return { turn }; - await ports.sessions.update(sessionId, (stored) => { - const current = sessionOf(stored); - return current.sessionEpoch === session.sessionEpoch && current.turn >= turn ? correctedSession(current, turn) : undefined; - }); - const sessionEpoch = session.sessionEpoch ?? await sourceEpochOf(ports, sessionId, turn); - await ports.draftCorrections.mark(sessionId, turn, sessionEpoch); - const text = correctionTextOf(promptTextOf(prompt)); - await ports.candidates.update((pending) => withCorrection(pending, sessionId, turn, text, ports.clock.now(), { sessionEpoch })); - return { turn, sessionEpoch }; + const epoch = sessionEpoch ?? await sourceEpochOf(ports, sessionId, turn); + await ports.draftCorrections.mark(sessionId, turn, epoch); + return { turn, sessionEpoch: epoch }; } // The lock only orders the mark against a publication's last check. One held past its wait must not cost the person // their turn and its recall, so the correction is then recorded without it and the hook reports the skipped lock. -async function recordedCorrection(ports, sessionId, session, prompt) { - const correct = () => correctedTurnOf(ports, sessionId, session, prompt); +async function recordedCorrection(ports, sessionId, session) { + const mark = () => markedCorrection(ports, sessionId, session); try { - return await ports.draftCorrections.withSession(sessionId, correct); + return await ports.draftCorrections.withSession(sessionId, mark); } catch (error) { if (error?.code !== 'ELOCKED') throw error; - return { ...(await correct()), skipped: error }; + return { ...(await mark()), skipped: error }; } } /** - * A correction marks the turn and changes its range before removing drafts from the prior version. Its reuses are - * reported again under `:correction`. Every prompt resets the quiet period for a range that is not yet due. + * The next prompt is the only verdict on a turn available without asking anyone: a correction stays with the last + * turn in the session's range, for distillation to read, takes back the drafts distilled from that turn before it, + * and the last turn's reuses are reported again under `:correction`. The turn is marked corrected first, so a draft's + * confirmation or publication sees the mark, and the range changes before drafts are removed, so a draft written + * after the removal still sees the turn it read changed. Every prompt is the session's activity, which a range waits + * out before it is distilled. Answers the error of a correction lock it had to go without, if any. */ async function labelFollowUp(ports, sessionId, prompt) { const { sessions, candidates, drafts, reports, clock } = ports; const label = followUpLabelOf(prompt); + const session = sessionOf(await sessions.read(sessionId)); let skipped; if (label === 'correction') { - const session = sessionOf(await sessions.read(sessionId)); - const correction = await recordedCorrection(ports, sessionId, session, prompt); + const correction = await recordedCorrection(ports, sessionId, session); skipped = correction.skipped; + await candidates.update((pending) => withCorrection(pending, sessionId, session.turn, correctionTextOf(promptTextOf(prompt)), clock.now(), correction)); if (correction.turn > 0) await drafts.update((list) => withoutDraftsForTurn(list, sessionId, correction.turn, correction.sessionEpoch)); } await candidates.update((pending) => withSessionActivity(pending, sessionId, clock.now())); @@ -157,8 +156,8 @@ async function rememberTurnHead({ project, sessions }, sessionId, { turn, sessio * only while the turn is still open, once a model step does. With `holdForToolResult`, for a host whose prompt hook * cannot add context, a strategy that lands in time waits for the turn's first tool result instead (see * `takeWaitingStrategy`). A plugin command's turn keeps the last real turn's reuses for the next prompt to label, is - * recalled for nothing and never becomes a distillation candidate. `skipped` is the error of a correction lock the - * prompt went without. + * recalled for nothing and never joins the session's range of work turns. `skipped` is the error of a correction lock + * the prompt went without. */ export async function promptSubmitted(ports, { sessionId, prompt, projectDir = null, turnId = null, turnStart = null, hostTurn = null, isCommand = false, waitMs, keepLate = false, holdForToolResult = false }) { const skipped = isCommand ? undefined : await labelFollowUp(ports, sessionId, prompt); @@ -208,3 +207,12 @@ export async function adoptStrategy({ sessions, clock }, { sessionId, strategy } return withInjection(session, strategy, turn, clock.now()).next; }); } + +/** + * Binds the open turn to the id a host announces once it runs (see `boundTurn`); the turn, `{ turn, turnId, + * sessionEpoch }`, or null when the announcement is not the open turn's. + */ +export async function turnStarted({ sessions }, { sessionId, turnId, prompt }) { + const bound = await sessions.update(sessionId, (stored) => boundTurn(sessionOf(stored), { turnId, prompt: prompt ? promptTextOf(prompt) : '' })); + return bound ? { turn: bound.turn, turnId: bound.turnId, sessionEpoch: bound.sessionEpoch } : null; +} diff --git a/lib/app/session-end.js b/lib/app/session-end.js index 410c628..cfdd647 100644 --- a/lib/app/session-end.js +++ b/lib/app/session-end.js @@ -4,22 +4,23 @@ import { withClosedRange } from '../distill/range.js'; import { endedSession, isEarlierTurn, markedReported, pendingReportsOf, sessionOf, unreportedOf } from '../session/state.js'; import { TASKS } from './ports.js'; -import { settledSessionOf, turnEnded, withoutPendingControls } from './turn-end.js'; +import { closeUnfinishedTurn, settledSessionOf, withoutPendingControls } from './turn-end.js'; import { turnReportsOf } from './turn-reports.js'; const NOTHING_DONE = Object.freeze({ checks: [], commands: [], paths: [], edits: [], answer: '' }); /** * Only writes, since hosts bound a session-end hook tightly: once the last turn's end had its chance to arrive - * (`settleMs`), a turn still open ends as `openTurnReason`, what is left unreported goes out as aborted with any trial - * control a turn end could not queue, and the session's range of work turns is closed for the background worker to - * distill. The session keeps its turn counter, so a resumed one never repeats a task id. An end that names an earlier - * turn of the session (`turnId`) is stale: a new prompt already continued the session, so it changes nothing. + * (`settleMs`), a turn still open ends as its host's status record says, else as `openTurnReason`; what is left + * unreported goes out as aborted with any trial control a turn end could not queue, and the session's range of work + * turns is closed for the background worker to distill. The session keeps its turn counter, so a resumed one never + * repeats a task id. An end that names an earlier turn of the session (`turnId`) is stale: a new prompt already + * continued the session, so it changes nothing. */ export async function sessionEnded(ports, { sessionId, projectDir, turnId = null, settleMs = 0, openTurnReason = 'aborted' }) { const session = await settledSessionOf(ports, sessionId, settleMs); if (isEarlierTurn(session, turnId)) return; - await turnEnded(ports, { sessionId, reason: openTurnReason, projectDir, recordOf: () => NOTHING_DONE }); + await closeUnfinishedTurn(ports, { sessionId, as: openTurnReason, projectDir, recordOf: () => NOTHING_DONE }); const ending = sessionOf(await ports.sessions.read(sessionId)); // Native mode only replays cached reports; session start settles explicitly adopted legacy injections. // An uncached native entry has no safe legacy provenance, so shutdown cannot classify it. diff --git a/lib/app/session-start.js b/lib/app/session-start.js index faa8d43..c7619e7 100644 --- a/lib/app/session-start.js +++ b/lib/app/session-start.js @@ -6,7 +6,7 @@ import { retentionReportsOf } from '../session/follow-ups.js'; import { markedReported, orphansOf, pendingReportsOf, sessionOf } from '../session/state.js'; import { reasonOfNativeStatus } from '../turn-outcomes.js'; import { TASKS } from './ports.js'; -import { turnEnded } from './turn-end.js'; +import { recordOfRow, turnEnded } from './turn-end.js'; import { turnReportsOf } from './turn-reports.js'; async function queueRetentionReports({ retention, reports }, sessionId, projectDir) { @@ -58,8 +58,9 @@ export async function recoverNativeTurns(ports) { } for (const at of open) { try { - const reason = reasonOfNativeStatus(ports.turnStatus(at.sessionId, at.turnId)?.status, true); - if (reason) await turnEnded(ports, { ...at, reason }); + const row = ports.turnStatus(at.sessionId, at.turnId); + const reason = reasonOfNativeStatus(row?.status, true); + if (reason) await turnEnded(ports, { ...at, reason, ...(row.record && { answer: row.answer, recordOf: recordOfRow(row) }) }); } catch (error) { failure ??= error; } diff --git a/lib/app/turn-end.js b/lib/app/turn-end.js index cb3beaa..5d4b8fa 100644 --- a/lib/app/turn-end.js +++ b/lib/app/turn-end.js @@ -1,15 +1,12 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { hasUnknownChecks, verdictOf } from '../checks.js'; +import { verdictOf } from '../checks.js'; import { gateOf } from '../distill/gate.js'; import { baseFor, rangeOf, withSessionActivity, withWorkTurn, withoutWorkTurn } from '../distill/range.js'; import { isWorkTurn, workTurnOf } from '../distill/work-turn.js'; import { reportedReuseOf } from '../reuse.js'; -import { - awaitsNativeTerminal, closedTurn, isCorrected, isSettling, markedReported, observedNativeTurn, pendingReportsOf, sessionOf, settledSession, unreportedOf, - withPendingReports, -} from '../session/state.js'; +import { closedTurn, isSettling, markedReported, pendingReportsOf, sessionOf, settledSession, unreportedOf, withPendingReports } from '../session/state.js'; import { trialControlEntriesOf } from '../trial-control.js'; import { reasonOfNativeStatus } from '../turn-outcomes.js'; import { turnRecordOf } from '../turn/record.js'; @@ -23,59 +20,34 @@ const SETTLE_POLL_MS = 100; const SETTLING_WAIT_MS = 2_000; const sameTurn = (session, expected) => session.turnId === expected.turnId && session.turn === expected.turn && session.sessionEpoch === expected.sessionEpoch; -// A native observer binds its turn id after the prompt, so it names its turn by number, owner and epoch. -const matchesTurn = (session, expected) => !expected - || (session.turn === expected.turn && session.pid === expected.pid && session.sessionEpoch === expected.sessionEpoch); -const hasDeferredTurn = (session, opened) => { - const stored = observedNativeTurn(session, opened.turn); - return session.turn > opened.turn && stored && matchesTurn(stored, opened) && stored.native.turnId === opened.turnId; -}; - -const canKeepTurn = (session, opened) => session.sessionEpoch === opened.sessionEpoch - && (session.turn > opened.turn || matchesTurn(session, opened)) && !isCorrected(session, opened.turn); - -function dropWorkTurn(ports, sessionId, opened, expected) { - return ports.candidates.update((pending) => { - const range = rangeOf(pending, sessionId); - const known = range?.turns.some((turn) => turn.turn === opened.turn && turn.sessionEpoch === opened.sessionEpoch); - if (expected ? !known : range?.sessionEpoch !== opened.sessionEpoch) return undefined; - return withoutWorkTurn(pending, sessionId, opened.turn, { sessionEpoch: opened.sessionEpoch, now: ports.clock.now() }); - }); -} +// A turn end that began later (a re-judge of the same turn) owns `settling` from then on; an earlier one leaves it. +const settleTurn = (ports, sessionId, opened) => ports.sessions.update(sessionId, (stored) => { + const current = sessionOf(stored); + return sameTurn(current, opened) && current.settling === opened.settling ? settledSession(current) : undefined; +}); -async function closeTurn({ sessions, clock }, sessionId, turnId, { expectedTurn, expected } = {}) { +async function closeTurn({ sessions, clock }, sessionId, turnId, expected) { let opened = null; await sessions.update(sessionId, (stored) => { - const session = sessionOf(stored); - if ((expectedTurn && !sameTurn(session, expectedTurn)) || !matchesTurn(session, expected)) return undefined; - const now = clock.now(); - const result = closedTurn(session, turnId, now); - opened = result.opened && { ...result.opened, settling: now }; + if (expected && !sameTurn(sessionOf(stored), expected)) return undefined; + const result = closedTurn(sessionOf(stored), turnId, clock.now()); + opened = result.opened && { ...result.opened, settling: result.next.settling }; return result.next; }); return opened; } -const settleTurn = (ports, sessionId, opened) => ports.sessions.update(sessionId, (stored) => { - const current = sessionOf(stored); - return sameTurn(current, opened) && current.settling === opened.settling ? settledSession(current) : undefined; -}); - // Reports are queued before their injections are marked reported, so a hook killed in between leaves an injection // reported twice, which the outbox keys away, rather than never. // Controls that could not be queued wait in the session for the next turn or session end. Hosts that accept inferred // completion also retry unreported injections at the next turn end. -async function reportTurn(ports, sessionId, opened, { reason, checks, source, verified, expected, deferred }) { - const { identity, sessions, reports, clock, confirmTurnCompletion } = ports; +async function reportTurn({ identity, sessions, reports, clock, confirmTurnCompletion }, sessionId, opened, { reason, checks, source, verified }) { const session = sessionOf(await sessions.read(sessionId)); - if (session.sessionEpoch !== opened.sessionEpoch || (deferred && !hasDeferredTurn(session, opened))) { - return { queued: [], controls: 0, strategies: [] }; - } + if (session.sessionEpoch !== opened.sessionEpoch) return { queued: [], controls: 0, strategies: [] }; // Native evidence covers exactly the turn that produced it. - const ownTurnOnly = confirmTurnCompletion || Boolean(expected); - const due = verified ? unreportedOf(session, sessionId, opened.turn, { includeDeferred: Boolean(expected) }) - .filter((asset) => !ownTurnOnly || asset.turn === opened.turn) : []; + const due = verified ? unreportedOf(session, sessionId, opened.turn) + .filter((asset) => !confirmTurnCompletion || asset.turn === opened.turn) : []; const queued = turnReportsOf(identity, due, { reason, checks, source }); const retries = confirmTurnCompletion ? pendingReportsOf(session, sessionId, opened.turn - 1) : []; const controls = [...session.pendingControls, ...(verified ? trialControlsOf(identity, session, sessionId, opened, checks) : [])]; @@ -94,8 +66,7 @@ async function reportTurn(ports, sessionId, opened, { reason, checks, source, ve const current = sessionOf(stored); if (current.sessionEpoch !== opened.sessionEpoch) return undefined; const session = markedReported(current, [...due, ...retries.map(({ asset }) => asset)], clock.now()) ?? current; - const isReplaced = !sameTurn(session, opened) || (expected && (!matchesTurn(session, expected) || isCorrected(session, opened.turn))); - if (isReplaced) return withoutPendingControls(session, controls); + if (!sameTurn(session, opened)) return withoutPendingControls(session, controls); const labelable = opened.isCommand ? session.labelable : queued.map(({ report }) => reportedReuseOf(report)); return { ...withoutPendingControls(session, controls), labelable }; }); @@ -146,52 +117,29 @@ const rangeInEpoch = (pending, sessionId, epoch) => { * A work turn joins the session's range, with the work tree its checks ran on: its diff against the commit the range * began on, taken now, while it is the tree the last check saw. */ -export const isNativeWorkTurn = (reason, record) => (reason === 'answer' || reason === 'error') && !hasUnknownChecks(record) && isWorkTurn(record); - -const keepsWork = ({ native }, { reason, record, expected }) => expected ? isNativeWorkTurn(reason, record) : !native && isWorkTurn(record); - -function joinedRange(latest, sessionId, workTurn, { opened, prior, expected, deferred, allowNewCandidate, model, modelForTurn, context }) { - const current = rangeOf(latest, sessionId); - const sameEpoch = rangeInEpoch(latest, sessionId, opened.sessionEpoch); - // Only replace a different epoch if it was already present before the snapshot. - if (current && !sameEpoch && (!prior || current.sessionEpoch !== prior.sessionEpoch) && !expected) return undefined; - if (modelForTurn && !canJoinRange(sameEpoch, model)) return undefined; - const turns = current?.turns ?? []; - const laterTurn = turns.some((turn) => turn.sessionEpoch === opened.sessionEpoch && turn.turn > opened.turn); - if (expected) { - const known = turns.some((turn) => turn.turn === opened.turn && turn.sessionEpoch === opened.sessionEpoch); - if ((!allowNewCandidate && !known) || (!deferred && laterTurn) || turns.some((turn) => turn.sessionEpoch > opened.sessionEpoch)) return undefined; - } - return withWorkTurn(latest, sessionId, workTurn, { ...context, beforeNewerTurn: deferred && laterTurn }); -} - -async function rememberWorkTurn(ports, sessionId, opened, options) { - const { record, projectDir, source, expected, allowNewCandidate = true, finalizationPending = false, deferred = false } = options; - const { candidates, owner, clock, project, sessions, native, modelForTurn, confirmTurnCompletion } = ports; - if (opened.isCommand || !keepsWork(ports, { reason: options.reason, record, expected })) return false; - const model = options.model ?? modelForTurn?.(sessionId, opened) ?? null; +async function rememberWorkTurn({ candidates, owner, clock, project, sessions, modelForTurn, confirmTurnCompletion }, sessionId, opened, { record, model, projectDir, source }) { + if (opened.isCommand || !isWorkTurn(record)) return false; + model ??= modelForTurn?.(sessionId, opened) ?? null; const pending = await candidates.read(); const prior = rangeOf(pending, sessionId); const matching = rangeInEpoch(pending, sessionId, opened.sessionEpoch); if (modelForTurn && !canJoinRange(matching, model)) return false; - const workTurn = { ...workTurnOf({ turn: opened.turn, prompt: opened.prompt, record, answer: record.answer, head: opened.turnHead, now: clock.now() }), - sessionEpoch: opened.sessionEpoch, ...(finalizationPending && { finalizationPending: true }) }; - const planned = matching ? baseFor(pending, sessionId, workTurn) : workTurn.head ?? null; - const snapshot = projectDir && !deferred ? await snapshotIfReadable(project, projectDir, planned) : null; + const workTurn = workTurnOf({ turn: opened.turn, prompt: opened.prompt, record, answer: record.answer, head: opened.turnHead, now: clock.now() }); + const planned = matching ? baseFor(pending, sessionId, workTurn, opened) : workTurn.head ?? null; + const snapshot = projectDir ? await snapshotIfReadable(project, projectDir, planned) : null; const base = planned ?? (snapshot?.head ? { root: snapshot.root, head: snapshot.head } : null); // A detached observer's parent is a hook process, while the open turn carries the native owner. - const pid = expected?.pid ?? (confirmTurnCompletion ? opened.pid : null) ?? owner.current(); - const context = { base, project: snapshot, projectDir: projectDir ?? null, model, sessionEpoch: opened.sessionEpoch, pid, native: Boolean(native || expected), source, now: clock.now() }; - // If the turn moved to deferred storage during the snapshot, its recovery uses native evidence alone. - const canKeep = (stored) => canKeepTurn(sessionOf(stored), opened) && Boolean(hasDeferredTurn(sessionOf(stored), opened)) === deferred; - const latest = await sessions.read(sessionId); - if (sessionOf(latest).sessionEpoch !== opened.sessionEpoch || (expected && !canKeep(latest))) return false; - const saved = await candidates.update((table) => joinedRange(table, sessionId, workTurn, { opened, prior, expected, deferred, allowNewCandidate, model, modelForTurn, context })); - if (saved && expected && !canKeep(await sessions.read(sessionId))) { - await dropWorkTurn(ports, sessionId, opened, expected); - return false; - } - return Boolean(saved); + const pid = (confirmTurnCompletion ? opened.pid : null) ?? owner.current(); + const context = { base, project: snapshot, projectDir: projectDir ?? null, model, sessionEpoch: opened.sessionEpoch, pid, source, now: clock.now() }; + if ((await sessions.read(sessionId)).sessionEpoch !== opened.sessionEpoch) return false; + return Boolean(await candidates.update((latest) => { + const current = rangeOf(latest, sessionId); + const sameEpoch = rangeInEpoch(latest, sessionId, opened.sessionEpoch); + // Only replace a different epoch if it was already present before the snapshot. + if (current && !sameEpoch && (!prior || current.sessionEpoch !== prior.sessionEpoch)) return undefined; + if (modelForTurn && !canJoinRange(sameEpoch, model)) return undefined; + return withWorkTurn(latest, sessionId, workTurn, context); + })); } // A turn read from the host's own record can run again after its end without a new prompt (a background command @@ -199,12 +147,11 @@ async function rememberWorkTurn(ports, sessionId, opened, options) { // any correction it got, when it still did work, else taken out. An agent's report is its record, so a second one is // a retry and changes nothing. The re-judge marks the session settling while it writes, like a first turn end, so a // correction that arrives meanwhile waits for it. -async function rejudgeWorkTurn(ports, sessionId, turnId, { reason, answer, model, projectDir, recordOf, expected, allowNewCandidate, finalizationPending }) { +async function rejudgeWorkTurn(ports, sessionId, turnId, { answer, model, projectDir, recordOf }) { let session = null; await ports.sessions.update(sessionId, (stored) => { const current = sessionOf(stored); - if (current.open || current.isCommand || current.turnId !== turnId || !matchesTurn(current, expected) - || (expected && isSettling(current, ports.clock.now()))) return undefined; + if (current.open || current.isCommand || current.turnId !== turnId) return undefined; session = { ...current, settling: ports.clock.now() }; return session; }); @@ -212,11 +159,9 @@ async function rejudgeWorkTurn(ports, sessionId, turnId, { reason, answer, model try { const record = await recordOf(session); const finished = { ...record, answer: String(answer || record.answer || '').trim() }; - const keeps = keepsWork(ports, { reason, record: finished, expected }); - const kept = keeps && await rememberWorkTurn(ports, sessionId, session, { - reason, record: finished, model, projectDir, source: OBSERVED, expected, allowNewCandidate, finalizationPending, - }); - if (expected ? !keeps : !kept && !ports.native) await dropWorkTurn(ports, sessionId, session, expected); + const isWork = await rememberWorkTurn(ports, sessionId, session, { record: finished, model, projectDir, source: OBSERVED }); + if (!isWork) await ports.candidates.update((pending) => rangeInEpoch(pending, sessionId, session.sessionEpoch) + ? withoutWorkTurn(pending, sessionId, session.turn, session) : undefined); } finally { await settleTurn(ports, sessionId, session); } @@ -230,7 +175,7 @@ async function rejudgeWorkTurn(ports, sessionId, turnId, { reason, answer, model * turn joins the session's range for distillation; `isDistillable` says the range passes the gate now. `strategies` * holds the text of each strategy it reported. Null when no such turn was open. */ -async function recordTurn(ports, sessionId, opened, { reason, answer, model, projectDir, recordOf, source, expected, allowNewCandidate, finalizationPending }) { +async function recordTurn(ports, sessionId, opened, { reason, answer, model, projectDir, recordOf, source }) { if ((await ports.sessions.read(sessionId)).sessionEpoch !== opened.sessionEpoch) return null; const [notes, edits] = await Promise.all([ ports.notes.take(sessionId, opened), ports.baseline.take(sessionId, opened.turnId, opened), @@ -242,50 +187,30 @@ async function recordTurn(ports, sessionId, opened, { reason, answer, model, pro const finished = { ...record, answer: String(answer || record.answer || '').trim() }; const verified = !ports.confirmTurnCompletion || (reason === 'answer' && finished.answer && finished.checks.length > 0 && finished.checks.every(check => verdictOf(check) !== 'unknown')); - const { queued, controls, strategies } = await reportTurn(ports, sessionId, opened, { reason, checks: finished.checks, source, verified, expected }); + const { queued, controls, strategies } = await reportTurn(ports, sessionId, opened, { reason, checks: finished.checks, source, verified }); if ((await ports.sessions.read(sessionId)).sessionEpoch !== opened.sessionEpoch) return null; const reuses = queued.map(({ report }) => reportedReuseOf(report)); if (verified) await ports.retention.remember({ sessionId, projectDir, reuses, editedBefore: edits.before }); await notes.commit(); await edits.commit(); - const isWork = verified && await rememberWorkTurn(ports, sessionId, opened, { - reason, record: finished, model, projectDir, source, expected, allowNewCandidate, finalizationPending, - }); - if (!isWork && !expected) await ports.candidates.update((pending) => rangeInEpoch(pending, sessionId, opened.sessionEpoch) + const isWork = verified && await rememberWorkTurn(ports, sessionId, opened, { record: finished, model, projectDir, source }); + if (!isWork) await ports.candidates.update((pending) => rangeInEpoch(pending, sessionId, opened.sessionEpoch) ? withSessionActivity(pending, sessionId, ports.clock.now()) : undefined); - const isDistillable = isWork && !finalizationPending - && gateOf(rangeInEpoch(await ports.candidates.read(), sessionId, opened.sessionEpoch)?.turns ?? []).passed; - return { - turn: opened.turn, record: finished, reports: queued.map(({ report }) => report), strategies, isDistillable, - ...(expected && { isCandidate: isWork }), controls, - }; + const isDistillable = isWork && gateOf(rangeInEpoch(await ports.candidates.read(), sessionId, opened.sessionEpoch)?.turns ?? []).passed; + return { turn: opened.turn, record: finished, reports: queued.map(({ report }) => report), strategies, isDistillable, controls }; } -/** A native record for a retained turn uses only its own evidence and captured route. */ -export async function finishDeferredNativeTurn(ports, sessionId, opened, { reason, record, model }) { - const { queued, controls } = await reportTurn(ports, sessionId, opened, { - reason, checks: record.checks, source: OBSERVED, verified: true, expected: opened, deferred: true, - }); - const isCandidate = await rememberWorkTurn(ports, sessionId, opened, { - reason, record, model, projectDir: opened.native.payload?.cwd, source: OBSERVED, expected: opened, - deferred: true, allowNewCandidate: !opened.native.candidateMade, finalizationPending: true, - }); - if (queued.length || controls) await scheduleDueDistillation(ports, [TASKS.DRAIN]); - return { turn: opened.turn, reports: queued.map(({ report }) => report), isCandidate }; -} - -export async function turnEnded(ports, { sessionId, turnId, reason, answer = '', model = null, projectDir, recordOf = null, source = OBSERVED, expectedTurn, ...native }) { - const { expected, allowNewCandidate, finalizationPending } = native; - const opened = await closeTurn(ports, sessionId, turnId, { expectedTurn, expected }); +export async function turnEnded(ports, { sessionId, turnId, reason, answer = '', model = null, projectDir, recordOf = null, source = OBSERVED, expectedTurn }) { + const opened = await closeTurn(ports, sessionId, turnId, expectedTurn); if (!opened) { if (ports.confirmTurnCompletion || !(turnId && recordOf && source === OBSERVED)) return null; - await rejudgeWorkTurn(ports, sessionId, turnId, { reason, answer, model, projectDir, recordOf, expected, allowNewCandidate, finalizationPending }); + await rejudgeWorkTurn(ports, sessionId, turnId, { answer, model, projectDir, recordOf }); await scheduleDueDistillation(ports); return null; } let ended; try { - ended = await recordTurn(ports, sessionId, opened, { reason, answer, model, projectDir, recordOf, source, expected, allowNewCandidate, finalizationPending }); + ended = await recordTurn(ports, sessionId, opened, { reason, answer, model, projectDir, recordOf, source }); } finally { await settleTurn(ports, sessionId, opened); } @@ -300,6 +225,9 @@ function isWorthWaiting(session, now, openUntil) { return isSettling(session, now) && now < session.settling + SETTLING_WAIT_MS; } +/** The evidence a host status row carries, when its record is the host's own (a session log), else `recordOf`. */ +export const recordOfRow = (row, recordOf = null) => (row?.record ? () => row.record : recordOf); + /** * Before a new prompt: waits while the last turn's end is still writing (a bounded while), and up to `settleMs` for * a host that sends the next prompt before the last turn's end, then closes a turn still open as `as`: it never got @@ -307,13 +235,13 @@ function isWorthWaiting(session, now, openUntil) { */ export async function closeUnfinishedTurn(ports, { sessionId, settleMs = 0, as = 'error', projectDir, recordOf = null }) { const session = await settledSessionOf(ports, sessionId, settleMs); - if (!session.open || (ports.native && awaitsNativeTerminal(session))) return; + if (!session.open) return; if (!ports.confirmTurnCompletion) return turnEnded(ports, { sessionId, reason: as, projectDir, recordOf }); const row = ports.turnStatus?.(sessionId, session.turnId); const reason = reasonOfNativeStatus(row?.status, true); const ended = await turnEnded(ports, { sessionId, turnId: session.turnId, reason: reason ?? (as === 'answer' ? 'error' : as), projectDir, - recordOf: reason ? recordOf : null, expectedTurn: session, + answer: (reason && row.answer) || '', recordOf: reason ? recordOfRow(row, recordOf) : null, expectedTurn: session, }); if (!ended) await settledSessionOf(ports, sessionId); } diff --git a/lib/checks.js b/lib/checks.js index ee87c37..9b5dc28 100644 --- a/lib/checks.js +++ b/lib/checks.js @@ -53,8 +53,6 @@ export function verdictOf(check) { return 'unknown'; } -export const hasUnknownChecks = (record) => record?.checks?.some((check) => verdictOf(check) === 'unknown') ?? false; - /** * How a check ended, as a distill trace row says it: the host's own `exit` code, else `ok`, whether its output said it * passed, which no exit code is made up for; null when neither is known. diff --git a/lib/distill/range.js b/lib/distill/range.js index ee75c27..839cf5e 100644 --- a/lib/distill/range.js +++ b/lib/distill/range.js @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { verdictOf } from '../checks.js'; import { isEpisodeReady } from './gate.js'; import { stepsOf } from './work-turn.js'; @@ -14,9 +13,11 @@ export const MAX_ATTEMPTS = 3; // With no model outside the conversation, a leftover waits this long for an agent to write its Gene from a // command, and is then dropped. export const UNDISTILLED_LEFTOVER_TTL_MS = 24 * 60 * 60 * 1000; -export const BLOCKED_ROUTE_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const IDLE_DISTILL_MS = 30 * 60 * 1000; export const EPISODE_QUIET_MS = 10 * 60 * 1000; +// A model route the session captured that is unavailable now (changed, or its credential gone) parks the range without +// costing a try, until that route works again or this long after it was first found blocked. +export const BLOCKED_ROUTE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // A failed background try waits RETRY_BASE_MS × 2^(attempts − 1), at most RETRY_MAX_MS, before the next one: every // hook event would otherwise retry a due range at once and use up its tries within a minute. export const RETRY_BASE_MS = 10 * 60 * 1000; @@ -24,42 +25,19 @@ const RETRY_MAX_MS = 6 * 60 * 60 * 1000; const MAX_RANGE_TURNS = 20; export const TRIGGERS = Object.freeze({ SESSION_END: 'session_end', IDLE: 'idle', ERROR_EPISODE: 'error_episode' }); -// Native capture binds the normalized model route, config revision and credential together in this digest. -const bindingOf = (model) => typeof model?.binding === 'string' && model.binding ? model.binding : null; -const isNativeRange = (range) => Boolean(range.native || bindingOf(range.model)); - // A candidate an earlier version left holds one verified turn: it becomes a range of that turn, for the gate to judge. function migrated(entry) { if (!entry || Array.isArray(entry.turns)) return entry; - const { prompt, commands = [], paths = [], checks = [], answer, turn, project, quiet, sessionEpoch, finalizationPending, ...kept } = entry; + const { prompt, commands = [], paths = [], checks = [], answer, turn, project, quiet, ...kept } = entry; const steps = stepsOf({ checks, commands, edits: paths }); const base = project?.head ? { root: project.root, head: project.head } : null; - const binding = bindingOf(entry.model); - const workTurn = { - turn: turn ?? 1, prompt: prompt ?? '', steps, answer: answer ?? '', at: entry.at, - ...(sessionEpoch !== undefined && { sessionEpoch }), ...(finalizationPending && { finalizationPending: true }), - ...(binding && { modelBinding: binding }), - }; - return { ...kept, sessionEpoch, turns: [workTurn], base, project: project ?? null, changedAt: entry.at, ...(quiet && { dueBy: TRIGGERS.IDLE }) }; + return { ...kept, turns: [{ turn: turn ?? 1, prompt: prompt ?? '', steps, answer: answer ?? '', at: entry.at }], base, project: project ?? null, changedAt: entry.at, ...(quiet && { dueBy: TRIGGERS.IDLE }) }; } /** The session's range, as this version keeps it; null when it has none. */ export const rangeOf = (pending, sessionId) => migrated(pending?.[sessionId]) ?? null; const rangesOf = (pending) => Object.entries(pending ?? {}).map(([sessionId, entry]) => [sessionId, migrated(entry)]); -/** Before prompting or posting, require one captured native binding across all work in the range. */ -export function rangeRouteProblem(range, { requireCapturedRoute = false } = {}) { - if (!(requireCapturedRoute || isNativeRange(range))) return null; - return range.routeConflict || capturedRouteProblem(range); -} - -function capturedRouteProblem(range) { - const binding = bindingOf(range.model); - const epochs = new Set(range.turns.map((turn) => turn.sessionEpoch).filter((epoch) => epoch != null)); - if (epochs.size > 1 || (binding && range.turns.some((turn) => turn.modelBinding && turn.modelBinding !== binding))) return 'route_changed'; - return binding && range.turns.every((turn) => turn.modelBinding === binding) ? null : 'route_not_captured'; -} - const lastActiveAt = (range) => Number(range.activeAt ?? range.changedAt ?? range.at); // `changedAt` names the turns as an IR was asked for (an agent's `writtenFor`), so two changes never share it. @@ -78,111 +56,67 @@ function withActivity(range, now) { return due ? { ...range, dueBy: due } : { ...range, activeAt: now }; } -function matchingTurn(turns, turnNumber, sessionEpoch) { - const matches = turns.filter((turn) => turn.turn === turnNumber); - if (sessionEpoch !== undefined) { - return matches.find((turn) => turn.sessionEpoch === sessionEpoch) - ?? (matches.length === 1 && matches[0].sessionEpoch === undefined ? matches[0] : undefined); - } - return matches.length === 1 ? matches[0] : undefined; -} +const isOtherEpoch = (epoch, range) => epoch !== undefined && range.sessionEpoch !== undefined && epoch !== range.sessionEpoch; -/** Where the range's diff is taken from once `workTurn` joins it: the HEAD its first kept turn began on. */ -export function baseFor(pending, sessionId, workTurn) { +/** + * Where the range's diff is taken from once `workTurn` joins it: the HEAD its first kept turn began on; the turn's own + * HEAD when the range is of another session epoch than the turn's (`sessionEpoch`). + */ +export function baseFor(pending, sessionId, workTurn, { sessionEpoch } = {}) { const range = rangeOf(pending, sessionId); - if (!range) return workTurn.head ?? null; - if (!isNativeRange(range) && range.sessionEpoch !== workTurn.sessionEpoch) return workTurn.head ?? null; - if (workTurn.sessionEpoch != null && range.turns.some((turn) => turn.sessionEpoch != null && turn.sessionEpoch !== workTurn.sessionEpoch)) return workTurn.head ?? null; - const isReplacing = Boolean(matchingTurn(range.turns, workTurn.turn, workTurn.sessionEpoch)); + if (!range || isOtherEpoch(sessionEpoch, range)) return workTurn.head ?? null; + const isReplacing = range.turns.some((turn) => turn.turn === workTurn.turn); const dropsFirst = !isReplacing && range.turns.length >= MAX_RANGE_TURNS; return dropsFirst ? (range.turns[1].head ?? range.base) : range.base; } function joinedTurns(turns, workTurn) { - const known = matchingTurn(turns, workTurn.turn, workTurn.sessionEpoch); - const merged = known ? { - ...workTurn, at: known.at, ...(known.correction && { correction: known.correction }), - ...(Object.hasOwn(known, 'modelBinding') && { modelBinding: known.modelBinding }), - } : workTurn; - return [...turns.filter((turn) => turn !== known), merged].sort((left, right) => - left.sessionEpoch === right.sessionEpoch ? left.turn - right.turn : left.at - right.at); + const known = turns.find((turn) => turn.turn === workTurn.turn); + const merged = known ? { ...workTurn, at: known.at, ...(known.correction && { correction: known.correction }) } : workTurn; + return [...turns.filter((turn) => turn.turn !== workTurn.turn), merged].sort((left, right) => left.turn - right.turn).slice(-MAX_RANGE_TURNS); } +// A session epoch is the time its state started over, so a turn of an older epoch ends after a newer one began only +// when its write was late: it must not replace the newer range. +const isOlderEpoch = (epoch, range) => Number.isFinite(epoch) && Number.isFinite(range?.sessionEpoch) && epoch < range.sessionEpoch; + /** * The table with `workTurn` in the session's range: added, or in place of the same turn judged again (keeping its - * correction). `context` is `{ base, project, projectDir, model, pid, source, native, now }`: the range's diff base and the - * project snapshot taken against it at this turn's end, and who runs the session. + * correction). `context` is `{ base, project, projectDir, model, sessionEpoch, pid, source, now }`: the range's diff + * base and the project snapshot taken against it at this turn's end, and who runs the session. A turn of a newer + * session epoch (its state started over, so its turn numbers did) starts a range of its own; its model route replaces + * a blocked one. A late turn of an older epoch changes nothing. */ -export function withWorkTurn(pending, sessionId, workTurn, { now, native, beforeNewerTurn, ...context }) { +export function withWorkTurn(pending, sessionId, workTurn, { now, ...context }) { const range = rangeOf(pending, sessionId); - const nativeRange = Boolean(native || bindingOf(context.model) || (range && isNativeRange(range))); - const turn = nativeRange ? { ...workTurn, modelBinding: bindingOf(context.model) } : workTurn; - if (!range || (!nativeRange && range.sessionEpoch !== context.sessionEpoch)) { - return { ...pending, [sessionId]: { turns: [turn], ...context, ...(nativeRange && { native: true }), at: now, changedAt: now, activeAt: now, attempts: 0 } }; + if (isOlderEpoch(context.sessionEpoch, range)) return undefined; + if (!range || range.sessionEpoch !== context.sessionEpoch) { + return { ...pending, [sessionId]: { turns: [workTurn], ...context, at: now, changedAt: now, activeAt: now, attempts: 0 } }; } - const turns = joinedTurns(range.turns, turn); - const retained = turns.slice(-MAX_RANGE_TURNS); - if (beforeNewerTurn && !matchingTurn(retained, turn.turn, turn.sessionEpoch)) return undefined; - // A delayed work turn retains the newer turn's route and snapshot, unless it changes the snapshot's base. - const first = range.turns[0]; - const changes = !beforeNewerTurn ? context - : workTurn.turn < first.turn && JSON.stringify(workTurn.head ?? null) !== JSON.stringify(range.base ?? null) - ? { base: workTurn.head ?? null, project: null } : {}; - const next = { ...withActivity(range, now), turns, ...changes, changedAt: changedAtAfter(range, now), held: false }; - if (range.omitProjectDiff) next.project = null; - if (nativeRange) { - next.native = true; - // Keep the block if a conflicting source turn rolls out. Its diff may still span that turn. - const problem = rangeRouteProblem(range, { requireCapturedRoute: true }) ?? rangeRouteProblem(next); - if (problem) { - next.routeConflict = problem; - // A runtime block belongs to the binding that failed, even while a structural conflict takes precedence. - const runtimeBinding = range.blocked?.runtimeBinding ?? (!range.routeConflict && range.blocked ? bindingOf(range.model) : null); - next.blocked = runtimeBinding ? { ...range.blocked, runtimeBinding } : { reason: problem, at: range.blocked ? blockedAtOf(range) : now }; - } - } else delete next.blocked; - next.turns = retained; - return { ...pending, [sessionId]: next }; -} - -// A native range binds each turn to its captured route: the remaining turns decide the route, base and conflicts again. -function withoutNativeTurn(range, turns, removedTurn, now) { - const model = turns.at(-1).modelBinding === bindingOf(range.model) ? range.model : null; - const base = range.turns[0] === removedTurn ? turns[0].head ?? null : range.base; - const problem = rangeRouteProblem(range); - const next = { ...range, turns, base, project: null, model, changedAt: changedAtAfter(range, now), ...(problem && { routeConflict: problem }) }; - if (!problem || capturedRouteProblem(range) !== problem || capturedRouteProblem(next)) return next; - delete next.routeConflict; - if (next.blocked?.runtimeBinding) { - const { runtimeBinding, ...runtimeBlock } = next.blocked; - if (runtimeBinding === bindingOf(model)) next.blocked = runtimeBlock; - else delete next.blocked; - } else if (range.routeConflict && next.blocked?.reason === range.routeConflict) delete next.blocked; - // A later snapshot could still include uncommitted edits from the removed turn. - next.omitProjectDiff = true; - return next; + const turns = joinedTurns(range.turns, workTurn); + // A turn judged again after a later one joined carries an older route, snapshot and owner: only its own turn changes. + if (range.turns.some((turn) => turn.turn > workTurn.turn)) { + return { ...pending, [sessionId]: { ...withActivity(range, now), turns, changedAt: changedAtAfter(range, now), held: false } }; + } + const { blocked, ...active } = withActivity(range, now); + return { ...pending, [sessionId]: { ...active, turns, ...context, changedAt: changedAtAfter(range, now), held: false } }; } /** The table without that turn of the session's range (judged again, it was no work); no range is left empty. */ -export function withoutWorkTurn(pending, sessionId, turnNumber, { sessionEpoch, now = 0 } = {}) { +export function withoutWorkTurn(pending, sessionId, turnNumber, { sessionEpoch } = {}) { const range = rangeOf(pending, sessionId); - const removedTurn = range && matchingTurn(range.turns, turnNumber, sessionEpoch); - if (!removedTurn) return undefined; - const turns = range.turns.filter((turn) => turn !== removedTurn); - if (turns.length === 0) { - const { [sessionId]: removed, ...rest } = pending; - return rest; - } - return { ...pending, [sessionId]: isNativeRange(range) ? withoutNativeTurn(range, turns, removedTurn, now) : { ...range, turns } }; + if (!range?.turns.some((turn) => turn.turn === turnNumber) || isOtherEpoch(sessionEpoch, range)) return undefined; + const turns = range.turns.filter((turn) => turn.turn !== turnNumber); + if (turns.length > 0) return { ...pending, [sessionId]: { ...range, turns } }; + const { [sessionId]: removed, ...rest } = pending; + return rest; } /** The person's next prompt corrected `turnNumber`: it stays in the range, with the correction as its last word. */ export function withCorrection(pending, sessionId, turnNumber, text, now, { sessionEpoch } = {}) { const range = rangeOf(pending, sessionId); - const corrected = range && matchingTurn(range.turns, turnNumber, sessionEpoch); - if (!text || !corrected) return undefined; - if (corrected.finalizationPending) return withoutWorkTurn(pending, sessionId, turnNumber, { sessionEpoch, now }); - const turns = range.turns.map((turn) => (turn === corrected ? { ...turn, correction: text } : turn)); + if (!text || !range?.turns.some((turn) => turn.turn === turnNumber) || isOtherEpoch(sessionEpoch, range)) return undefined; + const turns = range.turns.map((turn) => (turn.turn === turnNumber ? { ...turn, correction: text } : turn)); return { ...pending, [sessionId]: { ...range, turns, changedAt: changedAtAfter(range, now) } }; } @@ -196,32 +130,11 @@ export function withSessionActivity(pending, sessionId, now) { export const withClosedRange = (pending, sessionId) => (pending?.[sessionId] ? { ...pending, [sessionId]: { ...rangeOf(pending, sessionId), closed: true } } : undefined); -/** A native terminal can be known while one of its background checks still lacks a result. */ -export function withPendingNativeCheck(pending, sessionId, { turn, sessionEpoch }, now) { - const range = rangeOf(pending, sessionId); - const check = range?.pendingNativeCheck; - if (!range || range.sessionEpoch !== sessionEpoch || (check && check.sessionEpoch === sessionEpoch && check.turn >= turn) - || range.turns.some((work) => work.sessionEpoch === sessionEpoch && work.turn > turn)) return undefined; - return { ...pending, [sessionId]: { ...range, pendingNativeCheck: { turn, sessionEpoch }, changedAt: changedAtAfter(range, now) } }; -} - -export function withoutPendingNativeCheck(pending, sessionId, { turn, sessionEpoch }) { - const range = rangeOf(pending, sessionId); - const check = range?.pendingNativeCheck; - if (!check || check.turn !== turn || check.sessionEpoch !== sessionEpoch) return undefined; - const { pendingNativeCheck, ...rest } = range; - return { ...pending, [sessionId]: rest }; -} - +const isHeld = (range, isGone) => Boolean(range.claimedBy) && !isGone(range.claimedBy); const isOver = (range, isGone) => Boolean(range.closed) || isGone(range.pid); const isUnclaimed = (range, isGone) => !range.claimedBy || isGone(range.claimedBy); const isRetryDue = (range, now) => !(now < Number(range.retryAt)); -const isRouteBlocked = (range) => Boolean(range.blocked && (range.model || range.routeConflict)); -const isProvisional = (range) => (range.pendingNativeCheck && range.pendingNativeCheck.sessionEpoch === range.sessionEpoch) - || range.turns.some((turn) => turn.finalizationPending); -const isFinalized = (range, requireCompletedChecks) => !isProvisional(range) - && (!(requireCompletedChecks || isNativeRange(range)) || range.turns.every((turn) => - turn.steps.every((step) => step.kind !== 'check' || verdictOf(step) !== 'unknown'))); +const isRouteBlocked = (range) => Boolean(range.blocked && range.model); function blockedAtOf(range) { const at = range.blocked.at ?? range.at; @@ -235,15 +148,13 @@ function triggerOf(range, { now, isGone }) { return range.dueBy ?? timedTriggerOf(range, now); } -const isDue = (range, context) => !isRouteBlocked(range) && isFinalized(range, context.requireCompletedChecks) - && isUnclaimed(range, context.isGone) && isRetryDue(range, context.now) && triggerOf(range, context) !== null; +const isDue = (range, context) => + !isRouteBlocked(range) && isUnclaimed(range, context.isGone) && isRetryDue(range, context.now) && triggerOf(range, context) !== null; /** Whether a range waits to be distilled: due by a trigger, and no live process distills it. */ export const hasDueRange = (pending, context) => rangesOf(pending).some(([, range]) => isDue(range, context)); function dueAtOf(range, context) { - if (isRouteBlocked(range)) return isProvisional(range) ? null : blockedExpiryAt(range); - if (context.includeDistillation === false || !isFinalized(range, context.requireCompletedChecks)) return null; const retryAt = Number(range.retryAt) || 0; if (triggerOf(range, context)) return retryAt; const since = lastActiveAt(range); @@ -252,33 +163,34 @@ function dueAtOf(range, context) { } /** - * When the next range comes due, in epoch ms and never before `now`; null when none will without more work. One a - * live process distills is left out: that process settles it and looks again. + * When the next range comes due or a blocked route expires (only the latter without `includeDistillation`), in epoch + * ms and never before `now`; null when none will without more work. One a live process distills is left out: that + * process settles it and looks again. */ -export function nextDueAt(pending, context) { +export function nextDueAt(pending, { includeDistillation = true, ...context }) { const dueAts = rangesOf(pending) - .filter(([, range]) => isUnclaimed(range, context.isGone)) - .map(([, range]) => dueAtOf(range, context)) + .filter(([, range]) => (includeDistillation || isRouteBlocked(range)) && isUnclaimed(range, context.isGone)) + .map(([, range]) => (isRouteBlocked(range) ? blockedExpiryAt(range) : dueAtOf(range, context))) .filter((at) => at !== null); return dueAts.length > 0 ? Math.max(context.now, Math.min(...dueAts)) : null; } /** The due ranges, claimed for `pid` at once so two processes cannot distill one twice, each with its trigger. */ -export function claimedDue(pending, { pid, ...context }) { +export function claimedDue(pending, { pid, isGone, now }) { const next = { ...pending }; const claimed = []; for (const [sessionId, range] of rangesOf(pending)) { - if (!isDue(range, context) || range.claimedBy === pid) continue; + if (!isDue(range, { now, isGone }) || range.claimedBy === pid) continue; next[sessionId] = { ...range, claimedBy: pid }; - claimed.push({ sessionId, range: next[sessionId], trigger: triggerOf(range, context) }); + claimed.push({ sessionId, range: next[sessionId], trigger: triggerOf(range, { now, isGone }) }); } return { next: claimed.length > 0 ? next : undefined, claimed }; } /** The session's own range claimed for `pid`; none when there is none or a live process distills it. */ -export function claimedOwn(pending, sessionId, { pid, isGone, requireCompletedChecks }) { +export function claimedOwn(pending, sessionId, { pid, isGone }) { const range = rangeOf(pending, sessionId); - if (!range || !isFinalized(range, requireCompletedChecks) || !isUnclaimed(range, isGone)) return { next: undefined, claimed: null }; + if (!range || isHeld(range, isGone)) return { next: undefined, claimed: null }; const claimed = { ...range, claimedBy: pid }; return { next: { ...pending, [sessionId]: claimed }, claimed: { sessionId, range: claimed } }; } @@ -290,58 +202,64 @@ export function rangeSessionOf(pending, isWanted = null) { return ranges.sort(([, left], [, right]) => (right.changedAt ?? 0) - (left.changedAt ?? 0))[0]?.[0] ?? null; } -/** The table without leftovers older than UNDISTILLED_LEFTOVER_TTL_MS; undefined when none is. */ +/** + * The table without expired route blocks and, unless `blockedOnly`, without leftovers older than + * UNDISTILLED_LEFTOVER_TTL_MS; undefined when none is. + */ export function withoutExpiredLeftovers(pending, { now, isGone, blockedOnly = false }) { - const isExpired = (range) => !isProvisional(range) && isUnclaimed(range, isGone) && (isRouteBlocked(range) - ? now >= blockedExpiryAt(range) - : !blockedOnly && isOver(range, isGone) && now - Number(range.changedAt ?? range.at) > UNDISTILLED_LEFTOVER_TTL_MS); + const isExpired = (range) => (isRouteBlocked(range) + ? isUnclaimed(range, isGone) && now >= blockedExpiryAt(range) + : !blockedOnly && isOver(range, isGone) && isUnclaimed(range, isGone) && now - Number(range.changedAt ?? range.at) > UNDISTILLED_LEFTOVER_TTL_MS); const kept = rangesOf(pending).filter(([, range]) => !isExpired(range)); return kept.length < Object.keys(pending ?? {}).length ? Object.fromEntries(kept) : undefined; } -const isSameTurn = (left, right) => left.turn === right.turn && left.sessionEpoch === right.sessionEpoch; +// One made again in the same millisecond, after the turns a distillation read were done with, holds none of them. const isSameRange = (range, claimed) => Boolean(range) && range.at === claimed.at && range.claimedBy === claimed.claimedBy - && range.sessionEpoch === claimed.sessionEpoch && claimed.turns.some((read) => range.turns.some((turn) => isSameTurn(turn, read))); -const isAsRead = (turn, read) => isSameTurn(turn, read) && JSON.stringify(turn) === JSON.stringify(read); + && range.sessionEpoch === claimed.sessionEpoch && claimed.turns.some((read) => range.turns.some((turn) => turn.turn === read.turn)); +// A turn judged again while a distillation ran (a background command finished) keeps its number, not what it holds. +const isAsRead = (turn, read) => turn.turn === read.turn && turn.correction === read.correction + && JSON.stringify([turn.steps, turn.answer]) === JSON.stringify([read.steps, read.answer]); +const wasReadAsIs = (claimed, turn) => claimed.turns.some((read) => isAsRead(turn, read)); -/** Whether all the claimed work still exists as read, under the same claim. */ +/** + * Whether what a distillation of `claimed` read is still the session's work: the same range under the same claim, every + * turn it read still in it, and none corrected or judged again since. Turns joining meanwhile change nothing it read, + * unless `exact` asks for the range exactly as read (an agent's Gene was written for those turns alone). + */ export function isCurrentRange(pending, sessionId, claimed, { exact = false } = {}) { const range = rangeOf(pending, sessionId); - if (!isSameRange(range, claimed) || !isFinalized(range)) return false; - if (exact && range.changedAt !== claimed.changedAt) return false; - if (!claimed.turns.every((read) => range.turns.some((turn) => isAsRead(turn, read)))) return false; - return range.turns.length !== claimed.turns.length || range.changedAt === claimed.changedAt; + return isSameRange(range, claimed) && (!exact || range.changedAt === claimed.changedAt) + && claimed.turns.every((read) => range.turns.some((turn) => isAsRead(turn, read))); } const retryDelayOf = (attempts) => Math.min(RETRY_BASE_MS * 2 ** (attempts - 1), RETRY_MAX_MS); -// The turns a distillation read are done with; turns added while it ran start the next range, whose diff is taken -// again from their own start at the next work turn's end. +// The turns a distillation read are done with; turns added or corrected while it ran start the next range, whose diff +// is taken again from their own start at the next work turn's end. function withoutConsumed(pending, sessionId, range, claimed, now) { - const turns = range.turns.filter((turn) => !claimed.turns.some((read) => isAsRead(turn, read))); + const turns = range.turns.filter((turn) => !wasReadAsIs(claimed, turn)); const { [sessionId]: removed, ...rest } = pending; if (turns.length === 0) return rest; const { retryAt, dueBy, claimedBy, held, blocked, ...kept } = range; - return { ...rest, [sessionId]: { ...kept, turns, base: turns[0].head ?? null, project: null, at: now, changedAt: changedAtAfter(range, now), activeAt: now, attempts: 0 } }; + return { ...rest, [sessionId]: { ...kept, turns, base: turns[0].head ?? null, project: null, at: now, changedAt: now, activeAt: now, attempts: 0 } }; } /** * The table once a distillation of `claimed` ended: its turns `consume`d (done, refused for good, or given up after * MAX_ATTEMPTS counted tries); `hold` keeps a range below the threshold from coming due again until more work joins - * it; a `counted` failure puts the next try off (`retryAt`); anything else only releases the claim. A range replaced - * meanwhile is left alone. + * it; a `counted` failure puts the next try off (`retryAt`); `blocked` (the reason its model route is unavailable) + * parks it, dated from its first block; anything else only releases the claim. A range replaced meanwhile is left + * alone. */ -export function settledRange(pending, sessionId, claimed, { consume = false, hold = false, counted = false, blocked, now }) { +export function settledRange(pending, sessionId, claimed, { consume = false, hold = false, counted = false, blocked = null, now }) { const range = rangeOf(pending, sessionId); if (!isSameRange(range, claimed)) return undefined; - const attempts = (range.attempts ?? 0) + (counted && !blocked ? 1 : 0); + const attempts = (range.attempts ?? 0) + (counted ? 1 : 0); if (consume || attempts >= MAX_ATTEMPTS) return withoutConsumed(pending, sessionId, range, claimed, now); - const retry = counted && !blocked ? { retryAt: now + retryDelayOf(attempts) } : {}; + const retry = counted ? { retryAt: now + retryDelayOf(attempts) } : {}; const held = hold && range.changedAt === claimed.changedAt ? { held: true, dueBy: null } : {}; - const { blocked: previousBlock, ...active } = range; - // A late settlement must not reapply a structural conflict that was removed while claimed. - const reason = range.routeConflict ?? (claimed.routeConflict === blocked ? undefined : blocked); - const block = reason === undefined || (range.routeConflict && previousBlock?.runtimeBinding) - ? previousBlock : reason && { reason, at: previousBlock ? blockedAtOf(range) : now }; - return { ...pending, [sessionId]: { ...active, attempts, claimedBy: null, ...retry, ...held, ...(block && { blocked: block }) } }; + const { blocked: previous, ...rest } = range; + const routeBlock = blocked && { reason: blocked, at: previous ? blockedAtOf(range) : now }; + return { ...pending, [sessionId]: { ...rest, attempts, claimedBy: null, ...retry, ...held, ...(routeBlock && { blocked: routeBlock }) } }; } diff --git a/lib/runtime/acp-model.js b/lib/runtime/acp-model.js index 5a0da68..62c0ccb 100644 --- a/lib/runtime/acp-model.js +++ b/lib/runtime/acp-model.js @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { markHeadlessWorkdir } from './headless.js'; -import { ModelUnavailable, sameSnapshot, selectedConfig } from './native-config.js'; +import { configStampOf, ModelUnavailable, selectedConfig } from './native-config.js'; import { CHILD_FILE, guardianFile, OWNER_FILE, START_FILE } from './process/acp-child.js'; import { nodeExecutable } from './process/node.js'; import { executableOf } from './process/which.js'; @@ -385,13 +385,29 @@ function conversation(child, signal) { }; } +// The route a turn ran on, as its host's record named it (`selection`), under the configuration stamp it began and ended +// with (`configDigest`); `unavailable` says why none was captured. +function selectionOf(declaration, model, env) { + const { selection, configDigest } = model ?? {}; + if (!selection || !configDigest) throw new ModelUnavailable(model?.unavailable ?? 'route_unresolved'); + const selected = selectedConfig(declaration, selection, env); + if (selected.stamp !== configDigest) throw new ModelUnavailable('route_changed'); + if (['alias', 'model', 'protocol'].some((key) => selected.route[key] !== selection[key])) throw new ModelUnavailable('route_changed'); + return selected; +} + +/** + * The ModelRunner of an ACP declaration: `complete(model)` runs the route `turnEnd.modelOf` captured from a turn, and + * `configStamp()` stamps the configuration it is checked against. + */ export function acpModelRunnerOf(declaration, env) { return { - capture: (hint) => selectedConfig(declaration, hint, env).snapshot, - complete: async (snapshot, prompt, signal) => { + capture: true, + configStamp: () => configStampOf(declaration, env), + complete: async (model, prompt, signal) => { if (signal?.aborted) throw failure('ABORT_ERR'); - const selected = selectedConfig(declaration, snapshot, env); - if (!sameSnapshot(selected.snapshot, snapshot)) throw new ModelUnavailable('route_changed'); + const selected = selectionOf(declaration, model, env); + const route = selected.route; const found = typeof declaration.command === 'function' ? { file: declaration.command(env), viaNode: false } : executableOf({ ...declaration.command, dirs: declaration.command?.dirs?.(env) ?? [] }, { env }); @@ -418,7 +434,7 @@ export function acpModelRunnerOf(declaration, env) { const extra = filesPrepared(declaration, selected.selected, home, selected.secret); const runEnv = privateEnv(env, extra, home, selected.secret); const cwd = join(home, 'workspace'); mkdirSync(cwd, { mode: 0o700 }); - const args = [...leading, ...declaration.args(snapshot, { home })]; + const args = [...leading, ...declaration.args(route, { home })]; if (args.some((arg) => typeof arg !== 'string' || arg.includes(selected.secret))) { throw new ModelUnavailable('unsafe_runtime'); } @@ -450,8 +466,8 @@ export function acpModelRunnerOf(declaration, env) { sessionId = session?.sessionId; if (typeof sessionId !== 'string' || !sessionId) throw failure('ACP_PROTOCOL_ERROR'); const options = [ - { id: 'model', value: snapshot.alias }, - ...(declaration.sessionConfig?.(snapshot, selected.selected) ?? []), + { id: 'model', value: route.alias }, + ...(declaration.sessionConfig?.(route, selected.selected) ?? []), ]; if (options.length > 8 || new Set(options.map((option) => option.id)).size !== options.length) { throw failure('ACP_PROTOCOL_ERROR'); diff --git a/lib/runtime/environment.js b/lib/runtime/environment.js index f637376..d8a2e38 100644 --- a/lib/runtime/environment.js +++ b/lib/runtime/environment.js @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { join } from 'node:path'; - import { loadReuseLedger, recordReuseLocally } from '../local-ledger.js'; import { evolverCliOf } from './cli-executor.js'; import { clientOf } from './client.js'; @@ -13,8 +11,6 @@ import { detachedScheduler, waitForWakeUp } from './lifetime/ephemeral.js'; import { inProcessScheduler } from './lifetime/resident.js'; import { threadedLedger } from './ledger-thread.js'; import { modelRunnerOf } from './model-executor.js'; -import { nativeFactsOf } from './native-facts.js'; -import { monitorNativeTurn } from './native-monitor.js'; import { ownerProbeOf } from './process/owner.js'; import { projectSnapshotOf } from './project.js'; import { proxyFetchOf, runningProxy } from './proxy.js'; @@ -61,7 +57,6 @@ export function environmentFor({ profile, env = process.env, lifetime, runBackgr retention: stores.retention, proxy: { request }, model: modelRunnerOf(profile.model, env), - native: nativeFactsOf(profile.native, env), modelRecord: recordReaderOf(profile.modelRecord, env), turnStatus: recordReaderOf(profile.turnEnd.statusRecord, env), confirmTurnCompletion: profile.turnEnd.statusRecord?.confirmCompletion === true, @@ -83,8 +78,6 @@ export function environmentFor({ profile, env = process.env, lifetime, runBackgr if (profile.commands.background) ports.commandJobs ??= commandJobsOf({ dir: stores.folder('commands'), agent: profile.name, env, accepts: profile.commands.background }); if (ports.turnStatus) ports.turnMonitor ??= (at, onClosed) => monitorTurn(ports, at, onClosed, { dir: stores.folder('turn-monitors') }); - if (ports.native) ports.observeTurn ??= (at, reconcile, onClosed) => - monitorNativeTurn(ports, at, reconcile, onClosed, { dir: join(profile.stateDir(env), 'native-monitors') }); const runTasks = async (tasks) => { stores.prune(); return runBackground(ports, tasks); diff --git a/lib/runtime/native-config.js b/lib/runtime/native-config.js index 754eca6..b54559f 100644 --- a/lib/runtime/native-config.js +++ b/lib/runtime/native-config.js @@ -117,15 +117,62 @@ function credentialOf(credential, env) { return { secret }; } -/** Read one bounded snapshot; raw config and credential remain local to this invocation. */ +function configOf(declaration, env) { + const source = declaration.config.source(env); + const bytes = boundedBytes(source, CONFIG_MAX_BYTES, 'route_unresolved'); + try { + return { source, bytes, config: declaration.config.parseConfig(bytes.toString('utf8')) }; + } catch { + throw new ModelUnavailable('route_unresolved'); + } +} + +const digestOf = ({ secret, deviceId }) => createHash('sha256').update(JSON.stringify([secret, deviceId ?? null])).digest('hex'); + +function secretDigestOf(credential, env) { + try { + return digestOf(credentialOf(credential, env)); + } catch { + return 'unavailable'; + } +} + +/** + * A no-secret stamp of the configuration and of every credential it names, as the turn could use them: a model + * captured under one stamp is never run under another, so a changed route or account cannot receive its range. Null + * when the configuration cannot be read. + */ +export function configStampOf(declaration, env) { + try { + return stampOf(declaration, configOf(declaration, env), env); + } catch { + return null; + } +} + +// `used` is the credential a run already read, `{ credential, secret, deviceId }`: its digest is of that read, so the +// stamp checked and the secret handed to the run cannot come from two different reads. +function stampOf(declaration, { bytes, config }, env, used = null) { + const credentials = declaration.config.credentials(config, env); + if (!Array.isArray(credentials)) return null; + const usedKey = used && JSON.stringify(used.credential); + const hash = createHash('sha256').update(bytes); + for (const credential of credentials) { + hash.update('\0').update(JSON.stringify(credential) === usedKey ? digestOf(used) : secretDigestOf(credential, env)); + } + return hash.digest('hex'); +} + +/** + * Read one bounded selection for `hint`, with the stamp of that same read; raw config and credential remain local to + * this invocation. + */ export function selectedConfig(declaration, hint, env) { - let bytes; + let read; let selected; - let source; try { - source = declaration.config.source(env); - bytes = boundedBytes(source, CONFIG_MAX_BYTES, 'route_unresolved'); - selected = declaration.config.select(declaration.config.parseConfig(bytes.toString('utf8')), hint, env); + read = configOf(declaration, env); + selected = declaration.config.select(read.config, hint, env); } catch (error) { if (error?.code === 'MODEL_UNAVAILABLE') throw new ModelUnavailable(error.reason); throw new ModelUnavailable('route_unresolved'); @@ -133,22 +180,7 @@ export function selectedConfig(declaration, hint, env) { if (selected?.unavailable) throw new ModelUnavailable(selected.unavailable); const route = routeOf(selected?.route); const { secret, deviceId } = credentialOf(selected?.credential, env); - const configRevision = createHash('sha256').update(bytes).digest('hex'); - const hash = createHash('sha256') - .update(configRevision).update('\0').update(JSON.stringify(route)).update('\0').update(secret); - if (deviceId) hash.update('\0').update(deviceId); - const binding = hash.digest('hex'); - const origin = createHash('sha256').update(resolve(source)).digest('hex'); - return { - selected: deviceId ? { ...selected, deviceId } : selected, - route, - secret, - origin, - snapshot: { ...route, configRevision, binding }, - }; - -} - -export function sameSnapshot(actual, expected) { - return expected && typeof expected === 'object' && JSON.stringify(actual) === JSON.stringify(expected); + const origin = createHash('sha256').update(resolve(read.source)).digest('hex'); + const stamp = stampOf(declaration, read, env, { credential: selected.credential, secret, deviceId }); + return { selected: deviceId ? { ...selected, deviceId } : selected, route, secret, origin, stamp }; } diff --git a/lib/runtime/native-facts.js b/lib/runtime/native-facts.js deleted file mode 100644 index c096750..0000000 --- a/lib/runtime/native-facts.js +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 EvoMap - -import { closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs'; -import { createHash } from 'node:crypto'; -import { isAbsolute, join, relative, resolve, sep } from 'node:path'; - -const MAX_INDEX = 1024 * 1024; -const MAX_BATCH = 4 * 1024 * 1024; -const ID = /^[A-Za-z0-9_-]{1,128}$/; - -function isSameDirectory(candidate, directory) { - try { - return typeof candidate === 'string' && realpathSync(candidate) === directory; - } catch { - return false; - } -} - -function confined(root, file) { - const candidate = resolve(file); - const rel = relative(root, candidate); - if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('unsafe'); - let current = root; - for (const part of rel.split(sep)) { - current = join(current, part); - if (lstatSync(current).isSymbolicLink()) throw new Error('unsafe'); - } - const actual = realpathSync(candidate); - const back = relative(root, actual); - if (!back || back.startsWith('..') || isAbsolute(back) || !statSync(actual).isFile()) throw new Error('unsafe'); - return actual; -} - -function jsonOf(root, file, maximum) { - const safe = confined(root, file); - const before = statSync(safe); - const fd = openSync(safe, 'r'); - try { - const stat = fstatSync(fd); - if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('rotated'); - if (stat.size > maximum) throw new Error('oversize'); - const buffer = Buffer.alloc(maximum + 1); - let size = 0; - let read; - do { - read = readSync(fd, buffer, size, buffer.length - size, null); - size += read; - } while (read && size < buffer.length); - if (size > maximum) throw new Error('oversize'); - return JSON.parse(buffer.subarray(0, size).toString('utf8')); - } finally { - closeSync(fd); - } -} - -function recordsOf(root, file, after) { - const safe = confined(root, file); - const stat = statSync(safe); - const fileId = `${stat.dev}:${stat.ino}`; - const offset = after?.offset ?? 0; - if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size || (after?.fileId && after.fileId !== fileId)) { - throw new Error('rotated'); - } - const length = Math.min(MAX_BATCH, stat.size - offset); - const buffer = Buffer.alloc(length); - let fd; - let size = 0; - try { - fd = openSync(safe, 'r'); - const opened = fstatSync(fd); - if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) throw new Error('rotated'); - while (size < length) { - const read = readSync(fd, buffer, size, length - size, offset + size); - if (!read) break; - size += read; - } - } finally { - if (fd !== undefined) closeSync(fd); - } - const records = []; let start = 0; - const content = buffer.subarray(0, size); - for (let end = content.indexOf(10); end >= 0; end = content.indexOf(10, start)) { - const line = buffer.subarray(start, end).toString('utf8'); - if (line.trim()) { - const value = JSON.parse(line); - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid'); - Object.defineProperty(value, '$offset', { value: offset + start }); - records.push(value); - } - start = end + 1; - } - return { - records, - mark: { offset: offset + start, fileId }, - incompleteTail: start < size, - limited: stat.size - offset > MAX_BATCH || (size === MAX_BATCH && start < size), - }; -} - -/** - * Read-only native facts over a host's indexed session log. The declaration names the index file, lists its - * workspaces as `[id, root]` (`workspacesOf`, null for an unsupported index), and places a workspace's session folder - * (`sessionDir(workspaceId, sessionId)`) and its JSONL log (`logFile`) under its root. A failed read is never an - * empty valid turn. - */ -export function nativeFactsOf(declaration, env = process.env) { - if (!declaration) return null; - let scopedRoot = resolve(declaration.root(env)); - // A fresh native profile may not exist yet. - try { - scopedRoot = realpathSync(scopedRoot); - } catch {} - const scope = createHash('sha256').update(scopedRoot).digest('hex'); - return { scope, reference: declaration.reference, read(payload, context = {}) { - try { - const root = realpathSync(declaration.root(env)); - const sessionId = declaration.sessionId(payload); - const projectDir = realpathSync(declaration.projectDir(payload)); - if (!ID.test(sessionId)) return { status: 'unsupported' }; - const listed = declaration.workspacesOf(jsonOf(root, join(root, declaration.workspaceIndex), MAX_INDEX)); - if (!Array.isArray(listed)) return { status: 'unsupported' }; - const workspaces = listed.filter( - ([id, workspaceRoot]) => ID.test(id) && isSameDirectory(workspaceRoot, projectDir), - ); - if (workspaces.length !== 1) return { status: 'missing' }; - const sessionDir = join(root, declaration.sessionDir(workspaces[0][0], sessionId)); - const wire = join(sessionDir, declaration.logFile); - const batch = recordsOf(root, wire, context.after); - const ids = declaration.taskIds?.(batch.records, payload) ?? []; - if (!Array.isArray(ids) || ids.length > 64 || ids.some((id) => !ID.test(id))) { - return { status: 'partial', mark: batch.mark }; - } - const tasks = []; - const taskRoot = declaration.tasksRoot?.(env, sessionDir); - let budget = MAX_BATCH; - for (const id of new Set(ids)) { - if (!taskRoot) break; - try { - const file = confined(root, join(taskRoot, `${id}.json`)); - const size = statSync(file).size; - if (size > MAX_INDEX || size > budget) return { status: 'partial', mark: batch.mark }; - budget -= size; - const task = jsonOf(root, file, size); - if (task?.taskId !== id) return { status: 'partial', mark: batch.mark }; - tasks.push(task); - } catch (error) { - if (error?.code !== 'ENOENT') return { status: 'partial', mark: batch.mark }; - } - } - const decoded = declaration.decode(batch.records, tasks, payload, { ...context, projectDir }); - return { - ...decoded, - records: batch.records, - tasks, - mark: batch.mark, - incompleteTail: batch.incompleteTail, - limited: batch.limited, - status: batch.limited || batch.incompleteTail ? 'partial' : decoded.status ?? 'ok', - }; - } catch (error) { - return { - status: error?.code === 'ENOENT' - ? 'missing' - : error?.message === 'oversize' || error?.message === 'rotated' ? 'partial' : 'unreadable', - }; - } - - } }; -} diff --git a/lib/runtime/native-monitor.js b/lib/runtime/native-monitor.js deleted file mode 100644 index 58e4240..0000000 --- a/lib/runtime/native-monitor.js +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 EvoMap - -import { createHash } from 'node:crypto'; -import { join } from 'node:path'; - -import { observedNativeTurn } from '../session/state.js'; -import { withLock } from './store/file-lock.js'; - -const POLL_MS = 500; -const MISSING_MS = 30_000; -const MAX_WATCH_MS = 24 * 60 * 60 * 1000; - -/** Hook completion can precede the native terminal write. One owned monitor reconciles that logical turn. */ -export async function monitorNativeTurn(ports, at, reconcile, abort, { - dir, - now = Date.now, - sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), -}) { - if (!ports.native || !at || typeof at.sessionId !== 'string' || !at.sessionId || at.sessionId.length > 2048 - || !Number.isSafeInteger(at.turn) || at.turn < 1 || typeof at.projectDir !== 'string') return false; - const key = createHash('sha256').update(JSON.stringify([at.sessionId, at.turn, at.sessionEpoch])).digest('hex'); - return withLock(join(dir, key), async (lock) => { - const start = now(); - while (now() - start < MAX_WATCH_MS) { - const session = await ports.sessions.read(at.sessionId); - if (session.sessionEpoch !== at.sessionEpoch) return; - const observed = observedNativeTurn(session, at.turn); - if (!observed) return; - // Native terminal wins over owner death; orphan recovery must not turn a completed check into an abort. - const result = await reconcile({ ...at, sessionEpoch: session.sessionEpoch }); - if (result?.facts?.limited || ['unreadable', 'unsupported'].includes(result?.facts?.status)) return; - // An end before native admission cannot become a running turn; do not retain a detached observer for it. - if (observed.native.sessionEnded && !result?.native?.turnId) return; - if (ports.owner.isGone(observed.pid)) { - if (session.turn === at.turn && !result?.ended) { - await abort({ - ...at, - expected: { turn: observed.turn, pid: observed.pid, sessionEpoch: observed.sessionEpoch }, - reason: 'aborted', - }); - } - - return; - } - if (result?.ended && !result.waiting) return; - if (!observed.open && !result?.waiting) return; - if (!result?.native?.turnId && now() - start >= MISSING_MS) return; - lock.refresh(); - await sleep(POLL_MS); - } - }); -} diff --git a/lib/runtime/records.js b/lib/runtime/records.js index ff50806..f402cd5 100644 --- a/lib/runtime/records.js +++ b/lib/runtime/records.js @@ -4,11 +4,17 @@ import { lstatSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { sessionLogReaderOf } from './session-log.js'; + const require = createRequire(import.meta.url); -/** Reads one declared metadata projection; the host supplies a fixed query, never SQL from a payload. */ +/** + * Reads one declared metadata projection: from a host's database with a fixed query, never SQL from a payload, or from + * its JSONL session log (see session-log.js). + */ export function recordReaderOf(declaration, env) { if (!declaration) return undefined; + if (typeof declaration.query !== 'string') return sessionLogReaderOf(declaration, env); return (...keys) => { if (!keys.length || keys.length > 2 || keys.some(key => typeof key !== 'string' || !key || key.length > 256)) return null; let db; diff --git a/lib/runtime/session-log.js b/lib/runtime/session-log.js new file mode 100644 index 0000000..a3acd48 --- /dev/null +++ b/lib/runtime/session-log.js @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +const MAX_INDEX = 1024 * 1024; +const MAX_HEAD = 64 * 1024; +const MAX_TAIL = 4 * 1024 * 1024; +const MAX_TASKS = 64; +const ID = /^[A-Za-z0-9_-]{1,128}$/; + +function confined(root, file) { + const candidate = resolve(file); + const rel = relative(root, candidate); + if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('unsafe'); + let current = root; + for (const part of rel.split(sep)) { + current = join(current, part); + if (lstatSync(current).isSymbolicLink()) throw new Error('unsafe'); + } + const actual = realpathSync(candidate); + const back = relative(root, actual); + if (!back || back.startsWith('..') || isAbsolute(back) || !statSync(actual).isFile()) throw new Error('unsafe'); + return actual; +} + +// A file or folder on the path may be swapped between the check and the open: the path is checked again once open, +// and the open file must be the one that check finds. +function openConfined(root, file) { + const fd = openSync(confined(root, file), 'r'); + let isChecked = false; + try { + const opened = fstatSync(fd); + const checked = statSync(confined(root, file)); + isChecked = opened.isFile() && opened.dev === checked.dev && opened.ino === checked.ino; + } finally { + if (!isChecked) closeSync(fd); + } + if (!isChecked) throw new Error('replaced'); + return fd; +} + +function bytesOf(fd, offset, length) { + const buffer = Buffer.alloc(length); + let size = 0; + while (size < length) { + const read = readSync(fd, buffer, size, length - size, offset + size); + if (!read) break; + size += read; + } + return buffer.subarray(0, size); +} + +function jsonOf(root, file, maximum) { + const fd = openConfined(root, file); + try { + const { size } = fstatSync(fd); + if (size > maximum) throw new Error('oversize'); + return JSON.parse(bytesOf(fd, 0, size).toString('utf8')); + } finally { + closeSync(fd); + } +} + +// Only whole lines count: a line still being written, or one cut by the window's start, is no record yet. +function linesOf(bytes, offset, { skipFirst }) { + const records = []; + let start = 0; + if (skipFirst) start = bytes.indexOf(10) + 1 || bytes.length; + for (let end = bytes.indexOf(10, start); end >= 0; end = bytes.indexOf(10, start)) { + const line = bytes.subarray(start, end).toString('utf8'); + if (line.trim()) { + const value = JSON.parse(line); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid'); + Object.defineProperty(value, '$offset', { value: offset + start }); + records.push(value); + } + start = end + 1; + } + return records; +} + +// A long session's log is read as its head (the header) and its tail (recent turns), so a poll costs a bounded read; +// `unread` is the byte span between them that was not read, null when nothing was skipped. The tail starts one byte +// early: a window that opens on a line start keeps that line. +function recordsOf(fd, size) { + const tailStart = Math.max(0, size - MAX_TAIL - 1); + const tail = linesOf(bytesOf(fd, tailStart, size - tailStart), tailStart, { skipFirst: tailStart > 0 }); + if (tailStart === 0) return { records: tail, unread: null }; + const firstTail = tail[0]?.$offset ?? size; + const headEnd = Math.min(MAX_HEAD, size); + const head = linesOf(bytesOf(fd, 0, headEnd), 0, { skipFirst: false }); + return { records: [...head.filter((record) => record.$offset < firstTail), ...tail], unread: headEnd < firstTail ? [headEnd, firstTail] : null }; +} + +function tasksOf(root, dir, named) { + const ids = [...new Set(named)]; + if (!dir || ids.length === 0) return []; + if (ids.length > MAX_TASKS || ids.some((id) => !ID.test(id))) return null; + const tasks = []; + for (const id of ids) { + try { + const task = jsonOf(root, join(dir, `${id}.json`), MAX_INDEX); + if (task?.taskId !== id) return null; + tasks.push(task); + } catch (error) { + if (error?.code !== 'ENOENT') return null; + } + } + return tasks; +} + +function sessionDirOf(declaration, home, nativeId) { + const listed = declaration.workspacesOf(jsonOf(home, join(home, declaration.workspaceIndex), MAX_INDEX)); + if (!Array.isArray(listed)) return null; + const found = listed.filter(([id]) => ID.test(id)).flatMap(([id, projectDir]) => { + const dir = join(home, declaration.sessionDir(id, nativeId)); + try { + confined(home, join(dir, declaration.logFile)); + return [{ dir, projectDir }]; + } catch { + return []; + } + }); + return found.length === 1 ? found[0] : null; +} + +/** + * A turn's status read from a host's own JSONL session log, for a host whose evidence is only there. The declaration + * names the log home (`root(env)`), its workspace index (`workspaceIndex`, listed as `[id, projectDir]` by + * `workspacesOf`), a session's folder in a workspace (`sessionDir(workspaceId, nativeId)`) and its log (`logFile`); + * `nativeSessionOf(sessionId, scope)` takes the plugin's session id back to the host's. `decode(records, { turnId, + * tasks, projectDir, unread })` answers the row, where `unread` is the byte span of a long log left unread; `taskIds(records)` and `tasksDir(sessionDir)` name the background task files + * it also reads. `scope` is a pseudonym of the log home, for session ids, so no path reaches a Hub task id. Anything + * unreadable answers null, never an empty valid turn. + */ +export function sessionLogReaderOf(declaration, env = process.env) { + if (!declaration) return undefined; + let home = resolve(declaration.root(env)); + try { + home = realpathSync(home); + } catch {} + const scopeOf = (path) => createHash('sha256').update(path).digest('hex'); + const scope = scopeOf(home); + let parsed = null; + const read = (sessionId, turnId) => { + if (typeof sessionId !== 'string' || typeof turnId !== 'string' || !turnId || turnId.length > 256) return null; + let fd; + try { + const root = realpathSync(declaration.root(env)); + if (scopeOf(root) !== scope) return null; + const nativeId = declaration.nativeSessionOf(sessionId, scope); + if (!ID.test(nativeId ?? '')) return null; + const session = sessionDirOf(declaration, root, nativeId); + if (!session) return null; + fd = openConfined(root, join(session.dir, declaration.logFile)); + const stat = fstatSync(fd); + const key = JSON.stringify([session.dir, stat.dev, stat.ino, stat.size, stat.mtimeMs]); + if (parsed?.key !== key) parsed = { key, ...recordsOf(fd, stat.size) }; + const { records, unread } = parsed; + const tasks = tasksOf(root, declaration.tasksDir?.(session.dir), declaration.taskIds?.(records) ?? []); + if (!tasks) return null; + return declaration.decode(records, { turnId, tasks, projectDir: session.projectDir, unread }) ?? null; + } catch { + return null; + } finally { + if (fd !== undefined) closeSync(fd); + } + }; + return Object.assign(read, { scope }); +} diff --git a/lib/runtime/turn-monitor.js b/lib/runtime/turn-monitor.js index 898410d..8410186 100644 --- a/lib/runtime/turn-monitor.js +++ b/lib/runtime/turn-monitor.js @@ -32,7 +32,10 @@ export async function monitorTurn(ports, at, close, { dir, now = Date.now, sleep if (reason) { const { projectDir, expectedTurn, ...turn } = at; // Once its owner is gone, the current worktree need not be the one the turn checked. - await close({ ...turn, ...(!ownerGone && { projectDir }), reason, ...((expectedTurn || ports.confirmTurnCompletion) && { expectedTurn: watched }) }); + await close({ + ...turn, ...(!ownerGone && { projectDir }), reason, ...(row.record && { answer: row.answer, recordOf: () => row.record }), + ...((expectedTurn || ports.confirmTurnCompletion) && { expectedTurn: watched }), + }); return; } // Some native stores write the first row only at turn completion. diff --git a/lib/session/state.js b/lib/session/state.js index d777496..f6953b9 100644 --- a/lib/session/state.js +++ b/lib/session/state.js @@ -8,7 +8,7 @@ import { unreportedAssets, withInjected, withReported } from '../injected-ledger export const EMPTY_SESSION = Object.freeze({ turn: 0, turnId: null, open: false, turnStart: null, prompt: '', isCommand: false, strategy: '', - labelable: [], injected: {}, pid: null, startedUnder: null, settling: null, turnIds: [], correctedTurn: 0, shown: [], + labelable: [], injected: {}, pid: null, startedUnder: null, settling: null, turnIds: [], shown: [], waiting: null, task: '', stuckQueries: [], turnHead: null, withheld: [], pendingControls: [], }); @@ -23,21 +23,6 @@ const SETTLING_STALE_MS = 30_000; export const sessionOf = (stored) => ({ ...EMPTY_SESSION, ...stored }); -export const awaitsNativeTerminal = ({ native, turn }) => native?.turn === turn - && Boolean(native.turnId && native.promptId && native.mark?.fileId) && !native.settled; - -export function observedNativeTurn(session, turn = session.turn) { - if (turn === session.turn) return session.native?.turn === turn ? session : null; - return session.deferredNativeTurns?.find((entry) => entry.turn === turn && entry.sessionEpoch === session.sessionEpoch) ?? null; -} - -function deferNativeTurn(session) { - if (!awaitsNativeTerminal(session)) return {}; - const { turn, turnId, sessionEpoch, pid, prompt, turnHead, isCommand, withheld, native } = session; - const deferred = { turn, turnId, sessionEpoch, pid, prompt, turnHead, isCommand, withheld, native, open: false }; - return { native: null, deferredNativeTurns: [...(session.deferredNativeTurns ?? []), deferred].slice(-MAX_TURN_IDS) }; -} - /** * The session's next turn. A turn nobody typed a prompt for (`prompt` null) carries on the last turn's task. A host * that numbers its own turns (`hostTurn`) keeps the count at least there, so a session resumed after its state was @@ -48,7 +33,6 @@ function deferNativeTurn(session) { export function openedTurn(session, { turnId = null, turnStart = null, hostTurn = null, prompt = null, task = '', pid = null, isCommand = false, now }) { return { ...session, - ...deferNativeTurn(session), sessionEpoch: session.turn === 0 ? now : session.sessionEpoch, turn: Math.max(session.turn + 1, Number.isInteger(hostTurn) ? hostTurn : 0), turnId, @@ -69,6 +53,17 @@ export function openedTurn(session, { turnId = null, turnStart = null, hostTurn }; } +/** + * The session with its open turn bound to `turnId`, for a host that names a turn only once it runs: the announced + * `prompt` must be the one the turn opened with, and the turn still unbound or bound to that id; undefined otherwise. + */ +export function boundTurn(session, { turnId, prompt }) { + if (!session.open || session.isCommand || !turnId) return undefined; + if (session.turnId) return session.turnId === turnId ? session : undefined; + if (!prompt || prompt !== session.prompt || session.turnIds.includes(turnId)) return undefined; + return { ...session, turnId, turnIds: [...session.turnIds, turnId].slice(-MAX_TURN_IDS) }; +} + /** The session with the git HEAD its open `turn` began on (`{ root, head }`); undefined once that turn is not open. */ export function withTurnHead(session, head, turn) { if (!(session.open && session.turn === turn) || !head) return undefined; @@ -111,10 +106,6 @@ export const isSettling = (session, now) => Number.isFinite(session.settling) && export const settledSession = (session) => ({ ...session, settling: null }); -export const correctedSession = (session, turn) => ({ ...session, correctedTurn: Math.max(session.correctedTurn, turn) }); - -export const isCorrected = (session, turn) => session.correctedTurn >= turn; - /** * The session with `assetIds` noted as shown to the agent before any turn booked them, so no later recall of the * session offers them again; undefined when `turn` is no longer the open turn. @@ -161,10 +152,8 @@ export function joinedWaiting(session, { turnId = null, now }) { return { next: { ...(booked.next ?? session), waiting: null }, text: booked.added ? waiting.match.text : '' }; } -function dueOf(session, sessionId, turn, includeDeferred = false) { - return unreportedAssets(session.injected, turn) - .map((asset) => ({ ...asset, sessionId, turn: session.injected[asset.assetId].turn })) - .filter((asset) => includeDeferred || asset.turn === session.turn || !observedNativeTurn(session, asset.turn)); +function dueOf(session, sessionId, turn) { + return unreportedAssets(session.injected, turn).map((asset) => ({ ...asset, sessionId, turn: session.injected[asset.assetId].turn })); } // An injection adopted from a host's standalone plugin belongs to a turn this plugin never saw end: no later turn's @@ -172,8 +161,7 @@ function dueOf(session, sessionId, turn, includeDeferred = false) { const isAdopted = (session, asset) => Boolean(session.injected[asset.assetId]?.adopted); /** The injections of turns up to `turn` that no report covered yet; nothing is marked, so they can be queued first. */ -export const unreportedOf = (session, sessionId, turn, { includeDeferred = false } = {}) => - dueOf(session, sessionId, turn, includeDeferred).filter((asset) => !isAdopted(session, asset)); +export const unreportedOf = (session, sessionId, turn) => dueOf(session, sessionId, turn).filter((asset) => !isAdopted(session, asset)); /** Retains the verified payload for a failed queue write so another turn can retry it with its own evidence. */ export function withPendingReports(session, assets, queued) { @@ -220,12 +208,5 @@ export const isEarlierTurn = (session, turnId) => Boolean(turnId) && turnId !== /** What an ended session keeps: its turn counter, owner and ledger, so a resume neither repeats a task id nor re-injects. */ export function endedSession(session) { - return { - ...EMPTY_SESSION, turn: session.turn, sessionEpoch: session.sessionEpoch, pid: session.pid, injected: session.injected, - turnIds: session.turnIds, shown: session.shown, task: session.task, pendingControls: session.pendingControls, - ...((session.native || session.deferredNativeTurns?.length) && { correctedTurn: session.correctedTurn }), - ...(session.native && { native: { ...session.native, prompt: session.prompt || session.native.prompt || '', sessionEnded: true } }), - ...(session.nativeCursor && { nativeCursor: session.nativeCursor }), - ...(session.deferredNativeTurns?.length && { deferredNativeTurns: session.deferredNativeTurns }), - }; + return { ...EMPTY_SESSION, turn: session.turn, sessionEpoch: session.sessionEpoch, pid: session.pid, injected: session.injected, turnIds: session.turnIds, shown: session.shown, task: session.task, pendingControls: session.pendingControls }; } diff --git a/test/agent/kimi-late-terminal.test.js b/test/agent/kimi-late-terminal.test.js deleted file mode 100644 index 6dbc287..0000000 --- a/test/agent/kimi-late-terminal.test.js +++ /dev/null @@ -1,289 +0,0 @@ -import assert from 'node:assert/strict'; -import { appendFileSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; - -import profile from '../../hosts/kimi/profile.js'; -import { createDriver } from '../../lib/agent/driver.js'; -import { reconcileNativeTurn, recoverNativeTurns } from '../../lib/app/native-turn.js'; -import { taskSessionId } from '../../lib/app/turn-reports.js'; -import { turnTaskId } from '../../lib/reuse.js'; -import { nativeFactsOf } from '../../lib/runtime/native-facts.js'; -import { monitorNativeTurn } from '../../lib/runtime/native-monitor.js'; -import { ASSET, memoryPorts, PROMPT } from '../app/memory-ports.js'; - -const route = { alias: 'selected', providerId: 'fixture-account', model: 'fixture-model', protocol: 'openai', - endpoint: 'https://example.test/v1', effort: 'off', binding: 'fixture', configRevision: 'fixture' }; -const nextRoute = { ...route, providerId: 'fixture-account-next', binding: 'fixture-next' }; -const nextPrompt = 'Now update the documentation.'; -const terminal = { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }; -const nativePrompt = (turnId) => ({ type: 'turn.prompt', agentId: 'main', turnId, promptId: 'prompt-' + turnId, origin: { kind: 'user' } }); -const observedPrompt = (turnId, time, prompt = PROMPT) => ({ ...nativePrompt(turnId), time, input: [ - { type: 'text', text: 'Injected strategy', meta: { source: 'user prompt submit hook' } }, - { type: 'text', text: prompt }, -] }); -const loop = (event) => ({ type: 'context.append_loop_event', agentId: 'main', event }); -const wireHeader = [{ type: 'metadata', protocol_version: '1.5' }, { type: 'profile.bind', agentId: 'main', modelAlias: 'selected', thinkingEffort: 'off' }]; - -function fixture(t, { wireReady = true } = {}) { - const home = mkdtempSync(join(tmpdir(), 'evolver-kimi-late-terminal-')); - t.after(() => rmSync(home, { recursive: true, force: true })); - const project = join(home, 'project'); - const agent = join(home, 'sessions/workspace/session/agents/main'); - mkdirSync(project); - mkdirSync(agent, { recursive: true }); - writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: project } } })); - const wire = join(agent, 'wire.jsonl'); - const add = (...records) => appendFileSync(wire, records.map(record => JSON.stringify(record)).join('\n') + '\n'); - if (wireReady) add(...wireHeader); - const made = memoryPorts(); - const env = { KIMI_CODE_HOME: home }; - made.ports.model = { capture: () => route }; - made.ports.native = nativeFactsOf(profile.native, env); - const driver = createDriver({ profile, ports: made.ports, env, lifetime: 'ephemeral', transcripts: { size: () => 0, readSince: () => '' } }); - const payload = { session_id: 'session', cwd: project, client_type: 'kimi_code_cli' }; - const id = profile.payload.sessionId(payload, { env, nativeScope: made.ports.native.scope }); - const call = (event, extra = {}) => driver.handle(event, { ...payload, hook_event_name: event, ...extra }, { onError: error => assert.fail(error) }); - const prompt = (text) => call('UserPromptSubmit', { prompt: [{ type: 'text', text }] }); - return { ...made, add, call, home, id, project, prompt, wire }; -} - -function addVerifiedWork(f, turnId) { - const edit = 'edit-' + turnId, check = 'check-' + turnId, final = 'final-' + turnId; - f.add( - loop({ type: 'tool.call', uuid: edit, turnId, toolCallId: edit, name: 'Edit', args: { path: 'a.js' } }), - loop({ type: 'tool.result', parentUuid: edit, toolCallId: edit, result: { output: '', durationMs: 2 } }), - loop({ type: 'tool.call', uuid: check, turnId, toolCallId: check, name: 'Bash', args: { command: 'npm test' } }), - loop({ type: 'tool.result', parentUuid: check, toolCallId: check, result: { output: '# pass 1\n# fail 0', durationMs: 2 } }), - { type: 'llm.request', agentId: 'main', modelAlias: 'selected', thinkingEffort: 'off', model: 'fixture-model', provider: 'openai' }, - loop({ type: 'content.part', uuid: 'answer-' + turnId, turnId, stepUuid: final, part: { type: 'text', text: 'Fixed retries.' } }), - loop({ type: 'step.end', uuid: final, turnId, finishReason: 'end_turn', providerFinishReason: 'completed' }), - ); -} - -async function firstTurn(f) { - assert.match((await f.prompt(PROMPT)).message, /Evolution Memory/); - f.add(nativePrompt(0)); - await f.call('TurnStarted', { turn_id: 0, origin_kind: 'user' }); - addVerifiedWork(f, 0); - await f.call('Stop', { turn_id: 0 }); - assert.equal(f.sessions.get(f.id).open, true); - assert.equal(f.queued.length, 0); - return f.sessions.get(f.id).sessionEpoch; -} - -test('a native user TurnStarted recovers a verified turn when its wire was missing at prompt submit', async t => { - const f = fixture(t, { wireReady: false }); - f.ports.model.capture = hint => hint ? route : null; - assert.match((await f.prompt(PROMPT)).message, /Evolution Memory/); - f.add(...wireHeader, observedPrompt(0, f.ports.clock.now() + 1)); - f.ports.model.capture = () => assert.fail('must not bind later credentials to the original prompt'); - await f.call('TurnStarted', { turn_id: 0, origin_kind: 'user', prompt: PROMPT }); - addVerifiedWork(f, 0); - f.add(terminal); - await f.call('Notification', { turn_id: 0 }); - await recoverNativeTurns(f.ports); - assert.deepEqual(f.queued.map(({ report }) => [report.outcome, report.evidence.check?.passed]), [['success', true]]); - const candidate = (await f.ports.candidates.read())[f.id]; - assert.deepEqual(candidate.turns.map(({ turn }) => turn), [1]); - assert.ok(!candidate.model?.binding, 'a verified turn cannot invent a model binding'); -}); - -test('a missing wire mark cannot admit stale or unrelated user starts', async t => { - const f = fixture(t, { wireReady: false }); - const oldTime = f.ports.clock.now(), prompt = PROMPT + ' ' + 'x'.repeat(2000); - f.advance(1); - await f.prompt(prompt + ' expected'); - const native = () => f.sessions.get(f.id).native; - const started = (turn_id, text, extra = {}) => f.call('TurnStarted', { turn_id, origin_kind: 'user', prompt: text, ...extra }); - f.add(...wireHeader, observedPrompt(0, oldTime, prompt + ' expected')); - await started(0, prompt + ' expected'); - assert.equal(native().turnId, null, 'old same-text turn'); - f.add(observedPrompt(1, f.ports.clock.now() + 1, prompt + ' different')); - await started(1, prompt + ' different'); - assert.equal(native().turnId, null, 'a different suffix past the stored prompt limit'); - f.add(observedPrompt(2, f.ports.clock.now() + 2, prompt + ' expected')); - await f.call('Notification', { turn_id: 2, origin_kind: 'user', prompt: prompt + ' expected' }); - await started(2, prompt + ' expected', { prompt_id: 'different-id' }); - const owner = f.ports.owner.current; - f.ports.owner.current = () => 17; - await started(2, prompt + ' expected'); - assert.equal(native().turnId, null, 'an observer, a mismatched id or another owner'); - f.ports.owner.current = owner; - await started(2, prompt + ' expected'); - assert.equal(native().turnId, '2'); -}); - -test('a partial initial wire keeps the submit-time model and refuses a replaced file', async t => { - const f = fixture(t); - appendFileSync(f.wire, '{"type":"padding'); - f.ports.model.capture = hint => hint?.alias === route.alias ? route : null; - await f.prompt(PROMPT); - f.ports.model.capture = () => assert.fail('cannot recapture a route after submit'); - const start = () => f.call('TurnStarted', { turn_id: 0, origin_kind: 'user', prompt: PROMPT }); - const prompt = observedPrompt(0, f.ports.clock.now() + 1); - renameSync(f.wire, f.wire + '.old'); - f.add(...wireHeader, prompt); - await start(); - assert.equal(f.sessions.get(f.id).native.turnId, null); - renameSync(f.wire, f.wire + '.foreign'); - renameSync(f.wire + '.old', f.wire); - appendFileSync(f.wire, '"}\n'); - f.add(prompt); - await start(); - addVerifiedWork(f, 0); - f.add(terminal); - await f.call('Notification', { turn_id: 0 }); - assert.deepEqual((await f.ports.candidates.read())[f.id].model, route); -}); - -for (const timing of ['before the next prompt', 'while the next prompt is captured', 'after the next prompt opens', 'after the next native turn starts']) { - test('a verified Kimi turn reports and keeps work when its terminal arrives ' + timing, async t => { - const f = fixture(t); - const epoch = await firstTurn(f); - if (timing.startsWith('before')) f.add(terminal); - f.ports.model.capture = () => nextRoute; - if (timing.startsWith('while')) { - const capture = f.ports.model.capture; - f.ports.model.capture = (...args) => { - f.ports.model.capture = capture; - f.add(terminal); - return capture(...args); - }; - } - await f.prompt(nextPrompt); - if (timing === 'after the next prompt opens') f.add(terminal); - f.add(nativePrompt(1)); - await f.call('TurnStarted', { turn_id: 1, origin_kind: 'user' }); - if (timing === 'after the next native turn starts') { - const note = { kind: 'edit', turnId: '1', paths: [join(f.project, 'docs.md')] }; - await f.ports.notes.append(f.id, note); - f.add(terminal); - const at = { sessionId: f.id, projectDir: f.project, turn: 1, sessionEpoch: epoch }; - await monitorNativeTurn(f.ports, at, observed => reconcileNativeTurn(f.ports, observed), - () => assert.fail('the earlier monitor must leave the next turn open'), - { dir: join(f.home, 'monitor'), sleep: () => assert.fail('the verified terminal is already present') }); - assert.equal(f.queued[0]?.report.outcome, 'success', 'the earlier worker settles without another hook'); - assert.deepEqual(await f.ports.notes.read(f.id), [note]); - } - await f.call('Notification', { turn_id: 0 }); - // Recovery and a duplicate notification must leave just one report and one ready work turn. - await recoverNativeTurns(f.ports); - await f.call('Notification', { turn_id: 0 }); - - assert.deepEqual(f.queued.map(({ report }) => [report.task_id, report.asset_id, report.outcome, report.evidence.check?.passed]), - [[turnTaskId(taskSessionId(f.ports.identity, f.id), 1), ASSET.asset_id, 'success', true]]); - const candidate = (await f.ports.candidates.read())[f.id]; - assert.deepEqual(candidate.model, route); - assert.deepEqual(candidate.turns.map(({ turn, sessionEpoch, finalizationPending }) => [turn, sessionEpoch, finalizationPending]), [[1, epoch, undefined]]); - assert.ok(candidate.turns[0].steps.some(step => step.kind === 'check' && step.command === 'npm test' && step.failed === false)); - const { turn, turnId, open, prompt, sessionEpoch, native } = f.sessions.get(f.id); - assert.deepEqual(native.model, nextRoute); - assert.deepEqual({ turn, turnId, open, prompt, sessionEpoch }, { turn: 2, turnId: '1', open: true, prompt: nextPrompt, sessionEpoch: epoch }); - }); -} - -test('a late Kimi terminal cannot restore a corrected turn as a candidate', async t => { - const f = fixture(t); - const epoch = await firstTurn(f); - await f.prompt('still failing: ' + PROMPT); - assert.equal(await f.ports.draftCorrections.has(f.id, 1, epoch), true); - f.add(nativePrompt(1), terminal); - await f.call('TurnStarted', { turn_id: 1, origin_kind: 'user' }); - await f.call('Notification', { turn_id: 0 }); - await recoverNativeTurns(f.ports); - assert.deepEqual(await f.ports.candidates.read(), {}); - assert.equal(f.sessions.get(f.id).turnId, '1'); - assert.equal(f.sessions.get(f.id).open, true); -}); - -test('late work joins a range without replacing the already settled newer route', async t => { - const f = fixture(t); - await firstTurn(f); - f.ports.model.capture = () => nextRoute; - await f.prompt(nextPrompt); - f.add(nativePrompt(1)); - await f.call('TurnStarted', { turn_id: 1, origin_kind: 'user' }); - addVerifiedWork(f, 1); - f.add({ ...terminal, turnId: 1 }); - await f.call('Notification', { turn_id: 1 }); - assert.deepEqual((await f.ports.candidates.read())[f.id].turns.map(turn => turn.turn), [2]); - - f.add(terminal); - await recoverNativeTurns(f.ports); - const candidate = (await f.ports.candidates.read())[f.id]; - assert.deepEqual(candidate.model, nextRoute); - assert.deepEqual(candidate.turns.map(({ turn, modelBinding }) => [turn, modelBinding]), [[1, route.binding], [2, nextRoute.binding]]); - assert.equal(candidate.routeConflict, 'route_changed'); - assert.deepEqual(f.queued.map(({ report }) => [report.task_id, report.outcome, report.evidence.check?.passed]), - [[turnTaskId(taskSessionId(f.ports.identity, f.id), 1), 'success', true]]); -}); - -test('recovery retains older work when its finalizer overlaps a completed newer turn', async t => { - const f = fixture(t); - await firstTurn(f); - const entered = Promise.withResolvers(), release = Promise.withResolvers(); - const enqueue = f.ports.reports.enqueue; - const oldTask = turnTaskId(taskSessionId(f.ports.identity, f.id), 1); - let held = false; - f.ports.reports.enqueue = async (entries) => { - if (!held && entries.some(({ report }) => report.task_id === oldTask)) { - held = true; - entered.resolve(); - await release.promise; - } - return enqueue(entries); - }; - - f.add(terminal); - const finishing = f.call('Notification', { turn_id: 0 }); - try { - await Promise.race([entered.promise, finishing.then(() => assert.fail('older turn never reached report enqueue'))]); - f.ports.model.capture = () => nextRoute; - await f.prompt(nextPrompt); - f.add(nativePrompt(1)); - await f.call('TurnStarted', { turn_id: 1, origin_kind: 'user' }); - addVerifiedWork(f, 1); - f.add({ ...terminal, turnId: 1 }); - await f.call('Notification', { turn_id: 1 }); - assert.deepEqual((await f.ports.candidates.read())[f.id].turns.map(turn => turn.turn), [2]); - } finally { - release.resolve(); - await finishing; - } - - await recoverNativeTurns(f.ports); - const candidate = (await f.ports.candidates.read())[f.id]; - assert.deepEqual(candidate.model, nextRoute); - assert.deepEqual(candidate.turns.map(({ turn, modelBinding, finalizationPending }) => [turn, modelBinding, finalizationPending]), - [[1, route.binding, undefined], [2, nextRoute.binding, undefined]]); - assert.deepEqual(f.queued.map(({ report }) => [report.task_id, report.outcome, report.evidence.check?.passed]), [[oldTask, 'success', true]]); -}); - -test('a snapshot finishing after a newer prompt cannot attach the newer tree to the older check', async t => { - const f = fixture(t); - await firstTurn(f); - const entered = Promise.withResolvers(), release = Promise.withResolvers(); - f.ports.project.snapshot = async () => { - entered.resolve(); - await release.promise; - return { root: f.project, head: 'b'.repeat(40), diff: 'newer worktree contents' }; - }; - f.add(terminal); - const finishing = f.call('Notification', { turn_id: 0 }); - try { - await Promise.race([entered.promise, finishing.then(() => assert.fail('the older worker did not start a snapshot'))]); - await f.prompt(nextPrompt); - } finally { - release.resolve(); - await finishing; - } - - await recoverNativeTurns(f.ports); - const candidate = (await f.ports.candidates.read())[f.id]; - assert.deepEqual(candidate.turns.map(({ turn }) => turn), [1]); - assert.equal(candidate.projectDir, f.project); - assert.equal(candidate.project, null); - assert.deepEqual(f.queued.map(({ report }) => [report.outcome, report.evidence.check?.passed]), [['success', true]]); -}); diff --git a/test/agent/native-driver.test.js b/test/agent/native-driver.test.js deleted file mode 100644 index bf6329f..0000000 --- a/test/agent/native-driver.test.js +++ /dev/null @@ -1,120 +0,0 @@ -import assert from 'node:assert/strict'; -import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; - -import profile from '../../hosts/kimi/profile.js'; -import { createDriver } from '../../lib/agent/driver.js'; -import { nativeFactsOf } from '../../lib/runtime/native-facts.js'; -import { memoryPorts, PROMPT } from '../app/memory-ports.js'; - -const route = { alias: 'selected', providerId: 'account-b', model: 'upstream-model', protocol: 'openai', endpoint: 'https://example.test/v1', effort: 'off', binding: 'key-binding', configRevision: 'revision' }; - -function fixture(t) { - const home = mkdtempSync(join(tmpdir(), 'evolver-native-driver-')); - t.after(() => rmSync(home, { recursive: true, force: true })); - const project = join(home, 'project'); mkdirSync(project); - writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: project } } })); - const agent = join(home, 'sessions/workspace/session/agents/main'); mkdirSync(agent, { recursive: true }); - const wire = join(agent, 'wire.jsonl'); - const add = (...records) => appendFileSync(wire, records.map(record => JSON.stringify(record)).join('\n') + '\n'); - add({ type: 'metadata', protocol_version: '1.5' }, { type: 'profile.bind', agentId: 'main', modelAlias: 'selected', thinkingEffort: 'off' }); - const made = memoryPorts(), env = { KIMI_CODE_HOME: home }; - const captured = []; - made.ports.model = { capture: hint => { captured.push(hint); return { ...route }; } }; - made.ports.native = nativeFactsOf(profile.native, env); - const driver = createDriver({ profile, ports: made.ports, env, lifetime: 'ephemeral', transcripts: { size: () => 0, readSince: () => '' } }); - const payload = { session_id: 'session', cwd: project, client_type: 'kimi_code_cli' }; - const call = (event, extra = {}) => driver.handle(event, { ...payload, hook_event_name: event, ...extra }); - const id = profile.payload.sessionId(payload, { env, nativeScope: made.ports.native.scope }); - const loop = event => ({ type: 'context.append_loop_event', agentId: 'main', event }); - return { ...made, env, id, call, add, loop, project, agent, captured }; -} - -test('native driver captures the route and waits for both the terminal and an earlier background check', async t => { - const f = fixture(t); - const reply = await f.call('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); - assert.match(reply.message, /Evolution Memory/); - assert.deepEqual(f.captured[0], { alias: 'selected', effort: 'off' }); - assert.deepEqual(f.sessions.get(f.id).native.model, route); - const job = f.scheduled.find(task => task.startsWith('native:')); - assert.equal(JSON.parse(job.slice('native:'.length)).sessionEpoch, f.sessions.get(f.id).sessionEpoch); - f.add({ type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'prompt-a', origin: { kind: 'user' } }); - await f.call('TurnStarted', { turn_id: 0, origin_kind: 'user' }); - assert.equal(f.sessions.get(f.id).turnId, '0'); - await f.call('PreToolUse', { tool_name: 'Edit', tool_call_id: 'edit', tool_input: { path: 'a.js' } }); - f.add( - f.loop({ type: 'tool.call', uuid: 'edit-call', turnId: 0, toolCallId: 'edit', name: 'Edit', args: { path: 'a.js' } }), - f.loop({ type: 'tool.result', parentUuid: 'edit-call', toolCallId: 'edit', result: { output: '', durationMs: 2 } }), - f.loop({ type: 'tool.call', uuid: 'lint-call', turnId: 0, toolCallId: 'lint', name: 'Bash', args: { command: 'npm run lint', run_in_background: true } }), - f.loop({ type: 'tool.result', parentUuid: 'lint-call', toolCallId: 'lint', result: { output: 'task_id: lint-task\nstatus: running', durationMs: 2 } }), - f.loop({ type: 'tool.call', uuid: 'check-call', turnId: 0, toolCallId: 'check', name: 'Bash', args: { command: 'npm test' } }), - f.loop({ type: 'tool.result', parentUuid: 'check-call', toolCallId: 'check', result: { output: '# pass 1\n# fail 0', durationMs: 2 } }), - { type: 'llm.request', agentId: 'main', modelAlias: 'selected', thinkingEffort: 'off', model: 'upstream-model', provider: 'openai' }, - f.loop({ type: 'content.part', uuid: 'answer', turnId: 0, stepUuid: 'final', part: { type: 'text', text: 'Fixed retries.' } }), - f.loop({ type: 'step.end', uuid: 'final', turnId: 0, finishReason: 'end_turn', providerFinishReason: 'completed' }), - ); - assert.deepEqual(await f.call('Stop'), {}); - assert.equal(f.sessions.get(f.id).open, true); - assert.equal(f.queued.length, 0); - f.add({ type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }); - await f.call('Notification'); - assert.equal(f.sessions.get(f.id).open, false); - assert.equal(f.sessions.get(f.id).native.pending, true); - assert.deepEqual(await f.ports.candidates.read(), {}); - - const tasks = join(f.agent, 'tasks'); - mkdirSync(tasks); - writeFileSync(join(tasks, 'lint-task.json'), JSON.stringify({ taskId: 'lint-task', kind: 'process', parentToolCallId: 'lint', command: 'npm run lint', status: 'completed', exitCode: 0 })); - await f.call('Notification'); - assert.equal(f.sessions.get(f.id).native.pending, false); - const candidate = (await f.ports.candidates.read())[f.id]; - assert.deepEqual(candidate.model, route); - assert.deepEqual(candidate.turns.map(({ turn, sessionEpoch }) => [turn, sessionEpoch]), [[1, f.sessions.get(f.id).sessionEpoch]]); - assert.deepEqual(candidate.turns[0].steps.filter(({ kind }) => kind === 'check').map(({ command, exit, failed }) => ({ command, exit, failed })), - [{ command: 'npm run lint', exit: 0, failed: false }, { command: 'npm test', exit: null, failed: false }]); - assert.ok(f.retained[0].editedBefore.has(join(f.project, 'a.js'))); - assert.equal(f.queued.length, 1); - assert.equal(f.queued[0].report.task_id.includes(f.env.KIMI_CODE_HOME), false, 'native profile paths stay out of Hub task identifiers'); - await f.call('Stop'); - assert.equal(f.queued.length, 1); - await f.call('UserPromptSubmit', { prompt: [{ type: 'text', text: 'Now update docs.' }] }); - await f.call('Stop', { turn_id: 0 }); - assert.equal(f.sessions.get(f.id).open, true); - assert.equal(f.sessions.get(f.id).turn, 2); -}); - -test('recall that outlives its session does not attach an old model binding to a recreated turn', async t => { - const f = fixture(t); - const requested = Promise.withResolvers(), resume = Promise.withResolvers(); - const request = f.ports.proxy.request; - f.ports.proxy.request = async (...args) => { - if (args[1] === '/recipe/search') { - requested.resolve(); - await resume.promise; - } - return request(...args); - }; - const prompt = f.call('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); - await requested.promise; - try { - await f.ports.sessions.update(f.id, stored => ({ ...stored, sessionEpoch: stored.sessionEpoch + 1 })); - } finally { resume.resolve(); } - await prompt; - assert.equal(f.sessions.get(f.id).native, undefined); - assert.equal(f.scheduled.some(task => task.startsWith('native:')), false); -}); - -test('an explicit native session start leaves owner discovery to the first prompt', async t => { - const f = fixture(t); - const current = f.ports.owner.current; - let probes = 0; - f.ports.owner.current = () => { probes++; return current(); }; - assert.deepEqual(await f.call('SessionStart'), {}); - assert.equal(probes, 0); - assert.ok(f.scheduled.includes('drain')); - await f.call('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); - assert.ok(probes > 0); - assert.equal(f.sessions.get(f.id).pid, current()); -}); diff --git a/test/app/distill-session.test.js b/test/app/distill-session.test.js index dd3781f..d3e99c6 100644 --- a/test/app/distill-session.test.js +++ b/test/app/distill-session.test.js @@ -108,6 +108,25 @@ test('a distillation names only the assets injected into the turns it read', asy assert.deepEqual(body.reused_asset_ids, [ASSET.asset_id]); }); +test('a fitted distillation names only assets from the turns the model still reads', async () => { + const lastAsset = { ...ASSET, asset_id: 'sha256:' + '1'.repeat(64), short_title: 'Weather lookups', signals_match: ['weather'] }; + const made = memoryPorts({ assets: [ASSET, lastAsset], distillAnswer: DRAFTED, model: modelAnswering(IR) }); + const steps = [ + ...Array.from({ length: 56 }, (_, index) => ['run', `fixture-command-${index} ${'x'.repeat(940)}`]), + ['pass', 'npm test'], + ]; + for (let turn = 1; turn <= 8; turn++) { + await turnOf(made.ports, 's1', { steps, ...(turn === 8 && { prompt: 'what is the weather like' }) }); + } + await endedAndDistilled(made); + const [{ body }] = distills(made.requests); + const turns = body.record.filter(({ kind }) => kind === 'turn').map(({ text }) => text); + assert.ok(!turns.includes('Turn 1'), 'the oldest work was cut to meet the Proxy body limit'); + assert.ok(turns.includes('Turn 8')); + assert.deepEqual(body.reused_asset_ids, [lastAsset.asset_id]); + assert.ok(bytesOf(body) <= 256 * 1024); +}); + test('a session ledger that cannot be read leaves out the reused assets, never the distillation', async () => { const made = await fixedSession({ model: modelAnswering(IR) }); await sessionEnded(made.ports, { sessionId: 's1', projectDir: '/proj' }); @@ -495,8 +514,7 @@ test('a correction crossing a draft write takes back only that draft: earlier dr let replacement; if (replace) await update((drafts) => drafts.map((draft) => { if (draft.id !== DRAFTED.distill_id) return draft; - const [source] = draftSourcesOf(draft); - replacement = { ...draft, title: 'Concurrent method', sourceRange: { ...draft.sourceRange, turns: [{ ...source, turn: source.turn + 1 }] } }; + replacement = { ...draft, title: 'Concurrent method', sourceCandidate: { ...draft.sourceCandidate, turn: draft.sourceCandidate.turn + 1 } }; return replacement; })); await correct(ports); @@ -952,7 +970,7 @@ test('a command run can be stopped: its signal reaches the evolver CLI', async ( test('a correction marked while the model answers stops the distillation before its request is sent', async () => { let ports; const made = await fixedSession({ model: modelAnswering(IR, async () => { - const [{ turn, sessionEpoch }] = (await ports.candidates.read()).s1.turns; + const { turns: [{ turn }], sessionEpoch } = (await ports.candidates.read()).s1; await ports.draftCorrections.mark('s1', turn, sessionEpoch); }) }); ports = made.ports; @@ -970,3 +988,40 @@ test('an agent is neither asked for nor can post a Gene for work below the expli assert.equal((await postAgentGene(ports, { sessionId: 's1', text: JSON.stringify(IR), writtenFor })).status, 'dropped'); assert.deepEqual(distills(requests), []); }); + +test('a distillation that throws gives its claim back, so the same live process can try the range again', async () => { + const made = await fixedSession({ model: modelAnswering(IR) }); + const request = made.ports.proxy.request; + made.ports.proxy.request = async (...args) => { + if (args[1] === '/conversation/distill') throw new Error('the Proxy connection reset'); + return request(...args); + }; + await assert.rejects(endedAndDistilled(made), /connection reset/); + assert.equal((await made.ports.candidates.read()).s1.claimedBy, null); + made.ports.proxy.request = request; + const [outcome] = await distillDue(made.ports); + assert.equal(outcome.status, 'drafted'); +}); + +test('a model failure is logged without the credential its message carried', async () => { + const made = await fixedSession({ model: { complete: async () => { throw new Error('401 for Authorization: Bearer sk-fixture-secret-0123456789abcdef'); } } }); + const [outcome] = await endedAndDistilled(made); + assert.equal(outcome.status, 'model_failed'); + assert.equal(JSON.stringify([outcome, await made.ports.outcomes.read()]).includes('sk-fixture-secret-0123456789abcdef'), false); +}); + +test('a shutdown while the Proxy answers stops the distillation and leaves the range for the next start', async () => { + const made = await fixedSession({ model: modelAnswering(IR) }); + const stopping = new AbortController(); + made.ports.scheduler = { ...made.ports.scheduler, signal: stopping.signal }; + made.ports.proxy.request = async (method, path, body, { signal } = {}) => { + if (path !== '/conversation/distill') return { ok: true, data: { recorded: true } }; + stopping.abort(); + signal.throwIfAborted(); + return { ok: true, data: DRAFTED }; + }; + const [outcome] = await endedAndDistilled(made); + assert.equal(outcome.status, 'stopped'); + const range = (await made.ports.candidates.read()).s1; + assert.deepEqual([range.claimedBy, range.turns.length], [null, 1]); +}); diff --git a/test/app/kimi-session-range-trial.test.js b/test/app/kimi-session-range-trial.test.js deleted file mode 100644 index 50983df..0000000 --- a/test/app/kimi-session-range-trial.test.js +++ /dev/null @@ -1,263 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { runCommand } from '../../lib/app/commands/index.js'; -import { distillDue, distillSession, dropExpiredLeftovers } from '../../lib/app/distill-session.js'; -import { reconcileNativeTurn } from '../../lib/app/native-turn.js'; -import { promptSubmitted } from '../../lib/app/prompt.js'; -import { turnEnded } from '../../lib/app/turn-end.js'; -import { rangeOf, withCorrection, withoutWorkTurn, withWorkTurn } from '../../lib/distill/range.js'; -import { ModelUnavailable } from '../../lib/runtime/native-config.js'; -import { memoryPorts, PROMPT } from './memory-ports.js'; -import { changeTurn, DRAFTED, IR } from './work-turns.js'; - -const sessionId = 'session'; -const route = { alias: 'route-a', providerId: 'account-a', model: 'model-a', protocol: 'openai', effort: 'off', binding: 'binding-a' }; -const posts = ({ requests }) => requests.filter(({ path }) => path === '/conversation/distill'); - -test('Kimi cannot distill while an earlier background check is unresolved', async () => { - let calls = 0; - const made = memoryPorts({ distillAnswer: DRAFTED, model: { complete: async () => { calls++; return { answer: JSON.stringify(IR) }; } } }); - const { ports } = made; - const projectDir = '/workspace'; - const payload = { session_id: 'native-session', cwd: projectDir }; - for (const turn of [1, 2, 3]) await changeTurn(ports, sessionId, turn, { model: route, projectDir }); - assert.equal(rangeOf(await ports.candidates.read(), sessionId).turns.length, 3); - await promptSubmitted(ports, { sessionId, prompt: PROMPT, projectDir, waitMs: 500 }); - await ports.sessions.update(sessionId, (stored) => ({ ...stored, native: { - turn: stored.turn, payload, mark: { offset: 100, fileId: 'wire' }, turnId: null, model: route, - } })); - const record = { commands: ['npm run lint', 'npm test'], checks: [ - { command: 'npm run lint', failed: null }, { command: 'npm test', failed: false }, - ], paths: ['/workspace/app.js'], edits: ['/workspace/app.js'], answer: 'Implemented and tested.' }; - const facts = { status: 'ok', schemaSupported: true, schemaVersion: '1.5', mark: { offset: 500, fileId: 'wire' }, - bind: { turnId: '0', promptId: 'prompt', origin: 'user', mark: 120 }, - terminal: { turnId: '0', reason: 'completed', answer: record.answer, modelHint: route }, record, pendingVerification: true }; - ports.native = { read: async () => facts }; - const observed = await reconcileNativeTurn(ports, { sessionId, projectDir, payload }); - assert.equal(observed.waiting, true); - const outcome = await distillSession(ports, { sessionId }); - assert.deepEqual({ calls, posted: posts(made).length }, { calls: 0, posted: 0 }, outcome.status); - await ports.candidates.update((pending) => ({ ...pending, [sessionId]: { ...pending[sessionId], closed: true } })); - await distillDue(ports); - assert.deepEqual({ calls, posted: posts(made).length }, { calls: 0, posted: 0 }); -}); - -test('a stale finalizer cannot close a Kimi turn in a newer epoch with a reused native id', async () => { - const { ports } = memoryPorts(); - await promptSubmitted(ports, { sessionId, prompt: PROMPT, turnId: '0', waitMs: 500 }); - const current = await ports.sessions.update(sessionId, (stored) => ({ ...stored, sessionEpoch: 'new-owner' })); - await turnEnded(ports, { sessionId, turnId: '0', reason: 'answer', finalizationPending: true, - expected: { turn: current.turn, pid: current.pid, sessionEpoch: 'old-owner' }, recordOf: () => ({ - commands: ['npm test'], paths: ['app.js'], edits: ['app.js'], checks: [{ command: 'npm test', failed: false }], answer: 'Old result.', - }), - }); - assert.equal((await ports.sessions.read(sessionId)).open, true); - assert.deepEqual(await ports.candidates.read(), {}); -}); - -test('an unavailable Kimi model route parks the range without consuming retry attempts', async () => { - const { ports, advance } = memoryPorts({ model: { capture: async () => route, complete: async () => { throw new ModelUnavailable('route_changed'); } } }); - for (const turn of [1, 2, 3]) await changeTurn(ports, sessionId, turn, { model: route }); - await ports.candidates.update((pending) => ({ ...pending, [sessionId]: { ...pending[sessionId], closed: true } })); - const outcomes = []; - for (let run = 0; run < 3; run++) { - outcomes.push(...await distillDue(ports)); - advance(60 * 60 * 1000); - } - const range = rangeOf(await ports.candidates.read(), sessionId); - assert.deepEqual({ statuses: outcomes.map(({ status }) => status), attempts: range?.attempts }, { statuses: ['blocked'], attempts: 0 }); -}); - -test('an imported Kimi range keeps its blocked-route TTL after its original candidate ages out', async () => { - const day = 24 * 60 * 60 * 1000; - const now = 10 * day; - const { ports } = memoryPorts({ now }); - await ports.candidates.update(() => ({ [sessionId]: { at: now - 2 * day, blocked: { at: now - day / 2, reason: 'route_changed' }, - closed: true, model: route, turn: 1, prompt: PROMPT, checks: [{ command: 'npm test', failed: false }], paths: ['app.js'], - } })); - await dropExpiredLeftovers(ports); - assert.equal(rangeOf(await ports.candidates.read(), sessionId)?.blocked?.reason, 'route_changed'); -}); - -test('a correction during Kimi completion invalidates the IR before the Proxy receives it', async () => { - const made = memoryPorts({ distillAnswer: DRAFTED, model: { complete: async () => { - await made.ports.candidates.update((pending) => withCorrection(pending, sessionId, 1, 'That was wrong.', made.ports.clock.now())); - return { answer: JSON.stringify(IR) }; - } } }); - await changeTurn(made.ports, sessionId, 1, { model: route }); - const outcome = await distillSession(made.ports, { sessionId }); - assert.deepEqual({ posted: posts(made).length, drafts: (await made.ports.drafts.read()).length }, { posted: 0, drafts: 0 }, outcome.status); -}); - -test('removing a rejudged work turn invalidates the issued range revision', async () => { - const { ports } = memoryPorts(); - await changeTurn(ports, sessionId, 1, { model: route }); - await changeTurn(ports, sessionId, 2, { model: route }); - const before = rangeOf(await ports.candidates.read(), sessionId); - await ports.candidates.update((pending) => withoutWorkTurn(pending, sessionId, 2)); - const after = rangeOf(await ports.candidates.read(), sessionId); - assert.equal(after.turns.length, 1); - assert.notEqual(after.changedAt, before.changedAt); -}); - -test('a Kimi range never sends an earlier binding’s work to a different account', async () => { - let calls = 0; - const made = memoryPorts({ model: { complete: async () => { - calls++; - return { answer: JSON.stringify(IR) }; - } } }); - const { ports } = made; - await changeTurn(ports, sessionId, 1, { model: route, prompt: 'Earlier account fixture work' }); - const newerRoute = { ...route, providerId: 'account-b', binding: 'binding-b' }; - await changeTurn(ports, sessionId, 2, { model: newerRoute }); - await distillSession(ports, { sessionId }); - assert.equal(calls, 0); - assert.equal(posts(made).length, 0); -}); - -test('removing a conflicting Kimi turn lets the remaining account distill without the old diff', async () => { - const earlierPrompt = 'Earlier account fixture work'; - const earlierDiff = 'REMOVED_ACCOUNT_FIXTURE_CHANGE'; - const newerRoute = { ...route, providerId: 'account-b', binding: 'binding-b' }; - const calls = []; - const made = memoryPorts({ - distillAnswer: DRAFTED, - project: { root: '/proj', head: 'a'.repeat(40), diff: `diff --git a/lib/redis.js b/lib/redis.js\n+${earlierDiff}\n` }, - model: { complete: async (selected, prompt) => { calls.push({ selected, prompt }); return { answer: JSON.stringify(IR) }; } }, - }); - const { ports } = made; - await changeTurn(ports, sessionId, 1, { model: route, prompt: earlierPrompt }); - await changeTurn(ports, sessionId, 2, { model: newerRoute }); - assert.deepEqual(await distillSession(ports, { sessionId }), { status: 'blocked', reason: 'route_changed' }); - assert.equal(calls.length, 0); - assert.equal(posts(made).length, 0); - - await ports.candidates.update((pending) => withoutWorkTurn(pending, sessionId, 1, { now: ports.clock.now() })); - await changeTurn(ports, sessionId, 3, { model: newerRoute }); - - assert.equal((await distillSession(ports, { sessionId })).status, 'drafted'); - assert.equal(calls.length, 1); - assert.deepEqual(calls[0].selected, newerRoute); - assert.match(calls[0].prompt, /Turn 2/); - assert.match(calls[0].prompt, /Turn 3/); - assert.equal(posts(made).length, 1); - const sent = calls[0].prompt + JSON.stringify(posts(made)[0].body); - assert.equal(sent.includes(earlierPrompt), false); - assert.equal(sent.includes(earlierDiff), false); -}); - -test('removing a conflicting Kimi turn keeps a runtime block only for the same binding', async () => { - const newerRoute = { ...route, providerId: 'account-b', binding: 'binding-b' }; - for (const failedRoute of [route, newerRoute]) { - let calls = 0; - const made = memoryPorts({ model: { complete: async () => { calls++; throw new ModelUnavailable('route_changed'); } } }); - const { ports } = made; - await changeTurn(ports, sessionId, 1, { model: failedRoute }); - assert.deepEqual(await distillSession(ports, { sessionId }), { status: 'blocked', reason: 'route_changed' }); - const blockedRange = rangeOf(await ports.candidates.read(), sessionId); - made.advance(500); - if (failedRoute === route) { - await changeTurn(ports, sessionId, 2, { model: newerRoute }); - } else { - const deferred = { ...blockedRange.turns[0], turn: 0 }; - await ports.candidates.update((pending) => withWorkTurn(pending, sessionId, deferred, { - now: ports.clock.now(), model: route, native: true, beforeNewerTurn: true, - })); - } - assert.deepEqual(await distillSession(ports, { sessionId }), { status: 'blocked', reason: 'route_changed' }); - assert.equal(calls, 1); - - const removed = failedRoute === route ? 1 : 0; - await ports.candidates.update((pending) => withoutWorkTurn(pending, sessionId, removed, { now: ports.clock.now() })); - const recovered = rangeOf(await ports.candidates.read(), sessionId); - assert.equal(recovered.routeConflict, undefined); - assert.deepEqual(recovered.blocked, failedRoute === newerRoute ? blockedRange.blocked : undefined); - } -}); - -test('removing a Kimi turn without a captured route lets the remaining bound work distill safely', async () => { - const oldPrompt = 'Unbound Kimi fixture work'; - const oldDiff = 'REMOVED_UNBOUND_FIXTURE_CHANGE'; - const calls = []; - const made = memoryPorts({ - distillAnswer: DRAFTED, - project: { root: '/proj', head: 'a'.repeat(40), diff: `diff --git a/lib/redis.js b/lib/redis.js\n+${oldDiff}\n` }, - model: { complete: async (selected, prompt) => { calls.push({ selected, prompt }); return { answer: JSON.stringify(IR) }; } }, - }); - const { ports } = made; - const { turn, sessionEpoch } = await promptSubmitted(ports, { sessionId, prompt: oldPrompt, waitMs: 0 }); - await changeTurn(ports, sessionId, 2, { model: route }); - const { base, turns: [bound] } = rangeOf(await ports.candidates.read(), sessionId); - const deferred = { ...bound, turn, sessionEpoch, prompt: oldPrompt, head: base }; - await ports.candidates.update((pending) => withWorkTurn(pending, sessionId, deferred, { - now: ports.clock.now(), model: null, native: true, beforeNewerTurn: true, - })); - assert.deepEqual(await distillSession(ports, { sessionId }), { status: 'blocked', reason: 'route_not_captured' }); - assert.deepEqual(calls, []); - assert.deepEqual(posts(made), []); - - await ports.candidates.update((pending) => withoutWorkTurn(pending, sessionId, deferred.turn, { - sessionEpoch: deferred.sessionEpoch, now: ports.clock.now(), - })); - await changeTurn(ports, sessionId, 3, { model: route }); - - assert.equal((await distillSession(ports, { sessionId })).status, 'drafted'); - assert.equal(calls.length, 1); - assert.deepEqual(calls[0].selected, route); - assert.match(calls[0].prompt, /Turn 2/); - assert.match(calls[0].prompt, /Turn 3/); - assert.equal(posts(made).length, 1); - const sent = calls[0].prompt + JSON.stringify(posts(made)[0].body); - assert.equal(sent.includes(oldPrompt), false); - assert.equal(sent.includes(oldDiff), false); -}); - -test('a correction during the first Proxy reply drops the draft and stops the second IR', async () => { - const irs = [IR, { ...IR, title: 'Second verified method' }]; - const made = memoryPorts({ distillAnswer: DRAFTED, model: { complete: async () => ({ answer: JSON.stringify(irs) }) } }); - const { ports } = made; - await changeTurn(ports, sessionId, 1, { model: route }); - const request = ports.proxy.request; - ports.proxy.request = async (method, path, body) => { - const response = await request(method, path, body); - if (path === '/conversation/distill' && posts(made).length === 1) { - await promptSubmitted(ports, { sessionId, prompt: 'still failing', waitMs: 0 }); - } - return response; - }; - - await distillSession(ports, { sessionId }); - assert.equal(posts(made).length, 1); - assert.deepEqual(await ports.drafts.read(), []); - assert.equal(rangeOf(await ports.candidates.read(), sessionId).turns[0].correction, 'still failing'); -}); - -test('a correction marker for any source turn prevents publishing a lingering range draft', async () => { - const answer = { ...DRAFTED, publishable: true, publish_receipt: { receiptId: 'fixture' } }; - const made = memoryPorts({ distillAnswer: answer, model: { complete: async () => ({ answer: JSON.stringify(IR) }) } }); - const { ports } = made; - await changeTurn(ports, sessionId, 1, { model: route }); - await changeTurn(ports, sessionId, 2, { model: route }); - const first = rangeOf(await ports.candidates.read(), sessionId).turns[0]; - assert.equal((await distillSession(ports, { sessionId })).status, 'drafted'); - await ports.draftCorrections.mark(sessionId, first.turn, first.sessionEpoch); - - const result = await runCommand(ports, { sessionId, name: 'evolver-drafts', args: 'publish 1' }); - assert.equal(result.ok, false); - assert.match(result.text, /invalidated by a correction/i); - assert.equal(made.requests.some(({ path }) => path === '/asset/submit' || path === '/asset/reverify'), false); - assert.equal((await ports.drafts.read()).length, 1); -}); - -test('a correction removes only provisional work left before a native finalization could commit', async () => { - const { ports } = memoryPorts(); - await changeTurn(ports, sessionId, 1, { model: route }); - await changeTurn(ports, sessionId, 2, { model: route }); - await ports.candidates.update((pending) => { - const range = rangeOf(pending, sessionId); - return { ...pending, [sessionId]: { ...range, turns: [range.turns[0], { ...range.turns[1], finalizationPending: true }] } }; - }); - await promptSubmitted(ports, { sessionId, prompt: 'still failing', waitMs: 0 }); - assert.deepEqual(rangeOf(await ports.candidates.read(), sessionId).turns.map(({ turn }) => turn), [1]); -}); diff --git a/test/app/native-distill.test.js b/test/app/native-distill.test.js deleted file mode 100644 index 7452601..0000000 --- a/test/app/native-distill.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; - -import { distillSession } from '../../lib/app/distill-session.js'; -import { promptSubmitted } from '../../lib/app/prompt.js'; -import { toolFinished } from '../../lib/app/tool-result.js'; -import { turnEnded } from '../../lib/app/turn-end.js'; -import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; -import { declarationFor } from '../fixtures/acp/declaration.mjs'; -import { memoryPorts, PROMPT } from './memory-ports.js'; -import { IR } from './work-turns.js'; - -const KEY = 'fixture-private-api-key-NOT-A-REAL-CREDENTIAL'; -const DRAFT = { ok: true, status: 'draft', publishable: false, distill_id: 'd'.repeat(16), quality: { ok: true, score: 6, threshold: 5 }, signals: ['retry'], gene: { type: 'Gene', asset_id: `sha256:${'a'.repeat(64)}`, summary: IR.capability_summary }, capsule: { type: 'Capsule', asset_id: `sha256:${'b'.repeat(64)}`, summary: IR.capability_summary } }; - -async function fixture(t) { - const dir = mkdtempSync(join(tmpdir(), 'evolver-native-distill-')); - t.after(() => rmSync(dir, { recursive: true, force: true })); - const config = join(dir, 'config.toml'), receipt = join(dir, 'receipt.json'); - writeFileSync(config, '[provider]\nendpoint="https://example.invalid/v1"\nkey_env="FIXTURE_ACCOUNT_KEY"\n'); - const declaration = declarationFor(config, receipt); - const prepare = declaration.prepare; - declaration.prepare = (selected, context) => { - const prepared = prepare(selected, context), entry = prepared.files['fixture.json']; - entry.value = { ...entry.value, answer: JSON.stringify(IR) }; - return prepared; - }; - const env = { ...process.env, FIXTURE_ACCOUNT_KEY: KEY }; - const runner = modelRunnerOf(declaration, env); - const snapshot = runner.capture({ alias: 'route-a', effort: 'off' }); - const made = memoryPorts({ model: runner, distillAnswer: DRAFT }); - await promptSubmitted(made.ports, { sessionId: 'session', prompt: PROMPT, waitMs: 500 }); - await toolFinished(made.ports, { sessionId: 'session', note: { kind: 'edit', paths: ['retry.js'] } }); - await toolFinished(made.ports, { sessionId: 'session', note: { kind: 'shell', command: 'npm test', exit: 0 } }); - await turnEnded(made.ports, { sessionId: 'session', reason: 'answer', answer: 'Fixed retry policy.', model: snapshot }); - return { ...made, dir, receipt, env, snapshot }; -} - -test('a verified session range completes through the ACP fixture and creates a private IR draft', async (t) => { - const made = await fixture(t); - const { at, changedAt, turns: [source] } = (await made.ports.candidates.read()).session; - const outcome = await distillSession(made.ports, { sessionId: 'session' }); - assert.equal(outcome.status, 'drafted', () => JSON.stringify(outcome)); - const posts = made.requests.filter(({ path }) => path === '/conversation/distill'); - assert.equal(posts.length, 1); - const [post] = posts; - assert.deepEqual([post.body.ir_version, post.body.ir, post.body.persist, post.body.publish], ['session-gene-ir-v1', IR, true, false]); - assert.equal(JSON.stringify(post).includes(KEY), false); - assert.equal(JSON.stringify(post).includes(made.snapshot.binding), false); - const corpus = JSON.stringify(await made.ports.corpus.read()); - assert.equal(corpus.includes(KEY), false); - assert.equal(corpus.includes(made.snapshot.binding), false); - const [draft] = await made.ports.drafts.read(); - assert.equal(draft.title, IR.title); - assert.deepEqual(draft.sourceRange, { at, changedAt, turns: [{ turn: source.turn, at: source.at, sessionEpoch: source.sessionEpoch }] }); - assert.deepEqual(await made.ports.candidates.read(), {}); - const receipt = JSON.parse(readFileSync(made.receipt, 'utf8')); - assert.equal(existsSync(receipt.home), false); - assert.throws(() => process.kill(receipt.pid, 0), { code: 'ESRCH' }); -}); - -test('unavailable account preflight keeps the range without a model request and restoration retries its original binding', async t => { - const made = await fixture(t); - made.env.FIXTURE_ACCOUNT_KEY = 'different-fixture-account'; - assert.deepEqual(await distillSession(made.ports, { sessionId: 'session' }), { status: 'blocked', reason: 'route_changed' }); - const candidate = (await made.ports.candidates.read()).session; - assert.equal(candidate.attempts, 0); - assert.deepEqual(candidate.model, made.snapshot); - assert.equal(candidate.claimedBy, null); - assert.equal(existsSync(made.receipt), false, 'no native process launched on preflight failure'); - assert.equal(made.requests.some(request => request.path === '/conversation/distill'), false); - assert.deepEqual(await made.ports.drafts.read(), []); - made.env.FIXTURE_ACCOUNT_KEY = KEY; - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'drafted'); - assert.equal(made.requests.filter(request => request.path === '/conversation/distill').length, 1); - assert.equal((await made.ports.drafts.read()).length, 1); - assert.deepEqual(await made.ports.candidates.read(), {}); -}); diff --git a/test/app/native-turn.test.js b/test/app/native-turn.test.js deleted file mode 100644 index d5b9268..0000000 --- a/test/app/native-turn.test.js +++ /dev/null @@ -1,523 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { reconcileNativeTurn, recoverNativeTurns, turnObservationOf } from '../../lib/app/native-turn.js'; -import { runBackgroundTasks } from '../../lib/app/background.js'; -import { promptSubmitted } from '../../lib/app/prompt.js'; -import { sessionStarted } from '../../lib/app/session-start.js'; -import { sessionEnded } from '../../lib/app/session-end.js'; -import { TASKS } from '../../lib/app/ports.js'; -import { armDistillWakeUp, distillDue, distillSession, dropExpiredLeftovers } from '../../lib/app/distill-session.js'; -import { BLOCKED_ROUTE_TTL_MS, IDLE_DISTILL_MS, rangeOf, UNDISTILLED_LEFTOVER_TTL_MS } from '../../lib/distill/range.js'; -import { memoryPorts, PROMPT } from './memory-ports.js'; -import { changeTurn, IR } from './work-turns.js'; - -const route = { alias: 'route-b', providerId: 'account-b', model: 'upstream-b', protocol: 'openai', endpoint: 'https://api.example/v1', effort: 'off', binding: 'captured-account', configRevision: 'same-config' }; -const modelHint = { alias: 'route-b', effort: 'off' }; -const payload = { session_id: 'native-session', cwd: '/workspace' }; -const at = { sessionId: 'session', projectDir: '/workspace', payload, turn: 1 }; -const record = () => ({ commands: ['npm test'], paths: ['/workspace/file.js'], edits: ['/workspace/file.js'], checks: [{ command: 'npm test', failed: false }], answer: 'Fixed retries.' }); -const facts = (terminal = true) => ({ status: 'ok', schemaSupported: true, schemaVersion: '1.5', mark: { offset: 500, fileId: 'wire' }, modelHint, - bind: { turnId: '0', promptId: 'native-prompt', origin: 'user', mark: 120 }, - terminal: terminal ? { turnId: '0', reason: 'completed', answer: 'Fixed retries.', modelHint: { ...modelHint, model: 'upstream-b', protocol: 'openai' } } : null, - record: record() }); - -function legacyCandidate({ commands, paths, checks, answer }, { now, sessionEpoch, finalizationPending = false }) { - return { prompt: PROMPT, commands, paths, checks, answer, model: route, turn: 1, pid: 4242, attempts: 0, at: now, - ...(sessionEpoch !== undefined && { sessionEpoch }), ...(finalizationPending && { finalizationPending: true }) }; -} - -function fixedErrorFacts() { - const observed = facts(); - observed.record.checks.unshift({ command: 'npm test', exit: 1, failed: true }); - return observed; -} - -async function pending(options = {}) { - const made = memoryPorts(options); - await promptSubmitted(made.ports, { sessionId: 'session', prompt: PROMPT, waitMs: 500 }); - await made.ports.sessions.update('session', stored => ({ ...stored, native: { turn: 1, payload, mark: { offset: 100, fileId: 'wire' }, turnId: null, model: route, modelHint, schemaVersion: '1.5' } })); - made.ports.model = { capture: () => ({ ...route }) }; - made.ports.native = { read: async () => facts() }; - return made; -} - -test('native prompt binds turn zero before terminal; Stop hints cannot close it early', async () => { - const made = await pending(); - const observed = await reconcileNativeTurn(made.ports, at, facts(false)); - assert.equal(observed.waiting, true); - assert.equal(made.sessions.get('session').turnId, '0'); - assert.equal(made.sessions.get('session').open, true); - assert.deepEqual(made.queued, []); - const ended = await reconcileNativeTurn(made.ports, at, facts()); - assert.equal(ended.ended.isCandidate, true); - assert.equal(made.sessions.get('session').open, false); - assert.deepEqual((await made.ports.candidates.read()).session.model, route); - assert.equal(made.queued.length, 1); - assert.deepEqual(made.wakeUps, [{ at: made.ports.clock.now() + IDLE_DISTILL_MS, tasks: [TASKS.DISTILL] }]); - await reconcileNativeTurn(made.ports, at, facts()); - assert.equal(made.queued.length, 1); - assert.equal(made.wakeUps.length, 1, 'an already activated candidate does not schedule another wake'); - assert.deepEqual(made.sessions.get('session').nativeCursor.mark, { offset: 500, fileId: 'wire' }); -}); - -test('read errors, partial wire and a native prompt before the pending mark cannot settle a turn', async () => { - for (const bad of [{ ...facts(), status: 'partial' }, { ...facts(), bind: { ...facts().bind, mark: 50 } }, { ...facts(), mark: { offset: 500, fileId: 'rotated' } }]) { - const made = await pending(); - await reconcileNativeTurn(made.ports, at, bad); - assert.equal(made.sessions.get('session').open, true); - assert.deepEqual(made.queued, []); - } - const made = await pending(); - made.ports.native.read = async () => { throw new Error('unreadable'); }; - const result = await reconcileNativeTurn(made.ports, at); - assert.equal(result.facts.status, 'unreadable'); - assert.equal(made.sessions.get('session').open, true); -}); - -test('recovery settles a healthy native session even when the state scan reports a corrupt sibling', async () => { - const made = await pending(); - const updateEach = made.ports.sessions.updateEach; - const error = new Error('invalid session state'); - made.ports.sessions.updateEach = async mutate => { - await updateEach(mutate); - throw error; - }; - await assert.rejects(recoverNativeTurns(made.ports), error); - assert.deepEqual((await made.ports.candidates.read()).session.turns.map(({ turn }) => turn), [1]); - assert.equal(made.sessions.get('session').open, false); -}); - -test('a background distill command asks for a new work turn when the captured route cannot be verified', async () => { - const made = await pending(); - const observed = facts(); - observed.terminal.modelHint.model = 'unmatched-native-model'; - made.ports.model.complete = () => assert.fail('an unresolved account must not request a model'); - await reconcileNativeTurn(made.ports, at, observed); - made.ports.commandJobs = { - async run(id, run) { - assert.equal(id, 'distill-job'); - const result = await run({ sessionId: 'session', name: 'evolver-distill' }, new AbortController().signal); - assert.equal(result.ok, false); - assert.equal(result.text, "The session's work has no captured model route. Finish a new work turn so Evolver can capture its model route."); - return true; - }, - }; - assert.equal(await runBackgroundTasks(made.ports, ['command:distill-job']), true); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.equal(made.requests.some(request => request.path === '/conversation/distill'), false); -}); - -test('late background verification only rejudges its original still-last turn', async () => { - const made = await pending(); - const running = facts(); running.record.checks[0].failed = null; running.pendingVerification = true; - assert.equal((await reconcileNativeTurn(made.ports, at, running)).waiting, true); - assert.deepEqual(await made.ports.candidates.read(), {}); - await reconcileNativeTurn(made.ports, at, facts()); - assert.deepEqual((await made.ports.candidates.read()).session.turns.map(({ turn }) => turn), [1]); - assert.equal(made.queued.length, 1); - await promptSubmitted(made.ports, { sessionId: 'session', prompt: 'Continue a different task.', waitMs: 500 }); - await reconcileNativeTurn(made.ports, at, facts()); - assert.equal(made.sessions.get('session').open, true); - assert.equal(made.sessions.get('session').turn, 2); - assert.equal(made.queued.length, 1); -}); - -test('temporary account drift preserves the captured binding for retry while an actual native route mismatch stays unresolved', async () => { - const changed = await pending(); - changed.ports.model.capture = () => ({ ...route, binding: 'other-account' }); - await reconcileNativeTurn(changed.ports, at, facts()); - assert.deepEqual((await changed.ports.candidates.read()).session.model, route); - const wrong = await pending(); - const observed = facts(); observed.terminal.modelHint.model = 'wrong-model'; - await reconcileNativeTurn(wrong.ports, at, observed); - assert.equal((await wrong.ports.candidates.read()).session.model, null); -}); - -test('a missing or unmatched native model binding settles on the first distill without using a model', async () => { - for (const missingCapture of [true, false]) { - const made = await pending({ gone: new Set([4242]) }); - const observed = facts(); - if (missingCapture) await made.ports.sessions.update('session', stored => ({ ...stored, native: { ...stored.native, model: null } })); - else observed.terminal.modelHint.model = 'unmatched-native-model'; - made.ports.model.complete = () => assert.fail('an unresolved account must not request a model'); - await reconcileNativeTurn(made.ports, at, observed); - assert.deepEqual(await distillDue(made.ports), [{ status: 'route_not_captured' }]); - assert.deepEqual(await made.ports.candidates.read(), {}); - await reconcileNativeTurn(made.ports, at, observed); - assert.deepEqual(await distillDue(made.ports), []); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.equal(made.requests.some(request => request.path === '/conversation/distill'), false); - } -}); - -test('a known dead owner is reconciled from terminal wire before orphan abort reporting', async () => { - const made = await pending({ gone: new Set([4242]) }); - made.ports.owner.current = () => 9999; - await recoverNativeTurns(made.ports); - await sessionStarted(made.ports, { sessionId: 'new-session', projectDir: '/workspace' }); - assert.equal(made.queued.length, 1); - assert.equal(made.queued[0].report.outcome, 'success'); - assert.equal((await made.ports.candidates.read()).session.pid, 4242); -}); - -test('a prompt, owner or epoch change during a native read cannot close a newer unbound turn', async () => { - for (const change of [ - made => promptSubmitted(made.ports, { sessionId: 'session', prompt: 'New task.', waitMs: 500 }), - made => made.ports.sessions.update('session', stored => ({ ...stored, pid: 9999 })), - made => made.ports.sessions.update('session', stored => ({ ...stored, sessionEpoch: stored.sessionEpoch + 1 })), - ]) { - const made = await pending(); - made.ports.native.read = async () => { await change(made); return facts(); }; - await reconcileNativeTurn(made.ports, at); - assert.equal(made.sessions.get('session').open, true); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.deepEqual(made.queued, []); - } -}); - -test('a queued reconciliation from an older epoch stops before reading the new wire', async () => { - const made = await pending(); - made.ports.native.read = () => assert.fail('the recreated session is outside this job'); - const result = await reconcileNativeTurn(made.ports, { ...at, sessionEpoch: made.sessions.get('session').sessionEpoch - 1 }); - assert.equal(result.facts, null); - assert.equal(result.waiting, false); - assert.equal(made.sessions.get('session').open, true); - assert.deepEqual(await made.ports.candidates.read(), {}); -}); - -test('a duplicate terminal cannot recreate an explicitly discarded candidate', async () => { - const made = await pending(); - await reconcileNativeTurn(made.ports, at, facts()); - await made.ports.candidates.update(() => ({})); - await reconcileNativeTurn(made.ports, at, facts()); - assert.deepEqual(await made.ports.candidates.read(), {}); -}); - -test('a correction arriving during native candidate persistence cannot leave the older candidate behind', async () => { - const made = await pending(); - const writing = Promise.withResolvers(), resume = Promise.withResolvers(); - const update = made.ports.candidates.update; - let held = false; - made.ports.candidates.update = async mutate => { - if (!held) { - held = true; - writing.resolve(); - await resume.promise; - } - return update(mutate); - }; - const finishing = reconcileNativeTurn(made.ports, at, facts()); - await writing.promise; - try { - await promptSubmitted(made.ports, { sessionId: 'session', prompt: 'That is wrong. Revert the change.', waitMs: 500 }); - assert.equal(made.sessions.get('session').correctedTurn, 1); - } finally { resume.resolve(); } - assert.equal((await finishing).ended.isCandidate, false); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'nothing'); -}); - -test('session end preserves a pending native turn and its original prompt for late task completion', async () => { - const made = await pending(); - const running = facts(); running.record.checks[0].failed = null; running.pendingVerification = true; - await reconcileNativeTurn(made.ports, at, running); - await sessionEnded(made.ports, at); - assert.equal(made.sessions.get('session').native.pending, true); - await reconcileNativeTurn(made.ports, at, facts()); - const candidate = (await made.ports.candidates.read()).session; - assert.deepEqual(candidate.turns.map(({ prompt }) => prompt), [PROMPT]); - assert.equal(candidate.closed, true); - assert.equal(made.queued.length, 1); -}); - -test('a native finalization claim covers the candidate-consumed window before the settled marker is written', async () => { - const made = await pending({ distillAnswer: { status: 'stored' } }); - let completions = 0; - made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify(IR) }; }; - const entered = Promise.withResolvers(), release = Promise.withResolvers(), update = made.ports.sessions.update; - let held = false; - made.ports.sessions.update = async (id, mutate) => { - const next = await update(id, mutate); - if (!held && next && !next.open && next.settling === null && next.native?.turnId === '0' && !next.native.settled) { - held = true; entered.resolve(); await release.promise; - } - return next; - }; - const first = reconcileNativeTurn(made.ports, at, facts()); - await entered.promise; - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); - await reconcileNativeTurn(made.ports, at, facts()); - assert.equal(completions, 0); - release.resolve(); await first; - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'stored'); - await reconcileNativeTurn(made.ports, at, facts()); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.equal(completions, 1); -}); - -test('native work survives failed finalization writes without being consumed or recreated', async () => { - for (const stage of ['marker', 'activation']) { - const made = await pending({ distillAnswer: { status: 'stored' }, gone: new Set([4242]) }); - let completions = 0, failed = false; - made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify(IR) }; }; - const sessionUpdate = made.ports.sessions.update, candidateUpdate = made.ports.candidates.update; - if (stage === 'marker') { - made.ports.sessions.update = (id, mutate) => sessionUpdate(id, stored => { - const next = mutate(stored); - if (!failed && next?.native?.settled && !stored.native?.settled) { - failed = true; throw Object.assign(new Error('session replacement failed'), { code: 'EACCES' }); - } - return next; - }); - } else { - made.ports.candidates.update = mutate => candidateUpdate(pending => { - const next = mutate(pending); - if (!failed && pending.session?.turns[0]?.finalizationPending === true && next?.session && next.session.turns[0]?.finalizationPending !== true) { - failed = true; throw Object.assign(new Error('candidate activation failed'), { code: 'EACCES' }); - } - return next; - }); - } - await assert.rejects(reconcileNativeTurn(made.ports, at, facts()), { code: 'EACCES' }); - assert.equal((await made.ports.candidates.read()).session.turns[0].finalizationPending, true); - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); - assert.deepEqual(await distillDue(made.ports), []); - made.advance(UNDISTILLED_LEFTOVER_TTL_MS + 1); - await dropExpiredLeftovers(made.ports); - assert.ok((await made.ports.candidates.read()).session, 'unfinished persistence must retain its candidate past expiry'); - assert.equal(completions, 0); - made.ports.sessions.update = sessionUpdate; made.ports.candidates.update = candidateUpdate; - await recoverNativeTurns(made.ports); - assert.notEqual((await made.ports.candidates.read()).session.turns[0].finalizationPending, true); - assert.ok(made.scheduled.includes(TASKS.DISTILL), 'recovered activation schedules the due candidate without another host event'); - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'stored'); - await reconcileNativeTurn(made.ports, at, facts()); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.equal(completions, 1); - } -}); - -test('a later prompt cannot strand an older pending candidate when activation needs recovery', async () => { - const made = await pending({ distillAnswer: { status: 'stored' } }); - let completions = 0, held = false, failed = false; - made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify(IR) }; }; - const entered = Promise.withResolvers(), release = Promise.withResolvers(); - const sessionUpdate = made.ports.sessions.update, candidateUpdate = made.ports.candidates.update; - made.ports.sessions.update = async (id, mutate) => { - const next = await sessionUpdate(id, mutate); - if (!held && next && !next.open && next.settling === null && next.native?.turnId === '0' && !next.native.settled) { - held = true; entered.resolve(); await release.promise; - } - return next; - }; - made.ports.candidates.update = mutate => candidateUpdate(pending => { - const next = mutate(pending); - if (!failed && pending.session?.turns[0]?.finalizationPending === true && next?.session && next.session.turns[0]?.finalizationPending !== true) { - failed = true; throw Object.assign(new Error('activation replacement failed'), { code: 'EACCES' }); - } - return next; - }); - const first = reconcileNativeTurn(made.ports, at, facts()); - const rejected = assert.rejects(first, { code: 'EACCES' }); - await entered.promise; - await promptSubmitted(made.ports, { sessionId: 'session', prompt: 'Now update unrelated documentation.', waitMs: 500 }); - await made.ports.sessions.update('session', stored => ({ ...stored, native: { turn: 2, payload, mark: facts().mark, model: route, modelHint, schemaVersion: '1.5' } })); - release.resolve(); await rejected; - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); - await recoverNativeTurns(made.ports); - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'stored'); - await reconcileNativeTurn(made.ports, at, facts()); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.equal(completions, 1); - assert.equal(made.sessions.get('session').open, true); - assert.equal(made.sessions.get('session').turn, 2); -}); - -test('session end crossing first finalization closes the eventual candidate while its owner is still alive', async () => { - const made = await pending({ distillAnswer: { status: 'stored' } }); - made.ports.model.complete = async () => ({ answer: JSON.stringify(IR) }); - const entered = Promise.withResolvers(), release = Promise.withResolvers(), enqueue = made.ports.reports.enqueue; - let held = false; - made.ports.reports.enqueue = async entries => { - if (!held) { held = true; entered.resolve(); await release.promise; } - return enqueue(entries); - }; - const first = reconcileNativeTurn(made.ports, at, fixedErrorFacts()); - await entered.promise; - await sessionEnded(made.ports, at); - release.resolve(); await first; - assert.equal(made.ports.owner.isGone(4242), false); - assert.equal((await made.ports.candidates.read()).session.closed, true); - assert.deepEqual((await distillDue(made.ports)).map(outcome => outcome.status), ['stored']); -}); - -test('native claims refuse a live holder without TTL takeover and recover a dead stale holder', async () => { - const gone = new Set(), made = await pending({ gone }); - await made.ports.sessions.update('session', stored => ({ ...stored, open: false, turnId: '0', settling: made.ports.clock.now(), native: { ...stored.native, turnId: '0', promptId: 'native-prompt', finalizingPid: 9999 } })); - made.advance(60_000); - await reconcileNativeTurn(made.ports, at, facts()); - assert.deepEqual(await made.ports.candidates.read(), {}); - gone.add(9999); - await reconcileNativeTurn(made.ports, at, facts()); - assert.deepEqual((await made.ports.candidates.read()).session.turns.map(({ turn }) => turn), [1]); - assert.equal(made.sessions.get('session').native.finalizingPid, null); -}); - -test('failed and cancelled native turns stop polling pending checks that cannot become candidates', async () => { - for (const reason of ['failed', 'cancelled', 'interrupted']) { - const made = await pending(); - const terminal = facts(); terminal.terminal.reason = reason; terminal.record.checks[0].failed = null; - assert.equal((await reconcileNativeTurn(made.ports, at, terminal)).waiting, false); - assert.equal(made.sessions.get('session').native.pending, false); - assert.deepEqual(await made.ports.candidates.read(), {}); - } -}); - -test('a completed foreground check with an unknown verdict stops polling without becoming a candidate', async () => { - const made = await pending(); - const observed = facts(); - observed.record.checks[0] = { command: 'npm test', exit: null, failed: null }; - observed.pendingVerification = false; - - const result = await reconcileNativeTurn(made.ports, at, observed); - assert.equal(result.waiting, false, 'a completed tool result cannot gain evidence by polling again'); - assert.equal(result.ended.isCandidate, false); - assert.equal(made.sessions.get('session').open, false); - assert.equal(made.sessions.get('session').native.pending, true, 'an unknown check remains ineligible for distillation'); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.deepEqual(await distillDue(made.ports), []); - assert.equal(made.requests.some(({ path }) => path === '/conversation/distill'), false); -}); - -test('a legacy route-blocked native candidate with an unknown check still schedules expiry', async () => { - const made = await pending(); - const { ports } = made; - const at = ports.clock.now(); - const verified = record(); - verified.checks.push({ command: 'npm run lint', failed: null }); - const candidate = legacyCandidate(verified, { now: at }); - await ports.candidates.update(() => ({ session: { ...candidate, blocked: { reason: 'route_changed', at } } })); - ports.model.complete = () => assert.fail('expiry must not request a model'); - - assert.equal((await distillSession(ports, { sessionId: 'session' })).status, 'busy'); - await armDistillWakeUp(ports); - assert.deepEqual(made.wakeUps, [{ at: at + BLOCKED_ROUTE_TTL_MS, tasks: [TASKS.DISTILL] }]); - made.advance(BLOCKED_ROUTE_TTL_MS); - assert.deepEqual(await distillDue(ports), []); - assert.deepEqual(await ports.candidates.read(), {}); -}); - -test('a pending background check blocks native distillation until dead-owner recovery sees its result', async () => { - const made = await pending({ distillAnswer: { status: 'stored' }, gone: new Set([4242]) }); - let completions = 0; - made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify(IR) }; }; - const observed = fixedErrorFacts(); - observed.record.checks.unshift({ command: 'npm run lint', failed: null }); - observed.pendingVerification = true; - made.ports.native.read = async () => observed; - const first = await reconcileNativeTurn(made.ports, at, observed); - assert.equal(first.waiting, true); - assert.equal(first.ended.isCandidate, false); - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'nothing'); - assert.equal((await recoverNativeTurns(made.ports))[0].waiting, true); - assert.deepEqual(await distillDue(made.ports), []); - assert.equal(completions, 0); - assert.equal(made.requests.some(({ path }) => path === '/conversation/distill'), false); - assert.deepEqual(await made.ports.candidates.read(), {}); - - observed.record.checks[0].failed = false; - observed.pendingVerification = false; - assert.equal((await recoverNativeTurns(made.ports))[0].waiting, false); - assert.deepEqual((await distillDue(made.ports)).map(({ status }) => status), ['stored']); - await recoverNativeTurns(made.ports); - assert.deepEqual(await distillDue(made.ports), []); - assert.deepEqual(await made.ports.candidates.read(), {}); - assert.equal(completions, 1); - assert.equal(made.requests.filter(({ path }) => path === '/conversation/distill').length, 1); -}); - -test('a superseding native prompt clears an earlier check barrier without letting a late observation restore it', async () => { - const { ports } = memoryPorts({ model: { capture: () => route } }); - await changeTurn(ports, at.sessionId, 1, { model: route }); - ports.native = { reference: (payload) => payload, read: async () => ({ status: 'unreadable' }) }; - const observation = turnObservationOf(ports); - const open = async () => { - const attach = await observation.capturePrompt(at, payload, { nativeFacts: { ...facts(false), mark: { offset: 100, fileId: 'wire' } } }); - const { turn, sessionEpoch } = await promptSubmitted(ports, { sessionId: at.sessionId, prompt: PROMPT, waitMs: 500 }); - await attach({ turn, sessionEpoch }); - return { turn, sessionEpoch }; - }; - const range = async () => rangeOf(await ports.candidates.read(), at.sessionId); - const older = await open(); - const running = { ...facts(false), pendingVerification: true }; - await reconcileNativeTurn(ports, { ...at, ...older }, running); - await reconcileNativeTurn(ports, { ...at, ...older }, facts(false)); - assert.deepEqual((await range()).pendingNativeCheck, older, 'a result without a terminal does not release the barrier'); - - const update = ports.candidates.update; - const entered = Promise.withResolvers(), release = Promise.withResolvers(); - let held = false; - ports.candidates.update = async (mutate) => { - if (!held) { held = true; entered.resolve(); await release.promise; } - return update(mutate); - }; - const late = reconcileNativeTurn(ports, { ...at, ...older }, running); - await entered.promise; - const newer = await open(); - assert.equal((await range()).pendingNativeCheck, undefined); - release.resolve(); - await late; - assert.equal((await range()).pendingNativeCheck, undefined); - - await update((pending) => ({ ...pending, [at.sessionId]: { ...pending[at.sessionId], pendingNativeCheck: older } })); - await recoverNativeTurns(ports); - assert.equal((await range()).pendingNativeCheck, undefined, 'recovery removes a stale barrier left by a killed worker'); - - await reconcileNativeTurn(ports, { ...at, ...newer }, running); - assert.deepEqual((await range()).pendingNativeCheck, newer); - const unknown = facts(); - unknown.record.checks[0].failed = null; - await reconcileNativeTurn(ports, { ...at, ...newer }, unknown); - assert.equal((await range()).pendingNativeCheck, undefined, 'the terminal has no running background check'); - assert.deepEqual((await range()).turns.map(({ turn }) => turn), [1], 'unknown foreground work stays out of the range'); - await reconcileNativeTurn(ports, { ...at, ...newer }, facts()); - assert.equal((await range()).pendingNativeCheck, undefined); - assert.deepEqual((await range()).turns.map(({ turn }) => turn), [1, newer.turn]); -}); - -for (const [stage, turn] of [['provisional', 1], ['provisional', 2], ['activated', 1]]) { - test(`recovery cannot distill a previously ${stage} candidate with a pending check (current turn ${turn})`, async () => { - const made = await pending({ gone: new Set([4242]) }); - const observed = facts(); - observed.record.checks.unshift({ command: 'npm run lint', failed: null }); - observed.pendingVerification = true; - const candidate = legacyCandidate(observed.record, { - sessionEpoch: made.sessions.get('session').sessionEpoch, - now: made.ports.clock.now(), finalizationPending: stage === 'provisional' }); - await made.ports.candidates.update(() => ({ session: candidate })); - await made.ports.sessions.update('session', stored => ({ ...stored, turn, open: false, - native: { ...stored.native, turn, settled: true, pending: turn === 1, candidateMade: turn === 1 } })); - made.ports.native.read = async () => ({ status: 'unreadable' }); - made.ports.model.complete = () => assert.fail('a pending check must not request a model'); - - assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); - assert.deepEqual(await distillDue(made.ports), []); - await recoverNativeTurns(made.ports); - assert.deepEqual((await made.ports.candidates.read()).session, candidate); - assert.deepEqual(await distillDue(made.ports), []); - assert.equal(made.requests.some(({ path }) => path === '/conversation/distill'), false); - - if (turn === 1) { - observed.record.checks[0].failed = false; - observed.pendingVerification = false; - made.ports.native.read = async () => observed; - assert.equal((await recoverNativeTurns(made.ports))[0].waiting, false); - const ready = (await made.ports.candidates.read()).session; - assert.equal(ready.turns.length, 1); - const work = ready.turns[0]; - assert.deepEqual(work.steps.filter(({ kind }) => kind === 'check'), observed.record.checks.map(check => ({ kind: 'check', ...check, exit: null }))); - assert.equal(work.finalizationPending, undefined); - } - }); -} diff --git a/test/distill.test.js b/test/distill.test.js index 3aeca83..0f1eb99 100644 --- a/test/distill.test.js +++ b/test/distill.test.js @@ -2,7 +2,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { draftNamed, draftOf, draftsListText, skipReasonOf, submitRequestOf, withDraft, withoutDraft } from '../lib/distill.js'; -import { claimedOwn, hasDueRange, isCurrentRange, rangeRouteProblem, settledRange, withoutWorkTurn, withWorkTurn } from '../lib/distill/range.js'; +import { baseFor, isCurrentRange, settledRange, withCorrection, withoutExpiredLeftovers, withoutWorkTurn, withWorkTurn } from '../lib/distill/range.js'; +import { corpusEntryOf } from '../lib/distill/corpus.js'; test('settling what a distillation read cannot touch a range made at the same time or in another session epoch', () => { const turn = (number) => ({ turn: number, prompt: 'p', steps: [], answer: '', at: 1_000 }); @@ -18,43 +19,6 @@ test('settling what a distillation read cannot touch a range made at the same ti } }); -test('a delayed turn outside the retained window leaves the verified range intact', () => { - const turn = (number, modelBinding) => ({ turn: number, at: number, sessionEpoch: 1, modelBinding, steps: [] }); - const base = { root: '/repo', head: 'new' }; - const range = { at: 2, sessionEpoch: 1, native: true, base, project: { ...base, diff: 'verified diff' }, - model: { binding: 'new' }, turns: Array.from({ length: 20 }, (_, index) => turn(index + 2, 'new')) }; - const delayed = { ...turn(1, 'old'), head: { root: '/repo', head: 'old' } }; - assert.equal(withWorkTurn({ s: range }, 's', delayed, { - now: 22, native: true, sessionEpoch: 1, model: { binding: 'old' }, beforeNewerTurn: true, - }), undefined); -}); - -test('settling an old blocked claim does not park a range whose conflicting turn was removed', () => { - const turn = (number, modelBinding) => ({ turn: number, at: number, sessionEpoch: 1, modelBinding, steps: [] }); - const pending = { s: { at: 1, sessionEpoch: 1, native: true, closed: true, model: { binding: 'new' }, - turns: [turn(1, 'old'), turn(2, 'new')], routeConflict: 'route_changed', blocked: { reason: 'route_changed', at: 2 } } }; - const context = { pid: 42, isGone: () => false, now: 3 }; - const { next, claimed } = claimedOwn(pending, 's', context); - const recovered = withoutWorkTurn(next, 's', 1, { now: 4 }); - const settled = settledRange(recovered, 's', claimed.range, { blocked: 'route_changed', now: 5 }); - assert.equal(hasDueRange(settled, { ...context, now: 5 }), true); -}); - -test('rolling out the conflicting turn does not unblock a Kimi range', () => { - const turn = (number, modelBinding) => ({ turn: number, at: number, sessionEpoch: 1, modelBinding, steps: [] }); - const model = { binding: 'new' }; - const pending = { s: { at: 1, sessionEpoch: 1, native: true, model, - turns: [turn(1, 'old'), ...Array.from({ length: 19 }, (_, index) => turn(index + 2, 'new'))], - routeConflict: 'route_changed', blocked: { reason: 'route_changed', at: 2 } } }; - const stillMixed = withoutWorkTurn(pending, 's', 2); - assert.equal(rangeRouteProblem(stillMixed.s), 'route_changed'); - - const rolled = withWorkTurn(pending, 's', turn(21, 'new'), { now: 21, model, sessionEpoch: 1, native: true }); - assert.equal(rolled.s.turns[0].turn, 2); - assert.equal(rangeRouteProblem(rolled.s), 'route_changed'); - assert.equal(rangeRouteProblem(withoutWorkTurn(rolled, 's', 2).s), 'route_changed'); -}); - const RESULT = { ok: true, distill_id: 'abc123', @@ -124,3 +88,44 @@ test('a range claimed again by another process is no longer the one a distillati assert.equal(isCurrentRange({ s: read }, 's', read), true); assert.equal(isCurrentRange({ s: { ...read, claimedBy: 11 } }, 's', read), false); }); + +test('cleaning up an empty or missing range table changes nothing', () => { + for (const pending of [undefined, null, {}]) assert.equal(withoutExpiredLeftovers(pending, { now: 1, isGone: () => false }), undefined); +}); + +test('a work turn\'s stored route binding never reaches the calibration corpus', () => { + const turn = { turn: 1, prompt: 'p', steps: [], answer: 'a', at: 1, modelBinding: 'a'.repeat(64) }; + const entry = corpusEntryOf({ + platform: 'kimi', sessionId: 's', trigger: 'idle', range: { turns: [turn] }, + gate: { passed: true, reason: null, workTurns: 1, episodes: 1 }, kind: 'fix', session: { record: [], userPrompt: '', finalAnswer: '' }, now: 2, + }); + assert.equal(JSON.stringify(entry).includes('modelBinding'), false); +}); + +test('a late turn, removal or correction of an older session epoch leaves the newer epoch\'s range alone', () => { + const turn = (number) => ({ turn: number, prompt: 'p', steps: [], answer: '', at: number }); + const newer = { s: { at: 20, changedAt: 20, sessionEpoch: 20, turns: [turn(1)] } }; + assert.equal(withWorkTurn(newer, 's', turn(1), { now: 30, sessionEpoch: 10 }), undefined); + assert.equal(withCorrection(newer, 's', 1, 'no, still failing', 30, { sessionEpoch: 10 }), undefined); + assert.equal(withoutWorkTurn(newer, 's', 1, { sessionEpoch: 10 }), undefined); + assert.equal(withWorkTurn(newer, 's', turn(1), { now: 30, sessionEpoch: 40 }).s.sessionEpoch, 40); + assert.equal(withCorrection(newer, 's', 1, 'no, still failing', 30, { sessionEpoch: 20 }).s.turns[0].correction, 'no, still failing'); +}); + +test('a turn judged again after a later one joined keeps the range\'s newer route, snapshot and block', () => { + const turn = (number) => ({ turn: number, prompt: 'p', steps: [], answer: '', at: number }); + const newer = { root: '/repo', head: 'h2', diff: 'newer diff' }; + const range = { at: 1, changedAt: 2, sessionEpoch: 1, model: 'route-b', project: newer, pid: 2, blocked: { reason: 'route_changed', at: 2 }, turns: [turn(1), turn(2)] }; + const { s } = withWorkTurn({ s: range }, 's', { ...turn(1), answer: 'judged again' }, { + now: 3, sessionEpoch: 1, model: 'route-a', project: { root: '/repo', head: 'h1', diff: 'older diff' }, pid: 1, + }); + assert.deepEqual([s.model, s.project, s.pid, s.blocked], ['route-b', newer, 2, range.blocked]); + assert.equal(s.turns.find(({ turn }) => turn === 1).answer, 'judged again'); +}); + +test('a turn of another session epoch takes its diff from its own HEAD, not the old range\'s base', () => { + const range = { at: 1, sessionEpoch: 10, base: { root: '/repo', head: 'old' }, turns: [{ turn: 1, head: { root: '/repo', head: 'old' } }] }; + const workTurn = { turn: 1, head: { root: '/repo', head: 'new' } }; + assert.deepEqual(baseFor({ s: range }, 's', workTurn, { sessionEpoch: 20 }), workTurn.head); + assert.deepEqual(baseFor({ s: range }, 's', workTurn, { sessionEpoch: 10 }), range.base); +}); diff --git a/test/fixtures/acp/declaration.mjs b/test/fixtures/acp/declaration.mjs index 48652bc..87e34c2 100644 --- a/test/fixtures/acp/declaration.mjs +++ b/test/fixtures/acp/declaration.mjs @@ -2,6 +2,19 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from 'smol-toml'; +const credentialOf = (data) => data.provider.key_env + ? { kind: 'api-key', env: data.provider.key_env } + : { kind: 'api-key', value: data.provider.key }; + +// The Windows job helper compiles its Win32 bridge with Add-Type before the native client starts; on a cold CI runner +// that alone can take longer than 10 s. +export const COMPLETION_BUDGET_MS = 60_000; + +/** The model a turn's end captures for the fixture route, as a host's `turnEnd.modelOf` would. */ +export const capturedModel = (runner, { alias, effort = null }) => ({ + selection: { alias, model: 'fixture-model', protocol: 'openai', effort }, configDigest: runner.configStamp(), +}); + export function declarationFor(config, receipt, mode = '') { return { kind: 'acp', command: () => process.execPath, @@ -12,8 +25,9 @@ export function declarationFor(config, receipt, mode = '') { serializeConfig: JSON.stringify, select: (data, hint) => ({ route: { alias: hint.alias, providerId: 'fixture', model: 'fixture-model', protocol: 'openai', endpoint: data.provider.endpoint, ...(hint.effort ? { effort: hint.effort } : {}) }, - credential: data.provider.key_env ? { kind: 'api-key', env: data.provider.key_env } : { kind: 'api-key', value: data.provider.key }, + credential: credentialOf(data), }), + credentials: (data) => [credentialOf(data)], }, sessionConfig: snapshot => snapshot.effort ? [{ id: 'thinking', value: snapshot.effort }] : [], prepare: (selected, { credentialEnv }) => ({ diff --git a/test/fixtures/acp/owner.mjs b/test/fixtures/acp/owner.mjs index 66f347f..cf18fb0 100644 --- a/test/fixtures/acp/owner.mjs +++ b/test/fixtures/acp/owner.mjs @@ -2,7 +2,7 @@ import childProcess from 'node:child_process'; import { writeFileSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; import { modelRunnerOf } from '../../../lib/runtime/model-executor.js'; -import { declarationFor } from './declaration.mjs'; +import { capturedModel, declarationFor } from './declaration.mjs'; if (process.argv[5] === 'kill-guardian') { const fork = childProcess.fork; @@ -21,5 +21,5 @@ if (process.argv[5] === 'exit-before-fork') { }; } const runner = modelRunnerOf(declaration, process.env); -try { await runner.complete(runner.capture({ alias: 'route-a' }), 'disposable fixture'); } +try { await runner.complete(capturedModel(runner, { alias: 'route-a' }), 'disposable fixture'); } catch { process.exitCode = 1; } diff --git a/test/hosts/kimi/dist.test.js b/test/hosts/kimi/dist.test.js index d309b23..acb3b00 100644 --- a/test/hosts/kimi/dist.test.js +++ b/test/hosts/kimi/dist.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test'; import { pack } from '../../../scripts/pack.mjs'; -const EVENTS = ['SessionStart', 'UserPromptSubmit', 'TurnStarted', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop', 'StopFailure', 'Interrupt', 'SessionEnd', 'TaskStarted', 'Notification']; +const EVENTS = ['SessionStart', 'UserPromptSubmit', 'TurnStarted', 'PreToolUse', 'Stop', 'StopFailure', 'Interrupt', 'SessionEnd', 'Notification']; test('the Kimi bundle carries a native flat hook manifest and self-contained channel entries', t => { const dir = mkdtempSync(join(tmpdir(), 'evolver-kimi-pack-')); diff --git a/test/hosts/kimi/evidence.test.js b/test/hosts/kimi/evidence.test.js index 457b9e7..b3ea810 100644 --- a/test/hosts/kimi/evidence.test.js +++ b/test/hosts/kimi/evidence.test.js @@ -3,7 +3,7 @@ import { join, resolve } from 'node:path'; import { test } from 'node:test'; import kimi from '../../../hosts/kimi/profile.js'; -import { decodeNativeFacts } from '../../../hosts/kimi/transcript.js'; +import { turnStatusOf } from '../../../hosts/kimi/transcript.js'; const meta = { type: 'metadata', protocol_version: '1.5' }; const prompt = { type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'p', origin: { kind: 'user' } }; @@ -11,27 +11,27 @@ const loop = event => ({ type: 'context.append_loop_event', agentId: 'main', eve const call = (uuid, name, args) => loop({ type: 'tool.call', uuid, turnId: '0', toolCallId: uuid, name, args }); const result = (uuid, value) => loop({ type: 'tool.result', parentUuid: uuid, toolCallId: uuid, result: value }); -test('pre-edit paths require the bound main native turn and successful results alone count as edits', () => { +test('pre-edit paths resolve against the hook cwd and successful log results alone count as edits', () => { const cwd = resolve('fixture-project'); const payload = { hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { path: 'file.js' }, cwd }; - const context = { nativeFacts: { status: 'ok', schemaSupported: true, toolTurnId: '0' } }; - assert.deepEqual(kimi.evidence.editedFiles(payload, context), [join(cwd, 'file.js')]); - assert.deepEqual(kimi.evidence.editedFiles(payload), []); + assert.deepEqual(kimi.evidence.editedFiles(payload), [join(cwd, 'file.js')]); + assert.deepEqual(kimi.evidence.editedFiles({ ...payload, tool_name: 'Bash' }), []); + assert.deepEqual(kimi.evidence.editedFiles({ ...payload, cwd: 'relative' }), []); const records = [meta, prompt, call('failed-write', 'Write', { path: 'bad.js' }), result('failed-write', { output: 'denied', durationMs: 1, isError: true }), call('edited', 'Edit', { path: 'file.js' }), result('edited', { output: '', durationMs: 1 })]; - const facts = decodeNativeFacts(records, [], { cwd }, { turnId: '0' }); - assert.deepEqual(facts.record.edits, [join(cwd, 'file.js')]); + const row = turnStatusOf(records, { turnId: '0', projectDir: cwd }); + assert.deepEqual(row.record.edits, [join(cwd, 'file.js')]); }); test('a later uncompleted Bash check is pending even when an earlier check passed', () => { const records = [meta, prompt, call('pass', 'Bash', { command: 'npm test' }), result('pass', { output: '# pass 1\n# fail 0', durationMs: 1 }), call('pending', 'Bash', { command: 'npm test' })]; - const facts = decodeNativeFacts(records, [], {}, { turnId: '0' }); - assert.deepEqual(facts.record.checks.map(check => check.failed), [false, null]); - assert.equal(facts.pendingVerification, true); + const row = turnStatusOf([...records, { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }], { turnId: '0' }); + assert.deepEqual(row.record.checks.map(check => check.failed), [false, null]); + assert.equal(row.status, 'pending'); }); test('a background task record must match both the native call and its returned task ID', () => { const records = [meta, prompt, call('background', 'Bash', { command: 'npm test', run_in_background: true }), result('background', { output: 'task_id: expected-task\nstatus: running', durationMs: 1 })]; const task = { taskId: 'different-task', kind: 'process', parentToolCallId: 'background', command: 'npm test', status: 'completed', exitCode: 0 }; - const record = decodeNativeFacts(records, [task], {}, { turnId: '0' }).record; + const { record } = turnStatusOf(records, { turnId: '0', tasks: [task] }); assert.deepEqual(record.checks, [{ command: 'npm test', exit: null, failed: null }]); }); diff --git a/test/hosts/kimi/lifecycle.test.js b/test/hosts/kimi/lifecycle.test.js new file mode 100644 index 0000000..822c0d4 --- /dev/null +++ b/test/hosts/kimi/lifecycle.test.js @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import kimi from '../../../hosts/kimi/profile.js'; +import { createDriver } from '../../../lib/agent/driver.js'; +import { runBackgroundTasks } from '../../../lib/app/background.js'; +import { recordReaderOf } from '../../../lib/runtime/records.js'; +import { monitorTurn } from '../../../lib/runtime/turn-monitor.js'; +import { ASSET, memoryPorts, PROMPT } from '../../app/memory-ports.js'; + +const loop = (event) => ({ type: 'context.append_loop_event', agentId: 'main', event }); +const prompt = (turnId) => ({ type: 'turn.prompt', agentId: 'main', turnId, promptId: `prompt-${turnId}`, origin: { kind: 'user' }, input: [{ type: 'text', text: PROMPT }] }); +const passingCheck = (turnId, { background = false } = {}) => [ + loop({ type: 'tool.call', uuid: `check-${turnId}`, toolCallId: `call-${turnId}`, turnId: String(turnId), name: 'Bash', args: { command: 'node --test', ...(background && { run_in_background: true }) } }), + loop({ type: 'tool.result', parentUuid: `check-${turnId}`, toolCallId: `call-${turnId}`, result: { output: background ? `task_id: task-${turnId}\nstatus: running` : '# pass 1\n# fail 0', durationMs: 1 } }), +]; +const answered = (turnId) => [ + { type: 'llm.request', agentId: 'main', modelAlias: 'route-a', model: 'fixture-model', provider: 'openai', thinkingEffort: 'off' }, + loop({ type: 'content.part', uuid: `answer-${turnId}`, stepUuid: `step-${turnId}`, turnId: String(turnId), part: { type: 'text', text: 'The check passes.' } }), + loop({ type: 'step.end', uuid: `step-${turnId}`, turnId: String(turnId), finishReason: 'end_turn', providerFinishReason: 'completed' }), + { type: 'turn.ended', agentId: 'main', turnId, reason: 'completed' }, +]; + +function fixture(t, options = {}) { + const root = mkdtempSync(join(tmpdir(), 'evolver-kimi-lifecycle-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const home = join(root, 'home'), project = join(root, 'project'); + const agent = join(home, 'sessions', 'workspace', 'session-fixture', 'agents', 'main'); + mkdirSync(agent, { recursive: true }); mkdirSync(project); + writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: project } } })); + const append = (...records) => appendFileSync(join(agent, 'wire.jsonl'), records.map((record) => `${JSON.stringify(record)}\n`).join('')); + append({ type: 'metadata', protocol_version: '1.5' }); + const env = { KIMI_CODE_HOME: home }; + const made = memoryPorts(options); + Object.assign(made.ports, { + turnStatus: recordReaderOf(kimi.turnEnd.statusRecord, env), + modelRecord: recordReaderOf(kimi.modelRecord, env), + confirmTurnCompletion: true, + }); + made.ports.turnMonitor = (at, onClosed) => monitorTurn(made.ports, at, onClosed, { + dir: join(root, 'monitors'), sleep: async () => options.whileWatching?.(), + }); + made.ports.modelForTurn = (sessionId, opened) => kimi.turnEnd.modelOf(made.ports.modelRecord(sessionId, opened.turnId), 'stamp', 'stamp'); + const driver = createDriver({ profile: kimi, ports: made.ports, transcripts: { size: () => 0, readSince: () => '' }, env }); + const base = { session_id: 'session-fixture', cwd: project, client_type: 'kimi_code_cli', is_steer: false }; + const hook = (event, extra = {}) => driver.handle(event, { ...base, hook_event_name: event, ...extra }, { onError: (error) => { throw error; } }); + const sessionId = JSON.stringify([made.ports.turnStatus.scope, 'session-fixture']); + const session = () => made.ports.sessions.read(sessionId); + const started = (turnId) => hook('TurnStarted', { turn_id: turnId, prompt: PROMPT, origin_kind: 'user' }); + return { ...made, agent, append, hook, started, session, sessionId }; +} + +const successes = (queued) => queued.filter(({ report }) => report.asset_id === ASSET.asset_id && report.outcome === 'success'); + +test('TurnStarted binds the open turn only for its own prompt and starts the shared turn monitor', async (t) => { + const f = fixture(t); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); + f.append(prompt(0)); + await f.hook('TurnStarted', { turn_id: 0, prompt: 'another prompt', origin_kind: 'user' }); + assert.equal((await f.session()).turnId, null); + await f.hook('Stop', { turn_id: 0 }); + assert.deepEqual(f.scheduled.filter((task) => task.startsWith('turn:')), [], 'an unbound turn has nothing to watch'); + await f.started(0); + assert.equal((await f.session()).turnId, '0', 'native turn zero is a real identity'); + const [task] = f.scheduled.filter((item) => item.startsWith('turn:')); + assert.deepEqual(JSON.parse(task.slice('turn:'.length)).expectedTurn, { turn: 1, turnId: '0', sessionEpoch: (await f.session()).sessionEpoch }); + await f.hook('Stop', { turn_id: 0 }); + assert.equal(f.scheduled.filter((item) => item.startsWith('turn:')).length, 2, 'a later lifecycle hook watches again'); +}); + +test('the shared turn monitor waits while the log shows the turn running and closes it on the log\'s record', async (t) => { + let polls = 0; + const f = fixture(t, { whileWatching: () => { if (++polls === 3) f.append(...answered(0)); } }); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); + f.append(prompt(0), ...passingCheck(0)); + await f.started(0); + assert.equal(f.ports.turnStatus(f.sessionId, '0').status, 'accepted'); + await runBackgroundTasks(f.ports, f.scheduled.splice(0).filter((task) => task.startsWith('turn:'))); + assert.equal(polls, 3); + assert.equal((await f.session()).open, false); + assert.equal(successes(f.queued).length, 1); + assert.deepEqual((await f.ports.candidates.read())[f.sessionId].turns.map(({ turn, answer }) => [turn, answer]), [[1, 'The check passes.']]); +}); + +test('the next prompt closes an unfinished turn on the session log\'s evidence and its captured route', async (t) => { + const f = fixture(t); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); + f.append(prompt(0), ...passingCheck(0)); + await f.started(0); + f.append(...answered(0)); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: `${PROMPT} Next.` }] }); + assert.equal(successes(f.queued).length, 1); + const range = (await f.ports.candidates.read())[f.sessionId]; + assert.deepEqual(range.turns.map(({ turn, answer }) => [turn, answer]), [[1, 'The check passes.']]); + assert.deepEqual(range.model, { selection: { alias: 'route-a', model: 'fixture-model', protocol: 'openai', effort: 'off' }, configDigest: 'stamp' }); +}); + +test('a turn whose background check still runs at its end is not verified when the next prompt closes it', async (t) => { + const f = fixture(t); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); + f.append(prompt(0), ...passingCheck(0, { background: true })); + mkdirSync(join(f.agent, 'tasks')); + writeFileSync(join(f.agent, 'tasks', 'task-0.json'), JSON.stringify({ taskId: 'task-0', kind: 'process', parentToolCallId: 'call-0', command: 'node --test', status: 'running' })); + await f.started(0); + f.append(...answered(0)); + assert.equal(f.ports.turnStatus(f.sessionId, '0').status, 'pending'); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: `${PROMPT} Next.` }] }); + assert.equal((await f.session()).turn, 2); + assert.deepEqual(successes(f.queued), []); + assert.equal((await f.ports.candidates.read())[f.sessionId], undefined); +}); + +test('a session end closes the open turn as its log ended it, not as aborted', async (t) => { + const f = fixture(t); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); + f.append(prompt(0), ...passingCheck(0)); + await f.started(0); + f.append(...answered(0)); + await f.hook('SessionEnd'); + assert.equal(successes(f.queued).length, 1); + const range = (await f.ports.candidates.read())[f.sessionId]; + assert.equal(range.closed, true); + assert.deepEqual(range.turns.map(({ turn }) => turn), [1]); +}); + +test('a session whose Kimi process died is recovered from the session log at the next session start', async (t) => { + const f = fixture(t, { gone: new Set([4242]) }); + await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); + f.append(prompt(0), ...passingCheck(0)); + await f.started(0); + f.append(...answered(0)); + await f.hook('SessionStart', { session_id: 'another-session' }); + assert.equal(await runBackgroundTasks(f.ports, f.scheduled.splice(0).filter((task) => task === 'recover-turns')), true); + assert.equal((await f.session()).open, false); + assert.equal(successes(f.queued).length, 1); + assert.deepEqual((await f.ports.candidates.read())[f.sessionId].turns.map(({ turn }) => turn), [1]); +}); + +test('SessionStart does not look up the owner process of a host whose sessions start with an event', async (t) => { + const f = fixture(t); + let probes = 0; + const current = f.ports.owner.current; + f.ports.owner.current = () => { probes += 1; return current(); }; + await f.hook('SessionStart'); + assert.equal(probes, 0); + assert.equal((await f.session()).startedUnder, null); +}); diff --git a/test/hosts/kimi/managed-oauth.test.js b/test/hosts/kimi/managed-oauth.test.js index 86d3337..fcf257a 100644 --- a/test/hosts/kimi/managed-oauth.test.js +++ b/test/hosts/kimi/managed-oauth.test.js @@ -4,13 +4,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { headlessKimi } from '../../../hosts/kimi/model.js'; +import { headlessKimi, modelOf } from '../../../hosts/kimi/model.js'; import { modelRunnerOf } from '../../../lib/runtime/model-executor.js'; const ACCESS = 'fixture-coding-access-only'; const REFRESH = 'fixture-refresh-never-copied'; const DEVICE = '01234567-89ab-4def-8123-456789abcdef'; -const HINT = { alias: 'kimi-code/k3', effort: 'high' }; +const ROUTE = { alias: 'kimi-code/k3', model: 'k3', protocol: 'openai', effort: 'high' }; function fixture(t) { const profile = mkdtempSync(join(tmpdir(), 'evolver-kimi-oauth-')); @@ -42,23 +42,24 @@ default_effort = "high" const starts = []; const env = { HOME: profile, USERPROFILE: profile, KIMI_CODE_HOME: profile }; const runner = modelRunnerOf({ ...headlessKimi, command: () => { starts.push(true); return null; } }, env); - return { config, device, tokenFile, setToken, starts, runner }; + const captured = () => modelOf({ model: ROUTE }, runner.configStamp(), runner.configStamp()); + return { config, device, tokenFile, setToken, starts, runner, captured }; } -test('a current managed Kimi credential binds the exact route without exposing bearer, refresh or device', t => { +test('a current managed Kimi credential stamps the exact route without exposing bearer, refresh or device', t => { const f = fixture(t), original = readFileSync(f.tokenFile); - const model = f.runner.capture(HINT); - assert.deepEqual([model.providerId, model.providerType, model.protocol, model.model, model.effort], - ['managed:kimi-code', 'kimi', 'openai', 'k3', 'high']); + const model = f.captured(); + assert.deepEqual(model.selection, ROUTE); + assert.match(model.configDigest, /^[a-f0-9]{64}$/); for (const privateValue of [ACCESS, REFRESH, DEVICE]) assert.equal(JSON.stringify(model).includes(privateValue), false); assert.deepEqual(readFileSync(f.tokenFile), original); assert.deepEqual(f.starts, []); }); test('rotated or expired access credentials and changed device identity block before native launch', async t => { - const f = fixture(t), model = f.runner.capture(HINT); + const f = fixture(t), model = f.captured(); f.setToken({ refresh_token: 'different-unused-refresh' }); - assert.deepEqual(f.runner.capture(HINT), model, 'an unused refresh token is never bound'); + assert.deepEqual(f.captured(), model, 'an unused refresh token is never stamped'); f.setToken({ access_token: 'different-access-token' }); await assert.rejects(f.runner.complete(model, 'fixture'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); f.setToken(); diff --git a/test/hosts/kimi/packed-hooks.test.js b/test/hosts/kimi/packed-hooks.test.js index 229b025..e228a09 100644 --- a/test/hosts/kimi/packed-hooks.test.js +++ b/test/hosts/kimi/packed-hooks.test.js @@ -75,7 +75,7 @@ async function fixture(t, clientType = 'kimi_code_cli') { await hook('SessionEnd'); // Delivery acknowledgement precedes lock release; owned detached workers use this HOME as their cwd. await until(() => !existsSync(join(state, 'kimi-outbox.lock')) - && (!existsSync(join(state, 'native-monitors')) || readdirSync(join(state, 'native-monitors')).length === 0)); + && (!existsSync(join(state, 'kimi-turn-monitors')) || readdirSync(join(state, 'kimi-turn-monitors')).length === 0)); }; return { proxy, plugin, home, project, state, wire, append, hook, command, read, config }; } @@ -93,7 +93,7 @@ test('packed CLI prompt hooks return Kimi message context, ignore unsupported cl assert.equal(answer.decision, undefined, 'v1 never intercepts or blocks the native prompt'); assert.equal(answer.hookSpecificOutput, undefined, 'Kimi consumes its own message response schema'); f.append({ type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'first-prompt', origin: { kind: 'user' }, input: [{ type: 'text', text: PROMPT }] }); - await f.hook('TurnStarted', { turn_id: 0, prompt_id: 'first-prompt' }); + await f.hook('TurnStarted', { turn_id: 0, prompt_id: 'first-prompt', prompt: PROMPT, origin_kind: 'user' }); f.append({ type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'cancelled' }); await f.hook('Notification'); const off = await f.command('evolver-config', 'recall', 'off'); @@ -103,7 +103,7 @@ test('packed CLI prompt hooks return Kimi message context, ignore unsupported cl assert.deepEqual(await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }), {}); assert.equal(f.proxy.requests.filter(item => item.path === '/asset/fetch').length, recalls); f.append({ type: 'turn.prompt', agentId: 'main', turnId: 1, promptId: 'second-prompt', origin: { kind: 'user' }, input: [{ type: 'text', text: PROMPT }] }); - await f.hook('TurnStarted', { turn_id: 1, prompt_id: 'second-prompt' }); + await f.hook('TurnStarted', { turn_id: 1, prompt_id: 'second-prompt', prompt: PROMPT, origin_kind: 'user' }); f.append({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'cancelled' }); await f.hook('Notification'); assert.equal(readFileSync(join(f.home, 'config.toml'), 'utf8'), f.config); @@ -113,12 +113,12 @@ test('packed CLI prompt hooks return Kimi message context, ignore unsupported cl for (const [client, clientType] of [ ['CLI', 'kimi_code_cli'], ['Desktop', 'kimi_code_desktop'], -]) test(`packed ${client} observers wait for native terminal evidence before confirming turn zero or queuing reuse`, async t => { +]) test(`packed ${client} hooks wait for the session log's terminal before confirming turn zero or queuing reuse`, async t => { const f = await fixture(t, clientType); await f.hook('SessionStart'); assert.match((await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] })).message, /maxRetriesPerRequest/); f.append({ type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'prompt-zero', origin: { kind: 'user' }, input: [{ type: 'text', text: PROMPT }] }); - await f.hook('TurnStarted', { turn_id: 0, prompt_id: 'prompt-zero' }); + await f.hook('TurnStarted', { turn_id: 0, prompt_id: 'prompt-zero', prompt: PROMPT, origin_kind: 'user' }); const file = join(f.project, 'fixture.js'); writeFileSync(file, 'export const retries = 1;\n'); const edit = { tool_call_id: 'edit-call', tool_name: 'Edit', tool_input: { path: file } }; await f.hook('PreToolUse', edit); @@ -129,8 +129,7 @@ for (const [client, clientType] of [ { type: 'context.append_loop_event', agentId: 'main', event: { type: 'tool.call', uuid: 'check-uuid', toolCallId: 'check-call', turnId: '0', name: 'Bash', args: { command: 'node --test' } } }, { type: 'context.append_loop_event', agentId: 'main', event: { type: 'tool.result', parentUuid: 'check-uuid', toolCallId: 'check-call', result: { output: '# pass 1\n# fail 0', durationMs: 1, isError: false } } }, ); - assert.deepEqual(await f.hook('PostToolUse', { tool_call_id: 'check-call', tool_name: 'Bash', tool_input: { command: 'node --test' }, tool_output: 'truncated observer text' }), {}); - assert.deepEqual(await f.hook('Stop'), {}); + assert.deepEqual(await f.hook('Stop', { turn_id: 0 }), {}); assert.deepEqual(f.read('kimi-distill-ranges.json'), {}); assert.equal(f.proxy.requests.some(item => item.path === '/asset/reuse-result'), false); f.append( @@ -139,11 +138,11 @@ for (const [client, clientType] of [ { type: 'context.append_loop_event', agentId: 'main', event: { type: 'step.end', uuid: 'final-step', turnId: '0', finishReason: 'end_turn', providerFinishReason: 'completed' } }, { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }, ); - await f.hook('Notification'); + await f.hook('Notification', { turn_id: 0 }); const candidate = await until(() => Object.values(f.read('kimi-distill-ranges.json'))[0]); assert.deepEqual(candidate.turns.map(({ turn }) => turn), [1], 'native turn zero is the first internal turn'); - assert.equal(candidate.model.alias, 'route-a'); - assert.equal(candidate.model.model, 'fixture-model'); + assert.deepEqual(candidate.model.selection, { alias: 'route-a', model: 'fixture-model', protocol: 'openai', effort: 'off' }); + assert.match(candidate.model.configDigest, /^[a-f0-9]{64}$/); assert.equal(JSON.stringify(candidate).includes('fixture-not-a-real-key'), false); await until(() => f.proxy.requests.find(item => item.path === '/asset/reuse-result')); await f.hook('Notification'); await f.hook('SessionEnd'); diff --git a/test/hosts/kimi/profile.test.js b/test/hosts/kimi/profile.test.js index e76c35d..70977c0 100644 --- a/test/hosts/kimi/profile.test.js +++ b/test/hosts/kimi/profile.test.js @@ -3,6 +3,8 @@ import { join } from 'node:path'; import { test } from 'node:test'; import kimi from '../../../hosts/kimi/profile.js'; +import { EVENTS } from '../../../lib/agent/events.js'; +import { defineProfile } from '../../../lib/agent/profile.js'; import { ownerProbeOf } from '../../../lib/runtime/process/owner.js'; const payload = { session_id: 'session-fixture', cwd: join('D:', 'fixture'), client_type: 'kimi_code_cli', hook_event_name: 'UserPromptSubmit', prompt: [{ type: 'text', text: ' Fix retry. ' }, { type: 'image', image_url: 'untrusted' }, { type: 'text', text: 'Run tests.' }], is_steer: false }; @@ -28,41 +30,52 @@ test('Windows Desktop hooks accept the local Desktop client and Kimi Code owner' assert.equal(owner('Other Kimi Code.exe'), null); }); -test('Kimi uses its native prompt message contract and observer events cannot inject context', () => { +test('Kimi uses its native prompt message contract and lifecycle hooks cannot inject context', () => { assert.equal(kimi.payload.prompt(payload), 'Fix retry. \nRun tests.'); + assert.equal(kimi.payload.prompt({ ...payload, hook_event_name: 'TurnStarted', prompt: ' Fix retry. ' }), 'Fix retry.'); assert.deepEqual(kimi.reply.context('Recall text', 'UserPromptSubmit'), { message: 'Recall text' }); - assert.deepEqual(kimi.reply.context('Ignored', 'PostToolUse'), {}); + assert.deepEqual(kimi.reply.context('Ignored', 'Stop'), {}); assert.deepEqual(kimi.reply.notice('Later notice', 'SessionStart'), {}); assert.equal(kimi.reply.toolResultContext, false); assert.equal(kimi.commands.entry, 'cli-only'); - assert.deepEqual(kimi.native.events, ['TurnStarted', 'Stop', 'StopFailure', 'Interrupt', 'TaskStarted', 'Notification']); - for (const event of kimi.native.events) assert.equal(kimi.events[event], undefined); + for (const event of ['TurnStarted', 'Stop', 'StopFailure', 'Interrupt', 'Notification']) assert.equal(kimi.events[event], EVENTS.TURN_START); + assert.equal(kimi.turnEnd.statusRecord.confirmCompletion, true); }); test('native profile scope and turn zero survive identity normalization; steer and foreign agents are ignored', () => { const home = join('fixture', 'profile-a'); - const a = kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: home }, nativeScope: 'a'.repeat(64) }); - const b = kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: join('fixture', 'profile-b') }, nativeScope: 'b'.repeat(64) }); + const a = kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: home }, recordScope: 'a'.repeat(64) }); + const b = kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: join('fixture', 'profile-b') }, recordScope: 'b'.repeat(64) }); assert.notEqual(a, b); assert.equal(a.includes(home), false); + assert.equal(kimi.turnEnd.statusRecord.nativeSessionOf(a, 'a'.repeat(64)), 'session-fixture'); + assert.equal(kimi.turnEnd.statusRecord.nativeSessionOf(a, 'b'.repeat(64)), null); assert.equal(kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: home } }), null); - assert.equal(kimi.payload.sessionId(payload, { nativeScope: home }), null); - assert.equal(kimi.payload.sessionId({ ...payload, session_id: 'x'.repeat(129) }, { nativeScope: 'a'.repeat(64) }), null); + assert.equal(kimi.payload.sessionId(payload, { recordScope: home }), null); + assert.equal(kimi.payload.sessionId({ ...payload, session_id: 'x'.repeat(129) }, { recordScope: 'a'.repeat(64) }), null); assert.equal(kimi.payload.turnId({ ...payload, hook_event_name: 'TurnStarted', turn_id: 0 }), '0'); assert.equal(kimi.payload.turnId({ ...payload, turn_id: 9 }), null); - assert.equal(kimi.payload.turnId({ ...payload, hook_event_name: 'PostToolUse' }, { nativeFacts: { toolTurnId: '0' } }), '0'); assert.equal(kimi.payload.isIgnored(payload), false); for (const changed of [{ is_steer: true }, { agent_id: 'helper' }, { origin_kind: 'cron' }, { client_type: 'unknown' }]) assert.equal(kimi.payload.isIgnored({ ...payload, ...changed }), true); assert.equal(kimi.payload.sessionId({ session_id: '../outside' }), null); }); test('global default model and truncated hook output are never candidate authority', () => { - const facts = { status: 'ok', schemaSupported: true, bind: { turnId: '0' }, modelHint: { alias: 'route-b', effort: 'off' }, terminal: { turnId: '0', reason: 'failed', answer: '' }, record: { checks: [{ command: 'npm test', failed: true }] }, toolTurnId: '0' }; assert.equal(kimi.payload.model({ ...payload, model: 'default-a' }), null); - assert.deepEqual(kimi.payload.model(payload, { nativeFacts: facts }), facts.modelHint); assert.equal(kimi.evidence.toolNote({ ...payload, tool_output: 'tests passed' }), null); - assert.equal(kimi.turnEnd.reason(payload), 'unknown'); - assert.equal(kimi.turnEnd.reason(payload, { nativeFacts: facts }), 'error'); - assert.deepEqual(kimi.evidence.turnRecord(payload, { nativeFacts: facts }), facts.record); - assert.equal(kimi.evidence.turnRecord(payload, { nativeFacts: { ...facts, status: 'partial' } }), null); + assert.equal(kimi.evidence.turnRecord, undefined); +}); + +test('a turn\'s route is the one its log names, under the stamp it began and ended with', () => { + const route = { alias: 'custom/model-b', model: 'upstream-b', protocol: 'openai', effort: null }; + assert.deepEqual(kimi.turnEnd.modelOf({ model: route }, 'stamp', 'stamp'), { selection: route, configDigest: 'stamp' }); + assert.deepEqual(kimi.turnEnd.modelOf({ model: route }, 'stamp', 'other'), { unavailable: 'route_changed' }); + assert.deepEqual(kimi.turnEnd.modelOf({ model: route }, null, 'stamp'), { unavailable: 'credential_missing' }); + assert.deepEqual(kimi.turnEnd.modelOf({ model: null }, 'stamp', 'stamp'), { unavailable: 'route_unresolved' }); +}); + +test('a status record is a database query or a session log, never both', () => { + const { name, label, ...spec } = { ...kimi }; + const statusRecord = { ...kimi.turnEnd.statusRecord, file: () => 'state.db', query: 'SELECT 1' }; + assert.throws(() => defineProfile({ ...spec, name: 'mixed', label: 'Mixed', turnEnd: { ...kimi.turnEnd, statusRecord } }), /statusRecord requires/); }); diff --git a/test/hosts/kimi/readers.test.js b/test/hosts/kimi/readers.test.js index 276792f..b2411e8 100644 --- a/test/hosts/kimi/readers.test.js +++ b/test/hosts/kimi/readers.test.js @@ -1,20 +1,20 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { decodeNativeFacts, taskIdsOf } from '../../../hosts/kimi/transcript.js'; +import { taskIdsOf, turnStatusOf } from '../../../hosts/kimi/transcript.js'; const meta = { type: 'metadata', protocol_version: '1.5' }; const profile = { type: 'profile.bind', agentId: 'main', modelAlias: 'custom/model-b', thinkingEffort: 'off' }; const prompt = (id, promptId = `prompt-${id}`) => ({ type: 'turn.prompt', agentId: 'main', turnId: id, promptId, origin: { kind: 'user' }, input: [{ type: 'text', text: 'same prompt' }] }); const event = value => ({ type: 'context.append_loop_event', agentId: 'main', event: value }); +const ended = (turnId, reason = 'completed') => ({ type: 'turn.ended', agentId: 'main', turnId, reason }); +const status = (records, tasks = [], turnId = '0') => turnStatusOf(records, { turnId, tasks }); -test('Kimi native turn zero and the active binding are distinct from a global default model', () => { - const p = { ...prompt(0), $offset: 128 }; - const facts = decodeNativeFacts([meta, profile, p], [], { hook_event_name: 'TurnStarted', turn_id: 0 }); - assert.equal(facts.schemaSupported, true); - assert.deepEqual(facts.bind, { turnId: '0', promptId: 'prompt-0', origin: 'user', mark: 128 }); - assert.deepEqual(facts.modelHint, { alias: 'custom/model-b', effort: 'off' }); - assert.equal(facts.terminal, null); +test('Kimi native turn zero is a real identity and runs until its terminal', () => { + const row = status([meta, profile, { ...prompt(0), $offset: 128 }]); + assert.equal(row.status, 'accepted'); + assert.deepEqual(row.record.checks, []); + assert.equal(row.model, null, 'a profile binding alone is no route the turn ran on'); }); test('a matched native terminal cannot prove a silent Bash check passed and only the final model text is its answer', () => { @@ -27,11 +27,12 @@ test('a matched native terminal cannot prove a silent Bash check passed and only { type: 'agent.message.appended', message: { message: { role: 'assistant', content: [{ type: 'text', text: 'unrelated hook notice' }] } } }, { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }, ]; - const facts = decodeNativeFacts(records, [], { hook_event_name: 'Stop' }, { turnId: '0' }); - assert.deepEqual(facts.record.checks, [{ command: 'npm test', exit: null, failed: null }]); - assert.equal(facts.pendingVerification, false); - assert.equal(facts.record.answer, 'Fixed the retry; tests pass.'); - assert.deepEqual(facts.terminal, { turnId: '0', reason: 'completed', answer: 'Fixed the retry; tests pass.', modelHint: { alias: 'custom/model-b', effort: null, model: 'upstream-b', protocol: 'openai' } }); + const row = status(records); + assert.deepEqual(row.record.checks, [{ command: 'npm test', exit: null, failed: null }]); + assert.equal(row.status, 'completed'); + assert.equal(row.answer, 'Fixed the retry; tests pass.'); + assert.equal(row.record.answer, row.answer); + assert.deepEqual(row.model, { alias: 'custom/model-b', effort: null, model: 'upstream-b', protocol: 'openai' }); }); test('a bound turn reads a terminal after the next prompt with only its own evidence', () => { @@ -47,15 +48,14 @@ test('a bound turn reads a terminal after the next prompt with only its own evid event({ type: 'content.part', uuid: 'new-answer', turnId: 1, stepUuid: 'new-final', part: { type: 'text', text: 'Second answer' } }), event({ type: 'step.end', uuid: 'new-final', turnId: 1, finishReason: 'end_turn', providerFinishReason: 'completed' }), ]; - const ended = { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }; - const records = [...first, ...second, ended]; - const facts = decodeNativeFacts(records, [], {}, { turnId: '0' }); - assert.equal(facts.bind.promptId, 'prompt-0'); - assert.deepEqual(facts.record.checks, [{ command: 'npm test', exit: null, failed: false }]); - assert.deepEqual(facts.terminal, { turnId: '0', reason: 'completed', answer: 'First answer', - modelHint: { alias: 'first', effort: null, model: 'first-model', protocol: 'openai' } }); - assert.equal(decodeNativeFacts(records, [], { turn_id: 0 }).terminal, null, 'a hook payload alone cannot extend the read'); - assert.equal(decodeNativeFacts([...first, { ...ended, reason: 'failed' }, ...second, ended], [], {}, { turnId: '0' }).status, 'partial'); + const records = [...first, ...second, ended(0)]; + const row = status(records); + assert.equal(row.status, 'completed'); + assert.deepEqual(row.record.checks, [{ command: 'npm test', exit: null, failed: false }]); + assert.equal(row.answer, 'First answer'); + assert.deepEqual(row.model, { alias: 'first', effort: null, model: 'first-model', protocol: 'openai' }); + assert.equal(status(records, [], '1').status, 'accepted', 'the next turn has not ended'); + assert.equal(status([...first, ended(0, 'failed'), ...second, ended(0)]), null, 'contradictory terminals are no status'); }); test('a background Bash is settled by its task metadata, never by successful TaskOutput retrieval', () => { @@ -66,49 +66,52 @@ test('a background Bash is settled by its task metadata, never by successful Tas event({ type: 'tool.result', parentUuid: 'output-uuid', toolCallId: 'output-call', result: { output: 'retrieval_status: success\nstatus: failed', durationMs: 1 } }), ]; const task = { taskId: 'bash-task', kind: 'process', parentToolCallId: 'bg-call', command: 'npm test', status: 'failed', exitCode: 7 }; - assert.deepEqual(decodeNativeFacts(records, [task], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', exit: 7, failed: true }]); - assert.deepEqual(decodeNativeFacts(records, [{ ...task, parentToolCallId: 'another-turn' }], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', exit: null, failed: null }]); - for (const [status, exitCode, exit, failed] of [['failed', 0, 0, true], ['failed', undefined, null, true], ['completed', 0, 0, false], ['completed', undefined, null, null], ['running', 0, null, null]]) { - const facts = decodeNativeFacts(records, [{ ...task, status, exitCode }], {}, { turnId: '0' }); - assert.deepEqual(facts.record.checks, [{ command: 'npm test', exit, failed }]); - assert.equal(facts.pendingVerification, status === 'running'); + const done = [...records, ended(0)]; + assert.deepEqual(status(done, [task]).record.checks, [{ command: 'npm test', exit: 7, failed: true }]); + assert.deepEqual(status(done, [{ ...task, parentToolCallId: 'another-turn' }]).record.checks, [{ command: 'npm test', exit: null, failed: null }]); + for (const [taskStatus, exitCode, exit, failed] of [['failed', 0, 0, true], ['failed', undefined, null, true], ['completed', 0, 0, false], ['completed', undefined, null, null], ['running', 0, null, null]]) { + const row = status(done, [{ ...task, status: taskStatus, exitCode }]); + assert.deepEqual(row.record.checks, [{ command: 'npm test', exit, failed }]); + assert.equal(row.status, taskStatus === 'running' ? 'pending' : 'completed', 'a running background check keeps an ended turn pending'); } }); -test('late observer events cannot guess the latest of two identical prompts', () => { - const records = [meta, profile, { ...prompt(0), $offset: 100 }, { ...prompt(1), $offset: 200 }]; - assert.equal(decodeNativeFacts(records, [], { hook_event_name: 'Stop' }).bind, null); - assert.equal(decodeNativeFacts(records, [], { hook_event_name: 'Stop' }, { after: { offset: 100 } }).bind, null); - assert.equal(decodeNativeFacts(records, [], {}, { after: { offset: 200 } }).bind.turnId, '1'); - assert.equal(decodeNativeFacts(records, [], {}, { turnId: '0' }).bind.turnId, '0'); - assert.equal(decodeNativeFacts(records, [], { hook_event_name: 'UserPromptSubmit' }).bind, null); +test('a turn id names its own prompt, and an unknown or ambiguous one has no status', () => { + const records = [meta, profile, prompt(0), prompt(1)]; + assert.equal(status(records, [], '1').status, 'accepted'); + assert.equal(status(records, [], '2'), null); + assert.equal(status([...records, prompt(1, 'other-prompt')], [], '1'), null); + assert.equal(status(records, [], 'not-a-turn'), null); }); test('unsupported schemas and foreign agents do not supply evidence', () => { - assert.equal(decodeNativeFacts([{ ...meta, protocol_version: '9.0' }, prompt(0)], [], { turn_id: 0 }).status, 'unsupported'); - assert.equal(decodeNativeFacts([meta, { ...prompt(0), agentId: 'helper' }], [], { turn_id: 0 }).bind, null); - const facts = decodeNativeFacts([prompt(0)], [], { turn_id: 0 }, { schemaVersion: '1.5', modelHint: { alias: 'cached', effort: 'off' } }); - assert.deepEqual(facts.modelHint, { alias: 'cached', effort: 'off' }); - assert.equal(facts.bind.turnId, '0'); + assert.equal(status([{ ...meta, protocol_version: '9.0' }, prompt(0)]), null); + assert.equal(status([prompt(0)]), null); + assert.equal(status([meta, { ...prompt(0), agentId: 'helper' }]), null); + assert.equal(status([meta, { ...prompt(0), origin: { kind: 'cron' } }]), null); }); test('fixed task discovery rejects paths and does not inspect arbitrary output paths', () => { const records = [event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: 'bash-task' } }), event({ type: 'tool.result', result: { output: 'task_id: bash-task\nstatus: running' } }), event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: '../outside' } })]; - assert.deepEqual(taskIdsOf(records, { task_id: 'other-task' }), ['other-task', 'bash-task']); + assert.deepEqual(taskIdsOf(records), ['bash-task']); + const many = Array.from({ length: 70 }, (_, index) => event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: `task-${index}` } })); + const ids = taskIdsOf([...many, many[0]]); + assert.equal(ids.length, 64); + assert.equal(ids.at(-1), 'task-0', 'a task the newest turn names again is kept'); }); test('duplicate native events do not duplicate checks and mismatched results remain unknown', () => { const call = event({ type: 'tool.call', uuid: 'id', turnId: '0', toolCallId: 'test', name: 'Bash', args: { command: 'npm test' } }); const result = event({ type: 'tool.result', parentUuid: 'id', toolCallId: 'test', result: { output: '# pass 1\n# fail 0', durationMs: 1 } }); - assert.deepEqual(decodeNativeFacts([meta, prompt(0), call, call, result, result], [], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', exit: null, failed: false }]); - assert.deepEqual(decodeNativeFacts([meta, prompt(0), call, { ...result, event: { ...result.event, parentUuid: 'another-turn' } }], [], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', exit: null, failed: null }]); + assert.deepEqual(status([meta, prompt(0), call, call, result, result]).record.checks, [{ command: 'npm test', exit: null, failed: false }]); + assert.deepEqual(status([meta, prompt(0), call, { ...result, event: { ...result.event, parentUuid: 'another-turn' } }]).record.checks, [{ command: 'npm test', exit: null, failed: null }]); }); test('foreground native errors and summaries retain verdicts without inventing process exit codes', () => { const call = event({ type: 'tool.call', uuid: 'id', turnId: '0', toolCallId: 'test', name: 'Bash', args: { command: 'npm test' } }); for (const [output, isError, failed] of [['# pass 1\n# fail 0', false, false], ['', false, null], ['ordinary output', undefined, null], ['timed out', true, true]]) { const result = event({ type: 'tool.result', parentUuid: 'id', toolCallId: 'test', result: { output, durationMs: 1, isError } }); - assert.deepEqual(decodeNativeFacts([meta, prompt(0), call, result], [], {}, { turnId: '0' }).record.checks, + assert.deepEqual(status([meta, prompt(0), call, result]).record.checks, [{ command: 'npm test', exit: null, failed }]); } }); @@ -116,20 +119,32 @@ test('foreground native errors and summaries retain verdicts without inventing p test('conflicting call identities and completed terminals containing an error cannot verify a turn', () => { const call = event({ type: 'tool.call', uuid: 'id', turnId: '0', toolCallId: 'test', name: 'Bash', args: { command: 'npm test' } }); const result = event({ type: 'tool.result', parentUuid: 'id', toolCallId: 'test', result: { output: '', durationMs: 1 } }); - const facts = decodeNativeFacts([meta, prompt(0), call, { ...call, event: { ...call.event, args: { command: 'npm run build' } } }, result, { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed', error: { code: 'provider.api_error' } }], [], {}, { turnId: '0' }); - assert.equal(facts.status, 'partial'); - assert.equal(facts.terminal, null); - assert.equal(facts.record, null); + const conflicting = { ...call, event: { ...call.event, args: { command: 'npm run build' } } }; + assert.equal(status([meta, prompt(0), call, conflicting, result, ended(0)]), null); + assert.equal(status([meta, prompt(0), call, result, { ...ended(0), error: { code: 'provider.api_error' } }]), null); + assert.equal(status([meta, prompt(0), call, result, ended(0, 'failed')]).status, 'failed'); + assert.equal(status([meta, prompt(0), ended(0, 'interrupted')]).status, 'aborted'); }); -test('a later profile binding and a truncated continuation cannot become an earlier turn final answer', () => { +test('a later turn and a truncated continuation cannot become an earlier turn final answer', () => { const records = [meta, profile, prompt(0), event({ type: 'content.part', uuid: 'earlier', turnId: '0', stepUuid: 'first-step', part: { type: 'text', text: 'Earlier answer' } }), event({ type: 'step.end', uuid: 'first-step', turnId: '0', finishReason: 'end_turn', providerFinishReason: 'completed' }), event({ type: 'step.end', uuid: 'last-step', turnId: '0', finishReason: 'max_tokens', providerFinishReason: 'truncated' }), { ...profile, modelAlias: 'later-route' }, prompt(1), ]; - const facts = decodeNativeFacts(records, [], {}, { turnId: '0' }); - assert.deepEqual(facts.modelHint, { alias: 'custom/model-b', effort: 'off' }); - assert.equal(facts.record.answer, ''); + assert.equal(status(records).record.answer, ''); +}); + +test('a turn that runs through the unread middle of a long log has no status', () => { + const at = (record, offset) => Object.defineProperty({ ...record }, '$offset', { value: offset }); + const check = event({ type: 'tool.call', uuid: 'c', turnId: '0', toolCallId: 'c', name: 'Bash', args: { command: 'npm test', run_in_background: true } }); + const records = [at(meta, 0), at(prompt(0), 100), at(ended(0), 9_000_000)]; + assert.equal(turnStatusOf(records, { turnId: '0', unread: [65_536, 5_000_000] }), null); + assert.equal(turnStatusOf(records, { turnId: '0' }).status, 'completed', 'the same records read whole'); + const unread = [65_536, 5_000_000]; + const log = [at(meta, 0), at(prompt(0), 100), at(check, 200), at(ended(0), 300), at(prompt(1), 400), at(prompt(2), 6_000_000), at(ended(2), 6_000_100)]; + assert.equal(turnStatusOf(log, { turnId: '0', unread }).status, 'pending', 'a turn that ends in the head is still read'); + assert.equal(turnStatusOf(log, { turnId: '1', unread }), null, 'a turn that runs on into the unread span is not'); + assert.equal(turnStatusOf(log, { turnId: '2', unread }).status, 'completed', 'a turn wholly in the tail is read'); }); diff --git a/test/runtime/acp-model.test.js b/test/runtime/acp-model.test.js index c4a6c56..c397f57 100644 --- a/test/runtime/acp-model.test.js +++ b/test/runtime/acp-model.test.js @@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'; import { createConnection } from 'node:net'; import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; -import { declarationFor } from '../fixtures/acp/declaration.mjs'; +import { capturedModel, COMPLETION_BUDGET_MS, declarationFor } from '../fixtures/acp/declaration.mjs'; import { CHILD_FILE, guardianFile, OWNER_FILE, START_FILE } from '../../lib/runtime/process/acp-child.js'; import { windowsJobExecutable } from '../../lib/runtime/process/windows-job.js'; @@ -46,28 +46,33 @@ function promptStarted(dir) { try { return JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')).promptStarted === true; } catch { return false; } } -test('an ACP route snapshot binds the selected configuration and credential without retaining the key', async t => { +test('an ACP configuration stamp covers the configuration and its credential without retaining the key', async t => { const { runner, config } = fixture(t); assert.ok(runner, 'the declaration supplies the model port'); - const selected = await runner.capture({ alias: 'route-a' }); - assert.equal(selected.alias, 'route-a'); - assert.equal(selected.model, 'fixture-model'); - assert.equal(selected.providerId, 'fixture'); - assert.equal(selected.protocol, 'openai'); - assert.match(selected.configRevision, /^[a-f0-9]{64}$/); - assert.match(selected.binding, /^[a-f0-9]{64}$/); - assert.equal(JSON.stringify(selected).includes(SECRET), false); + assert.equal(runner.capture, true); + const stamp = runner.configStamp(); + assert.match(stamp, /^[a-f0-9]{64}$/); + assert.equal(runner.configStamp(), stamp); writeFileSync(config, readFileSync(config, 'utf8').replace(SECRET, 'different-fixture-key')); - const changed = await runner.capture({ alias: 'route-a' }); - assert.notEqual(changed.binding, selected.binding); + assert.notEqual(runner.configStamp(), stamp); +}); + +test('a model captured without a stamp or route cannot run', async t => { + const { runner, dir } = fixture(t); + await assert.rejects(runner.complete({ unavailable: 'route_changed' }, 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); + await assert.rejects(runner.complete(null, 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'route_unresolved' }); + const model = capturedModel(runner, { alias: 'route-a' }); + await assert.rejects(runner.complete({ ...model, selection: { ...model.selection, model: 'other-model' } }, 'candidate'), + { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); + assert.equal(existsSync(join(dir, 'receipt.json')), false); }); test('an ACP completion selects the captured route, uses a private credential environment and closes before cleanup', async t => { const { runner, dir, declaration } = fixture(t); const args = declaration.args(), extras = ['', 'path with spaces', 'trailing slash\\', 'embedded "quote"', 'đường dẫn']; declaration.args = () => [...args, ...extras]; - const snapshot = await runner.capture({ alias: 'route-a' }); - const answer = await runner.complete(snapshot, 'the private candidate digest', AbortSignal.timeout(10_000)); + const snapshot = capturedModel(runner, { alias: 'route-a' }); + const answer = await runner.complete(snapshot, 'the private candidate digest', AbortSignal.timeout(COMPLETION_BUDGET_MS)); assert.equal(answer.answer, 'answer: the private candidate digest'); const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); assert.equal(receipt.hasPrivateCredential, true); @@ -83,7 +88,7 @@ test('a native root exiting normally cannot leave its stdio-ignored descendant a const { runner, dir } = fixture(t, 'exit-descendant'); const file = join(dir, 'receipt.json'); try { - const result = await runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(10_000)); + const result = await runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS)); assert.equal(result.answer, 'answer: candidate'); const receipt = JSON.parse(readFileSync(file, 'utf8')); assert.equal(receipt.descendantReady, true); @@ -107,51 +112,53 @@ test('a native root exiting normally cannot leave its stdio-ignored descendant a test('an ACP completion rejects an incomplete trailing frame before accepting native success', async t => { const { runner, dir } = fixture(t, 'trailing-frame'); - const snapshot = await runner.capture({ alias: 'route-a' }); - await assert.rejects(runner.complete(snapshot, 'candidate', AbortSignal.timeout(10_000))); + const snapshot = capturedModel(runner, { alias: 'route-a' }); + await assert.rejects(runner.complete(snapshot, 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS))); const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); assert.equal(existsSync(receipt.home), false); }); test('an ACP completion rejects a late update belonging to another session', async t => { const { runner } = fixture(t, 'late-session'); - const snapshot = await runner.capture({ alias: 'route-a' }); - await assert.rejects(runner.complete(snapshot, 'candidate', AbortSignal.timeout(10_000))); + const snapshot = capturedModel(runner, { alias: 'route-a' }); + await assert.rejects(runner.complete(snapshot, 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS))); }); test('an ACP completion rejects answer chunks sent after the prompt response', async t => { const { runner } = fixture(t, 'late-chunk'); - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(10_000))); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS))); }); test('a changed configuration or account binding blocks before any native request', async t => { const { runner, dir, config, env } = fixture(t); const original = readFileSync(config, 'utf8'); - const snapshot = runner.capture({ alias: 'route-a' }); + const snapshot = capturedModel(runner, { alias: 'route-a' }); writeFileSync(config, original.replace('example.invalid', 'other.invalid')); await assert.rejects(runner.complete(snapshot, 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); assert.equal(existsSync(join(dir, 'receipt.json')), false); writeFileSync(config, '[provider]\nendpoint="https://example.invalid/v1"\nkey_env="FIXTURE_ACCOUNT_KEY"\n'); env.FIXTURE_ACCOUNT_KEY = SECRET; - const envSnapshot = runner.capture({ alias: 'route-a' }); + const envSnapshot = capturedModel(runner, { alias: 'route-a' }); env.FIXTURE_ACCOUNT_KEY = 'a-different-fixture-account'; await assert.rejects(runner.complete(envSnapshot, 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); assert.equal(existsSync(join(dir, 'receipt.json')), false); env.FIXTURE_ACCOUNT_KEY = SECRET; - assert.equal((await runner.complete(envSnapshot, 'candidate', AbortSignal.timeout(10_000))).answer, 'answer: candidate'); + assert.equal((await runner.complete(envSnapshot, 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS))).answer, 'answer: candidate'); nativeClosed(dir); }); -test('invalid native config yields a typed sanitized preflight error', t => { +test('invalid native config yields no stamp and a typed sanitized preflight error', async t => { const { runner, config, dir } = fixture(t); + const model = capturedModel(runner, { alias: 'route-a' }); writeFileSync(config, `[provider]\nkey = "${SECRET}"\ninvalid = [\n`); - assert.throws(() => runner.capture({ alias: 'route-a' }), error => { + assert.equal(runner.configStamp(), null); + await assert.rejects(runner.complete({ ...model, configDigest: 'stale' }, 'candidate'), error => { assert.equal(error.code, 'MODEL_UNAVAILABLE'); assert.equal(error.message.includes(SECRET), false); return true; }); writeFileSync(config, '#'.repeat(1024 * 1024 + 1)); - assert.throws(() => runner.capture({ alias: 'route-a' }), { code: 'MODEL_UNAVAILABLE' }); + assert.equal(runner.configStamp(), null); assert.equal(existsSync(join(dir, 'receipt.json')), false); }); @@ -172,7 +179,7 @@ test('an owner exiting before fork does not block a fresh ACP completion', async assert.equal(origin, createHash('sha256').update(config).digest('hex')); assert.equal(existsSync(join(home, CHILD_FILE)), false); - const result = await runner.complete(runner.capture({ alias: 'route-a' }), 'fresh candidate'); + const result = await runner.complete(capturedModel(runner, { alias: 'route-a' }), 'fresh candidate'); assert.equal(result.answer, 'answer: fresh candidate'); nativeClosed(dir); assert.equal(existsSync(home), false, 'the abandoned prelaunch scratch was removed'); @@ -185,11 +192,11 @@ test('orphan recovery rejects missing or malformed child receipts after launch w const ownerPid = 2147483647; assert.throws(() => process.kill(ownerPid, 0)); writeFileSync(join(orphan, OWNER_FILE), JSON.stringify({ nonce: 'a'.repeat(32), ownerPid, origin: createHash('sha256').update(config).digest('hex') })); - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); assert.equal(existsSync(orphan), true); for (const content of ['{incomplete', JSON.stringify({ nonce: 'a'.repeat(32) }), JSON.stringify({ nonce: 'b'.repeat(32), guardianPid: ownerPid, nativePid: ownerPid })]) { writeFileSync(join(orphan, CHILD_FILE), content); - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); assert.equal(existsSync(orphan), true); } assert.equal(existsSync(join(dir, 'receipt.json')), false, 'a fresh native process was not started'); @@ -201,7 +208,7 @@ test('orphan recovery ignores a matching scratch marker with a nonpositive owner t.after(() => rmSync(orphan, { recursive: true, force: true })); const origin = createHash('sha256').update(config).digest('hex'); writeFileSync(join(orphan, OWNER_FILE), JSON.stringify({ nonce: 'd'.repeat(32), ownerPid: 0, origin })); - assert.equal((await runner.complete(runner.capture({ alias: 'route-a' }), 'candidate')).answer, 'answer: candidate'); + assert.equal((await runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate')).answer, 'answer: candidate'); assert.equal(existsSync(orphan), true); }); @@ -215,12 +222,12 @@ test('orphan recovery removes only a matching receipt with proof all owned proce const receipt = { nonce, guardianPid: dead, nativePid: dead }; if (process.platform === 'win32') { writeFileSync(join(orphan, CHILD_FILE), JSON.stringify(receipt)); - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); assert.equal(existsSync(orphan), true, 'dead root PIDs alone cannot prove a Windows job is empty'); Object.assign(receipt, { helperPid: dead, jobEmpty: true }); } writeFileSync(join(orphan, CHILD_FILE), JSON.stringify(receipt)); - assert.equal((await runner.complete(runner.capture({ alias: 'route-a' }), 'candidate')).answer, 'answer: candidate'); + assert.equal((await runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate')).answer, 'answer: candidate'); assert.equal(existsSync(orphan), false); nativeClosed(dir); }); @@ -228,7 +235,7 @@ test('orphan recovery removes only a matching receipt with proof all owned proce test('cancelling a pending completion waits for its owned native process before removing scratch', async t => { const { runner, dir } = fixture(t, 'hang'); const controller = new AbortController(); - const result = runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal); + const result = runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', controller.signal); const rejection = assert.rejects(result, { code: 'ABORT_ERR' }); await until(() => promptStarted(dir)); controller.abort(); @@ -252,7 +259,7 @@ test('cancelling after native completion during cleanup rejects the answer after return facts; }; - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal), { code: 'ABORT_ERR' }); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', controller.signal), { code: 'ABORT_ERR' }); assert.equal(cancelledDuringCleanup, true); nativeClosed(dir); }); @@ -303,7 +310,7 @@ test('a late stop preserves the closed guardian result without signaling the for test('a stalled POSIX guardian retains ownership until it can stop the native group', { skip: process.platform === 'win32' }, async (t) => { const { runner, dir } = fixture(t, 'hang'); const controller = new AbortController(); - const completion = runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal); + const completion = runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', controller.signal); const rejection = assert.rejects(completion, { code: 'ACP_CLEANUP_FAILED' }); await until(() => promptStarted(dir)); const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); @@ -378,7 +385,7 @@ test('an abrupt Windows guardian death kills the whole job but retains scratch w test('hard-killing the exact Windows job keeper reaps native processes through kill-on-close', { skip: process.platform !== 'win32' }, async t => { const { runner, dir } = fixture(t, 'hang'); const controller = new AbortController(); - const completion = runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal); + const completion = runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', controller.signal); const rejected = assert.rejects(completion, { code: 'ACP_CLEANUP_FAILED' }); try { const job = await activeFixtureJob(dir, process.pid); @@ -416,13 +423,13 @@ try { test('the captured thinking choice must match native evidence even when the native default is high', async t => { const { runner, dir } = fixture(t, 'ignore-effort'); - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a', effort: 'off' }), 'candidate', AbortSignal.timeout(10_000))); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a', effort: 'off' }), 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS))); nativeClosed(dir); }); test('ACP selects thinking off explicitly instead of inheriting the native high default', async t => { const { runner, dir } = fixture(t); - assert.equal((await runner.complete(runner.capture({ alias: 'route-a', effort: 'off' }), 'candidate', AbortSignal.timeout(10_000))).answer, 'answer: candidate'); + assert.equal((await runner.complete(capturedModel(runner, { alias: 'route-a', effort: 'off' }), 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS))).answer, 'answer: candidate'); nativeClosed(dir); }); @@ -430,7 +437,7 @@ test('ACP rejects failed, unverified, truncated or unsafe native completions and for (const mode of ['native-error', 'missing-provider-finish', 'wrong-model', 'tools-loaded', 'native-tool-call', 'missing-proof', 'truncated-proof', 'wrong-session', 'length-cutoff', 'duplicate-response', 'nonzero-exit', 'client-request', 'tool-update', 'output-limit', 'combined-output-limit']) { await t.test(mode, async t => { const { runner, dir } = fixture(t, mode); - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(10_000))); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', AbortSignal.timeout(COMPLETION_BUDGET_MS))); nativeClosed(dir); }); } @@ -439,7 +446,7 @@ test('ACP rejects failed, unverified, truncated or unsafe native completions and test('a request deadline cancels the native completion and its inherited descendant', async t => { const { runner, dir } = fixture(t, 'hang'); const controller = new AbortController(); - const rejection = assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal), { code: 'ABORT_ERR' }); + const rejection = assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', controller.signal), { code: 'ABORT_ERR' }); await until(() => promptStarted(dir)); const deadline = setTimeout(() => controller.abort(), 500); try { await rejection; } finally { clearTimeout(deadline); } @@ -452,7 +459,7 @@ test('a 500 ms startup abort sends no native prompt and leaves no scratch', asyn let home; declaration.prepare = (selected, context) => { home = context.home; return prepare(selected, context); }; const runner = modelRunnerOf(declaration, env); - await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(500)), { code: 'ABORT_ERR' }); + await assert.rejects(runner.complete(capturedModel(runner, { alias: 'route-a' }), 'candidate', AbortSignal.timeout(500)), { code: 'ABORT_ERR' }); assert.ok(home); assert.equal(existsSync(home), false); const file = join(dir, 'receipt.json'); diff --git a/test/runtime/acp-receipt.test.js b/test/runtime/acp-receipt.test.js index ea28528..7c370f3 100644 --- a/test/runtime/acp-receipt.test.js +++ b/test/runtime/acp-receipt.test.js @@ -6,7 +6,7 @@ import { test } from 'node:test'; import { pathToFileURL } from 'node:url'; import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; -import { declarationFor } from '../fixtures/acp/declaration.mjs'; +import { capturedModel, COMPLETION_BUDGET_MS, declarationFor } from '../fixtures/acp/declaration.mjs'; test('a transient receipt replacement refusal does not crash the native fixture before its prompt completes', async t => { const dir = mkdtempSync(join(tmpdir(), 'evolver-acp-receipt-')); @@ -28,7 +28,7 @@ syncBuiltinESMExports(); const declaration = declarationFor(config, receipt), args = declaration.args; declaration.args = () => ['--import', pathToFileURL(preload).href, ...args()]; const runner = modelRunnerOf(declaration, process.env); - const result = await runner.complete(runner.capture({ alias: 'route-a' }), 'receipt regression', AbortSignal.timeout(10_000)); + const result = await runner.complete(capturedModel(runner, { alias: 'route-a' }), 'receipt regression', AbortSignal.timeout(COMPLETION_BUDGET_MS)); assert.equal(result.answer, 'answer: receipt regression'); const native = JSON.parse(readFileSync(receipt, 'utf8')); assert.equal(native.promptStarted, true); diff --git a/test/runtime/native-facts.test.js b/test/runtime/native-facts.test.js deleted file mode 100644 index aa6bc0c..0000000 --- a/test/runtime/native-facts.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, renameSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; - -import { nativeFactsOf } from '../../lib/runtime/native-facts.js'; - -function fixture(t) { - const root = mkdtempSync(join(tmpdir(), 'evolver-native-facts-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - const project = join(root, 'project'); mkdirSync(project); - const home = join(root, 'home'); mkdirSync(home); - writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: project } } })); - const agent = join(home, 'sessions/workspace/session/agents/main'); mkdirSync(agent, { recursive: true }); - const wire = join(agent, 'wire.jsonl'); - const declaration = { root: () => home, workspaceIndex: 'workspaces.json', sessionId: p => p.session_id, - workspacesOf: (index) => Object.entries(index.workspaces).map(([id, { root }]) => [id, root]), - sessionDir: (workspaceId, sessionId) => join('sessions', workspaceId, sessionId), logFile: 'agents/main/wire.jsonl', - projectDir: p => p.cwd, decode: (records, tasks, payload, context) => ({ records, tasks, context }) }; - return { home, project, wire, declaration, payload: { session_id: 'session', cwd: project } }; -} - -test('native facts read complete JSONL records under the selected workspace and leave an incomplete tail for the next read', t => { - const f = fixture(t); - const first = JSON.stringify({ type: 'metadata', protocol_version: '1.5' }) + '\n'; - writeFileSync(f.wire, first + '{"type":"turn.prompt"'); - const port = nativeFactsOf(f.declaration, {}); - const read = port.read(f.payload); - assert.equal(read.status, 'partial'); - assert.equal(read.records.length, 1); - assert.equal(read.records[0].$offset, 0); - assert.equal(read.mark.offset, Buffer.byteLength(first)); - writeFileSync(f.wire, first + '{"type":"turn.prompt","turnId":0}\n'); - const next = port.read(f.payload, { after: read.mark, schemaVersion: '1.5' }); - assert.equal(next.records[0].type, 'turn.prompt'); - assert.equal(next.records[0].$offset, Buffer.byteLength(first)); - assert.equal(next.context.schemaVersion, '1.5'); -}); - -test('native decoder rejection and mismatched task filename identities are never promoted to valid facts', t => { - const f = fixture(t); - writeFileSync(f.wire, '{}\n'); - f.declaration.decode = () => ({ status: 'unsupported' }); - assert.equal(nativeFactsOf(f.declaration).read(f.payload).status, 'unsupported'); - f.declaration.taskIds = () => ['task']; - f.declaration.tasksRoot = () => join(f.wire, '..', 'tasks'); - mkdirSync(f.declaration.tasksRoot(), { recursive: true }); - writeFileSync(join(f.declaration.tasksRoot(), 'task.json'), JSON.stringify({ taskId: 'wrong' })); - assert.equal(nativeFactsOf(f.declaration).read(f.payload).status, 'partial'); -}); - -test('missing, invalid, oversized and replaced native files cannot become an empty valid record', t => { - const f = fixture(t), port = nativeFactsOf(f.declaration, {}); - assert.equal(port.read(f.payload).status, 'missing'); - writeFileSync(f.wire, '{invalid}\n'); - assert.equal(port.read(f.payload).status, 'unreadable'); - writeFileSync(f.wire, '{}\n'); - const mark = port.read(f.payload).mark; - renameSync(f.wire, `${f.wire}.old`); - writeFileSync(f.wire, '{}\n'); - assert.equal(port.read(f.payload, { after: mark }).status, 'partial'); - writeFileSync(f.wire, 'x'.repeat(4 * 1024 * 1024)); - assert.equal(port.read(f.payload).status, 'partial'); - writeFileSync(join(f.home, 'workspaces.json'), ' '.repeat(1024 * 1024 + 1)); - assert.equal(port.read(f.payload).status, 'partial'); -}); - -test('native facts refuse path traversal, duplicate workspace mappings and linked files outside home', t => { - const f = fixture(t), port = nativeFactsOf(f.declaration, {}); - writeFileSync(f.wire, '{}\n'); - assert.equal(port.read({ ...f.payload, session_id: '../session' }).status, 'unsupported'); - writeFileSync(join(f.home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { a: { root: f.project }, b: { root: f.project } } })); - assert.equal(port.read(f.payload).status, 'missing'); - const foreign = join(f.project, 'foreign'); mkdirSync(foreign); - writeFileSync(join(foreign, 'wire.jsonl'), '{}\n'); - const agent = join(f.wire, '..'); - rmSync(agent, { recursive: true }); - symlinkSync(foreign, agent, process.platform === 'win32' ? 'junction' : 'dir'); - writeFileSync(join(f.home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: f.project } } })); - assert.equal(port.read(f.payload).status, 'unreadable'); -}); diff --git a/test/runtime/native-monitor.test.js b/test/runtime/native-monitor.test.js deleted file mode 100644 index 9a051e7..0000000 --- a/test/runtime/native-monitor.test.js +++ /dev/null @@ -1,92 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; - -import { monitorNativeTurn } from '../../lib/runtime/native-monitor.js'; - -function fixture(t) { - const dir = mkdtempSync(join(tmpdir(), 'evolver-native-monitor-')); - t.after(() => rmSync(dir, { recursive: true, force: true })); - const at = { sessionId: 's', turn: 1, sessionEpoch: 500, projectDir: dir }; - const session = { open: true, turn: 1, sessionEpoch: 500, native: { turn: 1 }, pid: 123 }; - const ports = { native: {}, sessions: { read: async () => session }, owner: { isGone: () => false } }; - return { at, session, ports, dir }; -} - -test('a native monitor waits for terminal and pending check records, then exits without touching a later turn', async t => { - const f = fixture(t); - let count = 0; - await monitorNativeTurn(f.ports, f.at, async () => ({ ended: ++count > 1, waiting: count < 3, native: { turnId: '0' } }), - () => assert.fail('not aborted'), { dir: f.dir, sleep: async () => {} }); - assert.equal(count, 3); - f.session.turn = 2; - await monitorNativeTurn(f.ports, f.at, () => assert.fail('stale'), () => assert.fail('stale'), { dir: f.dir }); -}); - -test('a deferred turn monitor observes its own owner and leaves the newer turn open', async t => { - const f = fixture(t); - const previous = { ...f.session, open: false, turnId: '0', native: { turn: 1, turnId: '0' } }; - Object.assign(f.session, { turn: 2, pid: 321, native: { turn: 2 }, deferredNativeTurns: [previous] }); - let reads = 0, slept = 0; - f.ports.owner.isGone = (pid) => { - assert.equal(pid, previous.pid); - return reads === 2; - }; - await monitorNativeTurn(f.ports, f.at, async (at) => { - assert.deepEqual(at, f.at); - reads++; - return { waiting: true, native: previous.native }; - }, () => assert.fail('a deferred monitor cannot abort the current turn'), { - dir: f.dir, sleep: async () => { slept++; }, - }); - assert.equal(reads, 2, 'the monitor rechecks native evidence after the old owner exits'); - assert.equal(slept, 1); - assert.equal(f.session.open, true); - assert.equal(f.session.turn, 2); -}); - -test('an old worker cannot observe a recreated session with the same turn number', async t => { - const f = fixture(t); - await monitorNativeTurn(f.ports, { ...f.at, sessionEpoch: 400, projectDir: join(f.dir, 'old-project') }, - () => assert.fail('an old worker must not reconcile a newer epoch'), () => assert.fail('an old worker must not close a newer epoch'), { dir: f.dir }); -}); - -test('owner death checks terminal first and only aborts the matching logical turn; missing identity has a bounded watch', async t => { - const f = fixture(t); - f.ports.owner.isGone = () => true; - const events = []; - await monitorNativeTurn(f.ports, f.at, async () => { events.push('terminal'); return { ended: true }; }, - () => assert.fail('completed is not aborted'), { dir: f.dir }); - await monitorNativeTurn(f.ports, f.at, async () => { events.push('missing'); return { ended: false }; }, - async closed => { events.push(closed); }, { dir: f.dir }); - assert.deepEqual(events, ['terminal', 'missing', { ...f.at, expected: { turn: 1, pid: 123, sessionEpoch: 500 }, reason: 'aborted' }]); - f.ports.owner.isGone = () => false; - let now = 0; - await monitorNativeTurn(f.ports, f.at, async () => ({ waiting: true }), () => assert.fail('no verdict'), - { dir: f.dir, now: () => now, sleep: async ms => { now += ms; } }); - assert.equal(now, 30_000); -}); - -test('a permanently oversized native turn stops its monitor without fabricating evidence', async t => { - const f = fixture(t); - let reads = 0; - await monitorNativeTurn(f.ports, f.at, async () => { reads++; return { facts: { status: 'partial', limited: true }, waiting: true, native: { turnId: '0' } }; }, - () => assert.fail('no terminal verdict'), { dir: f.dir, sleep: () => assert.fail('permanent limit is not polled') }); - assert.equal(reads, 1); - for (const status of ['unreadable', 'unsupported']) { - await monitorNativeTurn(f.ports, f.at, async () => ({ facts: { status }, waiting: true, native: { turnId: '0' } }), - () => assert.fail('no terminal verdict'), { dir: f.dir, sleep: () => assert.fail('invalid native history is not polled') }); - } -}); - -test('session end before native admission performs one final read then stops the unbound monitor', async t => { - const f = fixture(t); - Object.assign(f.session, { open: false, native: { turn: 1, turnId: null, sessionEnded: true } }); - let reads = 0, slept = 0, now = 0; - await monitorNativeTurn(f.ports, f.at, async () => { reads++; return { waiting: true, native: f.session.native }; }, - () => assert.fail('no native evidence is no new verdict'), { dir: f.dir, now: () => now, sleep: async ms => { slept++; now += ms; } }); - assert.equal(reads, 1); - assert.equal(slept, 0); -}); diff --git a/test/runtime/session-log.test.js b/test/runtime/session-log.test.js new file mode 100644 index 0000000..ed983a2 --- /dev/null +++ b/test/runtime/session-log.test.js @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { recordReaderOf } from '../../lib/runtime/records.js'; + +function fixture(t, overrides = {}) { + const root = mkdtempSync(join(tmpdir(), 'evolver-session-log-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const project = join(root, 'project'); mkdirSync(project); + const home = join(root, 'home'); mkdirSync(home); + writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ workspaces: { workspace: { root: project } } })); + const agent = join(home, 'sessions/workspace/session/agents/main'); mkdirSync(agent, { recursive: true }); + const wire = join(agent, 'wire.jsonl'); + const decoded = []; + const declaration = { + root: () => home, workspaceIndex: 'workspaces.json', logFile: 'agents/main/wire.jsonl', + nativeSessionOf: (sessionId, scope) => sessionId.startsWith(`${scope}:`) ? sessionId.slice(65) : null, + workspacesOf: (index) => Object.entries(index.workspaces).map(([id, { root }]) => [id, root]), + sessionDir: (workspaceId, sessionId) => join('sessions', workspaceId, sessionId), + decode: (records, context) => { + decoded.push({ records, context }); + return { status: 'completed', records }; + }, + ...overrides, + }; + const reader = recordReaderOf(declaration, {}); + return { home, project, agent, wire, decoded, declaration, reader, sessionId: `${reader.scope}:session` }; +} + +const line = (value) => `${JSON.stringify(value)}\n`; + +test('a session log row decodes the whole lines of the session found through the workspace index', (t) => { + const f = fixture(t); + writeFileSync(f.wire, line({ type: 'metadata' }) + line({ type: 'turn.prompt', turnId: 0 }) + '{"type":"turn.ended"'); + const row = f.reader(f.sessionId, '0'); + assert.equal(row.status, 'completed'); + assert.deepEqual(row.records.map((record) => record.type), ['metadata', 'turn.prompt']); + assert.equal(row.records[1].$offset, line({ type: 'metadata' }).length); + assert.deepEqual(f.decoded[0].context, { turnId: '0', tasks: [], projectDir: f.project, unread: null }); +}); + +test('a long log is read as its head and tail, and an unchanged log is parsed once', (t) => { + const f = fixture(t); + writeFileSync(f.wire, line({ type: 'metadata' }) + line({ filler: 'x'.repeat(5 * 1024 * 1024) }) + line({ type: 'turn.prompt' })); + const { records } = f.reader(f.sessionId, '1'); + assert.deepEqual(records.map((record) => record.type), ['metadata', 'turn.prompt']); + assert.equal(f.reader(f.sessionId, '2').records, records); + const [from, to] = f.decoded.at(-1).context.unread; + assert.ok(from <= 64 * 1024 && to > 5 * 1024 * 1024 - 64 * 1024, 'the decoder is told which bytes were left unread'); + appendFileSync(f.wire, line({ type: 'turn.ended' })); + assert.equal(f.reader(f.sessionId, '1').records.at(-1).type, 'turn.ended'); +}); + +test('a session of another log home, an unsafe id or a missing session has no row', (t) => { + const f = fixture(t); + writeFileSync(f.wire, line({})); + assert.equal(f.reader(`${'0'.repeat(64)}:session`, '0'), null); + assert.equal(f.reader(`${f.reader.scope}:../session`, '0'), null); + assert.equal(f.reader(`${f.reader.scope}:other`, '0'), null); + assert.equal(f.reader(f.sessionId, null), null); +}); + +test('invalid, oversized, linked or ambiguous files never become an empty valid row', (t) => { + const f = fixture(t); + writeFileSync(f.wire, '{invalid}\n'); + assert.equal(f.reader(f.sessionId, '0'), null); + writeFileSync(f.wire, line({})); + writeFileSync(join(f.home, 'workspaces.json'), ' '.repeat(1024 * 1024 + 1)); + assert.equal(f.reader(f.sessionId, '0'), null); + const twin = join(f.home, 'sessions/twin/session/agents/main'); + mkdirSync(twin, { recursive: true }); + writeFileSync(join(twin, 'wire.jsonl'), line({})); + writeFileSync(join(f.home, 'workspaces.json'), JSON.stringify({ workspaces: { workspace: { root: f.project }, twin: { root: f.project } } })); + assert.equal(f.reader(f.sessionId, '0'), null); + rmSync(join(f.home, 'sessions/twin'), { recursive: true }); + const foreign = join(f.project, 'foreign'); mkdirSync(foreign); + writeFileSync(join(foreign, 'wire.jsonl'), line({})); + rmSync(f.agent, { recursive: true }); + symlinkSync(foreign, f.agent, process.platform === 'win32' ? 'junction' : 'dir'); + writeFileSync(join(f.home, 'workspaces.json'), JSON.stringify({ workspaces: { workspace: { root: f.project } } })); + assert.equal(f.reader(f.sessionId, '0'), null); +}); + +test('task files the log names are read by id, and a mismatched identity spoils the row', (t) => { + const f = fixture(t, { taskIds: () => ['task'], tasksDir: (dir) => join(dir, 'agents', 'main', 'tasks') }); + writeFileSync(f.wire, line({})); + mkdirSync(join(f.agent, 'tasks')); + writeFileSync(join(f.agent, 'tasks', 'task.json'), JSON.stringify({ taskId: 'task', status: 'running' })); + assert.ok(f.reader(f.sessionId, '0')); + assert.deepEqual(f.decoded.at(-1).context.tasks, [{ taskId: 'task', status: 'running' }]); + writeFileSync(join(f.agent, 'tasks', 'task.json'), JSON.stringify({ taskId: 'task', status: 'completed' })); + f.reader(f.sessionId, '0'); + assert.deepEqual(f.decoded.at(-1).context.tasks, [{ taskId: 'task', status: 'completed' }], 'a task settling without a log write is seen'); + writeFileSync(join(f.agent, 'tasks', 'task.json'), JSON.stringify({ taskId: 'wrong' })); + assert.equal(f.reader(f.sessionId, '0'), null); +}); + +test('a tail window that opens on a line start keeps that line, and a header crossing it comes from the head', (t) => { + const f = fixture(t); + const tailBytes = 4 * 1024 * 1024; + const header = line({ type: 'metadata' }); + const exact = line({ type: 'turn.prompt' }); + const rest = line({ b: 'w'.repeat(tailBytes - exact.length - '{"b":""}\n'.length) }); + assert.equal(Buffer.byteLength(exact + rest), tailBytes); + writeFileSync(f.wire, header + line({ before: 'z'.repeat(100 * 1024) }) + exact + rest); + assert.deepEqual(f.reader(f.sessionId, '0').records.map((record) => record.type).filter(Boolean), ['metadata', 'turn.prompt']); + const crossing = line({ type: 'metadata', padding: 'h'.repeat(200) }); + writeFileSync(f.wire, crossing + line({ b: 'w'.repeat(tailBytes - 50) })); + const records = f.reader(f.sessionId, '1').records; + assert.equal(records[0].type, 'metadata', 'a header the tail window cuts is read from the head'); +}); + +test('a log home that moves after the reader started is not read under the old home\'s scope', (t) => { + const f = fixture(t); + writeFileSync(f.wire, line({})); + let home = f.home; + const other = `${f.home}-other`; + const reader = recordReaderOf({ ...f.declaration, root: () => home }, {}); + const sessionId = `${reader.scope}:session`; + assert.ok(reader(sessionId, '0')); + mkdirSync(join(other, 'sessions/workspace/session/agents/main'), { recursive: true }); + writeFileSync(join(other, 'workspaces.json'), JSON.stringify({ workspaces: { workspace: { root: f.project } } })); + writeFileSync(join(other, 'sessions/workspace/session/agents/main/wire.jsonl'), line({})); + t.after(() => rmSync(other, { recursive: true, force: true })); + home = other; + assert.equal(reader(sessionId, '0'), null); +}); + +test('a task the log names many times counts once toward the task limit', (t) => { + const f = fixture(t, { taskIds: () => Array(100).fill('task'), tasksDir: (dir) => join(dir, 'agents', 'main', 'tasks') }); + writeFileSync(f.wire, line({})); + mkdirSync(join(f.agent, 'tasks')); + writeFileSync(join(f.agent, 'tasks', 'task.json'), JSON.stringify({ taskId: 'task', status: 'running' })); + assert.ok(f.reader(f.sessionId, '0')); + assert.deepEqual(f.decoded.at(-1).context.tasks, [{ taskId: 'task', status: 'running' }]); +});