diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0a7a6feadf8..c56dd9941e8 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13289,7 +13289,7 @@ "https://github.com/stablyai/orca/issues/13821", "https://github.com/stablyai/orca/issues/14347" ], - "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.", + "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Local worker-start with supported observation must preserve an unobserved turn as start_unknown without revoking authority, closing questions, or triggering a rescue Enter; a worker report during observation must settle normally. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.", "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts", @@ -13339,7 +13339,8 @@ "file": "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts", "assertions": [ "delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts", - "a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter" + "a swallowed Enter durably records start_unknown without a rescue Enter or capability revocation", + "early worker reports settle during observation, and outstanding questions survive observation uncertainty" ] }, { diff --git a/config/scripts/locale-collator-sort-benchmark.mjs b/config/scripts/locale-collator-sort-benchmark.mjs index 3d34466532a..1a0b08e1643 100644 --- a/config/scripts/locale-collator-sort-benchmark.mjs +++ b/config/scripts/locale-collator-sort-benchmark.mjs @@ -129,6 +129,7 @@ for (const count of [36, 50, 250]) { const issues = makeJiraIssues(count) const before = () => [...issues] + // oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator. .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) .map((issue) => issue.key) const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key) @@ -142,6 +143,7 @@ for (const count of [36, 50, 250]) { for (const count of [10, 50, 250]) { const values = makeBaseSensitivityValues(count) const before = () => + // oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator. [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) const after = () => [...values].sort(compareBaseSensitivityLocaleText) assertSameOrder(before, after, `base ${count}`) diff --git a/config/scripts/native-chat-live-session-benchmark.ts b/config/scripts/native-chat-live-session-benchmark.ts index c21ae759a7f..5983dc2f4e7 100644 --- a/config/scripts/native-chat-live-session-benchmark.ts +++ b/config/scripts/native-chat-live-session-benchmark.ts @@ -117,7 +117,7 @@ function blockContent(message: NativeChatMessage): string { if (block.type === 'tool-result') { return block.output } - return block.path ?? block.url ?? block.alt ?? '' + return block.type === 'image-ref' ? (block.path ?? block.url ?? block.alt ?? '') : block.groupId } function messageWeight(message: NativeChatMessage, content: string): number { diff --git a/docs/review-evidence/pr-19217/README.md b/docs/review-evidence/pr-19217/README.md new file mode 100644 index 00000000000..ca664ae0c9a --- /dev/null +++ b/docs/review-evidence/pr-19217/README.md @@ -0,0 +1,46 @@ +# Structured worktree status validation + +Validated on September 7, 2026 in a background Electron dev instance of +`pr19217-review-r2`, based on `ce1024096b` with the source-adapter refactor. +CDP app identity confirmed the checkout; screenshots show the full hidden renderer. +The command output is the real `orca worktree ps --json` response reduced to status, +agent state, provider, and pane key for readability. + +## Functional correctness + +A real Codex structured session appeared as `working` in `worktree.ps` while the +sidebar showed working. Closing its chat tab removed that exact session's row and +returned the worktree to `active`. A different completed chat remained present, +confirming that closure removed only the selected session. + +- [Working: CLI and sidebar](working.png) +- [Closed: CLI and sidebar](closed.png) + +The disappearing session is `codex_40677067_f492_4d7d_86dd_ec566ede04c3`. +The host's held-session roster controls eligibility; its retained broadcast cache +is history, not a roster. Failed eviction intentionally keeps an entry for retry. + +## Architecture + +PTY reconciliation and process admission belong to the PTY source adapter. +Structured input comes from the current host's held-session projections. One +admitted collection feeds row shaping and worktree aggregation, with no structured +boolean bypass. PTY hooks and retained reports still arrive independently, so their +precedence and conservative remote evidence rules remain necessary. No second +persistent status store or provider polling was introduced. + +## Validation and limits + +Independent final review found no proven issues. Runtime, host lifecycle, status +feed and source-admission suites passed: 1,344 tests, one skipped. Node typecheck, +targeted lint and diff checks passed. Ablating the runtime call to enumerate +retained history caused the executable call-site test to fail with two rows where +one was expected; restoring the live accessor passed both call-site tests. + +Live screenshots prove Codex working and closure on macOS. Claude provider turns, +approval/input states, live Windows/Linux/WSL/SSH/relay/mobile scenarios and +release-scale latency/heap measurements remain unverified. Existing tests cover +remote/WSL evidence, monitoring precedence and lifecycle cases. The existing +30-minute freshness rule and CLI activity timestamps are preserved; complete +CLI/sidebar timing parity is not claimed. The wire keeps its existing row shape +and status vocabulary; mobile receives the new rows without a new opcode. diff --git a/docs/review-evidence/pr-19217/closed.png b/docs/review-evidence/pr-19217/closed.png new file mode 100644 index 00000000000..7f1d8160844 Binary files /dev/null and b/docs/review-evidence/pr-19217/closed.png differ diff --git a/docs/review-evidence/pr-19217/working.png b/docs/review-evidence/pr-19217/working.png new file mode 100644 index 00000000000..8ee57052134 Binary files /dev/null and b/docs/review-evidence/pr-19217/working.png differ diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index cddd36a4cf7..931c4477855 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -104,6 +104,34 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBeUndefined() }) + it.each(['succeeded', 'failed'])( + 'accepts a successful start whose task already %s', + async (workerOutcome) => { + const receipt = { + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + stage: 'settled', + workerOutcome, + effects: [], + residualResources: [] + } + callMock.mockResolvedValue({ result: receipt }) + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['from', 'term_coord'] + ]) + ) + expect(process.exitCode).toBeUndefined() + expect(printResult).toHaveBeenCalledWith( + expect.objectContaining({ result: receipt }), + true, + expect.any(Function) + ) + } + ) + it('capability-gates and forwards per-invocation launch preferences', async () => { callMock .mockResolvedValueOnce({ diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 162b431f1bf..4c178b71fd8 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -18,6 +18,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, @@ -70,6 +71,7 @@ describe('CLI remote WebSocket transport', () => { expect(runtime.authFrames).toContainEqual( expect.objectContaining({ clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/main/ai-vault/remote-session-file-stat.ts b/src/main/ai-vault/remote-session-file-stat.ts index 57e6d2c8ed6..1453af3b1b8 100644 --- a/src/main/ai-vault/remote-session-file-stat.ts +++ b/src/main/ai-vault/remote-session-file-stat.ts @@ -13,7 +13,13 @@ export async function statRemoteSessionFile( agent: AiVaultAgent, executionHostId: ExecutionHostId, issues: AiVaultScanIssue[], - options?: { missingIsExpected?: boolean; signal?: AbortSignal } + options?: { + missingIsExpected?: boolean + signal?: AbortSignal + // Lets a caller tell a missing path from a failed stat, which both report + // as null; the issue is recorded either way before this rethrows. + rethrowFailures?: boolean + } ): Promise { try { throwIfAiVaultScanCancelled(options?.signal) @@ -31,7 +37,8 @@ export async function statRemoteSessionFile( } } catch (error) { throwIfAiVaultScanCancelled(options?.signal) - if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) { + const missing = isMissingRemoteSessionPathError(error) + if (!options?.missingIsExpected || !missing) { recordSessionScanIssue(issues, { executionHostId, agent, @@ -39,6 +46,9 @@ export async function statRemoteSessionFile( message: errorMessage(error) }) } + if (options?.rethrowFailures && !missing) { + throw error + } return null } } diff --git a/src/main/ai-vault/remote-session-parse-cache.ts b/src/main/ai-vault/remote-session-parse-cache.ts index 7d8b0ebe2c7..c892c4961ef 100644 --- a/src/main/ai-vault/remote-session-parse-cache.ts +++ b/src/main/ai-vault/remote-session-parse-cache.ts @@ -1,5 +1,6 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' +import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat' // Matches the local scanner's cap. The relay sidecar is forked with // --max-old-space-size=384, and a retained session row is a title, a preview @@ -11,6 +12,7 @@ type RemoteSessionParseCacheEntry = { mtimeMs: number sizeBytes: number | null hostKey: string + sidecar?: SessionSidecarObservation session: AiVaultSession | null } @@ -55,13 +57,15 @@ function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void { * (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly * this reason; this is its remote counterpart. * - * `(mtimeMs, sizeBytes)` is a sound validity key here because discovery already - * folds a source's `contentDependencyPath` stat into both fields - * (remote-session-scanner-discovery.ts), so a metadata-only transcript whose - * companion file changed still looks changed. Sources whose parse reads a file - * discovery does not stat — Codex looks its title up in `session_index.jsonl` — - * are not covered by that key and pass `refreshReusedSession` to re-derive the - * uncovered part without touching the transcript. + * `(mtimeMs, sizeBytes)` covers the transcript, and the sidecar observation + * discovery records beside it (remote-session-scanner-discovery.ts) covers a + * source's companion file, so a metadata-only transcript whose companion + * changed still looks changed. Remote Cline is the only such source; remote + * Cursor streams transcript content with no sibling to read. Sources whose + * parse reads a file discovery does not stat — Codex looks its title up in + * `session_index.jsonl` — are not covered by either and pass + * `refreshReusedSession` to re-derive the uncovered part without touching the + * transcript. * * Only a completed parse is stored. A read that threw stays uncached so a * transient filesystem failure cannot pin a wrong answer for the corpus's life. @@ -80,7 +84,10 @@ export async function parseRemoteSessionFileCached(args: { entry !== undefined && entry.hostKey === args.hostKey && entry.mtimeMs === file.mtimeMs && - (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) + (entry.sizeBytes === null || + file.sizeBytes === undefined || + entry.sizeBytes === file.sizeBytes) && + sidecarUnchanged(entry.sidecar, file.sidecar) if (unchanged) { if (args.stats) { args.stats.reused++ @@ -101,6 +108,7 @@ export async function parseRemoteSessionFileCached(args: { mtimeMs: file.mtimeMs, sizeBytes: file.sizeBytes ?? null, hostKey: args.hostKey, + sidecar: file.sidecar, session }) return session diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 159bf049da6..1fb25213f32 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' import type { FileWithMtime } from './session-scanner-types' +import type { SessionSidecarObservation } from './session-sidecar-stat' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' @@ -61,23 +62,38 @@ async function statRemoteCandidateFile( if (!file || !source.contentDependencyPath) { return file } - const dependency = await statRemoteSessionFile( - context.provider, - source.contentDependencyPath(path), - source.agent, - context.executionHostId, - issues, - { missingIsExpected: true, signal: context.signal } - ) - if (!dependency) { - return file - } - const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs) - return { - ...file, - mtimeMs, - modifiedAt: new Date(mtimeMs).toISOString(), - sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0) + const sidecarPath = source.contentDependencyPath(path) + // Recorded beside the transcript's own stat, never folded into it: one key + // cannot mean both "the transcript grew" and "the sibling changed". + return { ...file, sidecar: await observeRemoteSidecar(source, context, sidecarPath, issues) } +} + +/** + * A stat that failed for any reason other than a missing path is `'unknown'`, + * not `'none'`: serving the cached session over an unreadable sibling would + * publish metadata nobody can currently see. `statRemoteSessionFile` already + * recorded the issue for the failure. + */ +async function observeRemoteSidecar( + source: RemoteSessionSource, + context: RemoteScannerContext, + sidecarPath: string, + issues: AiVaultScanIssue[] +): Promise { + try { + const sidecar = await statRemoteSessionFile( + context.provider, + sidecarPath, + source.agent, + context.executionHostId, + issues, + { missingIsExpected: true, signal: context.signal, rethrowFailures: true } + ) + return sidecar + ? { path: sidecarPath, mtimeMs: sidecar.mtimeMs, sizeBytes: sidecar.sizeBytes ?? 0 } + : 'none' + } catch { + return 'unknown' } } diff --git a/src/main/ai-vault/remote-session-sidecar-observation.test.ts b/src/main/ai-vault/remote-session-sidecar-observation.test.ts new file mode 100644 index 00000000000..92ff41775ec --- /dev/null +++ b/src/main/ai-vault/remote-session-sidecar-observation.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' +import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache' + +describe('remote sidecar observations', () => { + const sessionId = '1786466194549_sidecar' + const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` + const messagesPath = `${sessionDir}/${sessionId}.messages.json` + + function addCline(provider: MemoryRemoteProvider, firstPrompt: string): void { + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + version: 1, + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + cwd: '/home/ada/repo' + }), + 10 + ) + provider.addFile( + messagesPath, + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:38:00.000Z', + sessionId, + messages: [{ role: 'user', content: [{ type: 'text', text: firstPrompt }] }] + }), + 11 + ) + } + + const scan = (provider: MemoryRemoteProvider): ReturnType => + scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + it('re-parses when a messages-file stat fails rather than serving the cached row', async () => { + resetRemoteSessionParseCacheForTests() + const provider = new MemoryRemoteProvider() + addCline(provider, 'first prompt') + expect((await scan(provider)).sessions[0]).toMatchObject({ title: 'first prompt' }) + + // The sidecar changed underneath, and its stat now fails for a reason that + // is not "missing": nothing about it may be assumed, so the row is re-read. + addCline(provider, 'second prompt') + provider.failStat( + messagesPath, + Object.assign(new Error('permission denied'), { code: 'EACCES' }) + ) + + const refused = await scan(provider) + + expect(refused.sessions[0]).toMatchObject({ title: 'second prompt' }) + expect(refused.issues.map((issue) => issue.path)).toContain(messagesPath) + }) + + it('treats a genuinely missing messages file as no sidecar, not as unknown', async () => { + resetRemoteSessionParseCacheForTests() + const provider = new MemoryRemoteProvider() + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + version: 1, + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + cwd: '/home/ada/repo' + }), + 10 + ) + + const first = await scan(provider) + const second = await scan(provider) + + expect(first.issues).toEqual([]) + expect(second.issues).toEqual([]) + expect(second.sessions[0]?.sessionId).toBe(sessionId) + }) +}) diff --git a/src/main/ai-vault/session-newest-files.test.ts b/src/main/ai-vault/session-newest-files.test.ts new file mode 100644 index 00000000000..092def09a16 --- /dev/null +++ b/src/main/ai-vault/session-newest-files.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from 'vitest' +import { SessionNewestFiles } from './session-newest-files' +import type { FileWithMtime } from './session-scanner-types' + +function file(i: number): FileWithMtime { + const mtimeMs = (i * 7919) % 997 + return { path: String(i), mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } +} + +it('retains at most 12 of 100,000 candidates with stable newest-first ties', () => { + const all = Array.from({ length: 100_000 }, (_, i) => file(i)) + const retained = new SessionNewestFiles(12) + let peak = 0 + for (const candidate of all) { + retained.add(candidate) + peak = Math.max(peak, retained.size) + } + expect(peak).toBe(12) + expect(retained.newest()).toEqual(all.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 12)) +}) + +it('supports full backfill and empty requests', () => { + const all = new SessionNewestFiles(Infinity) + const none = new SessionNewestFiles(0) + for (let i = 0; i < 100; i++) { + all.add(file(i)) + none.add(file(i)) + } + expect(all.newest()).toHaveLength(100) + expect(none.newest()).toEqual([]) +}) diff --git a/src/main/ai-vault/session-newest-files.ts b/src/main/ai-vault/session-newest-files.ts new file mode 100644 index 00000000000..c315c26dfad --- /dev/null +++ b/src/main/ai-vault/session-newest-files.ts @@ -0,0 +1,50 @@ +import type { FileWithMtime } from './session-scanner-types' + +/** Retain only the requested newest files, preserving traversal order on ties. */ +export class SessionNewestFiles { + private readonly files: FileWithMtime[] = [] + + private readonly limit: number + + constructor(limit: number) { + this.limit = Math.max(0, Math.trunc(limit) || 0) + } + + add(file: FileWithMtime): void { + // The backfill enumerates with no limit; skip the insert search entirely. + if (!Number.isFinite(this.limit)) { + this.files.push(file) + return + } + if (this.limit <= 0) { + return + } + const last = this.files.at(-1) + if (this.files.length >= this.limit && last && file.mtimeMs <= last.mtimeMs) { + return + } + let low = 0 + let high = this.files.length + while (low < high) { + const middle = (low + high) >>> 1 + if (this.files[middle].mtimeMs >= file.mtimeMs) { + low = middle + 1 + } else { + high = middle + } + } + this.files.splice(low, 0, file) + if (this.files.length > this.limit) { + this.files.pop() + } + } + + get size(): number { + return this.files.length + } + + /** The unbounded path appends in traversal order, so the sort is not redundant. */ + newest(): FileWithMtime[] { + return [...this.files].sort((a, b) => b.mtimeMs - a.mtimeMs) + } +} diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts index 27aac09ce22..ee617a2352d 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.test.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts @@ -29,9 +29,12 @@ import { parseAgentSessionFileCached, resetSessionParseCacheForTests, seedSessionParseCache, + snapshotSessionParseCacheForPersistence, type PersistedSessionParseCacheEntry, type SessionParseStats } from './session-scanner-parse-cache' +import { getSessionParseCacheEntry } from './session-parse-cache-store' +import type { SessionSidecarObservation } from './session-sidecar-stat' import { isolatedScanRoots } from './session-scanner-test-fixtures' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' @@ -526,3 +529,38 @@ describe('session parse cache persistence', () => { debugSpy.mockRestore() }) }) + +describe('sidecar observations survive the round trip', () => { + const OBSERVATIONS: [string, SessionSidecarObservation | undefined][] = [ + ['an object', { path: '/chats/a/meta.json', mtimeMs: 42, sizeBytes: 7 }], + ['none', 'none'], + ['unknown', 'unknown'], + ['absent', undefined] + ] + + it.each(OBSERVATIONS)('restores %s exactly', async (_label, sidecar) => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + const path = await writeTranscript(root) + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + await ensureSessionParseCacheLoaded() + + const stats = createSessionParseStats() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + const seeded = snapshotSessionParseCacheForPersistence().map( + ([entryPath, entry]): [string, PersistedSessionParseCacheEntry] => [ + entryPath, + sidecar === undefined ? entry : { ...entry, sidecar } + ] + ) + resetSessionParseCacheForTests() + seedSessionParseCache(seeded) + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + + expect(getSessionParseCacheEntry(path)?.sidecar).toEqual(sidecar) + }) +}) diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 0a0cd44095e..a394ea2ad91 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -11,6 +11,7 @@ import { type PersistedSessionParseCacheEntry, type SessionParseStats } from './session-scanner-parse-cache' +import type { SessionSidecarObservation } from './session-sidecar-stat' // Bump when the persisted entry layout or cached session semantics change; a // mismatched file is discarded whole. @@ -181,17 +182,37 @@ function parsePersistedEntry(item: unknown): [string, PersistedSessionParseCache if (entry.session !== null && typeof entry.session !== 'object') { return null } + const sidecar = parsePersistedSidecar(entry.sidecar) return [ path, { mtimeMs: entry.mtimeMs, sizeBytes: entry.sizeBytes, platform: entry.platform as NodeJS.Platform, - session: entry.session as PersistedSessionParseCacheEntry['session'] + session: entry.session as PersistedSessionParseCacheEntry['session'], + ...(sidecar === undefined ? {} : { sidecar }) } ] } +// Why: added after SCHEMA_VERSION 2 shipped, so a file an older build wrote has +// no such field. Absent (or unreadable) means unknown, which costs one re-parse +// of the rows that have a sibling and nothing at all for the rest. +function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined { + if (value === 'none' || value === 'unknown') { + return value + } + if (typeof value !== 'object' || value === null) { + return undefined + } + const record = value as Record + return typeof record.path === 'string' && + typeof record.mtimeMs === 'number' && + typeof record.sizeBytes === 'number' + ? { path: record.path, mtimeMs: record.mtimeMs, sizeBytes: record.sizeBytes } + : undefined +} + async function persistSnapshot(current: SessionParseCachePersistenceOptions): Promise { const directory = dirname(current.filePath) const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`) diff --git a/src/main/ai-vault/session-parse-cache-store.ts b/src/main/ai-vault/session-parse-cache-store.ts new file mode 100644 index 00000000000..1f1d624c85d --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-store.ts @@ -0,0 +1,111 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ResumableSessionParseState } from './session-scanner-types' +import type { SessionSidecarObservation } from './session-sidecar-stat' +import type { TranscriptMessageChannel } from './session-transcript-channel' + +// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a +// full steady-state result set stays resident between forced rescans. +const MAX_CACHE_ENTRIES = 4096 + +export type SessionParseResumePoint = { + state: ResumableSessionParseState + // Byte offset just past the last complete ('\n'-terminated) line consumed; + // a trailing unterminated line is deliberately left before this point. + byteOffset: number + // Bound to the cached state, which keeps the reference its parsers were built + // with; a resumed read re-points this channel instead of replacing it. + channel: TranscriptMessageChannel +} + +export type SessionParseCacheEntry = { + mtimeMs: number + sizeBytes: number | null + platform: NodeJS.Platform + session: AiVaultSession | null + // What the sibling file looked like when `session` was built. Tracked apart + // from the transcript's key so each can go stale on its own. + sidecar?: SessionSidecarObservation + // The session the transcript alone produced, before any sibling was merged + // onto it. In-memory only: without it a sibling change costs one re-parse. + foldSession?: AiVaultSession | null + resume: SessionParseResumePoint | null +} + +const cache = new Map() + +export function resetSessionParseCacheForTests(): void { + cache.clear() +} + +// Drops one entry after its file is deleted. Cleanliness, not correctness: +// discovery walks disk first, so a trashed file is never rediscovered anyway. +export function invalidateSessionParseCacheEntry(path: string): void { + cache.delete(path) +} + +// Persisted subset of a cache entry: the non-serializable `resume` parser +// state is dropped, and `foldSession` with it, so a restart pays one re-parse +// for a session whose sibling moved rather than storing every row twice +// (see session-parse-cache-persistence.ts). +export type PersistedSessionParseCacheEntry = Omit + +export function snapshotSessionParseCacheForPersistence(): [ + string, + PersistedSessionParseCacheEntry +][] { + return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session, + ...(entry.sidecar === undefined ? {} : { sidecar: entry.sidecar }) + } + ]) +} + +// Seeded entries carry `resume: null`: after a restart an unchanged file is a +// cache hit; a file that changed while the app was closed pays one full +// (not incremental) re-parse. +export function seedSessionParseCache( + entries: Iterable<[string, PersistedSessionParseCacheEntry]> +): void { + const list = [...entries] + // Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest + // tail rather than seeding the oldest entries and dropping the tail. + for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) { + if (cache.size >= MAX_CACHE_ENTRIES) { + return + } + // In-process entries are always fresher than persisted ones; never clobber. + if (cache.has(path)) { + continue + } + cache.set(path, { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session, + // Absent in files an older build wrote; `sidecarUnchanged` reads that as + // unknown, so such a row re-enriches on its first scan. + sidecar: entry.sidecar, + resume: null + }) + } +} + +export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined { + return cache.get(path) +} + +export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void { + cache.delete(path) + cache.set(path, entry) + if (cache.size > MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } +} diff --git a/src/main/ai-vault/session-parse-file-lane.ts b/src/main/ai-vault/session-parse-file-lane.ts new file mode 100644 index 00000000000..34722300834 --- /dev/null +++ b/src/main/ai-vault/session-parse-file-lane.ts @@ -0,0 +1,28 @@ +const pending = new Map>() + +/** + * Serializes parses of one transcript path. + * + * Two callers really do overlap on the same file: a forced refresh aborts the + * running scan while its in-flight parse keeps going as the replacement scan + * starts it again, and `session-title-file-reader.ts` parses on its own, with + * no scan involved. Overlapping reads share the cached resume point's message + * channel, so the second `beginRead` would drop the first read's consumers and + * the first `finishRead` would hand them the wrong outcome; the later store + * could also move the cursor backwards. + */ +export async function inSessionParseFileLane(path: string, parse: () => Promise): Promise { + const previous = pending.get(path) + const run = (async () => { + await previous?.catch(() => undefined) + return parse() + })() + pending.set(path, run) + try { + return await run + } finally { + if (pending.get(path) === run) { + pending.delete(path) + } + } +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index caf08f4259d..88e09627eb7 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -23,6 +23,12 @@ import { normalizePreviewText, timestampMs } from './session-scanner-values' +import { NO_TRANSCRIPT_MESSAGES, type TranscriptMessageSink } from './session-transcript-consumers' +import { + boundedText, + transcriptMessageRole, + transcriptMessagesFromContent +} from './session-transcript-message-content' const SESSION_PREVIEW_MESSAGE_LIMIT = 5 @@ -30,9 +36,12 @@ export function createAccumulator(args: { agent: AiVaultAgent file: FileWithMtime sessionId: string + // Where every decoded message goes; absent for one-shot parses with no reader. + messages?: TranscriptMessageSink }): SessionAccumulator { return { agent: args.agent, + messages: args.messages ?? NO_TRANSCRIPT_MESSAGES, sessionId: args.sessionId, title: null, fallbackTitle: null, @@ -75,6 +84,9 @@ export function accumulatorFoldResumeState( }, // Finalize a snapshot: the live accumulator (and its preview array) keeps // accumulating appended lines after this session object is handed out. + // A sibling file's metadata is merged onto this result by the parse cache, + // never into the fold, so re-merging it later starts from what the + // transcript alone said (see session-scanner-sidecar-enrichment.ts). finalize: (platform, options) => finalizeSession(cloneSessionAccumulator(accumulator), platform, options) } @@ -96,7 +108,7 @@ export function finalizeSession( const title = accumulator.title || accumulator.fallbackTitle || - `${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}` + generatedSessionTitle(accumulator.agent, sessionId) const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID @@ -137,6 +149,15 @@ export function finalizeSession( } } +/** + * The title a session gets when neither the transcript nor the agent named it. + * Exported so a later merge can tell "the fold found no title" from a real one + * without re-deriving the string (session-scanner-sidecar-enrichment.ts). + */ +export function generatedSessionTitle(agent: AiVaultAgent, sessionId: string): string { + return `${aiVaultAgentLabel(agent)} ${sessionId.slice(0, 8)}` +} + export function updateTimeline(accumulator: SessionAccumulator, timestamp: unknown): void { const parsed = timestampMs(timestamp) if (!Number.isFinite(parsed)) { @@ -161,8 +182,13 @@ export function addPreviewMessage( // Why: Claude meta/injected turns still preview, but must not seed the // copyable first-prompt row. seedFirstUserPrompt?: boolean + // Set false by callers that already published this record's messages. + publishMessage?: boolean } ): void { + if (args.publishMessage !== false && accumulator.messages.active) { + publishTranscriptMessage(accumulator, args.role, args.text, args.timestamp) + } // Seeded before the preview-empty return so the copy body never depends on // preview-only normalization rules. seedFullFirstUserPrompt( @@ -199,15 +225,41 @@ export function addPreviewContent( () => extractFullFirstUserPromptText(content), options?.seedFirstUserPrompt ) + // Published from the content value, not the preview string: a consumer needs + // the whole turn, including the tool blocks the 220-char preview drops. + if (accumulator.messages.active) { + for (const message of transcriptMessagesFromContent(role, content, timestampIso(timestamp))) { + accumulator.messages.push(message) + } + } addPreviewMessage(accumulator, { role, text: extractPreviewContentText(content), timestamp, // Content path already seeded above when capture is enabled. - seedFirstUserPrompt: false + seedFirstUserPrompt: false, + publishMessage: false }) } +/** One already-flattened turn; the content path publishes per block instead. */ +function publishTranscriptMessage( + accumulator: SessionAccumulator, + role: AiVaultSessionPreviewMessage['role'], + text: string | null, + timestamp: unknown +): void { + const messageRole = transcriptMessageRole(role) + const messageText = text === null ? null : boundedText(text) + if (messageRole && messageText) { + accumulator.messages.push({ + role: messageRole, + text: messageText, + timestamp: timestampIso(timestamp) + }) + } +} + /** * Seed the copyable first prompt from the first real user turn. `fullText` is a * thunk so list scans (capture mode `none`) never pay the extraction cost. diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 83f23943241..7b0d05f3683 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -6,7 +6,10 @@ import { parseClineSessionFile } from './session-scanner-cline-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' -import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { + looksLikeOpenCodeSqliteCandidate, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' import { parseOpenCodeSqliteSessionViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import { parseGeminiSessionFile } from './session-scanner-gemini-parsers' @@ -16,6 +19,16 @@ import { parseCursorSessionFile } from './session-scanner-cursor-parser' import { parseHermesSessionFile } from './session-scanner-hermes-parser' import { parseOpenCodeSessionFile } from './session-scanner-opencode-parser' import type { SessionFileCandidate } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' + +/** + * False when a parser decodes its messages somewhere the channel cannot reach. + * OpenCode's SQLite sessions are read on a worker thread, so their messages + * never come back over the sink and the read must not be reported as complete. + */ +export function parserPublishesMessages(candidate: SessionFileCandidate): boolean { + return candidate.agent !== 'opencode' || !looksLikeOpenCodeSqliteCandidate(candidate.file.path) +} /** * Parse a single agent session file into an `AiVaultSession`. Routes to the @@ -24,25 +37,33 @@ import type { SessionFileCandidate } from './session-scanner-types' * `parseOpenCodeSqliteSession` instead of the legacy JSON parser. * @param candidate - The session file candidate to parse. * @param platform - The platform to use for resume command generation. + * @param messages - Where the parser publishes every decoded message. * @returns The parsed `AiVaultSession`, or `null` if parsing fails. */ export async function parseAgentSessionFile( candidate: SessionFileCandidate, - platform: NodeJS.Platform + platform: NodeJS.Platform, + messages?: TranscriptMessageSink ): Promise { switch (candidate.agent) { case 'claude': - return parseClaudeSessionFile(candidate.file, platform) + return parseClaudeSessionFile(candidate.file, platform, messages) case 'codex': - return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) + return parseCodexSessionFile( + candidate.file, + platform, + candidate.codexHome, + undefined, + messages + ) case 'gemini': - return parseGeminiSessionFile(candidate.file, platform) + return parseGeminiSessionFile(candidate.file, platform, messages) case 'antigravity': - return parseAntigravitySessionFile(candidate.file, platform) + return parseAntigravitySessionFile(candidate.file, platform, messages) case 'copilot': - return parseCopilotSessionFile(candidate.file, platform) + return parseCopilotSessionFile(candidate.file, platform, messages) case 'cursor': - return parseCursorSessionFile(candidate.file, platform) + return parseCursorSessionFile(candidate.file, platform, messages) case 'opencode': { // Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic // # candidate path. Legacy file-based sessions use @@ -55,29 +76,29 @@ export async function parseAgentSessionFile( platform }) } - return parseOpenCodeSessionFile(candidate.file, platform) + return parseOpenCodeSessionFile(candidate.file, platform, messages) } case 'grok': - return parseGrokSessionFile(candidate.file, platform) + return parseGrokSessionFile(candidate.file, platform, messages) case 'hermes': - return parseHermesSessionFile(candidate.file, platform) + return parseHermesSessionFile(candidate.file, platform, messages) case 'rovo': - return parseRovoSessionFile(candidate.file, platform) + return parseRovoSessionFile(candidate.file, platform, messages) case 'openclaw': - return parseMessageGraphSessionFile('openclaw', candidate.file, platform) + return parseMessageGraphSessionFile('openclaw', candidate.file, platform, messages) case 'pi': - return parseMessageGraphSessionFile('pi', candidate.file, platform) + return parseMessageGraphSessionFile('pi', candidate.file, platform, messages) case 'omp': - return parseMessageGraphSessionFile('omp', candidate.file, platform) + return parseMessageGraphSessionFile('omp', candidate.file, platform, messages) case 'prime-agent': - return parseMessageGraphSessionFile('prime-agent', candidate.file, platform) + return parseMessageGraphSessionFile('prime-agent', candidate.file, platform, messages) case 'droid': - return parseDroidSessionFile(candidate.file, platform) + return parseDroidSessionFile(candidate.file, platform, messages) case 'cline': - return parseClineSessionFile(candidate.file, platform) + return parseClineSessionFile(candidate.file, platform, messages) case 'devin': - return parseDevinSessionFile(candidate.file, platform) + return parseDevinSessionFile(candidate.file, platform, messages) case 'kimi': - return parseKimiSessionFile(candidate.file, platform) + return parseKimiSessionFile(candidate.file, platform, messages) } } diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 8b2e66ea5b0..957d8d680a6 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -8,6 +8,7 @@ import { clineMessagesPathForMetadata, isClineSessionMetadataPath } from './session-scanner-cline-parser' +import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta' import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' @@ -61,8 +62,9 @@ export type AiVaultAgentSource = { rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[] extensions: readonly string[] filePredicate?: (filePath: string) => boolean - // A sibling whose stat participates in candidate freshness and recency. - contentDependencyPath?: (filePath: string) => string + // A sibling whose stat participates in candidate freshness and recency; async + // for agents that have to look the sibling up rather than derive its path. + contentDependencyPath?: (filePath: string) => string | undefined | Promise // Return false to skip a directory; depth 0 is a child of the root. directoryPredicate?: (name: string, depth: number) => boolean // Roots that are alternates for one install rather than distinct locations, @@ -126,7 +128,8 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { 'projects' ]), extensions: ['.jsonl'], - filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts') + filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts'), + contentDependencyPath: cursorChatMetaPath }, grok: { rootDirs: (options, wslHomeDirs) => diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 1896138749c..126b56ce19a 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -15,6 +15,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' type ParserSessionOptions = { @@ -24,12 +25,13 @@ type ParserSessionOptions = { export async function parseAntigravitySessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') const lines = createInterface({ input, crlfDelay: Infinity }) try { - return await parseAntigravitySessionLines({ file, lines, platform }) + return await parseAntigravitySessionLines({ file, lines, platform, messages }) } finally { // readline.close() leaves the underlying stream open; destroy it so a // mid-parse throw cannot leak the gated transcript handle. @@ -54,13 +56,14 @@ export async function parseAntigravitySessionContent( } export function createAntigravitySessionResumeState( - file: FileWithMtime + file: FileWithMtime, + messages?: TranscriptMessageSink ): ResumableSessionParseState { const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? '' // Why: the transcript has no cwd/model fields. Workspace enrichment is a // separate, conservative history join; protobuf/SQLite blobs are unstable. return accumulatorFoldResumeState( - createAccumulator({ agent: 'antigravity', file, sessionId }), + createAccumulator({ agent: 'antigravity', file, sessionId, messages }), consumeAntigravityRecordLine ) } @@ -70,8 +73,9 @@ async function parseAntigravitySessionLines(args: { lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions + messages?: TranscriptMessageSink }): Promise { - const state = createAntigravitySessionResumeState(args.file) + const state = createAntigravitySessionResumeState(args.file, args.messages) for await (const line of args.lines) { state.consumeLine(line) } diff --git a/src/main/ai-vault/session-scanner-candidates.ts b/src/main/ai-vault/session-scanner-candidates.ts new file mode 100644 index 00000000000..80f146367e5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-candidates.ts @@ -0,0 +1,46 @@ +import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta' +import { codexRolloutHardlinkIdentity, dedupeCodexRolloutAliases } from './codex-session-root-dedup' +import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' +import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { DEFAULT_CODEX_HOME_DIR } from './session-scanner-source-discovery' +import type { + AiVaultScanOptions, + SessionFileCandidate, + SessionFileDiscovery +} from './session-scanner-types' + +/** Newest-first parse candidates for a discovery set, with Codex hardlink aliases collapsed. */ +export async function sessionCandidatesFromDiscoveries( + discoveries: SessionFileDiscovery[], + options: AiVaultScanOptions +): Promise { + return dedupeCodexRolloutAliases( + discoveries + .flatMap((discovery) => + discovery.files.map((file): SessionFileCandidate => ({ + agent: discovery.agent, + file, + codexHome: + discovery.agent === 'codex' + ? codexHomeForSessionsDir( + discovery.rootDir, + options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR + ) + : null, + antigravityHistoryPath: + discovery.agent === 'antigravity' + ? antigravityHistoryPathForBrainDir(discovery.rootDir) + : undefined + })) + ) + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), + { + isCodex: (candidate) => candidate.agent === 'codex', + getFilePath: (candidate) => candidate.file.path, + getCodexHome: (candidate) => candidate.codexHome, + getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) + }, + (filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'), + options.signal + ) +} diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts index 21a21664205..462108cfed9 100644 --- a/src/main/ai-vault/session-scanner-cline-parser.ts +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -9,6 +9,7 @@ import { updateTimeline } from './session-scanner-accumulator' import type { FileWithMtime } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { arrayValue, asRecord, @@ -35,7 +36,8 @@ export function clineMessagesPathForMetadata(filePath: string): string { export async function parseClineSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messageSink?: TranscriptMessageSink ): Promise { const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan') let messagesContent: string | null = null @@ -52,7 +54,7 @@ export async function parseClineSessionFile( throw error } } - return parseClineSessionContent(file, metadataContent, messagesContent, platform) + return parseClineSessionContent(file, metadataContent, messagesContent, platform, {}, messageSink) } function isMissingSessionPathError(error: unknown): boolean { @@ -68,7 +70,8 @@ export function parseClineSessionContent( metadataContent: string, messagesContent: string | null, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + messageSink?: TranscriptMessageSink ): AiVaultSession | null { const metadata = parseJsonRecord(metadataContent) if (!metadata) { @@ -76,7 +79,12 @@ export function parseClineSessionContent( } const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean) const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? '' - const accumulator = createAccumulator({ agent: 'cline', file, sessionId }) + const accumulator = createAccumulator({ + agent: 'cline', + file, + sessionId, + messages: messageSink + }) accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) accumulator.model = extractString(metadata.model) updateTimeline(accumulator, metadata.started_at) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 80a76ce0a92..02a385400de 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -22,6 +22,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { addCodexUsage, asRecord, @@ -29,18 +30,22 @@ import { extractModel, extractString, normalizeCodexUsage, - normalizeTitleText, parseJsonObject, subtractCodexUsage } from './session-scanner-values' import { remoteSessionContentLines } from './remote-session-content-lines' import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path' +import { + extractCodexSessionMetadataTitle, + isCodexWorkerSession +} from './session-scanner-codex-session-meta' export async function parseCodexSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform, codexHome: string | null = null, - executionHostId?: ExecutionHostId + executionHostId?: ExecutionHostId, + messages?: TranscriptMessageSink ): Promise { const lines = createInterface({ input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), @@ -53,6 +58,7 @@ export async function parseCodexSessionFile( platform, codexHome, executionHostId, + messages, titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId) }) } @@ -89,12 +95,16 @@ type CodexSessionParseState = { titleSource: 'meta' | 'user' | null } -function createCodexParseState(file: FileWithMtime): CodexSessionParseState { +function createCodexParseState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): CodexSessionParseState { return { accumulator: createAccumulator({ agent: 'codex', file, - sessionId: sessionIdFromFileName(file.path) + sessionId: sessionIdFromFileName(file.path), + messages }), previousTotals: null, rejectedWorkerSession: false, @@ -257,10 +267,13 @@ async function finalizeCodexParseState( export function createCodexSessionResumeState( file: FileWithMtime, - codexHome: string | null + codexHome: string | null, + messages?: TranscriptMessageSink ): ResumableSessionParseState { - return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) => - readCodexSessionIndexTitle(file.path, codexHome, sessionId) + return codexResumeStateFromParseState( + createCodexParseState(file, messages), + codexHome, + (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId) ) } @@ -298,8 +311,9 @@ async function parseCodexSessionLines(args: { executionHostId?: ExecutionHostId executionHostPlatform?: NodeJS.Platform | null titleReader?: (sessionId: string) => Promise + messages?: TranscriptMessageSink }): Promise { - const state = createCodexParseState(args.file) + const state = createCodexParseState(args.file, args.messages) for await (const line of args.lines) { consumeCodexRecordLine(state, line) if (state.rejectedWorkerSession) { @@ -314,21 +328,3 @@ async function parseCodexSessionLines(args: { executionHostPlatform: args.executionHostPlatform }) } - -function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource) - if (threadSource) { - return threadSource.toLowerCase() !== 'user' - } - - const source = asRecord(payload.source) - return Boolean(asRecord(source?.subagent)) -} - -function extractCodexSessionMetadataTitle(payload: Record): string | null { - return ( - normalizeTitleText(extractString(payload.title) ?? '') ?? - normalizeTitleText(extractString(payload.thread_name) ?? '') ?? - normalizeTitleText(extractString(payload.threadName) ?? '') - ) -} diff --git a/src/main/ai-vault/session-scanner-codex-session-meta.ts b/src/main/ai-vault/session-scanner-codex-session-meta.ts new file mode 100644 index 00000000000..f2cba3c97d0 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-session-meta.ts @@ -0,0 +1,22 @@ +import { asRecord, extractString, normalizeTitleText } from './session-scanner-values' + +// Field readers for Codex's `session_meta` record, whose key spelling has drifted +// across Codex releases (snake_case rollouts, camelCase app-server rollouts). + +export function isCodexWorkerSession(payload: Record): boolean { + const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource) + if (threadSource) { + return threadSource.toLowerCase() !== 'user' + } + + const source = asRecord(payload.source) + return Boolean(asRecord(source?.subagent)) +} + +export function extractCodexSessionMetadataTitle(payload: Record): string | null { + return ( + normalizeTitleText(extractString(payload.title) ?? '') ?? + normalizeTitleText(extractString(payload.thread_name) ?? '') ?? + normalizeTitleText(extractString(payload.threadName) ?? '') + ) +} diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts index 7e5450393e0..239c5983457 100644 --- a/src/main/ai-vault/session-scanner-copilot-parser.ts +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -8,6 +8,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { accumulatorFoldResumeState, addPreviewMessage, @@ -32,13 +33,14 @@ type ParserSessionOptions = { export async function parseCopilotSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const lines = createInterface({ input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) - return parseCopilotSessionLines({ file, lines, platform }) + return parseCopilotSessionLines({ file, lines, platform, messages }) } export async function parseCopilotSessionContent( @@ -107,9 +109,17 @@ function consumeCopilotRecordLine(accumulator: SessionAccumulator, line: string) } } -export function createCopilotSessionResumeState(file: FileWithMtime): ResumableSessionParseState { +export function createCopilotSessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { return accumulatorFoldResumeState( - createAccumulator({ agent: 'copilot', file, sessionId: sessionIdFromFileName(file.path) }), + createAccumulator({ + agent: 'copilot', + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), consumeCopilotRecordLine ) } @@ -119,8 +129,9 @@ async function parseCopilotSessionLines(args: { lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions + messages?: TranscriptMessageSink }): Promise { - const state = createCopilotSessionResumeState(args.file) + const state = createCopilotSessionResumeState(args.file, args.messages) for await (const line of args.lines) { state.consumeLine(line) } diff --git a/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts new file mode 100644 index 00000000000..0516ff457a7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts @@ -0,0 +1,552 @@ +import { appendFile, mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error' + +// Why: a refused WSL read is the one build failure that must not be cached. +let failNextChatsReaddir = false +let failNextChatsRootReaddir = false +let failMetaJsonReads = false +let failMetaJsonStats: false | true | 'eacces' = false +let chatsRootReads = 0 +vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + wslGatedReaddir: ( + ...args: Parameters + ): ReturnType => { + if (args[0].endsWith('chats')) { + chatsRootReads += 1 + if (failNextChatsRootReaddir) { + failNextChatsRootReaddir = false + return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out')) + } + } + if (failNextChatsReaddir && args[0].includes('workspace-hash')) { + failNextChatsReaddir = false + return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out')) + } + return actual.wslGatedReaddir(...args) + }, + wslGatedReadFile: ( + ...args: Parameters + ): ReturnType => { + if (failMetaJsonReads && String(args[0]).endsWith('meta.json')) { + return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out')) + } + return actual.wslGatedReadFile(...args) + }, + wslGatedStat: ( + ...args: Parameters + ): ReturnType => { + if (failMetaJsonStats && String(args[0]).endsWith('meta.json')) { + return Promise.reject( + failMetaJsonStats === 'eacces' + ? Object.assign(new Error('permission denied'), { code: 'EACCES' }) + : new WslTranscriptFsError('timeout', 'wsl fs timed out') + ) + } + return actual.wslGatedStat(...args) + } + } +}) +import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access' +import { + cursorChatMetaPath, + readCursorChatMeta, + resetCursorChatMetaIndexCacheForTests, + withCursorChatMetaScan +} from './session-scanner-cursor-chat-meta' +import { parseCursorSessionContent } from './session-scanner-cursor-parser' +import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources' +import { discoverFiles } from './session-scanner-discovery' +import { scanAiVaultSessions } from './session-scanner' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests, + seedSessionParseCache, + snapshotSessionParseCacheForPersistence, + type SessionParseStats +} from './session-scanner-parse-cache' +import { + getSessionParseCacheEntry, + type PersistedSessionParseCacheEntry +} from './session-parse-cache-store' +import { isolatedScanRoots } from './session-scanner-test-fixtures' +import type { FileWithMtime } from './session-scanner-types' +import type { SessionSidecarStat } from './session-sidecar-stat' + +// Cursor's real meta.json keys (~/.cursor/chats///meta.json, 2026-09). +type CursorMetaFixture = { + schemaVersion: number + createdAtMs: number + updatedAtMs: number + cwd: string + hasConversation: boolean + title?: string +} + +const CREATED_AT_MS = 1_787_039_612_017 +const UPDATED_AT_MS = 1_787_039_640_532 + +let tempRoots: string[] = [] + +afterEach(async () => { + resetCursorChatMetaIndexCacheForTests() + resetSessionParseCacheForTests() + failNextChatsRootReaddir = false + failMetaJsonReads = false + failMetaJsonStats = false + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function createCursorHome(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-chat-meta-')) + tempRoots.push(root) + const cursorHome = join(root, '.cursor') + await mkdir(cursorHome, { recursive: true }) + return cursorHome +} + +async function writeTranscript( + cursorHome: string, + projectSlug: string, + chatId: string, + lines: string[] +): Promise { + const chatDir = join(cursorHome, 'projects', projectSlug, 'agent-transcripts', chatId) + await mkdir(chatDir, { recursive: true }) + const transcriptPath = join(chatDir, `${chatId}.jsonl`) + await writeFile(transcriptPath, lines.map((line) => `${line}\n`).join('')) + return transcriptPath +} + +async function writeChatMeta( + cursorHome: string, + workspaceHash: string, + chatId: string, + meta: Partial = {} +): Promise { + const chatDir = join(cursorHome, 'chats', workspaceHash, chatId) + await mkdir(chatDir, { recursive: true }) + const metaPath = join(chatDir, 'meta.json') + await writeFile( + metaPath, + JSON.stringify({ + schemaVersion: 1, + createdAtMs: CREATED_AT_MS, + updatedAtMs: UPDATED_AT_MS, + cwd: '/private/tmp/workspace', + hasConversation: true, + ...meta + } satisfies CursorMetaFixture) + ) + return metaPath +} + +function fileWithMtime(path: string): FileWithMtime { + return { path, mtimeMs: 1, modifiedAt: new Date(1).toISOString() } +} + +describe('cursor chat meta', () => { + it('resolves the meta.json under the workspace hash that holds the chat id', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'aa37220647fb7ce5eb044aa4bda60807', 'other-chat') + const metaPath = await writeChatMeta(cursorHome, '96fa26ac0f433670ebec73ecef20b47b', 'chat-1', { + title: 'Shell Command Hostname' + }) + const transcriptPath = await writeTranscript(cursorHome, 'private-tmp-workspace', 'chat-1', []) + + expect(await cursorChatMetaPath(transcriptPath)).toBe(metaPath) + expect(await readCursorChatMeta(transcriptPath)).toEqual({ + title: 'Shell Command Hostname', + cwd: '/private/tmp/workspace', + createdAt: new Date(CREATED_AT_MS).toISOString(), + updatedAt: new Date(UPDATED_AT_MS).toISOString() + }) + }) + + it('re-indexes after a chat appears under an already indexed workspace', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-first') + const firstTranscript = await writeTranscript(cursorHome, 'slug', 'chat-first', []) + expect(await cursorChatMetaPath(firstTranscript)).toBeDefined() + + const laterMetaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-later') + const laterTranscript = await writeTranscript(cursorHome, 'slug', 'chat-later', []) + + expect(await cursorChatMetaPath(laterTranscript)).toBe(laterMetaPath) + }) + + it('does not cache a metadata index whose build was refused by the WSL gate', async () => { + const cursorHome = await createCursorHome() + const metaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-refused') + const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-refused', []) + + failNextChatsReaddir = true + // A refusal degrades to "no metadata" rather than taking the session down. + await expect(cursorChatMetaPath(transcriptPath)).resolves.toBeUndefined() + // The next scan rebuilds instead of replaying the rejected promise. + await expect(cursorChatMetaPath(transcriptPath)).resolves.toBe(metaPath) + }) + + it('validates the index once per scan, not once per transcript', async () => { + const cursorHome = await createCursorHome() + const transcripts: string[] = [] + for (const chatId of ['chat-a', 'chat-b', 'chat-c']) { + await writeChatMeta(cursorHome, 'workspace-hash', chatId) + transcripts.push(await writeTranscript(cursorHome, 'slug', chatId, [])) + } + chatsRootReads = 0 + + const inScan = await withCursorChatMetaScan(() => + Promise.all(transcripts.map((path) => cursorChatMetaPath(path))) + ) + expect(inScan.every(Boolean)).toBe(true) + expect(chatsRootReads).toBe(1) + + // Outside a scan every lookup re-validates, which is what the parse path needs. + chatsRootReads = 0 + await Promise.all(transcripts.map((path) => cursorChatMetaPath(path))) + expect(chatsRootReads).toBe(3) + }) + + it('yields nothing and does not throw when there is no chats tree', async () => { + const cursorHome = await createCursorHome() + const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-orphan', []) + + await expect(cursorChatMetaPath(transcriptPath)).resolves.toBeUndefined() + await expect(readCursorChatMeta(transcriptPath)).resolves.toBeNull() + await expect(readCursorChatMeta('/nowhere/near/cursor/chat.jsonl')).resolves.toBeNull() + }) + + it('yields nothing and does not throw when meta.json is malformed', async () => { + const cursorHome = await createCursorHome() + const chatDir = join(cursorHome, 'chats', 'workspace-hash', 'chat-bad') + await mkdir(chatDir, { recursive: true }) + await writeFile(join(chatDir, 'meta.json'), '{ not json') + const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-bad', []) + + await expect(readCursorChatMeta(transcriptPath)).resolves.toBeNull() + }) +}) + +async function cursorCandidate(cursorHome: string): Promise { + const issues: AiVaultScanIssue[] = [] + const discovery = await discoverFiles({ + rootDir: join(cursorHome, 'projects'), + limit: 10, + agent: 'cursor', + issues, + extensions: [...AI_VAULT_AGENT_SOURCES.cursor.extensions], + filePredicate: AI_VAULT_AGENT_SOURCES.cursor.filePredicate, + contentDependencyPath: AI_VAULT_AGENT_SOURCES.cursor.contentDependencyPath + }) + return discovery.files[0] +} + +/** The production path: the parse cache owns the sidecar merge, not the parser. */ +function parseCursorCached( + file: FileWithMtime, + stats: SessionParseStats = createSessionParseStats() +): Promise<{ session: AiVaultSession | null; stats: SessionParseStats }> { + return withCursorChatMetaScan(async () => { + const session = await parseAgentSessionFileCached( + { agent: 'cursor', file, codexHome: null }, + 'darwin', + stats + ) + return { session, stats } + }) +} + +async function writeCursorScanFixture(chatIds: string[]): Promise<{ + cursorHome: string + scanOptions: ReturnType & { cursorProjectsDir: string } +}> { + const cursorHome = await createCursorHome() + for (const chatId of chatIds) { + await writeChatMeta(cursorHome, 'workspace-hash', chatId, { cwd: `/tmp/ws-${chatId}` }) + await writeTranscript(cursorHome, 'slug', chatId, [ + JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: chatId }] } }) + ]) + } + const root = join(cursorHome, '..') + return { + cursorHome, + scanOptions: { + ...isolatedScanRoots(root), + cursorProjectsDir: join(cursorHome, 'projects') + } + } +} + +describe('cursor discovery sidecar observation', () => { + it('records meta.json beside the transcript stat instead of folding it in', async () => { + const cursorHome = await createCursorHome() + const metaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-7') + const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-7', []) + + const before = await cursorCandidate(cursorHome) + const future = new Date(Date.now() + 10_000) + await utimes(metaPath, future, future) + const after = await cursorCandidate(cursorHome) + + // The transcript's own key is untouched by a sibling rewrite. + const transcriptStat = await stat(transcriptPath) + expect(after.mtimeMs).toBe(transcriptStat.mtimeMs) + expect(after.sizeBytes).toBe(transcriptStat.size) + expect(after.mtimeMs).toBe(before.mtimeMs) + // The sibling is observed separately, and it did move. + expect(before.sidecar).toMatchObject({ path: metaPath }) + expect(after.sidecar).toMatchObject({ path: metaPath }) + expect((after.sidecar as SessionSidecarStat).mtimeMs).toBeGreaterThan( + (before.sidecar as SessionSidecarStat).mtimeMs + ) + }) +}) + +describe('cursor sidecar enrichment', () => { + it('fills cwd, timestamps and title from meta.json', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-2', { title: 'Named From Meta' }) + await writeTranscript(cursorHome, 'slug', 'chat-2', [ + JSON.stringify({ role: 'assistant', message: { content: 'hello' } }) + ]) + resetSessionParseCacheForTests() + + const { session } = await parseCursorCached(await cursorCandidate(cursorHome)) + + expect(session?.cwd).toBe('/private/tmp/workspace') + expect(session?.title).toBe('Named From Meta') + expect(session?.createdAt).toBe(new Date(CREATED_AT_MS).toISOString()) + expect(session?.updatedAt).toBe(new Date(UPDATED_AT_MS).toISOString()) + }) + + it('keeps a transcript title and timestamps over meta.json', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-3', { title: 'Meta Title' }) + await writeTranscript(cursorHome, 'slug', 'chat-3', [ + JSON.stringify({ + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + message: { content: 'transcript first prompt' } + }) + ]) + resetSessionParseCacheForTests() + + const { session } = await parseCursorCached(await cursorCandidate(cursorHome)) + + expect(session?.title).toBe('transcript first prompt') + expect(session?.createdAt).toBe('2026-01-01T00:00:00.000Z') + // cwd is never in the transcript, so it still comes from meta.json. + expect(session?.cwd).toBe('/private/tmp/workspace') + }) + + it('builds the resume command from the meta.json cwd', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-4', { cwd: '/repo/from-meta' }) + await writeTranscript(cursorHome, 'slug', 'chat-4', [ + JSON.stringify({ role: 'user', message: { content: 'hi' } }) + ]) + resetSessionParseCacheForTests() + + const { session } = await parseCursorCached(await cursorCandidate(cursorHome)) + + expect(session?.resumeCommand).toContain('/repo/from-meta') + }) + + it('leaves remote content parses to the transcript alone', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-5', { title: 'Meta Title' }) + const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-5', []) + + const session = await parseCursorSessionContent( + fileWithMtime(transcriptPath), + `${JSON.stringify({ role: 'assistant', message: { content: 'remote' } })}\n`, + 'linux' + ) + + expect(session?.cwd).toBeNull() + expect(session?.title).not.toBe('Meta Title') + }) + + it('re-enriches without a parse when only the sidecar is rewritten', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-8', { cwd: '/repo/first' }) + await writeTranscript(cursorHome, 'slug', 'chat-8', [ + JSON.stringify({ role: 'user', message: { content: 'hi' } }) + ]) + resetSessionParseCacheForTests() + await parseCursorCached(await cursorCandidate(cursorHome)) + + const future = new Date(Date.now() + 10_000) + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-8', { cwd: '/repo/second' }) + await utimes(join(cursorHome, 'chats', 'workspace-hash', 'chat-8', 'meta.json'), future, future) + + const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome)) + + // The transcript is not re-read: the merge runs over the stored fold result. + expect(stats.reused).toBe(1) + expect(stats.fullParses).toBe(0) + expect(stats.incremental).toBe(0) + // A rewritten cwd REPLACES the merged one; `??=` on the cached session could + // never do this, because the cached cwd is already non-null. + expect(session?.cwd).toBe('/repo/second') + expect(session?.resumeCommand).toContain('/repo/second') + }) + + it('treats a persisted entry with no sidecar as unknown and enriches once', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-9', { cwd: '/repo/persisted' }) + await writeTranscript(cursorHome, 'slug', 'chat-9', [ + JSON.stringify({ role: 'user', message: { content: 'hi' } }) + ]) + resetSessionParseCacheForTests() + await parseCursorCached(await cursorCandidate(cursorHome)) + + // What a build older than the sidecar field wrote: no such key. + const persisted = snapshotSessionParseCacheForPersistence().map( + ([path, entry]): [string, PersistedSessionParseCacheEntry] => { + const { sidecar: _sidecar, ...rest } = entry + return [path, rest] + } + ) + resetSessionParseCacheForTests() + seedSessionParseCache(persisted) + + const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome)) + expect(stats.reused).toBe(0) + expect(session?.cwd).toBe('/repo/persisted') + }) +}) + +describe('cursor chat meta scan failures', () => { + it('lists cursor sessions without metadata when the chats tree is refused, then heals', async () => { + const { cursorHome, scanOptions } = await writeCursorScanFixture(['chat-a', 'chat-b']) + resetSessionParseCacheForTests() + + failNextChatsRootReaddir = true + const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + const refusedCursor = refused.sessions.filter((session) => session.agent === 'cursor') + expect(refusedCursor).toHaveLength(2) + expect(refusedCursor.map((session) => session.cwd)).toEqual([null, null]) + // One issue for the chats root, not one per transcript. + expect(refused.issues).toHaveLength(1) + expect(refused.issues[0].path).toBe(join(cursorHome, 'chats')) + expect(refused.issues[0].agent).toBe('cursor') + + const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + expect(healed.issues).toEqual([]) + expect( + healed.sessions + .filter((session) => session.agent === 'cursor') + .map((session) => session.cwd) + .sort() + ).toEqual(['/tmp/ws-chat-a', '/tmp/ws-chat-b']) + }) + + it('re-enriches after a refused meta.json read without losing the resume cursor', async () => { + const { scanOptions } = await writeCursorScanFixture(['chat-a']) + resetSessionParseCacheForTests() + + failMetaJsonReads = true + const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + const listed = refused.sessions.find((session) => session.agent === 'cursor') + expect(listed?.cwd).toBeNull() + expect(refused.issues).toHaveLength(1) + + // The sibling alone is unknown; the transcript's work and its resume point + // are kept, so the next healthy scan merges without re-reading bytes. + const entry = getSessionParseCacheEntry(listed?.filePath ?? '') + expect(entry?.sidecar).toBe('unknown') + expect(entry?.resume).not.toBeNull() + + failMetaJsonReads = false + const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + expect(healed.issues).toEqual([]) + expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe( + '/tmp/ws-chat-a' + ) + }) + + it('treats a local EACCES on the sidecar stat as unknown, not as absent', async () => { + const { scanOptions } = await writeCursorScanFixture(['chat-a']) + resetSessionParseCacheForTests() + + // On mac/Linux/Windows the gated stat is a bare fs stat, so a permissions + // failure is not a WslTranscriptFsError and must not read as "no sidecar". + failMetaJsonStats = 'eacces' + const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + const listed = refused.sessions.find((session) => session.agent === 'cursor') + expect(listed?.sessionId).toBeTruthy() + expect(refused.issues).toHaveLength(1) + expect(refused.issues[0].agent).toBe('cursor') + expect(getSessionParseCacheEntry(listed?.filePath ?? '')?.sidecar).toBe('unknown') + + failMetaJsonStats = false + const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + expect(healed.issues).toEqual([]) + expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe( + '/tmp/ws-chat-a' + ) + }) + + it('lists a cursor session whose meta.json stat is refused instead of dropping it', async () => { + const { scanOptions } = await writeCursorScanFixture(['chat-a']) + resetSessionParseCacheForTests() + + failMetaJsonStats = true + const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + expect(refused.sessions.filter((session) => session.agent === 'cursor')).toHaveLength(1) + expect(refused.issues).toHaveLength(1) + expect(refused.issues[0].agent).toBe('cursor') + + failMetaJsonStats = false + const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + expect(healed.issues).toEqual([]) + expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe( + '/tmp/ws-chat-a' + ) + }) + + it('reads the chats root once per scan across discovery and parse', async () => { + const { scanOptions } = await writeCursorScanFixture(['chat-a', 'chat-b', 'chat-c']) + resetSessionParseCacheForTests() + chatsRootReads = 0 + + const result = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 }) + + expect(result.sessions.filter((session) => session.agent === 'cursor')).toHaveLength(3) + expect(chatsRootReads).toBe(1) + }) + + it('resumes an appended transcript after a refused sidecar scan', async () => { + const cursorHome = await createCursorHome() + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-r', { cwd: '/repo/resume' }) + const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-r', [ + JSON.stringify({ role: 'user', message: { content: 'one' } }) + ]) + resetSessionParseCacheForTests() + await parseCursorCached(await cursorCandidate(cursorHome)) + + await appendFile( + transcriptPath, + `${JSON.stringify({ role: 'user', message: { content: 'two' } })}\n` + ) + failMetaJsonReads = true + const { stats: refusedStats } = await parseCursorCached(await cursorCandidate(cursorHome)) + expect(refusedStats.incremental).toBe(1) + + failMetaJsonReads = false + const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome)) + expect(stats.reused).toBe(1) + expect(stats.fullParses).toBe(0) + expect(session?.cwd).toBe('/repo/resume') + }) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-chat-meta.ts b/src/main/ai-vault/session-scanner-cursor-chat-meta.ts new file mode 100644 index 00000000000..866792412eb --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-chat-meta.ts @@ -0,0 +1,294 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { basename, dirname, join } from 'node:path' +import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { timestampIso } from './session-scanner-accumulator' +import { extractString, normalizeTitleText, readJsonObjectIfExists } from './session-scanner-values' + +// Cursor keeps a chat's transcript and its metadata in two unrelated trees: +// /projects//agent-transcripts//.jsonl holds the +// messages, while /chats///meta.json holds the cwd, +// title and timestamps. The md5 hashes the very cwd we are looking for, so the +// only way across is an index of the chat directories. + +const CURSOR_CHATS_DIR = 'chats' +const CURSOR_CHAT_META_FILE = 'meta.json' +const CURSOR_TRANSCRIPTS_DIR = 'agent-transcripts' +const CURSOR_PROJECTS_DIR = 'projects' +// Why: custom and WSL Cursor homes can vary over a long-lived main process. +const CURSOR_CHAT_META_INDEX_CACHE_MAX = 8 + +export type CursorChatMeta = { + title: string | null + cwd: string | null + createdAt: string | null + updatedAt: string | null +} + +type CursorChatMetaIndexEntry = { + signature: string + metaPathByChatId: Map +} + +const cursorChatMetaIndexCache = new Map>() + +type CursorChatMetaScan = { + index: Map>> + // Chats roots this scan could not read, reported once by the scan owner. + refusals: Map + // Transcripts whose own meta.json read was refused, so the metadata merged + // onto them is not what the file on disk says. + refusedTranscripts: Set +} + +// Why: validating the module cache costs a readdir of the chats root plus a stat +// per workspace, and it cannot be skipped because the signature is built from +// those stats. Discovery asks once per transcript and finalize asks again, so +// the scope has to span both phases for one scan to see the tree once. +const scanScopedIndex = new AsyncLocalStorage() + +export function resetCursorChatMetaIndexCacheForTests(): void { + cursorChatMetaIndexCache.clear() +} + +/** Runs one whole scan, discovery and parse; every Cursor transcript in it shares one index read. */ +export function withCursorChatMetaScan(fn: () => Promise): Promise { + return scanScopedIndex.run( + { index: new Map(), refusals: new Map(), refusedTranscripts: new Set() }, + fn + ) +} + +/** + * True when this transcript's own meta.json read was refused, so the caller + * records the sidecar as unknown rather than as the observation discovery made. + * The transcript's own work and its resume point are kept either way. + */ +export function wasCursorChatMetaRefused(transcriptPath: string): boolean { + return scanScopedIndex.getStore()?.refusedTranscripts.has(transcriptPath) ?? false +} + +/** Chats roots the current scan was refused, for the caller to report as scan issues. */ +export function cursorChatMetaRefusals(): { chatsRoot: string; message: string }[] { + const scan = scanScopedIndex.getStore() + return scan ? [...scan.refusals].map(([chatsRoot, message]) => ({ chatsRoot, message })) : [] +} + +/** Path a discovery stat can watch so a rewritten meta.json invalidates the parse cache. */ +export async function cursorChatMetaPath(transcriptPath: string): Promise { + const chatsRoot = cursorChatsRootFromTranscriptPath(transcriptPath) + const chatId = cursorChatIdFromTranscriptPath(transcriptPath) + if (!chatsRoot || !chatId) { + return undefined + } + const index = await readCursorChatMetaIndexOncePerScan(chatsRoot) + return index.get(chatId) +} + +function readCursorChatMetaIndexOncePerScan(chatsRoot: string): Promise> { + const scan = scanScopedIndex.getStore() + if (!scan) { + return readCursorChatMetaIndexOrNone(chatsRoot) + } + let pending = scan.index.get(chatsRoot) + if (!pending) { + pending = readCursorChatMetaIndexOrNone(chatsRoot) + scan.index.set(chatsRoot, pending) + } + return pending +} + +/** + * A refused WSL read is not "no chats", but it must not take the transcript + * down with it: before this join a stalled distro could not hide a Cursor + * session at all. Degrade to no metadata for the scan and report the root once. + * The session still lists from its transcript alone, and the sidecar is + * recorded as unknown, so the next healthy scan merges the real metadata in + * without re-reading a byte of the transcript. + */ +async function readCursorChatMetaIndexOrNone(chatsRoot: string): Promise> { + try { + return await readCursorChatMetaIndex(chatsRoot) + } catch (error) { + if (!(error instanceof WslTranscriptFsError)) { + throw error + } + recordCursorChatMetaRefusal(chatsRoot, error.message) + return new Map() + } +} + +function recordCursorChatMetaRefusal(chatsRoot: string, message: string): void { + const scan = scanScopedIndex.getStore() + if (scan && !scan.refusals.has(chatsRoot)) { + scan.refusals.set(chatsRoot, message) + } +} + +export async function readCursorChatMeta(transcriptPath: string): Promise { + const metaPath = await cursorChatMetaPath(transcriptPath) + if (!metaPath) { + return null + } + let record: Record | null + try { + record = await readJsonObjectIfExists(metaPath) + } catch (error) { + if (!(error instanceof WslTranscriptFsError)) { + throw error + } + // The session still lists, but unlike the index read this transcript's key + // already includes meta.json's stat, so the caller must not cache the + // un-enriched result. One issue per chats root, as for a refused index. + recordCursorChatMetaRefusal( + cursorChatsRootFromTranscriptPath(transcriptPath) ?? metaPath, + error.message + ) + scanScopedIndex.getStore()?.refusedTranscripts.add(transcriptPath) + return null + } + if (!record) { + return null + } + return { + title: normalizeTitleText(extractString(record.title) ?? ''), + cwd: extractString(record.cwd), + createdAt: timestampIso(record.createdAtMs), + updatedAt: timestampIso(record.updatedAtMs) + } +} + +function cursorChatIdFromTranscriptPath(transcriptPath: string): string | null { + const chatDir = dirname(transcriptPath) + return basename(dirname(chatDir)) === CURSOR_TRANSCRIPTS_DIR ? basename(chatDir) : null +} + +function cursorChatsRootFromTranscriptPath(transcriptPath: string): string | null { + let currentDir = dirname(transcriptPath) + while (currentDir && dirname(currentDir) !== currentDir) { + // The chats tree is a sibling of the projects tree, custom Cursor homes included. + if (basename(currentDir) === CURSOR_PROJECTS_DIR) { + return join(dirname(currentDir), CURSOR_CHATS_DIR) + } + currentDir = dirname(currentDir) + } + return null +} + +async function readCursorChatMetaIndex(chatsRoot: string): Promise> { + let workspaceDirs: string[] + try { + workspaceDirs = (await wslGatedReaddir(chatsRoot, 'scan')) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + } catch (error) { + // Why: a refused WSL read is not "no chats"; letting it through keeps the + // session out of the parse cache instead of caching it without metadata. + if (error instanceof WslTranscriptFsError) { + throw error + } + return new Map() + } + const signature = await readCursorChatsSignature(chatsRoot, workspaceDirs) + const cached = await readCachedCursorChatMetaIndex(chatsRoot, signature) + if (cached) { + return cached + } + const pending = buildCursorChatMetaIndex(chatsRoot, workspaceDirs).then((metaPathByChatId) => ({ + signature, + metaPathByChatId + })) + storeCursorChatMetaIndexEntry(chatsRoot, pending) + // Why: a rejected build (a refused WSL read) must not be served from the + // cache forever; the next scan rebuilds while this one still sees the error. + pending.catch(() => { + if (cursorChatMetaIndexCache.get(chatsRoot) === pending) { + cursorChatMetaIndexCache.delete(chatsRoot) + } + }) + return (await pending).metaPathByChatId +} + +// Why: a new chat only bumps its own workspace directory, so the chats root's +// own mtime would keep serving an index that is missing the newest sessions. +async function readCursorChatsSignature( + chatsRoot: string, + workspaceDirs: string[] +): Promise { + const parts = await Promise.all( + workspaceDirs.map(async (name) => { + try { + const dirStat = await wslGatedStat(join(chatsRoot, name), 'scan') + return `${name}:${dirStat.mtimeMs}` + } catch { + return `${name}:?` + } + }) + ) + return parts.join('|') +} + +async function buildCursorChatMetaIndex( + chatsRoot: string, + workspaceDirs: string[] +): Promise> { + const metaPathByChatId = new Map() + for (const workspaceDir of workspaceDirs) { + let chatDirs + try { + chatDirs = await wslGatedReaddir(join(chatsRoot, workspaceDir), 'scan') + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + continue + } + for (const chatDir of chatDirs) { + // Why: the same chat id never appears under two workspace hashes, so the + // first hit wins and a duplicate would only cost a wasted read. + if (chatDir.isDirectory() && !metaPathByChatId.has(chatDir.name)) { + metaPathByChatId.set( + chatDir.name, + join(chatsRoot, workspaceDir, chatDir.name, CURSOR_CHAT_META_FILE) + ) + } + } + } + return metaPathByChatId +} + +async function readCachedCursorChatMetaIndex( + chatsRoot: string, + signature: string +): Promise | undefined> { + const cached = cursorChatMetaIndexCache.get(chatsRoot) + if (!cached) { + return undefined + } + const entry = await cached + if (entry.signature !== signature) { + return undefined + } + // Why: a concurrent scan can replace this Promise while it resolves; only the + // still-current entry may refresh recency without bypassing the cap. + if (cursorChatMetaIndexCache.get(chatsRoot) === cached) { + cursorChatMetaIndexCache.delete(chatsRoot) + cursorChatMetaIndexCache.set(chatsRoot, cached) + } + return entry.metaPathByChatId +} + +function storeCursorChatMetaIndexEntry( + chatsRoot: string, + pending: Promise +): void { + cursorChatMetaIndexCache.delete(chatsRoot) + cursorChatMetaIndexCache.set(chatsRoot, pending) + if (cursorChatMetaIndexCache.size > CURSOR_CHAT_META_INDEX_CACHE_MAX) { + const oldest = cursorChatMetaIndexCache.keys().next() + if (!oldest.done) { + cursorChatMetaIndexCache.delete(oldest.value) + } + } +} diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts index 0f44aa923fa..bfa15caa530 100644 --- a/src/main/ai-vault/session-scanner-cursor-parser.ts +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -8,6 +8,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { accumulatorFoldResumeState, addPreviewContent, @@ -30,13 +31,14 @@ type ParserSessionOptions = { export async function parseCursorSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const lines = createInterface({ input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) - return parseCursorSessionLines({ file, lines, platform }) + return parseCursorSessionLines({ file, lines, platform, messages }) } export async function parseCursorSessionContent( @@ -75,9 +77,17 @@ function consumeCursorRecordLine(accumulator: SessionAccumulator, line: string): } } -export function createCursorSessionResumeState(file: FileWithMtime): ResumableSessionParseState { +export function createCursorSessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { return accumulatorFoldResumeState( - createAccumulator({ agent: 'cursor', file, sessionId: sessionIdFromFileName(file.path) }), + createAccumulator({ + agent: 'cursor', + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), consumeCursorRecordLine ) } @@ -87,8 +97,9 @@ async function parseCursorSessionLines(args: { lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions + messages?: TranscriptMessageSink }): Promise { - const state = createCursorSessionResumeState(args.file) + const state = createCursorSessionResumeState(args.file, args.messages) for await (const line of args.lines) { state.consumeLine(line) } diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index a43603bca47..12e40ef3fc2 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -2,6 +2,7 @@ import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewContent, createAccumulator, @@ -25,20 +26,34 @@ type ParserSessionOptions = { export async function parseDevinSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { - return parseDevinSessionContent( + return parseDevinSessionRecord( file, await wslGatedReadFile(file.path, 'utf-8', 'scan'), - platform + platform, + {}, + messages ) } +/** Remote transcript content, streamed from a host that has no reader attached. */ export function parseDevinSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {} +): AiVaultSession | null { + return parseDevinSessionRecord(file, content, platform, options) +} + +function parseDevinSessionRecord( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: ParserSessionOptions, + messages?: TranscriptMessageSink ): AiVaultSession | null { const record = asRecord(JSON.parse(content) as unknown) if (!record) { @@ -48,7 +63,7 @@ export function parseDevinSessionContent( extractString(record.session_id) ?? extractString(record.sessionId) ?? sessionIdFromFileName(file.path) - const accumulator = createAccumulator({ agent: 'devin', file, sessionId }) + const accumulator = createAccumulator({ agent: 'devin', file, sessionId, messages }) const agentRecord = asRecord(record.agent) accumulator.model = extractString(agentRecord?.model_name) ?? diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index a5cf386043e..f7835dbdcc5 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,10 +1,12 @@ import type { Dirent } from 'node:fs' import { extname, join } from 'node:path' +import { SessionNewestFiles } from './session-newest-files' +import type { SessionSidecarObservation } from './session-sidecar-stat' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { recordSessionScanIssue } from './session-scan-issues' -import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' +import type { SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' export async function discoverFiles(args: { @@ -14,16 +16,55 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean - contentDependencyPath?: (path: string) => string + contentDependencyPath?: (path: string) => string | undefined | Promise directoryPredicate?: (name: string, depth: number) => boolean }): Promise { - let paths: string[] + const files = new SessionNewestFiles(args.limit) + let refusedSidecar = false try { - paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { - extensions: new Set(args.extensions), - filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate - }) + await forEachSessionFile( + args.rootDir, + args.agent, + args.issues, + { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate + }, + async (path) => { + try { + const fileStat = await wslGatedStat(path, 'scan') + const sidecarPath = await args.contentDependencyPath?.(path) + const sidecar = await observeSessionSidecar(sidecarPath) + if (sidecar === 'unknown' && !refusedSidecar) { + // One issue per root: a refused sibling is a property of the tree, + // not of each transcript that happens to point at it. + refusedSidecar = true + recordSessionScanIssue(args.issues, { + agent: args.agent, + path: sidecarPath ?? args.rootDir, + message: 'Session metadata could not be read this scan.' + }) + } + files.add({ + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: new Date(fileStat.mtimeMs).toISOString(), + sizeBytes: fileStat.size, + sidecar, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink + }) + } catch (err) { + recordSessionScanIssue(args.issues, { + agent: args.agent, + path, + message: errorMessage(err) + }) + } + } + ) } catch (err) { // Why: discoverAiVaultSessionSources fans out with Promise.all, so one // stalled distro would otherwise reject the whole vault scan — including @@ -38,68 +79,74 @@ export async function discoverFiles(args: { }) return { agent: args.agent, rootDir: args.rootDir, files: [] } } - const files: FileWithMtime[] = [] - for (const path of paths) { - try { - const fileStat = await wslGatedStat(path, 'scan') - const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path)) - const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0) - files.push({ - path, - mtimeMs, - modifiedAt: new Date(mtimeMs).toISOString(), - sizeBytes: fileStat.size + (dependencyStat?.size ?? 0), - dev: fileStat.dev, - ino: fileStat.ino, - nlink: fileStat.nlink - }) - } catch (err) { - recordSessionScanIssue(args.issues, { - agent: args.agent, - path, - message: errorMessage(err) - }) - } - } - return { - agent: args.agent, - rootDir: args.rootDir, - files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit) - } + return { agent: args.agent, rootDir: args.rootDir, files: files.newest() } } -async function optionalContentDependencyStat( +/** + * A sibling that cannot be statted is not "no sibling": it must not take the + * transcript down with it, and it must not read as absent either, or the parse + * cache would treat a session enriched from a file nobody can see as current + * forever. Only a genuinely missing path is `'none'`; every other failure — + * a stalled WSL distro, EACCES, EIO — is `'unknown'`. + */ +async function observeSessionSidecar( filePath: string | undefined -): Promise<{ mtimeMs: number; size: number } | null> { +): Promise { if (!filePath) { - return null + return 'none' } try { const fileStat = await wslGatedStat(filePath, 'scan') - return { mtimeMs: fileStat.mtimeMs, size: fileStat.size } + return { path: filePath, mtimeMs: fileStat.mtimeMs, sizeBytes: fileStat.size } } catch (error) { - if (error instanceof WslTranscriptFsError) { - throw error - } - return null + return isMissingSidecarError(error) ? 'none' : 'unknown' } } +function isMissingSidecarError(error: unknown): boolean { + if (error instanceof WslTranscriptFsError) { + return false + } + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return code === 'ENOENT' || code === 'ENOTDIR' +} + +export type SessionFileWalkOptions = { + extensions: Set + filePredicate?: (path: string) => boolean + // Return false to skip descending into a directory; depth 0 is a child of + // rootDir, so pruned subtrees are never stat'd or parsed. + directoryPredicate?: (name: string, depth: number) => boolean + readDirectory?: (dirPath: string) => Promise + signal?: AbortSignal +} + +/** Collecting form for callers that want every match; bounded scans stream. */ export async function walkSessionFiles( dirPath: string, agent: AiVaultAgent, issues: AiVaultScanIssue[], - options: { - extensions: Set - filePredicate?: (path: string) => boolean - // Return false to skip descending into a directory; depth 0 is a child of - // rootDir, so pruned subtrees are never stat'd or parsed. - directoryPredicate?: (name: string, depth: number) => boolean - readDirectory?: (dirPath: string) => Promise - signal?: AbortSignal - }, - depth = 0 + options: SessionFileWalkOptions ): Promise { + const files: string[] = [] + await forEachSessionFile(dirPath, agent, issues, options, async (path) => { + files.push(path) + }) + return files +} + +/** Streams matches to `onFile` so a bounded consumer never retains the whole tree. */ +export async function forEachSessionFile( + dirPath: string, + agent: AiVaultAgent, + issues: AiVaultScanIssue[], + options: SessionFileWalkOptions, + onFile: (path: string) => Promise, + depth = 0 +): Promise { options.signal?.throwIfAborted() let entries try { @@ -113,10 +160,9 @@ export async function walkSessionFiles( if (error instanceof WslTranscriptFsError) { throw error } - return [] + return } - const files: string[] = [] for (const entry of entries) { options.signal?.throwIfAborted() const fullPath = join(dirPath, entry.name) @@ -124,7 +170,7 @@ export async function walkSessionFiles( // Skip whole subtrees an agent never wants (e.g. subagent transcripts), // avoiding the readdir cost of descending into them. if (options.directoryPredicate?.(entry.name, depth) ?? true) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) + await forEachSessionFile(fullPath, agent, issues, options, onFile, depth + 1) } continue } @@ -133,8 +179,7 @@ export async function walkSessionFiles( options.extensions.has(extname(entry.name).toLowerCase()) && (options.filePredicate?.(fullPath) ?? true) ) { - files.push(fullPath) + await onFile(fullPath) } } - return files } diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index c896b47a3e8..748a8a6bcc9 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -8,6 +8,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { accumulatorFoldResumeState, addPreviewMessage, @@ -32,12 +33,13 @@ type ParserSessionOptions = { export async function parseDroidSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') const lines = createInterface({ input, crlfDelay: Infinity }) try { - return await parseDroidSessionLines({ file, lines, platform }) + return await parseDroidSessionLines({ file, lines, platform, messages }) } finally { // readline.close() leaves the underlying stream open; destroy it so a // mid-parse throw cannot leak the gated transcript handle. @@ -94,9 +96,17 @@ function consumeDroidRecordLine(accumulator: SessionAccumulator, line: string): } } -export function createDroidSessionResumeState(file: FileWithMtime): ResumableSessionParseState { +export function createDroidSessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { return accumulatorFoldResumeState( - createAccumulator({ agent: 'droid', file, sessionId: sessionIdFromFileName(file.path) }), + createAccumulator({ + agent: 'droid', + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), consumeDroidRecordLine ) } @@ -106,8 +116,9 @@ async function parseDroidSessionLines(args: { lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions + messages?: TranscriptMessageSink }): Promise { - const state = createDroidSessionResumeState(args.file) + const state = createDroidSessionResumeState(args.file, args.messages) for await (const line of args.lines) { state.consumeLine(line) } diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index d8d8a485d8f..efc9eef9e10 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -8,6 +8,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { accumulatorFoldResumeState, addPreviewContent, @@ -27,16 +28,19 @@ import { export async function parseGeminiSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { if (file.path.endsWith('.jsonl')) { - return parseGeminiJsonlSessionFile(file, platform) + return parseGeminiJsonlSessionFile(file, platform, messages) } return parseGeminiJsonSessionContent( file, await wslGatedReadFile(file.path, 'utf-8', 'scan'), - platform + platform, + {}, + messages ) } @@ -62,7 +66,8 @@ function parseGeminiJsonSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: ResumableParseFinalizeOptions = {} + options: ResumableParseFinalizeOptions = {}, + messages?: TranscriptMessageSink ): AiVaultSession | null { const record = asRecord(JSON.parse(content) as unknown) if (!record) { @@ -71,7 +76,8 @@ function parseGeminiJsonSessionContent( const accumulator = createAccumulator({ agent: 'gemini', file, - sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path) + sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path), + messages }) updateTimeline(accumulator, extractString(record.startTime)) updateTimeline(accumulator, extractString(record.lastUpdated)) @@ -83,13 +89,14 @@ function parseGeminiJsonSessionContent( export async function parseGeminiJsonlSessionFile( file: FileWithMtime, - platform: NodeJS.Platform + platform: NodeJS.Platform, + messages?: TranscriptMessageSink ): Promise { const lines = createInterface({ input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) - return parseGeminiJsonlSessionLines({ file, lines, platform }) + return parseGeminiJsonlSessionLines({ file, lines, platform, messages }) } function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void { @@ -114,10 +121,16 @@ function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: str // Resumable only for the JSONL log format; Gemini's legacy single-JSON // session documents are rewritten in place and must be re-read whole. export function createGeminiJsonlSessionResumeState( - file: FileWithMtime + file: FileWithMtime, + messages?: TranscriptMessageSink ): ResumableSessionParseState { return accumulatorFoldResumeState( - createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }), + createAccumulator({ + agent: 'gemini', + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), consumeGeminiJsonlRecordLine ) } @@ -127,8 +140,9 @@ async function parseGeminiJsonlSessionLines(args: { lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ResumableParseFinalizeOptions + messages?: TranscriptMessageSink }): Promise { - const state = createGeminiJsonlSessionResumeState(args.file) + const state = createGeminiJsonlSessionResumeState(args.file, args.messages) for await (const line of args.lines) { state.consumeLine(line) } diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index d99893acaa2..581e885fd96 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -10,6 +10,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { accumulatorFoldResumeState, addPreviewContent, @@ -38,7 +39,8 @@ type ParserSessionOptions = { export async function parseRovoSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const metadata = asRecord( JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown @@ -49,7 +51,8 @@ export async function parseRovoSessionFile( const accumulator = createAccumulator({ agent: 'rovo', file, - sessionId: basename(dirname(file.path)) + sessionId: basename(dirname(file.path)), + messages }) accumulator.title = firstString(metadata, ['title', 'name', 'summary']) accumulator.cwd = firstString(metadata, [ @@ -174,12 +177,13 @@ export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' | 'prime-agent' export async function parseMessageGraphSessionFile( agent: MessageGraphAgent, file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan') const lines = createInterface({ input, crlfDelay: Infinity }) try { - return await parseMessageGraphSessionLines({ agent, file, lines, platform }) + return await parseMessageGraphSessionLines({ agent, file, lines, platform, messages }) } finally { // readline.close() leaves the underlying stream open; destroy it so a // mid-parse throw cannot leak the gated transcript handle. @@ -245,10 +249,11 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st export function createMessageGraphSessionResumeState( agent: MessageGraphAgent, - file: FileWithMtime + file: FileWithMtime, + messages?: TranscriptMessageSink ): ResumableSessionParseState { const state = accumulatorFoldResumeState( - createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), + createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path), messages }), consumeMessageGraphRecordLine ) // Why: only OMP materializes task-subagent transcripts beside its sessions @@ -263,8 +268,9 @@ async function parseMessageGraphSessionLines(args: { lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions + messages?: TranscriptMessageSink }): Promise { - const state = createMessageGraphSessionResumeState(args.agent, args.file) + const state = createMessageGraphSessionResumeState(args.agent, args.file, args.messages) for await (const line of args.lines) { state.consumeLine(line) } diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 94e41e4b052..d9e88077c39 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewMessage, createAccumulator, @@ -34,7 +35,8 @@ const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096 export async function parseGrokSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown) if (!record) { @@ -42,7 +44,7 @@ export async function parseGrokSessionFile( } const info = asRecord(record.info) const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path)) - const accumulator = createAccumulator({ agent: 'grok', file, sessionId }) + const accumulator = createAccumulator({ agent: 'grok', file, sessionId, messages }) accumulator.cwd = extractString(info?.cwd) accumulator.title = normalizeTitleText(extractString(record.generated_title) ?? '') ?? diff --git a/src/main/ai-vault/session-scanner-hermes-parser.ts b/src/main/ai-vault/session-scanner-hermes-parser.ts index 017fd9c8fdc..f532fa8241d 100644 --- a/src/main/ai-vault/session-scanner-hermes-parser.ts +++ b/src/main/ai-vault/session-scanner-hermes-parser.ts @@ -2,6 +2,7 @@ import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewContent, createAccumulator, @@ -24,20 +25,34 @@ type ParserSessionOptions = { export async function parseHermesSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { - return parseHermesSessionContent( + return parseHermesSessionRecord( file, await wslGatedReadFile(file.path, 'utf-8', 'scan'), - platform + platform, + {}, + messages ) } +/** Remote transcript content, streamed from a host that has no reader attached. */ export async function parseHermesSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {} +): Promise { + return parseHermesSessionRecord(file, content, platform, options) +} + +async function parseHermesSessionRecord( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: ParserSessionOptions, + messages?: TranscriptMessageSink ): Promise { const record = asRecord(JSON.parse(content) as unknown) if (!record) { @@ -46,7 +61,8 @@ export async function parseHermesSessionContent( const accumulator = createAccumulator({ agent: 'hermes', file, - sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path) + sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path), + messages }) accumulator.model = extractString(record.model) accumulator.cwd = extractString(record.cwd) diff --git a/src/main/ai-vault/session-scanner-kimi-parser.ts b/src/main/ai-vault/session-scanner-kimi-parser.ts index ba6deaa8fb8..cb5a93279ad 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.ts @@ -16,6 +16,7 @@ import { readKimiWorkDirBySessionId } from './session-scanner-kimi-paths' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { asRecord, extractContentText, @@ -32,7 +33,8 @@ import { // session_index.jsonl; model/messages/tokens come from the wire transcript. export async function parseKimiSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { let stateRecord: Record | null try { @@ -53,7 +55,7 @@ export async function parseKimiSessionFile( } const sessionId = kimiSessionIdFromStatePath(file.path) - const accumulator = createAccumulator({ agent: 'kimi', file, sessionId }) + const accumulator = createAccumulator({ agent: 'kimi', file, sessionId, messages }) // Why: Kimi sessions are work-dir-scoped — the resume command must `cd` into // the original directory or the CLI rejects it. That path lives only in the diff --git a/src/main/ai-vault/session-scanner-opencode-parser.ts b/src/main/ai-vault/session-scanner-opencode-parser.ts index 30223e70866..2bc0245bcb0 100644 --- a/src/main/ai-vault/session-scanner-opencode-parser.ts +++ b/src/main/ai-vault/session-scanner-opencode-parser.ts @@ -3,6 +3,7 @@ import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { join } from 'node:path' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewMessage, createAccumulator, @@ -25,14 +26,15 @@ import { export async function parseOpenCodeSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown) if (!record) { return null } const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) - const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) + const accumulator = createAccumulator({ agent: 'opencode', file, sessionId, messages }) accumulator.title = normalizeTitleText(extractString(record.title) ?? '') accumulator.cwd = extractString(record.directory) updateTimeline(accumulator, timeObjectValue(record.time, 'created')) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-schema.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-schema.ts new file mode 100644 index 00000000000..b7420c80043 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-schema.ts @@ -0,0 +1,29 @@ +import type SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: OpenCode's schema has moved more than once, so every read probes for the +// columns it names. These are the two shapes the session parser depends on; +// keeping them here stops each reader from inventing its own partial gate. + +/** Enough of `message` to count a session's turns. */ +export function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +/** Enough of `message`×`part` to read a session's parts in turn order. */ +export function canReadOpenCodeMessageParts(db: SyncDatabase): boolean { + return ( + canCountOpenCodeMessages(db) && + columnExists(db, 'message', 'id') && + // Every parts read orders by it; unprobed, a schema without it throws mid-read. + columnExists(db, 'message', 'time_created') && + tableExists(db, 'part') && + columnExists(db, 'part', 'message_id') && + columnExists(db, 'part', 'time_created') && + columnExists(db, 'part', 'data') + ) +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index e8fc78907ba..7d7772bcf1d 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -1,4 +1,4 @@ -import type { Worker } from 'node:worker_threads' +import { LazyWorkerThreadHost, type WorkerThreadFactory } from '../lazy-worker-thread-host' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { OpenCodeSqliteListRequest, @@ -11,9 +11,10 @@ import type { SessionFileCandidate } from './session-scanner-types' import { errorMessage } from './session-scanner-values' // Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off -// the main-process event loop. Lifecycle (idle teardown, FIFO one-at-a-time -// dispatch, per-call timeouts, respawn-on-fault) mirrors src/main/speech/ -// stt-service.ts. The default spawn + shared singleton live in +// the main-process event loop. This module owns the request half (FIFO +// one-at-a-time dispatch, per-call timeouts, respawn-on-fault); the thread's +// own lifetime belongs to LazyWorkerThreadHost, shared with the port-scan probe +// client. The default spawn + shared singleton live in // session-scanner-opencode-sqlite-worker-spawn.ts. export const LIST_TIMEOUT_MS = 30_000 @@ -25,8 +26,6 @@ export const IDLE_TEARDOWN_MS = 30_000 // fresh scan burst starts from idle (so the cap is per-scan, not process-wide). export const MAX_CONSECUTIVE_DEATHS = 3 -export type WorkerFactory = () => Worker - // Omit collapses to the shared keys, so omit each member and let // the client stamp the correlation id. type OpenCodeSqliteRequestBody = @@ -53,20 +52,27 @@ class OpenCodeSqliteWorkerUnavailableError extends Error {} * no worker can be spawned rather than moving SQLite work onto the main thread. */ export class OpenCodeSqliteWorkerClient { - private worker: Worker | null = null private active: PendingCall | null = null private queue: PendingCall[] = [] - private idleTimer: NodeJS.Timeout | null = null private consecutiveDeaths = 0 private nextId = 1 - private loggedWorkerUnavailable = false - private cleanupWorkerListeners: (() => void) | null = null - private readonly workerFactory: WorkerFactory - private readonly log: (message: string) => void + private readonly host: LazyWorkerThreadHost - constructor(options: { workerFactory: WorkerFactory; log?: (message: string) => void }) { - this.workerFactory = options.workerFactory - this.log = options.log ?? ((message) => console.warn(message)) + constructor(options: { workerFactory: WorkerThreadFactory; log?: (message: string) => void }) { + const log = options.log ?? ((message: string) => console.warn(message)) + this.host = new LazyWorkerThreadHost({ + factory: options.workerFactory, + idleTeardownMs: IDLE_TEARDOWN_MS, + onMessage: (response) => this.onMessage(response), + onError: (error) => this.onWorkerFault(error), + onExit: (code) => this.onWorkerExit(code), + isIdle: () => !this.active && this.queue.length === 0, + // Why (#8864): never fall back to synchronous SQLite reads here; a missing + // bundle or resource-exhausted spawn must omit OpenCode history rather than + // reintroduce the main-process hang this worker boundary prevents. + onUnavailable: (err) => + log(`OpenCode SQLite worker unavailable; skipping its history. ${errorMessage(err)}`) + }) } /** @@ -167,7 +173,7 @@ export class OpenCodeSqliteWorkerClient { if (this.active || this.queue.length === 0) { return } - const worker = this.ensureWorker() + const worker = this.host.ensure() if (!worker) { this.failQueuedAsUnavailable() return @@ -177,7 +183,7 @@ export class OpenCodeSqliteWorkerClient { return } this.active = call - this.clearIdleTimer() + this.host.clearIdleTimer() // Timeout clock starts at dispatch (not enqueue): a batch may enqueue up to // 8 parses at once, and a queue-inclusive timeout would fire falsely. call.timer = setTimeout(() => this.onTimeout(call), call.timeoutMs) @@ -185,39 +191,6 @@ export class OpenCodeSqliteWorkerClient { worker.postMessage(call.request) } - private ensureWorker(): Worker | null { - if (this.worker) { - return this.worker - } - try { - const worker = this.workerFactory() - const onMessage = (response: OpenCodeSqliteWorkerResponse): void => this.onMessage(response) - const onError = (error: Error): void => this.onWorkerFault(error) - const onExit = (code: number): void => this.onWorkerExit(code) - worker.on('message', onMessage) - worker.on('error', onError) - worker.on('exit', onExit) - this.cleanupWorkerListeners = () => { - worker.off('message', onMessage) - worker.off('error', onError) - worker.off('exit', onExit) - } - // Never keep the app alive for a scan worker. - worker.unref?.() - this.worker = worker - return worker - } catch (err) { - // Why (#8864): never fall back to synchronous SQLite reads here; a missing - // bundle or resource-exhausted spawn must omit OpenCode history rather than - // reintroduce the main-process hang this worker boundary prevents. - if (!this.loggedWorkerUnavailable) { - this.loggedWorkerUnavailable = true - this.log(`OpenCode SQLite worker unavailable; skipping its history. ${errorMessage(err)}`) - } - return null - } - } - private onMessage(response: OpenCodeSqliteWorkerResponse): void { const call = this.active if (!call || call.request.id !== response.id) { @@ -243,7 +216,7 @@ export class OpenCodeSqliteWorkerClient { // A clean self-exit is not a death, but the stale handle must be dropped // or the next dispatch would post into the dead worker and stall to timeout. if (code === 0 && !this.active && this.queue.length === 0) { - this.destroyWorker() + this.host.destroy() return } this.onWorkerFault(new Error(`OpenCode SQLite worker exited with code ${code}`)) @@ -251,7 +224,7 @@ export class OpenCodeSqliteWorkerClient { private onWorkerFault(error: Error): void { const failed = this.active - this.destroyWorker() + this.host.destroy() this.consecutiveDeaths++ if (failed) { this.settle(failed, () => failed.reject(error)) @@ -302,46 +275,7 @@ export class OpenCodeSqliteWorkerClient { if (this.queue.length > 0) { this.pump() } else { - this.scheduleIdleTeardown() + this.host.scheduleIdleTeardown() } } - - private scheduleIdleTeardown(): void { - this.clearIdleTimer() - if (!this.worker) { - return - } - this.idleTimer = setTimeout(() => this.teardownIfIdle(), IDLE_TEARDOWN_MS) - this.idleTimer.unref?.() - } - - private teardownIfIdle(): void { - this.idleTimer = null - // Only tear down with nothing active AND nothing queued: a request arriving - // as the timer fires must never be lost to a self-exiting worker. - if (this.active || this.queue.length > 0) { - return - } - this.destroyWorker() - } - - private clearIdleTimer(): void { - if (this.idleTimer) { - clearTimeout(this.idleTimer) - this.idleTimer = null - } - } - - private destroyWorker(): void { - this.clearIdleTimer() - const worker = this.worker - this.worker = null - if (!worker) { - return - } - this.cleanupWorkerListeners?.() - this.cleanupWorkerListeners = null - worker.removeAllListeners() - void worker.terminate().catch(() => undefined) - } } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index 322a38f214f..607534186da 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -10,6 +10,10 @@ import { shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' +import { + canCountOpenCodeMessages, + canReadOpenCodeMessageParts +} from './session-scanner-opencode-sqlite-schema' import { normalizeTitleText } from './session-scanner-values' import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -70,14 +74,6 @@ function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' } -function canCountOpenCodeMessages(db: SyncDatabase): boolean { - return ( - tableExists(db, 'message') && - columnExists(db, 'message', 'session_id') && - columnExists(db, 'message', 'data') - ) -} - function buildSessionQuery(db: SyncDatabase): string { const messageCountSubquery = canCountOpenCodeMessages(db) ? `(SELECT COUNT(*) FROM message m @@ -154,14 +150,7 @@ function extractPartText(partData: string): string | null { } function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null { - if ( - !canCountOpenCodeMessages(db) || - !tableExists(db, 'part') || - !columnExists(db, 'message', 'id') || - !columnExists(db, 'part', 'message_id') || - !columnExists(db, 'part', 'time_created') || - !columnExists(db, 'part', 'data') - ) { + if (!canReadOpenCodeMessageParts(db)) { return null } @@ -206,14 +195,7 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): } function buildPreviewQuery(db: SyncDatabase): string | null { - if ( - !canCountOpenCodeMessages(db) || - !tableExists(db, 'part') || - !columnExists(db, 'message', 'id') || - !columnExists(db, 'part', 'message_id') || - !columnExists(db, 'part', 'time_created') || - !columnExists(db, 'part', 'data') - ) { + if (!canReadOpenCodeMessageParts(db)) { return null } return `SELECT json_extract(m.data, '$.role') AS role, diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index 9208b490fb1..cc086dd50e2 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -205,6 +205,61 @@ describe('codex-specific resume behavior', () => { }) describe('non-resumable formats keep reuse-only caching', () => { + it('re-parses cline when only its messages sidecar changed', async () => { + const root = await makeTempDir() + const sessionDir = join(root, 'cline-1') + await mkdir(sessionDir, { recursive: true }) + const metadataPath = join(sessionDir, 'cline-1.json') + const messagesPath = join(sessionDir, 'cline-1.messages.json') + await writeFile( + metadataPath, + JSON.stringify({ + session_id: 'cline-1', + cwd: '/tmp/cline', + started_at: '2026-05-01T10:00:00Z' + }) + ) + const writeMessages = (text: string): Promise => + writeFile( + messagesPath, + JSON.stringify({ + updated_at: '2026-05-01T10:00:01Z', + messages: [{ role: 'user', content: [{ type: 'text', text }] }] + }) + ) + await writeMessages('first ask') + + // Cline reads the sidecar as part of its parse, so a change to it has to + // re-parse; there is no metadata-only merge to re-run. + const candidate = async (): Promise => { + const base = await candidateFor('cline', metadataPath) + const sidecarStat = await stat(messagesPath) + return { + ...base, + file: { + ...base.file, + sidecar: { + path: messagesPath, + mtimeMs: sidecarStat.mtimeMs, + sizeBytes: sidecarStat.size + } + } + } + } + + const stats = createSessionParseStats() + const seeded = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + expect(seeded?.title).toBe('first ask') + await parseAgentSessionFileCached(await candidate(), process.platform, stats) + expect(stats).toMatchObject({ fullParses: 1, reused: 1 }) + + await writeMessages('second ask, rather longer than the first') + const rewritten = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + + expect(rewritten?.title).toBe('second ask, rather longer than the first') + expect(stats).toMatchObject({ fullParses: 2, reused: 1 }) + }) + it('re-parses a changed grok summary fully and reuses it when unchanged', async () => { const root = await makeTempDir() const sessionDir = join(root, 'session-1') diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 139940ba005..46fb4754a32 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,7 +1,6 @@ -import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { inSessionParseFileLane } from './session-parse-file-lane' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' -import { parseAgentSessionFile } from './session-scanner-agent-parser' import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers' @@ -13,27 +12,30 @@ import { countSubagentTranscripts } from './session-scanner-subagent-transcripts import { countOmpSubagentTranscripts } from './session-scanner-omp-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' import { refreshCachedCodexTitle } from './session-scanner-codex-cached-title' -import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader' +import { + getSessionParseCacheEntry, + storeSessionParseCacheEntry, + type SessionParseCacheEntry +} from './session-parse-cache-store' +import type { TranscriptMessageSink } from './session-transcript-consumers' +import { sidecarUnchanged } from './session-sidecar-stat' +import { + enrichSessionFromSidecar, + sidecarEnrichesWithoutReparse +} from './session-scanner-sidecar-enrichment' +import { + readResumableTranscript, + readWholeTranscript, + type TranscriptReadStats +} from './session-transcript-reader' -// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a -// full steady-state result set stays resident between forced rescans. -const MAX_CACHE_ENTRIES = 4096 -const NEWLINE_BYTE = 0x0a - -type ResumePoint = { - state: ResumableSessionParseState - // Byte offset just past the last complete ('\n'-terminated) line consumed; - // a trailing unterminated line is deliberately left before this point. - byteOffset: number -} - -type SessionParseCacheEntry = { - mtimeMs: number - sizeBytes: number | null - platform: NodeJS.Platform - session: AiVaultSession | null - resume: ResumePoint | null -} +export { + invalidateSessionParseCacheEntry, + resetSessionParseCacheForTests, + seedSessionParseCache, + snapshotSessionParseCacheForPersistence, + type PersistedSessionParseCacheEntry +} from './session-parse-cache-store' // Incremental append-parsing applies only to transcripts that are append-only // JSONL line-folds. Whole-JSON documents (grok/rovo/devin/hermes/gemini-json) @@ -44,31 +46,32 @@ type SessionParseCacheEntry = { // cached state instead, never pay for a throwaway accumulator. function resumableStateFactoryFor( candidate: SessionFileCandidate -): (() => ResumableSessionParseState) | null { +): ((messages: TranscriptMessageSink) => ResumableSessionParseState) | null { switch (candidate.agent) { case 'claude': - return () => createClaudeSessionResumeState(candidate.file) + return (messages) => createClaudeSessionResumeState(candidate.file, messages) case 'codex': - return () => createCodexSessionResumeState(candidate.file, candidate.codexHome) + return (messages) => + createCodexSessionResumeState(candidate.file, candidate.codexHome, messages) case 'cursor': - return () => createCursorSessionResumeState(candidate.file) + return (messages) => createCursorSessionResumeState(candidate.file, messages) case 'copilot': - return () => createCopilotSessionResumeState(candidate.file) + return (messages) => createCopilotSessionResumeState(candidate.file, messages) case 'droid': - return () => createDroidSessionResumeState(candidate.file) + return (messages) => createDroidSessionResumeState(candidate.file, messages) case 'openclaw': case 'pi': case 'omp': case 'prime-agent': { const agent = candidate.agent - return () => createMessageGraphSessionResumeState(agent, candidate.file) + return (messages) => createMessageGraphSessionResumeState(agent, candidate.file, messages) } case 'gemini': return candidate.file.path.endsWith('.jsonl') - ? () => createGeminiJsonlSessionResumeState(candidate.file) + ? (messages) => createGeminiJsonlSessionResumeState(candidate.file, messages) : null case 'antigravity': - return () => createAntigravitySessionResumeState(candidate.file) + return (messages) => createAntigravitySessionResumeState(candidate.file, messages) case 'devin': case 'grok': case 'hermes': @@ -80,245 +83,138 @@ function resumableStateFactoryFor( } } -export type SessionParseStats = { +export type SessionParseStats = TranscriptReadStats & { reused: number - incremental: number - fullParses: number - // Transcripts the parser already excluded (Codex workers), re-listed after a - // write and dismissed without reading. Counted apart from `incremental` so a - // scan span still shows how much work the early stop actually removed. - earlyStopped: number - bytesRead: number } export function createSessionParseStats(): SessionParseStats { return { reused: 0, incremental: 0, fullParses: 0, earlyStopped: 0, bytesRead: 0 } } -const cache = new Map() - -export function resetSessionParseCacheForTests(): void { - cache.clear() -} - -// Drops one entry after its file is deleted. Cleanliness, not correctness: -// discovery walks disk first, so a trashed file is never rediscovered anyway. -export function invalidateSessionParseCacheEntry(path: string): void { - cache.delete(path) -} - -// Persisted subset of a cache entry: the non-serializable `resume` parser -// state is dropped (see session-parse-cache-persistence.ts). -export type PersistedSessionParseCacheEntry = Omit - -export function snapshotSessionParseCacheForPersistence(): [ - string, - PersistedSessionParseCacheEntry -][] { - return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [ - path, - { - mtimeMs: entry.mtimeMs, - sizeBytes: entry.sizeBytes, - platform: entry.platform, - session: entry.session - } - ]) -} - -// Seeded entries carry `resume: null`: after a restart an unchanged file is a -// cache hit; a file that changed while the app was closed pays one full -// (not incremental) re-parse. -export function seedSessionParseCache( - entries: Iterable<[string, PersistedSessionParseCacheEntry]> -): void { - const list = [...entries] - // Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest - // tail rather than seeding the oldest entries and dropping the tail. - for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) { - if (cache.size >= MAX_CACHE_ENTRIES) { - return - } - // In-process entries are always fresher than persisted ones; never clobber. - if (cache.has(path)) { - continue - } - cache.set(path, { - mtimeMs: entry.mtimeMs, - sizeBytes: entry.sizeBytes, - platform: entry.platform, - session: entry.session, - resume: null - }) - } -} - -function storeEntry(path: string, entry: SessionParseCacheEntry): void { - cache.delete(path) - cache.set(path, entry) - if (cache.size > MAX_CACHE_ENTRIES) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } - } -} - /** - * Parse a session file, reusing prior work where the file is provably - * unchanged (mtime+size) and, for append-only JSONL transcripts (Claude, - * Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the - * parse from the last consumed byte when the file only grew. This is what - * keeps the renderer's ~5s forced rescans from re-reading gigabytes of - * transcripts (STA-1278/STA-1417: main process pegging one core during - * multi-agent workloads). + * The session list's cursor over the transcript reader: it remembers what each + * file looked like when it was last listed, reuses that work where the file is + * provably unchanged (mtime+size), and otherwise asks the reader to resume from + * the last consumed byte or re-read the file whole. This is what keeps the + * renderer's ~5s forced rescans from re-reading gigabytes of transcripts + * (STA-1278/STA-1417: main process pegging one core during multi-agent + * workloads). Other consumers of the reader keep their own equivalent cursor + * and never consult this one. */ export async function parseAgentSessionFileCached( candidate: SessionFileCandidate, platform: NodeJS.Platform, stats?: SessionParseStats ): Promise { - const { file } = candidate - const entry = cache.get(file.path) + // The whole lookup-read-store sequence runs in the lane: a concurrent parse of + // the same path shares this entry's resume point and its message channel. + return inSessionParseFileLane(candidate.file.path, () => + parseCachedInLane(candidate, platform, stats) + ) +} - const unchanged = +async function parseCachedInLane( + candidate: SessionFileCandidate, + platform: NodeJS.Platform, + stats?: SessionParseStats +): Promise { + const { file } = candidate + const entry = getSessionParseCacheEntry(file.path) + + const transcriptUnchanged = entry !== undefined && entry.platform === platform && entry.mtimeMs === file.mtimeMs && (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) - if (unchanged) { - if (stats) { - stats.reused++ + if (transcriptUnchanged) { + if (sidecarUnchanged(entry.sidecar, file.sidecar)) { + return reuseCachedSession(candidate, entry, stats) } - // A zero-turn transcript usually never changes again, but its sibling - // subagent dir (Claude `/subagents/`, OMP's same-named artifact - // dir) can gain files after the parent's last write (a still-running - // subagent finishing). The mtime+size key can't see that, so refresh the - // cheap directory count on reuse. - if (entry.session && entry.session.messageCount === 0) { - const subagentTranscriptCount = - candidate.agent === 'claude' - ? await countSubagentTranscripts(file.path) - : candidate.agent === 'omp' - ? await countOmpSubagentTranscripts(file.path) - : null - if ( - subagentTranscriptCount !== null && - subagentTranscriptCount !== entry.session.subagentTranscriptCount - ) { - entry.session = { ...entry.session, subagentTranscriptCount } + // Only the sibling moved. For an agent whose sibling just adds metadata, + // re-merge it onto the stored fold result; the transcript is not re-read. + if (sidecarEnrichesWithoutReparse(candidate) && entry.foldSession !== undefined) { + const enriched = await enrichSessionFromSidecar(candidate, entry.foldSession, platform) + entry.session = enriched.session + entry.sidecar = enriched.refused ? 'unknown' : file.sidecar + storeSessionParseCacheEntry(file.path, entry) + if (stats) { + stats.reused++ } + return entry.session } - // Codex titles come from session_index.jsonl, which mtime+size can't see. - // Remote counterpart: remote-session-scanner.ts's reusedCodexTitleRefresh. - if (entry.session && candidate.agent === 'codex') { - entry.session = await refreshCachedCodexTitle(candidate, entry.session) - } - storeEntry(file.path, entry) - return entry.session } const stateFactory = resumableStateFactoryFor(candidate) if (stateFactory) { - const parsed = await parseResumableCandidate({ + const read = await readResumableTranscript({ candidate, platform, - entry, - stats, - stateFactory + resume: entry?.platform === platform ? entry.resume : null, + stateFactory, + stats }) - storeEntry(file.path, parsed) - return parsed.session + const enriched = await enrichSessionFromSidecar(candidate, read.session, platform) + storeSessionParseCacheEntry(file.path, { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + platform, + session: enriched.session, + // A refused sibling leaves the transcript's own work cached and resumable; + // only the sibling is recorded as unknown, so the next healthy scan + // re-merges it without re-reading the transcript. + sidecar: enriched.refused ? 'unknown' : file.sidecar, + foldSession: read.session, + resume: read.resume + }) + return enriched.session } - if (stats) { - stats.fullParses++ - stats.bytesRead += file.sizeBytes ?? 0 - } - const session = await parseAgentSessionFile(candidate, platform) - storeEntry(file.path, { + const session = await readWholeTranscript({ candidate, platform, stats }) + storeSessionParseCacheEntry(file.path, { mtimeMs: file.mtimeMs, sizeBytes: file.sizeBytes ?? null, platform, session, + // A whole-file parse reads the sibling itself, so a change to it re-parses. + sidecar: file.sidecar, + foldSession: session, resume: null }) return session } -async function parseResumableCandidate(args: { - candidate: SessionFileCandidate - platform: NodeJS.Platform - entry: SessionParseCacheEntry | undefined +async function reuseCachedSession( + candidate: SessionFileCandidate, + entry: SessionParseCacheEntry, stats?: SessionParseStats - stateFactory: () => ResumableSessionParseState -}): Promise { - const { file } = args.candidate - const resume = args.entry?.platform === args.platform ? args.entry.resume : null - const canResume = - resume !== null && - resume !== undefined && - typeof file.sizeBytes === 'number' && - file.sizeBytes >= resume.byteOffset && - (resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset))) - - // Clone before consuming: a failed read must not corrupt the cached state, - // or the next resume would double-count the lines applied before the error. - const state = canResume ? resume.state.clone() : args.stateFactory() - const startOffset = canResume ? resume.byteOffset : 0 - // Mirrors the reader's entry guard so a dismissed transcript is not reported - // as an incremental parse that read nothing. - const stoppedBeforeRead = state.shouldStop?.() === true - if (args.stats) { - if (stoppedBeforeRead) { - args.stats.earlyStopped++ - } else if (canResume) { - args.stats.incremental++ - } else { - args.stats.fullParses++ +): Promise { + if (stats) { + stats.reused++ + } + // A zero-turn transcript usually never changes again, but its sibling + // subagent dir (Claude `/subagents/`, OMP's same-named artifact + // dir) can gain files after the parent's last write (a still-running + // subagent finishing). The mtime+size key can't see that, so refresh the + // cheap directory count on reuse. + if (entry.session && entry.session.messageCount === 0) { + const subagentTranscriptCount = + candidate.agent === 'claude' + ? await countSubagentTranscripts(candidate.file.path) + : candidate.agent === 'omp' + ? await countOmpSubagentTranscripts(candidate.file.path) + : null + if ( + subagentTranscriptCount !== null && + subagentTranscriptCount !== entry.session.subagentTranscriptCount + ) { + entry.session = { ...entry.session, subagentTranscriptCount } } } - - const readResult = await consumeCompleteJsonlLines({ - path: file.path, - start: startOffset, - onLine: (line) => state.consumeLine(line), - // Bound: the optional hooks are declared as methods, so a parser written - // with method syntax must not lose `this` on the way into the reader. - onLineBytes: state.consumeLineBytes?.bind(state), - shouldStop: state.shouldStop?.bind(state) - }) - if (args.stats) { - args.stats.bytesRead += readResult.bytesRead - } - - // The stat this scan displays is current even when nothing new was consumed. - state.touchFile(file) - - // Keep parity with the one-shot parser: a final unterminated line is shown, - // but stays out of the resumable state so the (possibly still-growing) line - // is re-read once complete instead of being half-counted. - let displayState = state - if (readResult.trailingPartialLine !== null) { - displayState = state.clone() - displayState.consumeLine(readResult.trailingPartialLine) - } - - return { - mtimeMs: file.mtimeMs, - sizeBytes: file.sizeBytes ?? null, - platform: args.platform, - session: await displayState.finalize(args.platform), - resume: { state, byteOffset: readResult.consumedThrough } + // Codex titles come from session_index.jsonl, which mtime+size can't see. + // Remote counterpart: remote-session-scanner.ts's reusedCodexTitleRefresh. + if (entry.session && candidate.agent === 'codex') { + entry.session = await refreshCachedCodexTitle(candidate, entry.session) } -} - -// A resume point is only valid if it still sits just past a line break; -// anything else means the file was rewritten, not appended. Heuristic: a -// grown rewrite keeping '\n' at exactly this byte would slip through, but -// agent transcripts are append-only so that trade is accepted (worst case is -// a stale vault row until the file is next truncated or the app restarts). -async function endsWithNewlineAt(path: string, offset: number): Promise { - const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan') - return slice.length === 1 && slice[0] === NEWLINE_BYTE + storeSessionParseCacheEntry(candidate.file.path, entry) + return entry.session } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index f554268c7e5..9783f8a0520 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -10,6 +10,7 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import type { TranscriptMessageSink } from './session-transcript-consumers' import { addPreviewContent, createAccumulator, @@ -42,12 +43,16 @@ export type ClaudeSessionParseState = { firstUserTitle: string | null } -export function createClaudeSessionParseState(file: FileWithMtime): ClaudeSessionParseState { +export function createClaudeSessionParseState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ClaudeSessionParseState { return { accumulator: createAccumulator({ agent: 'claude', file, - sessionId: sessionIdFromFileName(file.path) + sessionId: sessionIdFromFileName(file.path), + messages }), metaTitle: null, generatedTitle: null, @@ -188,8 +193,11 @@ export async function finalizeClaudeSessionParseState( return finalizeSession(snapshot.accumulator, platform, options) } -export function createClaudeSessionResumeState(file: FileWithMtime): ResumableSessionParseState { - return claudeResumeStateFromParseState(createClaudeSessionParseState(file)) +export function createClaudeSessionResumeState( + file: FileWithMtime, + messages?: TranscriptMessageSink +): ResumableSessionParseState { + return claudeResumeStateFromParseState(createClaudeSessionParseState(file, messages)) } function claudeResumeStateFromParseState( @@ -207,13 +215,14 @@ function claudeResumeStateFromParseState( export async function parseClaudeSessionFile( file: FileWithMtime, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + messages?: TranscriptMessageSink ): Promise { const lines = createInterface({ input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'), crlfDelay: Infinity }) - return parseClaudeSessionLines({ file, lines, platform }) + return parseClaudeSessionLines({ file, lines, platform, messages }) } export async function parseClaudeSessionContent( @@ -236,8 +245,9 @@ async function parseClaudeSessionLines(args: { lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions + messages?: TranscriptMessageSink }): Promise { - const state = createClaudeSessionParseState(args.file) + const state = createClaudeSessionParseState(args.file, args.messages) for await (const line of args.lines) { consumeClaudeSessionLine(state, line) } diff --git a/src/main/ai-vault/session-scanner-sidecar-enrichment.ts b/src/main/ai-vault/session-scanner-sidecar-enrichment.ts new file mode 100644 index 00000000000..983e200e987 --- /dev/null +++ b/src/main/ai-vault/session-scanner-sidecar-enrichment.ts @@ -0,0 +1,79 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { buildAiVaultResumeCommand } from '../../shared/ai-vault-resume-command' +import { generatedSessionTitle } from './session-scanner-accumulator' +import { readCursorChatMeta, wasCursorChatMetaRefused } from './session-scanner-cursor-chat-meta' +import type { SessionFileCandidate } from './session-scanner-types' + +/** + * Merges an agent's sibling file onto the session its transcript alone + * produced. Kept out of the fold and applied here so it is a pure function of + * (fold result, sibling): re-running it starts from what the transcript said, + * never from a previous merge, so a rewritten sibling replaces the fields it + * supplied last time instead of losing to them. + * + * The reuse-time counterpart of session-scanner-codex-cached-title.ts, for + * agents whose sibling the transcript key cannot see. + */ + +export type SidecarEnrichment = { + session: AiVaultSession | null + /** The sibling could not be read; the caller records the observation as unknown. */ + refused: boolean +} + +/** True when the sibling only adds metadata, so a change to it needs no re-parse. */ +export function sidecarEnrichesWithoutReparse(candidate: SessionFileCandidate): boolean { + return candidate.agent === 'cursor' +} + +export async function enrichSessionFromSidecar( + candidate: SessionFileCandidate, + foldSession: AiVaultSession | null, + platform: NodeJS.Platform +): Promise { + if (candidate.agent !== 'cursor' || !foldSession) { + return { session: foldSession, refused: false } + } + const meta = await readCursorChatMeta(candidate.file.path) + if (!meta) { + return { session: foldSession, refused: wasCursorChatMetaRefused(candidate.file.path) } + } + return { session: mergeCursorChatMeta(foldSession, meta, platform), refused: false } +} + +/** Fills only what the transcript never recorded; its own records always win. */ +export function mergeCursorChatMeta( + session: AiVaultSession, + meta: { + title: string | null + cwd: string | null + createdAt: string | null + updatedAt: string | null + }, + platform: NodeJS.Platform +): AiVaultSession { + // A generated title means the fold found none, so the sibling's may stand in. + const named = session.title !== generatedSessionTitle(session.agent, session.sessionId) + const cwd = session.cwd ?? meta.cwd + const merged: AiVaultSession = { + ...session, + title: named ? session.title : (meta.title ?? session.title), + cwd, + createdAt: session.createdAt ?? meta.createdAt, + updatedAt: session.updatedAt ?? meta.updatedAt + } + if (cwd === session.cwd) { + return merged + } + // The resume command embeds the cwd, so it has to be rebuilt with it. + return { + ...merged, + resumeCommand: buildAiVaultResumeCommand({ + agent: merged.agent, + sessionId: merged.sessionId, + resumeFilePath: merged.filePath, + cwd, + platform + }) + } +} diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index fef37ebcda0..d62fdd4d19d 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -17,6 +17,8 @@ export async function discoverAiVaultSessionSources(args: { const { options, limitPerAgent, issues } = args const wslHomeDirs = normalizedWslHomeDirs(options.wslHomeDirs) + // The Cursor chat-meta scan scope is owned by scanAiVaultSessions: it has to + // span parse as well, and finalize runs after this returns. return Promise.all([ // Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 6216e3d7cf8..b1d480aa944 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -5,6 +5,8 @@ import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import type { TranscriptMessageSink } from './session-transcript-consumers' +import type { SessionSidecarObservation } from './session-sidecar-stat' export type AiVaultScanOptions = { claudeProjectsDir?: string @@ -54,8 +56,12 @@ export type FileWithMtime = { modifiedAt: string // Present when discovery statted the file; lets the parse cache detect // unchanged/truncated files without a second stat. Synthetic candidates - // such as OpenCode SQLite rows omit it. + // such as OpenCode SQLite rows omit it. The transcript's own length: a byte + // offset into it may be compared against this directly. sizeBytes?: number + // What discovery saw of the agent's sibling file, tracked apart from the + // transcript's own stat (see session-sidecar-stat.ts). + sidecar?: SessionSidecarObservation // Present when discovery can prove filesystem identity. Codex dual-root // scans use a multi-link inode to collapse only actual hardlink aliases. dev?: number @@ -108,6 +114,9 @@ export type ResumableSessionParseState = { export type SessionAccumulator = { agent: AiVaultAgent + // Every decoded message this fold sees also goes here, for the reader's + // consumers. Shared by clones on purpose: one read, one message stream. + messages: TranscriptMessageSink sessionId: string title: string | null fallbackTitle: string | null diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 3d27dccd1ef..8c3ac157e4f 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -6,19 +6,13 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' -import { - codexRolloutHardlinkIdentity, - dedupeCodexRolloutAliases, - dedupeCodexSessionsBySessionId -} from './codex-session-root-dedup' -import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta' +import { dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' import { createAntigravityWorkspaceResolver, readLocalAntigravityHistory, type AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' -import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' -import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { sessionCandidatesFromDiscoveries } from './session-scanner-candidates' import { ensureSessionParseCacheLoaded, scheduleSessionParseCachePersist @@ -30,10 +24,8 @@ import { } from './session-scanner-parse-cache' import { recordSessionScanIssue } from './session-scan-issues' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' -import { - DEFAULT_CODEX_HOME_DIR, - discoverAiVaultSessionSources -} from './session-scanner-source-discovery' +import { discoverAiVaultSessionSources } from './session-scanner-source-discovery' +import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta' import type { AiVaultScanOptions, SessionFileCandidate, @@ -62,103 +54,87 @@ export async function scanAiVaultSessions( // The span makes scan cost visible in the local trace file: STA-1278-style // "one core pegged" reports need to show whether transcript scanning is the // subsystem burning CPU, and how much of each scan the cache absorbed. - return withSpan('aiVault.scan', async (span) => { - const limit = options.unlimited - ? Number.POSITIVE_INFINITY - : clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT) - const limitPerAgent = options.unlimited - ? Number.POSITIVE_INFINITY - : clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER) - const platform = options.platform ?? process.platform - const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID - const issues: AiVaultScanIssue[] = [] - const parseStats = createSessionParseStats() - const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver( - readLocalAntigravityHistory - ) - // Why: persisted entries must be seeded before any candidate is parsed, or - // the cold scan gains nothing from the cache file (#9210). - throwIfAiVaultScanCancelled(options.signal) - await ensureSessionParseCacheLoaded() - const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) - throwIfAiVaultScanCancelled(options.signal) + // The Cursor chat-meta scope spans discovery AND parse: its sibling meta.json + // is looked up in both phases, and one scan must read the chats tree once. + return withSpan('aiVault.scan', (span) => + withCursorChatMetaScan(async () => { + const limit = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT) + const limitPerAgent = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER) + const platform = options.platform ?? process.platform + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID + const issues: AiVaultScanIssue[] = [] + const parseStats = createSessionParseStats() + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver( + readLocalAntigravityHistory + ) + // Why: persisted entries must be seeded before any candidate is parsed, or + // the cold scan gains nothing from the cache file (#9210). + throwIfAiVaultScanCancelled(options.signal) + await ensureSessionParseCacheLoaded() + const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) + throwIfAiVaultScanCancelled(options.signal) - const candidates = await dedupeCodexRolloutAliases( - discoveries - .flatMap((discovery) => - discovery.files.map((file): SessionFileCandidate => ({ - agent: discovery.agent, - file, - codexHome: - discovery.agent === 'codex' - ? codexHomeForSessionsDir( - discovery.rootDir, - options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR - ) - : null, - antigravityHistoryPath: - discovery.agent === 'antigravity' - ? antigravityHistoryPathForBrainDir(discovery.rootDir) - : undefined - })) - ) - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), - { - isCodex: (candidate) => candidate.agent === 'codex', - getFilePath: (candidate) => candidate.file.path, - getCodexHome: (candidate) => candidate.codexHome, - getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) - }, - (filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'), - options.signal - ) + const candidates = await sessionCandidatesFromDiscoveries(discoveries, options) - const parsedSessions = await parseSessionCandidates({ - candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), - limit, - platform, - executionHostId, - issues, - parseStats, - signal: options.signal, - antigravityWorkspaceResolver + const parsedSessions = await parseSessionCandidates({ + candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), + limit, + platform, + executionHostId, + issues, + parseStats, + signal: options.signal, + antigravityWorkspaceResolver + }) + + const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) + + const scopeSessions = await scanInScopeSessions({ + discoveries, + scopePaths: options.scopePaths ?? [], + limit, + alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), + platform, + executionHostId, + issues, + parseStats, + signal: options.signal + }) + // Scope discovery can return without parsing anything, so an abort landing + // here would otherwise persist and return a cancelled scan as complete. + throwIfAiVaultScanCancelled(options.signal) + for (const refusal of cursorChatMetaRefusals()) { + // One issue per refused chats root, not one per Cursor transcript. + recordSessionScanIssue(issues, { + agent: 'cursor', + path: refusal.chatsRoot, + message: refusal.message + }) + } + + span.setAttribute('candidates', candidates.length) + span.setAttribute('reused', parseStats.reused) + span.setAttribute('incremental', parseStats.incremental) + span.setAttribute('fullParses', parseStats.fullParses) + span.setAttribute('earlyStopped', parseStats.earlyStopped) + span.setAttribute('bytesRead', parseStats.bytesRead) + span.setAttribute('issues', issues.length) + + scheduleSessionParseCachePersist(parseStats) + + return { + sessions: mergeSessions(cappedSessions, scopeSessions), + issues: issues.map((issue) => ({ executionHostId, ...issue })), + scannedAt: new Date().toISOString() + } }) - - const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions) - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - - const scopeSessions = await scanInScopeSessions({ - discoveries, - scopePaths: options.scopePaths ?? [], - limit, - alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), - platform, - executionHostId, - issues, - parseStats, - signal: options.signal - }) - // Scope discovery can return without parsing anything, so an abort landing - // here would otherwise persist and return a cancelled scan as complete. - throwIfAiVaultScanCancelled(options.signal) - - span.setAttribute('candidates', candidates.length) - span.setAttribute('reused', parseStats.reused) - span.setAttribute('incremental', parseStats.incremental) - span.setAttribute('fullParses', parseStats.fullParses) - span.setAttribute('earlyStopped', parseStats.earlyStopped) - span.setAttribute('bytesRead', parseStats.bytesRead) - span.setAttribute('issues', issues.length) - - scheduleSessionParseCachePersist(parseStats) - - return { - sessions: mergeSessions(cappedSessions, scopeSessions), - issues: issues.map((issue) => ({ executionHostId, ...issue })), - scannedAt: new Date().toISOString() - } - }) + ) } // In-scope sessions are guaranteed regardless of the recency cap, so the global diff --git a/src/main/ai-vault/session-sidecar-stat.test.ts b/src/main/ai-vault/session-sidecar-stat.test.ts new file mode 100644 index 00000000000..e8d384d6f6b --- /dev/null +++ b/src/main/ai-vault/session-sidecar-stat.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat' + +const META = { path: '/chats/a/meta.json', mtimeMs: 100, sizeBytes: 20 } as const +const OTHER = { path: '/chats/a/meta.json', mtimeMs: 101, sizeBytes: 20 } as const + +type Named = [label: string, value: SessionSidecarObservation | undefined] + +const ENTRIES: Named[] = [ + ['undefined', undefined], + ["'none'", 'none'], + ["'unknown'", 'unknown'], + ['object', { ...META }] +] +const OBSERVED: Named[] = [ + ['undefined', undefined], + ["'none'", 'none'], + ["'unknown'", 'unknown'], + ['same object', { ...META }], + ['different object', { ...OTHER }] +] + +// entry (row) x observed (column). A `true` cell is a cache hit. +const TRUTH_TABLE: Record> = { + undefined: { + undefined: true, + "'none'": true, + "'unknown'": false, + 'same object': false, + 'different object': false + }, + "'none'": { + undefined: true, + "'none'": true, + "'unknown'": false, + 'same object': false, + 'different object': false + }, + "'unknown'": { + undefined: false, + "'none'": false, + "'unknown'": false, + 'same object': false, + 'different object': false + }, + object: { + undefined: false, + "'none'": false, + "'unknown'": false, + 'same object': true, + 'different object': false + } +} + +describe.each(ENTRIES)('cached %s', (entryLabel, entry) => { + it.each(OBSERVED)(`vs observed %s`, (observedLabel, observed) => { + expect(sidecarUnchanged(entry, observed)).toBe(TRUTH_TABLE[entryLabel][observedLabel]) + }) +}) + +it('treats a vanished sidecar as a change, not as "never had one"', () => { + expect(sidecarUnchanged({ ...META }, 'none')).toBe(false) +}) + +it('never concludes anything from an unreadable sidecar, in either position', () => { + expect(sidecarUnchanged('unknown', 'none')).toBe(false) + expect(sidecarUnchanged('unknown', { ...META })).toBe(false) + expect(sidecarUnchanged({ ...META }, 'unknown')).toBe(false) +}) + +it('keeps an agent with no sidecar at all a cache hit', () => { + expect(sidecarUnchanged(undefined, undefined)).toBe(true) + expect(sidecarUnchanged(undefined, 'none')).toBe(true) +}) diff --git a/src/main/ai-vault/session-sidecar-stat.ts b/src/main/ai-vault/session-sidecar-stat.ts new file mode 100644 index 00000000000..e176da342e2 --- /dev/null +++ b/src/main/ai-vault/session-sidecar-stat.ts @@ -0,0 +1,49 @@ +// Why: some agents keep part of a session beside its transcript — Cursor's +// chat meta.json, Cline's messages file. Folding that file's stat into the +// transcript's own mtime/size makes one key mean two things, so a byte offset +// into the transcript can no longer be compared against it and a refused read +// of the sibling takes the transcript down with it. The sidecar is observed +// separately and compared separately. + +export type SessionSidecarStat = { + path: string + mtimeMs: number + sizeBytes: number +} + +export type SessionSidecarObservation = + | SessionSidecarStat + /** This agent declares no sidecar, or it does not exist. */ + | 'none' + /** It could not be read this scan; nothing may be concluded from its absence. */ + | 'unknown' + +/** + * Whether a cached observation still describes what discovery just saw. + * + * Asymmetric on purpose: `file` is observed now, so a missing value means the + * agent has no sidecar, while `entry` may predate the field (an entry seeded + * from a cache file an older build wrote), so a missing value means unknown. + * + * `'none'` is a claim, not an absence of one: a sidecar that was there and is + * gone changed, and one that was unreadable last time is still unknown now. + */ +export function sidecarUnchanged( + entry: SessionSidecarObservation | undefined, + file: SessionSidecarObservation | undefined +): boolean { + const observed = file ?? 'none' + if (observed === 'unknown' || entry === 'unknown') { + return false + } + if (observed === 'none') { + // Absent now: a hit only if it was absent before, or the agent never had one. + return entry === undefined || entry === 'none' + } + return ( + typeof entry === 'object' && + entry.path === observed.path && + entry.mtimeMs === observed.mtimeMs && + entry.sizeBytes === observed.sizeBytes + ) +} diff --git a/src/main/ai-vault/session-title-file-reader.ts b/src/main/ai-vault/session-title-file-reader.ts index 15228d132bb..bfbb4e073dd 100644 --- a/src/main/ai-vault/session-title-file-reader.ts +++ b/src/main/ai-vault/session-title-file-reader.ts @@ -31,6 +31,9 @@ async function readOneTitle( if (!stats.isFile() || signal?.aborted) { return null } + // Why: this key is a raw lstat with no content dependency, so it only + // matches the scanner's for providers that declare none — today claude and + // codex, which is all this request type carries. const session = await parseAgentSessionFileCached( { agent: request.agent, diff --git a/src/main/ai-vault/session-transcript-channel.ts b/src/main/ai-vault/session-transcript-channel.ts new file mode 100644 index 00000000000..1b1b3950949 --- /dev/null +++ b/src/main/ai-vault/session-transcript-channel.ts @@ -0,0 +1,92 @@ +import { + hasTranscriptConsumers, + transcriptConsumers, + type TranscriptMessage, + type TranscriptMessageSink, + type TranscriptReadConsumer, + type TranscriptReadOutcome, + type TranscriptReadStart +} from './session-transcript-consumers' + +/** + * The sink a parser pushes into, and the fan-out to every registered consumer. + * + * One channel belongs to one file for as long as its resumable parse state + * lives, because the cached state (and every clone of it) holds this reference. + * A read re-points the channel at that read's consumers instead of replacing it. + */ +export class TranscriptMessageChannel implements TranscriptMessageSink { + private readers: TranscriptReadConsumer[] = [] + + private muted = false + + /** True while a read is open with at least one consumer attached. */ + get active(): boolean { + return this.readers.length > 0 + } + + beginRead(start: TranscriptReadStart): void { + this.muted = false + this.readers = [] + // Keeps a scan with no consumers allocation-free on its hottest path. + if (!hasTranscriptConsumers()) { + return + } + for (const consumer of transcriptConsumers()) { + try { + const reader = consumer.beginRead(start) + if (reader) { + this.readers.push(reader) + } + } catch { + // A consumer that cannot open this read simply does not see it. + } + } + } + + push(message: TranscriptMessage): void { + if (this.muted || this.readers.length === 0) { + return + } + // A throwing consumer is dropped for the rest of the read rather than + // failing the parse; it then gets no `finish`, so it never records a cursor + // for a stream it did not see in full. + let index = 0 + while (index < this.readers.length) { + try { + this.readers[index].message(message) + index++ + } catch { + this.readers.splice(index, 1) + } + } + } + + /** + * Suppresses emission for a display-only re-read: the trailing unterminated + * line is shown in the list but is re-read once complete, so emitting it here + * would hand every consumer the same line twice. `fn` must be synchronous. + */ + mute(fn: () => T): T { + const previous = this.muted + this.muted = true + try { + return fn() + } finally { + this.muted = previous + } + } + + finishRead(outcome: TranscriptReadOutcome): void { + const readers = this.readers + this.readers = [] + this.muted = false + for (const reader of readers) { + try { + reader.finish(outcome) + } catch { + // A consumer failure must never fail the session list. + } + } + } +} diff --git a/src/main/ai-vault/session-transcript-consumers.test.ts b/src/main/ai-vault/session-transcript-consumers.test.ts new file mode 100644 index 00000000000..cf9a6068673 --- /dev/null +++ b/src/main/ai-vault/session-transcript-consumers.test.ts @@ -0,0 +1,330 @@ +import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from './session-scanner-parse-cache' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' +import { readWholeTranscript } from './session-transcript-reader' + +const OPENCODE_SQLITE_SESSION = { + id: 'local:opencode:sqlite-session:db', + agent: 'opencode' as const, + sessionId: 'sqlite-session' +} + +// Stands in for the worker thread: the point is that its messages never come +// back over the channel, not what the SQLite read returns. +vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + parseOpenCodeSqliteSessionViaWorker: () => Promise.resolve(OPENCODE_SQLITE_SESSION) +})) +import type * as OpenCodeSqliteWorkerSpawn from './session-scanner-opencode-sqlite-worker-spawn' +import { + registerTranscriptConsumer, + resetTranscriptConsumersForTests, + type TranscriptMessage, + type TranscriptReadOutcome, + type TranscriptReadStart +} from './session-transcript-consumers' + +type RecordedRead = { + start: TranscriptReadStart + messages: TranscriptMessage[] + outcome: TranscriptReadOutcome | null +} + +function recordingConsumer(): { reads: RecordedRead[]; unregister: () => void } { + const reads: RecordedRead[] = [] + const unregister = registerTranscriptConsumer({ + beginRead: (start) => { + const read: RecordedRead = { start, messages: [], outcome: null } + reads.push(read) + return { + message: (message) => read.messages.push(message), + finish: (outcome) => { + read.outcome = outcome + } + } + } + }) + return { reads, unregister } +} + +function textsFor(reads: RecordedRead[], agent: string): string[] { + return reads + .filter((read) => read.start.candidate.agent === agent) + .flatMap((read) => read.messages.map((message) => `${message.role}:${message.text}`)) +} + +let tempRoots: string[] = [] + +afterEach(async () => { + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function claudeTurns(from: number, to: number): unknown[] { + const records: unknown[] = [] + for (let index = from; index <= to; index++) { + records.push({ + type: 'user', + sessionId: 'claude-session', + timestamp: `2026-05-01T10:0${index}:00.000Z`, + cwd: '/tmp/claude', + message: { role: 'user', content: `ask ${index}` } + }) + records.push({ + type: 'assistant', + sessionId: 'claude-session', + timestamp: `2026-05-01T10:0${index}:01.000Z`, + message: { + role: 'assistant', + content: [ + { type: 'text', text: `reply ${index}` }, + { type: 'tool_use', name: 'Bash', input: { command: `ls ${index}` } } + ] + } + }) + } + return records +} + +async function writeClaudeFixture(): Promise<{ + root: string + roots: ReturnType + transcript: string +}> { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-consumers-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const transcript = join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl') + await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) + await writeFile(transcript, `${jsonLines(claudeTurns(1, 4))}\n`) + return { root, roots, transcript } +} + +it('delivers one message stream to every registered consumer', async () => { + const { roots } = await writeClaudeFixture() + const first = recordingConsumer() + const second = recordingConsumer() + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + expect(result.issues).toEqual([]) + const stream = textsFor(first.reads, 'claude') + expect(stream).toEqual(textsFor(second.reads, 'claude')) + expect(stream).toEqual([ + 'user:ask 1', + 'assistant:reply 1', + 'tool:Bash: ls 1', + 'user:ask 2', + 'assistant:reply 2', + 'tool:Bash: ls 2', + 'user:ask 3', + 'assistant:reply 3', + 'tool:Bash: ls 3', + 'user:ask 4', + 'assistant:reply 4', + 'tool:Bash: ls 4' + ]) + // The list's own fold keeps only the newest five preview turns, so the stream + // is demonstrably the reader's, not a projection of the session row. + const session = result.sessions.find((entry) => entry.agent === 'claude') + expect(session?.previewMessages).toHaveLength(5) + expect(session?.messageCount).toBe(8) +}) + +it('leaves the session list identical whether or not a consumer is registered', async () => { + const withoutConsumer = await writeClaudeFixture() + const bare = await scanAiVaultSessions({ + ...withoutConsumer.roots, + platform: 'darwin', + limit: 20 + }) + + resetSessionParseCacheForTests() + recordingConsumer() + const observed = await scanAiVaultSessions({ + ...withoutConsumer.roots, + platform: 'darwin', + limit: 20 + }) + + expect(observed.sessions).toEqual(bare.sessions) +}) + +it('replays only the appended lines on a resumed read', async () => { + const { roots, transcript } = await writeClaudeFixture() + const consumer = recordingConsumer() + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + const firstRead = consumer.reads.at(-1) + expect(firstRead?.start.mode).toBe('replace') + expect(firstRead?.start.previousByteOffset).toBe(0) + expect(firstRead?.outcome?.incomplete).toBe(false) + + await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`) + consumer.reads.length = 0 + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + const resumed = consumer.reads.find((read) => read.start.candidate.agent === 'claude') + expect(resumed?.start.mode).toBe('append') + expect(resumed?.start.previousByteOffset).toBe(firstRead?.outcome?.byteOffset) + expect(textsFor(consumer.reads, 'claude')).toEqual([ + 'user:ask 5', + 'assistant:reply 5', + 'tool:Bash: ls 5' + ]) +}) + +it('publishes a trailing unterminated line once, when it is complete', async () => { + const { roots, transcript } = await writeClaudeFixture() + const consumer = recordingConsumer() + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + // A half-written record: the list shows it, the stream must not carry it yet. + const [partial] = claudeTurns(5, 5) + await appendFile(transcript, JSON.stringify(partial)) + consumer.reads.length = 0 + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + expect(textsFor(consumer.reads, 'claude')).toEqual([]) + + await appendFile(transcript, '\n') + consumer.reads.length = 0 + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + expect(textsFor(consumer.reads, 'claude')).toEqual(['user:ask 5']) +}) + +it('keeps the session list working when a consumer throws', async () => { + const { roots } = await writeClaudeFixture() + registerTranscriptConsumer({ + beginRead: () => ({ + message: () => { + throw new Error('consumer exploded') + }, + finish: () => undefined + }) + }) + const healthy = recordingConsumer() + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + expect(result.issues).toEqual([]) + expect(result.sessions.find((entry) => entry.agent === 'claude')?.messageCount).toBe(8) + expect(textsFor(healthy.reads, 'claude')).toHaveLength(12) +}) + +it('skips a read a consumer declines without disturbing the others', async () => { + const { roots } = await writeClaudeFixture() + registerTranscriptConsumer({ beginRead: () => null }) + const healthy = recordingConsumer() + + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + expect(textsFor(healthy.reads, 'claude')).toHaveLength(12) +}) + +async function claudeCandidate(transcript: string): Promise { + const stats = await stat(transcript) + const file: FileWithMtime = { + path: transcript, + mtimeMs: stats.mtimeMs, + modifiedAt: stats.mtime.toISOString(), + sizeBytes: stats.size + } + return { agent: 'claude', file, codexHome: null } +} + +it('serializes overlapping parses of one path so no consumer read is orphaned', async () => { + const { transcript } = await writeClaudeFixture() + // Seed a resume point: the channel it stores is what concurrent reads share. + await parseAgentSessionFileCached(await claudeCandidate(transcript), 'darwin') + + await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`) + const consumer = recordingConsumer() + const appended = await claudeCandidate(transcript) + + const [first, second] = await Promise.all([ + parseAgentSessionFileCached(appended, 'darwin'), + parseAgentSessionFileCached(appended, 'darwin') + ]) + + // Every read that opened must also close, or its consumer keeps a half-read + // stream forever and never learns the outcome. + expect(consumer.reads.filter((read) => read.outcome === null)).toEqual([]) + expect(consumer.reads).toHaveLength(1) + expect(consumer.reads[0].start.mode).toBe('append') + expect(textsFor(consumer.reads, 'claude')).toEqual([ + 'user:ask 5', + 'assistant:reply 5', + 'tool:Bash: ls 5' + ]) + // The later caller reuses the stored entry rather than moving the cursor back. + expect(first?.messageCount).toBe(10) + expect(second?.messageCount).toBe(10) +}) + +it('reports a read whose parser cannot publish its messages as not complete', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-opencode-')) + tempRoots.push(root) + const dbPath = join(root, 'opencode.db') + await writeFile(dbPath, '') + const consumer = recordingConsumer() + + const session = await readWholeTranscript({ + candidate: { + agent: 'opencode', + codexHome: null, + file: { + path: `${dbPath}#sqlite-session`, + mtimeMs: 1, + modifiedAt: new Date(1).toISOString(), + sizeBytes: 10 + } + }, + platform: 'darwin' + }) + + expect(session).toEqual(OPENCODE_SQLITE_SESSION) + expect(consumer.reads).toHaveLength(1) + expect(consumer.reads[0].messages).toEqual([]) + expect(consumer.reads[0].outcome?.incomplete).toBe(true) +}) + +it('reports the transcript size, not the cache key, as a whole-file read offset', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-cline-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + // Cline is whole-file and declares a sibling content dependency, so its cache + // key covers two files while the read covers one. + const sessionDir = join(roots.clineSessionsDir, 'cline-session') + await mkdir(sessionDir, { recursive: true }) + const metadataPath = join(sessionDir, 'cline-session.json') + await writeFile( + metadataPath, + JSON.stringify({ + session_id: 'cline-session', + started_at: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/cline' + }) + ) + await writeFile( + join(sessionDir, 'cline-session.messages.json'), + JSON.stringify({ + updated_at: '2026-05-01T10:00:01.000Z', + messages: [{ role: 'user', content: [{ type: 'text', text: 'x'.repeat(400) }] }] + }) + ) + const consumer = recordingConsumer() + + await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + + const read = consumer.reads.find((entry) => entry.start.candidate.agent === 'cline') + expect(read?.outcome?.byteOffset).toBe((await stat(metadataPath)).size) +}) diff --git a/src/main/ai-vault/session-transcript-consumers.ts b/src/main/ai-vault/session-transcript-consumers.ts new file mode 100644 index 00000000000..6707b298586 --- /dev/null +++ b/src/main/ai-vault/session-transcript-consumers.ts @@ -0,0 +1,85 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { SessionFileCandidate } from './session-scanner-types' + +// Why: the transcript reader owns discovery, per-file cursors and decoding; a +// consumer only folds the message stream. Registering a second consumer (a +// search index, a digest) must not require touching the reader or the parse +// cache, so the reader publishes reads rather than knowing who reads them. + +export type TranscriptMessageRole = 'user' | 'assistant' | 'tool' + +export type TranscriptMessage = { + role: TranscriptMessageRole + /** Untruncated decoded text; caps and redaction are consumer policy. */ + text: string + timestamp: string | null +} + +/** Where a parser hands its decoded messages; the reader supplies the instance. */ +export type TranscriptMessageSink = { + /** False when nobody is listening: parsers skip the extraction entirely. */ + readonly active: boolean + push(message: TranscriptMessage): void +} + +export const NO_TRANSCRIPT_MESSAGES: TranscriptMessageSink = { + active: false, + push: () => undefined +} + +export type TranscriptReadStart = { + candidate: SessionFileCandidate + /** `replace`: the whole file is being re-read; `append`: a resumed read. */ + mode: 'replace' | 'append' + /** Byte offset the messages of this read continue from. */ + previousByteOffset: number +} + +export type TranscriptReadOutcome = { + /** Null when the parser rejected the file (an excluded Codex worker transcript). */ + session: AiVaultSession | null + /** Byte offset just past the last complete line this read consumed. */ + byteOffset: number + /** + * The messages of this read are not the whole span: the read failed part way, + * or the parser decodes where the channel cannot reach it. A consumer must + * not record a cursor for an incomplete read. + */ + incomplete: boolean +} + +/** One consumer's view of one file read. */ +export type TranscriptReadConsumer = { + message(message: TranscriptMessage): void + finish(outcome: TranscriptReadOutcome): void +} + +export type TranscriptConsumer = { + /** + * Open this read, or return null to ignore it. A consumer whose own cursor is + * behind `previousByteOffset` declines here and re-reads on its own schedule; + * it must never ask another consumer where it is. + */ + beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null +} + +const consumers = new Set() + +export function registerTranscriptConsumer(consumer: TranscriptConsumer): () => void { + consumers.add(consumer) + return () => { + consumers.delete(consumer) + } +} + +export function transcriptConsumers(): readonly TranscriptConsumer[] { + return [...consumers] +} + +export function hasTranscriptConsumers(): boolean { + return consumers.size > 0 +} + +export function resetTranscriptConsumersForTests(): void { + consumers.clear() +} diff --git a/src/main/ai-vault/session-transcript-message-content.test.ts b/src/main/ai-vault/session-transcript-message-content.test.ts new file mode 100644 index 00000000000..fa8c3934409 --- /dev/null +++ b/src/main/ai-vault/session-transcript-message-content.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from 'vitest' +import { transcriptMessagesFromContent } from './session-transcript-message-content' + +const AT = '2026-05-01T10:00:00.000Z' + +it('keeps a plain string turn under the record role', () => { + expect(transcriptMessagesFromContent('user', 'just words', AT)).toEqual([ + { role: 'user', text: 'just words', timestamp: AT } + ]) +}) + +it('drops turns whose role a consumer cannot use', () => { + expect(transcriptMessagesFromContent('system', 'boot', AT)).toEqual([]) + expect(transcriptMessagesFromContent('unknown', 'noise', AT)).toEqual([]) +}) + +it('joins text blocks and appends tool blocks as their own messages', () => { + expect( + transcriptMessagesFromContent( + 'assistant', + [ + { type: 'text', text: 'first' }, + { type: 'tool_use', name: 'Bash', input: { command: 'ls -la', description: 'ignored' } }, + { type: 'thinking', text: 'second' }, + { type: 'image', source: {} } + ], + AT + ) + ).toEqual([ + { role: 'assistant', text: 'first\nsecond', timestamp: AT }, + { role: 'tool', text: 'Bash: ls -la', timestamp: AT } + ]) +}) + +it('reads a tool result carried on a user record as a tool message', () => { + expect( + transcriptMessagesFromContent( + 'user', + [{ type: 'tool_result', content: [{ type: 'text', text: 'exit 0' }] }], + AT + ) + ).toEqual([{ role: 'tool', text: 'exit 0', timestamp: AT }]) +}) + +it('names a tool call even with no recognisable argument', () => { + expect( + transcriptMessagesFromContent('assistant', [{ type: 'tool_use', name: 'Read', input: {} }], AT) + ).toEqual([{ role: 'tool', text: 'Read', timestamp: AT }]) +}) + +it('emits nothing for blank or absent content', () => { + expect(transcriptMessagesFromContent('user', ' ', AT)).toEqual([]) + expect(transcriptMessagesFromContent('user', null, AT)).toEqual([]) + expect(transcriptMessagesFromContent('assistant', [{ type: 'tool_use' }], AT)).toEqual([]) +}) + +it('does not apply the list preview cap', () => { + const long = 'x'.repeat(5000) + const [message] = transcriptMessagesFromContent('user', [{ type: 'text', text: long }], AT) + expect(message.text).toHaveLength(5000) +}) diff --git a/src/main/ai-vault/session-transcript-message-content.ts b/src/main/ai-vault/session-transcript-message-content.ts new file mode 100644 index 00000000000..2dda8493c31 --- /dev/null +++ b/src/main/ai-vault/session-transcript-message-content.ts @@ -0,0 +1,138 @@ +import { asRecord } from './session-scanner-record-value' +import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization' +import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers' + +// Safety bound only: a consumer applies its own caps. Matches the first-prompt +// copy path's ceiling so one pathological paste cannot dominate a scan. +const TRANSCRIPT_MESSAGE_TEXT_LIMIT = 256 * 1024 +const TOOL_ARGUMENT_SCAN_LIMIT = 2000 + +const TEXT_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text', 'thinking', 'reasoning']) +// The argument that identifies what a tool call actually did. +const TOOL_INPUT_KEYS = ['command', 'cmd', 'file_path', 'path', 'pattern', 'query', 'description'] + +type PreviewRole = AiVaultSessionPreviewMessage['role'] + +/** Only conversational roles reach consumers; system/unknown turns are noise. */ +export function transcriptMessageRole(role: PreviewRole): TranscriptMessageRole | null { + return role === 'user' || role === 'assistant' || role === 'tool' ? role : null +} + +export function toolCallText(name: unknown, input: unknown): string | null { + const toolName = typeof name === 'string' && name.trim() ? name.trim() : null + const inputRecord = asRecord(input) + let argument: string | null = null + if (inputRecord) { + for (const key of TOOL_INPUT_KEYS) { + const value = inputRecord[key] + if (typeof value === 'string' && value.trim()) { + argument = value + break + } + } + } else if (typeof input === 'string' && input.trim()) { + argument = input + } + if (!toolName && !argument) { + return null + } + const bounded = argument ? sliceAtCodeUnitLimit(argument, TOOL_ARGUMENT_SCAN_LIMIT) : null + return toolName && bounded ? `${toolName}: ${bounded}` : (toolName ?? bounded) +} + +/** Flattens a tool_result body (a string, or an array of text blocks). */ +function toolResultText(content: unknown): string | null { + if (typeof content === 'string') { + return content.trim() ? content : null + } + if (!Array.isArray(content)) { + return null + } + const parts: string[] = [] + let length = 0 + for (const item of content) { + const text = typeof item === 'string' ? item : asRecord(item)?.text + if (typeof text === 'string' && text) { + parts.push(text) + length += text.length + if (length >= TRANSCRIPT_MESSAGE_TEXT_LIMIT) { + break + } + } + } + const joined = parts.join('\n') + return joined.trim() ? joined : null +} + +/** + * Splits one provider content value into the messages it decodes to. Text + * blocks keep the record's role; tool_use and tool_result blocks become `tool` + * messages whichever record carried them (Claude stores tool results on user + * records), so a consumer never has to know a provider's record shapes. + */ +export function transcriptMessagesFromContent( + role: PreviewRole, + content: unknown, + timestamp: string | null +): TranscriptMessage[] { + const messages: TranscriptMessage[] = [] + const textRole = transcriptMessageRole(role) + if (typeof content === 'string') { + const text = boundedText(content) + return text && textRole ? [{ role: textRole, text, timestamp }] : [] + } + const blocks = Array.isArray(content) ? content : content != null ? [content] : [] + const textParts: string[] = [] + for (const block of blocks) { + if (typeof block === 'string') { + textParts.push(block) + continue + } + const item = asRecord(block) + if (!item) { + continue + } + const type = typeof item.type === 'string' ? item.type : null + if (type === 'tool_use') { + pushMessage(messages, 'tool', toolCallText(item.name, item.input), timestamp) + continue + } + if (type === 'tool_result') { + pushMessage(messages, 'tool', toolResultText(item.content), timestamp) + continue + } + if (type !== null && !TEXT_BLOCK_TYPES.has(type)) { + continue + } + const text = typeof item.text === 'string' ? item.text : item.content + if (typeof text === 'string' && text) { + textParts.push(text) + } + } + if (textRole && textParts.length > 0) { + // The record's own words lead; its tool blocks follow in transcript order. + const text = boundedText(textParts.join('\n')) + if (text) { + messages.unshift({ role: textRole, text, timestamp }) + } + } + return messages +} + +function pushMessage( + messages: TranscriptMessage[], + role: TranscriptMessageRole, + text: string | null, + timestamp: string | null +): void { + const bounded = text === null ? null : boundedText(text) + if (bounded) { + messages.push({ role, text: bounded, timestamp }) + } +} + +export function boundedText(value: string): string | null { + const bounded = sliceAtCodeUnitLimit(value, TRANSCRIPT_MESSAGE_TEXT_LIMIT) + return bounded.trim() ? bounded : null +} diff --git a/src/main/ai-vault/session-transcript-reader.ts b/src/main/ai-vault/session-transcript-reader.ts new file mode 100644 index 00000000000..228b0c832a3 --- /dev/null +++ b/src/main/ai-vault/session-transcript-reader.ts @@ -0,0 +1,150 @@ +import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser' +import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader' +import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' +import type { SessionParseResumePoint } from './session-parse-cache-store' +import { TranscriptMessageChannel } from './session-transcript-channel' + +const NEWLINE_BYTE = 0x0a + +// Why: this layer owns reading a transcript and nothing else. It decides where +// a read starts, drives the parser, publishes the decoded messages to every +// registered consumer, and reports where the read ended. Which of those results +// are cached, listed or indexed belongs to the callers. + +export type TranscriptReadStats = { + incremental: number + fullParses: number + // Transcripts the parser already excluded (Codex workers), re-listed after a + // write and dismissed without reading. Counted apart from `incremental` so a + // scan span still shows how much work the early stop actually removed. + earlyStopped: number + bytesRead: number +} + +export type ResumableTranscriptRead = { + session: AiVaultSession | null + /** The fold to resume from next time, and the channel bound to it. */ + resume: SessionParseResumePoint +} + +/** + * Read an append-only transcript, resuming from `resume` when the file only + * grew and the recorded offset still sits on a line boundary. Anything else + * (a rewrite, a truncation, a platform change) re-reads the whole file. + */ +export async function readResumableTranscript(args: { + candidate: SessionFileCandidate + platform: NodeJS.Platform + resume: SessionParseResumePoint | null + stateFactory: (messages: TranscriptMessageChannel) => ResumableSessionParseState + stats?: TranscriptReadStats +}): Promise { + const { file } = args.candidate + const resume = args.resume + const canResume = + resume !== null && + typeof file.sizeBytes === 'number' && + file.sizeBytes >= resume.byteOffset && + (resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset))) + + // Clone before consuming: a failed read must not corrupt the cached state, + // or the next resume would double-count the lines applied before the error. + const channel = canResume ? resume.channel : new TranscriptMessageChannel() + const state = canResume ? resume.state.clone() : args.stateFactory(channel) + const startOffset = canResume ? resume.byteOffset : 0 + // Mirrors the reader's entry guard so a dismissed transcript is not reported + // as an incremental parse that read nothing. + const stoppedBeforeRead = state.shouldStop?.() === true + if (args.stats) { + if (stoppedBeforeRead) { + args.stats.earlyStopped++ + } else if (canResume) { + args.stats.incremental++ + } else { + args.stats.fullParses++ + } + } + + channel.beginRead({ + candidate: args.candidate, + mode: canResume ? 'append' : 'replace', + previousByteOffset: startOffset + }) + try { + const readResult = await consumeCompleteJsonlLines({ + path: file.path, + start: startOffset, + onLine: (line) => state.consumeLine(line), + // Bound: the optional hooks are declared as methods, so a parser written + // with method syntax must not lose `this` on the way into the reader. + onLineBytes: state.consumeLineBytes?.bind(state), + shouldStop: state.shouldStop?.bind(state) + }) + if (args.stats) { + args.stats.bytesRead += readResult.bytesRead + } + + // The stat this scan displays is current even when nothing new was consumed. + state.touchFile(file) + + // Keep parity with the one-shot parser: a final unterminated line is shown, + // but stays out of the resumable state so the (possibly still-growing) line + // is re-read once complete instead of being half-counted. + let displayState = state + if (readResult.trailingPartialLine !== null) { + const partialLine = readResult.trailingPartialLine + displayState = state.clone() + channel.mute(() => displayState.consumeLine(partialLine)) + } + + const session = await displayState.finalize(args.platform) + channel.finishRead({ session, byteOffset: readResult.consumedThrough, incomplete: false }) + return { + session, + resume: { state, byteOffset: readResult.consumedThrough, channel } + } + } catch (error) { + channel.finishRead({ session: null, byteOffset: startOffset, incomplete: true }) + throw error + } +} + +/** + * Read a transcript whose format is rewritten in place rather than appended + * (whole-JSON documents, Kimi's state doc, OpenCode). There is no cursor to + * keep, so every read is a whole-file `replace`. + */ +export async function readWholeTranscript(args: { + candidate: SessionFileCandidate + platform: NodeJS.Platform + stats?: TranscriptReadStats +}): Promise { + const { file } = args.candidate + if (args.stats) { + args.stats.fullParses++ + args.stats.bytesRead += file.sizeBytes ?? 0 + } + const publishes = parserPublishesMessages(args.candidate) + const channel = new TranscriptMessageChannel() + channel.beginRead({ candidate: args.candidate, mode: 'replace', previousByteOffset: 0 }) + try { + const session = await parseAgentSessionFile(args.candidate, args.platform, channel) + channel.finishRead({ session, byteOffset: file.sizeBytes ?? 0, incomplete: !publishes }) + return session + } catch (error) { + channel.finishRead({ session: null, byteOffset: 0, incomplete: true }) + throw error + } +} + +// A resume point is only valid if it still sits just past a line break; +// anything else means the file was rewritten, not appended. Heuristic: a +// grown rewrite keeping '\n' at exactly this byte would slip through, but +// agent transcripts are append-only so that trade is accepted (worst case is +// a stale vault row until the file is next truncated or the app restarts). +async function endsWithNewlineAt(path: string, offset: number): Promise { + const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan') + return slice.length === 1 && slice[0] === NEWLINE_BYTE +} diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index a1504a65dec..de24b9a4fba 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -20,14 +20,22 @@ function record(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null } -function taskId(message: Record): string | null { - const value = message.task_id - return typeof value === 'string' && value.length > 0 && value.length <= MAX_TASK_ID_LENGTH - ? value - : null +/** The bound every task id shares, wherever it enters. An id the roster stores + * becomes a durable entry key, so a provisional one takes the same bound the + * announced path applies — an over-long id is rejected, never truncated. */ +export function isBoundedClaudeTaskId(value: string): boolean { + return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH } -function taskDescription(value: unknown): string | undefined { +/** The task's canonical, resume-stable id. Shared with the subagent roster so + * both readers of this channel agree on what identifies a task. */ +export function claudeTaskId(message: Record): string | null { + const value = message.task_id + return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null +} + +/** A task's human label, collapsed and bounded. */ +export function claudeTaskDescription(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined } @@ -107,7 +115,7 @@ export class ClaudeBackgroundTaskTracker { this.replaceAggregateRoster(message.tasks) return true } - const id = taskId(message) + const id = claudeTaskId(message) if (!id) { return false } @@ -126,13 +134,13 @@ export class ClaudeBackgroundTaskTracker { } const existing = this.tasks.get(id) if ( - (patch.is_backgrounded === true || taskDescription(patch.description)) && + (patch.is_backgrounded === true || claudeTaskDescription(patch.description)) && (!this.aggregateRosterObserved || existing) ) { this.upsert(id, { backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, kind: existing?.kind ?? 'unknown', - description: taskDescription(patch.description) ?? existing?.description + description: claudeTaskDescription(patch.description) ?? existing?.description }) return true } @@ -152,7 +160,7 @@ export class ClaudeBackgroundTaskTracker { this.upsert(id, { backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', kind, - description: taskDescription(message.description) + description: claudeTaskDescription(message.description) }) return true } @@ -172,14 +180,14 @@ export class ClaudeBackgroundTaskTracker { if (!task || task.ambient === true) { continue } - const id = taskId(task) + const id = claudeTaskId(task) if (!id) { continue } this.tasks.set(id, { backgrounded: true, kind: classifyClaudeBackgroundTaskKind(task.task_type), - description: taskDescription(task.description) + description: claudeTaskDescription(task.description) }) } } diff --git a/src/main/claude/claude-structured-dispatch-content.test.ts b/src/main/claude/claude-structured-dispatch-content.test.ts new file mode 100644 index 00000000000..02ee6fa721d --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-content.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { + claudeDispatchInvokesSlashCommand, + claudeDispatchMessageContent +} from './claude-structured-dispatch-content' + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +) + +function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +const REMOTE_IMAGE = { type: 'image-ref' as const, url: 'https://example.test/a.png' } + +describe('claudeDispatchMessageContent', () => { + it('puts the text block last so a slash command still expands with an attachment', async () => { + const content = await claudeDispatchMessageContent( + // The composer builds text-then-images; Claude only treats a leading `/` as a + // command when the LAST block is text. + userMessage([{ type: 'text', text: '/goal ship the parser' }, REMOTE_IMAGE]) + ) + + expect(content).toEqual([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship the parser' } + ]) + }) + + it('keeps every image ahead of the text and preserves each side’s order', async () => { + const second = { type: 'image-ref' as const, url: 'https://example.test/b.png' } + + const content = await claudeDispatchMessageContent( + userMessage([{ type: 'text', text: 'look' }, REMOTE_IMAGE, second]) + ) + + expect(content.map((part) => (part as { type: string }).type)).toEqual([ + 'image', + 'image', + 'text' + ]) + expect(content[0]).toEqual({ + type: 'image', + source: { type: 'url', url: 'https://example.test/a.png' } + }) + expect(content[1]).toEqual({ + type: 'image', + source: { type: 'url', url: 'https://example.test/b.png' } + }) + }) + + it('sends text alone unchanged', async () => { + const content = await claudeDispatchMessageContent(userMessage([{ type: 'text', text: 'hi' }])) + + expect(content).toEqual([{ type: 'text', text: 'hi' }]) + }) + + it('sends an image with no text', async () => { + const content = await claudeDispatchMessageContent(userMessage([REMOTE_IMAGE])) + + expect(content).toEqual([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } } + ]) + }) + + it('rejects a message with no renderable block', async () => { + await expect( + claudeDispatchMessageContent(userMessage([{ type: 'text', text: '' }])) + ).rejects.toThrow('Claude dispatch requires text or an image') + }) + + it('rejects a non-user message', async () => { + await expect( + claudeDispatchMessageContent({ + ...userMessage([{ type: 'text', text: 'hi' }]), + role: 'assistant' + }) + ).rejects.toThrow('Claude dispatch accepts only user messages') + }) + + it('joins several text blocks so a command is not stranded ahead of trailing prose', async () => { + // Appending each block would leave `thanks` trailing, and Claude reads only that block. + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: '/goal ship' }, + REMOTE_IMAGE, + { type: 'text', text: 'thanks' } + ]) + ) + + expect(content).toEqual([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship\nthanks' } + ]) + expect(claudeDispatchInvokesSlashCommand(content)).toBe(true) + }) + + it('puts a locally attached image ahead of the text, the shape the composer sends', async () => { + const dir = await mkdtemp(join(tmpdir(), 'claude-dispatch-content-')) + const path = join(dir, 'shot.png') + await writeFile(path, PNG) + + try { + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: '/goal ship' }, + { type: 'image-ref', path } + ]) + ) + + expect(content).toEqual([ + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: PNG.toString('base64') } + }, + { type: 'text', text: '/goal ship' } + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('claudeDispatchInvokesSlashCommand', () => { + it('reads the trailing prompt Claude recovers, not any text block', () => { + expect( + claudeDispatchInvokesSlashCommand([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship' } + ]) + ).toBe(true) + // The pre-fix order: Claude recovers no prompt at all, so no command runs. + expect( + claudeDispatchInvokesSlashCommand([ + { type: 'text', text: '/goal ship' }, + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } } + ]) + ).toBe(false) + }) + + it('reads the joined prompt, so a command behind leading prose is not one', async () => { + // Keeping the blocks separate would leave `/goal ship` trailing and falsely claim a command. + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: 'take a look' }, + { type: 'text', text: '/goal ship' } + ]) + ) + + expect(content).toEqual([{ type: 'text', text: 'take a look\n/goal ship' }]) + expect(claudeDispatchInvokesSlashCommand(content)).toBe(false) + }) + + it('matches untrimmed, as Claude does, and ignores a promptless turn', () => { + expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: ' /goal ship' }])).toBe(false) + expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: 'ship it' }])).toBe(false) + expect(claudeDispatchInvokesSlashCommand([])).toBe(false) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 71f180bc3ac..7df84091b7c 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,6 +3,7 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' +import { claudeRecord } from './claude-structured-item-translation' const MAX_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_IMAGE_COUNT = 20 @@ -88,27 +89,49 @@ async function imageContent( } } +/** + * Claude encodes a user turn as attachment blocks followed by the typed text, and recovers the + * typed prompt by reading only the trailing text block. Verified against the real CLI over + * stream-json: a body ending in an image has no recoverable prompt, so its `/command` reaches + * the model as prose instead of being expanded. + */ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { throw new Error('Claude dispatch accepts only user messages') } - const content: unknown[] = [] + const images: unknown[] = [] + const texts: string[] = [] const imageBudget: ImageBudget = { count: 0, localBytes: 0 } for (const block of body.blocks as NativeChatBlock[]) { if (block.type === 'text' && block.text.length > 0) { - content.push({ type: 'text', text: block.text }) + texts.push(block.text) } else if (block.type === 'image-ref') { - content.push(await imageContent(block, imageBudget)) + images.push(await imageContent(block, imageBudget)) } } + // Join rather than append each block: only the trailing text is read as the prompt, so several + // text blocks would silently discard every one but the last. + const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { throw new Error('Claude dispatch requires text or an image') } return content } +/** The prompt Claude recovers from a dispatch, or null when the turn carries no prompt. */ +function claudeDispatchPrompt(content: readonly unknown[]): string | null { + const last = claudeRecord(content.at(-1)) + return last?.type === 'text' && typeof last.text === 'string' ? last.text : null +} + +/** Mirrors how Claude decides a turn is a command. Untrimmed on purpose: Claude does not trim + * here either, so leading whitespace really does mean no command runs. */ +export function claudeDispatchInvokesSlashCommand(content: readonly unknown[]): boolean { + return claudeDispatchPrompt(content)?.startsWith('/') === true +} + /** * Keep waiter metadata bounded even when a dispatch contains large base64 images. * The digest is only diagnostic: replay acknowledgement must use provider identity. @@ -117,10 +140,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string { const digest = createHash('sha256') const summary = content .map((part) => { - const record = - typeof part === 'object' && part !== null && !Array.isArray(part) - ? (part as Record) - : null + const record = claudeRecord(part) const type = typeof record?.type === 'string' ? record.type : 'unknown' if (type === 'text') { return `text:${typeof record?.text === 'string' ? record.text.length : 0}` @@ -136,10 +156,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string { }) .join(',') for (const [index, part] of content.entries()) { - const record = - typeof part === 'object' && part !== null && !Array.isArray(part) - ? (part as Record) - : null + const record = claudeRecord(part) const type = typeof record?.type === 'string' ? record.type : 'unknown' digest.update(`${index}:${type}:`) if (type === 'text' && typeof record?.text === 'string') { diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index ad09357df58..933bd77774b 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -423,6 +423,73 @@ describe('Claude structured dispatch image limits', () => { }) }) + it('accepts a slash command sent with an attachment from its result receipt', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { + clientMessageId: 'client-1', + body: userMessage([ + { type: 'text', text: '/permissions' }, + { type: 'image-ref', url: 'https://example.test/a.png' } + ]) + }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + // The mapper moves the image ahead of the prompt, so Claude runs the command and replies + // with a result receipt instead of a user replay. + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'command-result-uuid' } + }) + // The sent order is the fix: the waiter's verdict alone was already what it is today. + expect(session.connection.send).toHaveBeenCalledWith( + expect.objectContaining({ + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/permissions' } + ] + } + }) + ) + }) + + it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: ' /permissions' }]) + }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'unrelated-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + }) + it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => { const session = sessionFor() const first = dispatchClaudeTurn( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index b7619a1e94e..3080b5479e4 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -12,6 +12,7 @@ import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-se import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, + claudeDispatchInvokesSlashCommand, claudeDispatchMessageContent } from './claude-structured-dispatch-content' @@ -231,9 +232,9 @@ export async function dispatchClaudeTurn( return { state: 'rejected', reason: (error as Error).message } } const dispatchSequence = ++session.dispatchSequence - const acceptsResult = input.body.blocks.some( - (block) => block.type === 'text' && block.text.trimStart().startsWith('/') - ) + // Read the sent content, not the journal blocks: only the mapped trailing prompt decides + // whether Claude runs a command, so the two cannot disagree about which frame settles this. + const acceptsResult = claudeDispatchInvokesSlashCommand(content) const sentUuid = randomUUID() const replay = waitForReplay( session, diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts index c0ce20908d8..1c1673b59cd 100644 --- a/src/main/claude/claude-structured-item-translation.ts +++ b/src/main/claude/claude-structured-item-translation.ts @@ -74,6 +74,18 @@ export function claudeMessageIdentity( return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid } } +/** User bubbles belong to the submitted message; SDK user frames carry echoes + * and tool results, so a user envelope keeps only its tool results. */ +export function claudeOutputEnvelope(envelope: ClaudeMessageEnvelope): ClaudeMessageEnvelope { + if (envelope.role !== 'user') { + return envelope + } + return { + ...envelope, + content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result') + } +} + function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] { const blocks: NativeChatBlock[] = [] for (const value of envelope.content) { diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts new file mode 100644 index 00000000000..3b280020c31 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + NativeChatSubagentEntry, + NativeChatSubagentGroupBlock +} from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1' + +/** The union's other arms carry no client message id, so reading one narrows. */ +function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null { + return identity.provider === 'orca' ? identity.clientMessageId : null +} + +function harness() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const groupRows = () => + items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID) + const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => { + if (!body || body.kind !== 'message') { + return [] + } + const block = body.blocks.find( + (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' + ) + return block ? block.agents : [] + } + /** The last roster row written for one group, so a test can read a group that + * is no longer the live one. */ + const rosterIn = (groupId: string): NativeChatSubagentEntry[] => + agentsOf( + items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`) + ?.body + ) + const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] => + rosterIn(`claude-session:${turnUuid}`) + const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body) + const fallbackRows = (): AgentJournalItemBody[] => + items + .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:')) + .map((item) => item.body) + return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows } +} + +function userTurn(uuid: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + startsTurn: true as const, + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'go' }] } + } + } +} + +function systemFrame(subtype: string, fields: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { type: 'system', subtype, session_id: 'claude-session', ...fields } + } +} + +function spawnResult(uuid: string, toolUseId: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'done' }] + } + } + } +} + +function resultFrame() { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + session_id: 'claude-session', + uuid: 'result-1', + result: 'ok' + } + } +} + +describe('claude journal translation — subagents', () => { + it('rosters a spawned subagent and settles it on the spawn call result', () => { + const { translator, roster, fallbackRows } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_agent', + subagent_type: 'explorer', + description: 'Map the lane' + }) + ) + expect(roster()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' }) + ]) + // The task frames stay status-chrome, so none of them prints an opcode row. + expect(fallbackRows()).toEqual([]) + translator.handle(spawnResult('user-2', 'toolu_1')) + expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('marks a child still working at turn end unverifiable', () => { + const { translator, roster } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + description: 'Map the lane' + }) + ) + translator.handle(resultFrame()) + expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('leaves a backgrounded child running past the end of its turn', () => { + const { translator, roster } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_agent', + description: 'Watch the build', + is_backgrounded: true + }) + ) + // A backgrounded spawn returns its tool result immediately; the child runs on. + translator.handle(spawnResult('user-2', 'toolu_1')) + translator.handle(resultFrame()) + expect(roster()).toEqual([expect.objectContaining({ state: 'working' })]) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('keeps a backgrounded shell task out of the roster entirely', () => { + const { translator, groupRows } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'sleep 20', + is_backgrounded: true + }) + ) + translator.handle(resultFrame()) + expect(groupRows()).toEqual([]) + }) + + it('shows a subagent whose release announces no task frames, from its child traffic', () => { + const { translator, roster } = harness() + translator.handle(userTurn('user-1')) + translator.handle({ + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: 'child-1', + session_id: 'claude-session', + parent_tool_use_id: 'toolu_1', + message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] } + } + }) + expect(roster()).toEqual([ + expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' }) + ]) + }) + + it('settles the turn a new turn superseded, and leaves the new one running', () => { + const { translator, rosterOf } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + description: 'First turn' + }) + ) + // A second turn starts with no result frame for the first: the first turn + // ends here, and nothing else will ever name its group again. + translator.handle(userTurn('user-2')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-2', + task_type: 'local_agent', + description: 'Second turn' + }) + ) + expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })]) + }) + + it('does not let an unrelated turn end settle a child announced outside a turn', () => { + const { translator, rosterIn } = harness() + // No turn is live yet, so this child has no turn key to belong to. + translator.handle( + systemFrame('task_started', { + task_id: 'task-early', + task_type: 'local_agent', + description: 'Before the turn' + }) + ) + translator.handle(userTurn('user-1')) + translator.handle(resultFrame()) + expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) + // The outcome still lands, which a latched `unverifiable` would have lost. + translator.handle( + systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) + ) + expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('settles a child left outside every turn when the session ends', () => { + const { translator, rosterIn } = harness() + translator.handle( + systemFrame('task_started', { + task_id: 'task-early', + task_type: 'local_agent', + description: 'Before the turn' + }) + ) + translator.handle(userTurn('user-1')) + translator.handle(resultFrame()) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) +}) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index df8e8e67f53..759c2926763 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -11,9 +11,8 @@ import { claudeMessageBody, claudeMessageIdentity, claudeHasReplayContent, - claudeRecord, + claudeOutputEnvelope, claudeStreamingMessageBody, - claudeText, claudeThinkingIdentity, claudeThinkingText, claudeToolBody, @@ -29,16 +28,15 @@ import { claudeQuestionItems } from './claude-structured-prompt-items' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { - CLAUDE_UNRENDERABLE_CONTENT_TEXT, + appendUnmodeledClaudeContent, claudeProviderFrameKind, claudeResultFailure, createClaudeProviderFrameFallback, - isModeledClaudeContent, isSettledClaudeResultKind } from './claude-structured-provider-fallback' +import { ClaudeSubagentRoster } from './claude-subagent-roster' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' @@ -89,10 +87,16 @@ export function createClaudeJournalTranslator( const promptItems = new Map() const streamedBlocks = createClaudeStreamedBlockRegistry() let currentTurn: { sessionId: string; turnId: string } | null = null + const groupKeyOf = (turn: { sessionId: string; turnId: string } | null): string | null => + turn ? `${turn.sessionId}:${turn.turnId}` : null const providerFallback = createClaudeProviderFrameFallback( deps.sink, deps.fallbackIdPrefix ?? 'acquisition' ) + const subagents = new ClaudeSubagentRoster({ + sink: deps.sink, + currentGroupKey: () => groupKeyOf(currentTurn) + }) const streamedText = createClaudeStreamedTextCheckpoints({ ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -144,14 +148,10 @@ export function createClaudeJournalTranslator( return false } let changed = false - // User bubbles belong to the submitted message; SDK user frames carry echoes and tool results. - const outputEnvelope = - envelope.role === 'user' - ? { - ...envelope, - content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result') - } - : envelope + if (envelope.parentToolUseId) { + subagents.observeChildActivity(envelope.parentToolUseId) + } + const outputEnvelope = claudeOutputEnvelope(envelope) const body = claudeMessageBody(outputEnvelope) // The final frame of a streamed block lands on the block's identity, not its own uuid. const identity = @@ -180,6 +180,8 @@ export function createClaudeJournalTranslator( claudeToolIdentity(envelope.sessionId, result.toolUseId), claudeToolBody({ tool, result }) ) + // A spawn call's result is the parent turn's evidence its child finished. + subagents.observeToolResult(result.toolUseId, result.failed) // Tool inputs are only needed until their matching result arrives. tools.delete(result.toolUseId) changed = true @@ -192,21 +194,7 @@ export function createClaudeJournalTranslator( }) changed = true } - const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part)) - for (const part of unhandledContent) { - const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' - providerFallback.append( - `message:${envelope.role}:content:${partType}`, - part, - readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT - ) - changed = true - } - // An empty user frame is a replay with nothing to show, not an unknown kind. - if (envelope.content.length === 0 && envelope.role === 'assistant') { - providerFallback.append(`message:${envelope.role}:empty`, message) - changed = true - } + changed = appendUnmodeledClaudeContent(providerFallback, outputEnvelope, message) || changed if ( envelope.role === 'user' && startsTurn && @@ -214,6 +202,9 @@ export function createClaudeJournalTranslator( message.parent_tool_use_id === null ) { if (currentTurn) { + // A new turn starting is the only end the previous one gets when its + // result never arrives; settling it later would sweep THIS turn. + subagents.settleTurn(groupKeyOf(currentTurn)) publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) } currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid } @@ -254,6 +245,8 @@ export function createClaudeJournalTranslator( handle: (event) => { if (event.type === 'ended') { streamedText.flush() + // No event will ever settle a child once the provider is gone. + subagents.settleSession() if (currentTurn) { publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) currentTurn = null @@ -274,6 +267,9 @@ export function createClaudeJournalTranslator( promptItems.delete(event.promptKey) deps.sink.publish() } else if (event.type === 'message' && event.message.type === 'result') { + // The turn is over however it ended, so a foreground child still + // reported as working will never be settled by an event. + subagents.settleTurn(groupKeyOf(currentTurn)) if (currentTurn) { publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) currentTurn = null @@ -291,6 +287,9 @@ export function createClaudeJournalTranslator( providerFallback.append(kind, event.message, failure?.text) } } else if (event.type === 'message') { + // These frames stay `status-chrome`: the roster reads them here, and the + // fallback below still drops the raw frame instead of printing an opcode. + subagents.observeSystemFrame(event.message) const kind = claudeProviderFrameKind(event.message) if (!handleMessage(event.message, event.startsTurn === true)) { providerFallback.append(kind, event.message) @@ -310,6 +309,7 @@ export function createClaudeJournalTranslator( tools.clear() promptItems.clear() streamedBlocks.clear() + subagents.dispose() } } } diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 2528ac027df..68aec07976b 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -4,8 +4,15 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema' -import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' -import { claudeRecord, claudeText } from './claude-structured-item-translation' +import { + readableProviderFrameText, + unhandledProviderFrameJournalItem +} from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { + claudeRecord, + claudeText, + type ClaudeMessageEnvelope +} from './claude-structured-item-translation' export function claudeProviderFrameKind(message: Record): string { const type = claudeText(message.type) ?? 'unknown' @@ -123,3 +130,30 @@ export function createClaudeProviderFrameFallback( } } } + +export type ClaudeProviderFrameFallback = ReturnType + +/** Journal each content part this build does not model, plus the empty assistant + * frame a replay leaves behind (an empty USER frame is a replay with nothing to + * show, not an unknown kind). Returns whether anything was appended. */ +export function appendUnmodeledClaudeContent( + fallback: ClaudeProviderFrameFallback, + envelope: ClaudeMessageEnvelope, + message: Record +): boolean { + let changed = false + for (const part of envelope.content.filter((part) => !isModeledClaudeContent(part))) { + const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' + fallback.append( + `message:${envelope.role}:content:${partType}`, + part, + readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT + ) + changed = true + } + if (envelope.content.length === 0 && envelope.role === 'assistant') { + fallback.append(`message:${envelope.role}:empty`, message) + changed = true + } + return changed +} diff --git a/src/main/claude/claude-subagent-group-row.test.ts b/src/main/claude/claude-subagent-group-row.test.ts new file mode 100644 index 00000000000..2d5ac56e22c --- /dev/null +++ b/src/main/claude/claude-subagent-group-row.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import { claudeSubagentGroupBody } from './claude-subagent-group-row' + +function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry { + return { id, label: id, state, startedAt: 1 } +} + +/** The fallback sentence is the WHOLE row on mobile and paired web, which have + * no roster renderer, so these assertions are the entire contract there. */ +function sentence(agents: readonly NativeChatSubagentEntry[]): string { + const body = claudeSubagentGroupBody('turn-1', agents) + const block = body.kind === 'message' ? body.blocks[0] : undefined + return block && block.type === 'text' ? block.text : '' +} + +describe('claudeSubagentGroupBody fallback sentence', () => { + it('reads as a plain completion when every child completed', () => { + expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents') + }) + + it('keeps the singular noun for a lone child', () => { + expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent') + expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent') + }) + + it('names an unverifiable child instead of claiming the group ran', () => { + expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe( + 'Ran 2 subagents (1 unverifiable)' + ) + }) + + it('ranks the adverse outcome worst-first', () => { + expect( + sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')]) + ).toBe('Ran 3 subagents (1 failed)') + expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe( + 'Ran 2 subagents (1 stopped)' + ) + }) + + it('shows the adverse outcome while a sibling still works', () => { + expect( + sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')]) + ).toBe('Kicked off 3 subagents (1 unverifiable)') + }) + + it('leaves a benign settled state out of the sentence', () => { + expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents') + }) + + it('counts every child holding the worst adverse state', () => { + expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe( + 'Ran 3 subagents (2 failed)' + ) + }) +}) diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts new file mode 100644 index 00000000000..58af6b6b346 --- /dev/null +++ b/src/main/claude/claude-subagent-group-row.ts @@ -0,0 +1,32 @@ +// The journal row one Claude spawn group writes: its durable identity and the +// body it revises in place. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + +/** Durable journal identity for the group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` } +} + +/** The roster row: the structured block plus the plain sentence an older client + * renders in its place. A message whose only block is the new variant would + * reach such a client with nothing it can draw. */ +export function claudeSubagentGroupBody( + groupId: string, + agents: readonly NativeChatSubagentEntry[] +): AgentJournalItemBody { + return { + kind: 'message', + role: 'system', + blocks: [ + { type: 'text', text: subagentGroupFallbackText(agents) }, + { type: 'subagent-group', groupId, agents: [...agents] } + ] + } +} diff --git a/src/main/claude/claude-subagent-id-aliases.test.ts b/src/main/claude/claude-subagent-id-aliases.test.ts new file mode 100644 index 00000000000..bffb549d173 --- /dev/null +++ b/src/main/claude/claude-subagent-id-aliases.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { ClaudeSubagentIds } from './claude-subagent-id-aliases' + +describe('ClaudeSubagentIds', () => { + it('resolves an aliased tool id to its task, and an unaliased id to itself', () => { + const ids = new ClaudeSubagentIds() + ids.alias('toolu_1', 'task-1') + expect(ids.canonical('toolu_1')).toBe('task-1') + expect(ids.canonical('toolu_unknown')).toBe('toolu_unknown') + }) + + it('remembers an exclusion under either of the ids that named it', () => { + const ids = new ClaudeSubagentIds() + ids.exclude('task-bash') + expect(ids.isExcluded('toolu_bash', 'task-bash')).toBe(true) + expect(ids.isExcluded(null, null)).toBe(false) + expect(ids.isExcluded('task-agent')).toBe(false) + }) + + it('drops the oldest alias past the bound and keeps the newest', () => { + const ids = new ClaudeSubagentIds() + for (let index = 0; index <= 512; index += 1) { + ids.alias(`toolu_${index}`, `task-${index}`) + } + // Evicted: the id now stands only for itself. + expect(ids.canonical('toolu_0')).toBe('toolu_0') + expect(ids.canonical('toolu_512')).toBe('task-512') + expect(ids.canonical('toolu_1')).toBe('task-1') + }) + + it('drops the oldest exclusion past the bound and keeps the newest', () => { + const ids = new ClaudeSubagentIds() + for (let index = 0; index <= 512; index += 1) { + ids.exclude(`task-${index}`) + } + expect(ids.isExcluded('task-0')).toBe(false) + expect(ids.isExcluded('task-512')).toBe(true) + expect(ids.isExcluded('task-1')).toBe(true) + }) + + it('does not retain oversized aliases or exclusions', () => { + const ids = new ClaudeSubagentIds() + const oversized = 'x'.repeat(513) + ids.alias(oversized, 'task-1') + ids.alias('tool-1', oversized) + ids.exclude(oversized) + expect(ids.canonical(oversized)).toBe(oversized) + expect(ids.canonical('tool-1')).toBe('tool-1') + expect(ids.isExcluded(oversized)).toBe(false) + }) + + it('forgets everything on clear', () => { + const ids = new ClaudeSubagentIds() + ids.alias('toolu_1', 'task-1') + ids.exclude('task-1') + ids.clear() + expect(ids.canonical('toolu_1')).toBe('toolu_1') + expect(ids.isExcluded('task-1')).toBe(false) + }) +}) diff --git a/src/main/claude/claude-subagent-id-aliases.ts b/src/main/claude/claude-subagent-id-aliases.ts new file mode 100644 index 00000000000..d06bc00bf8d --- /dev/null +++ b/src/main/claude/claude-subagent-id-aliases.ts @@ -0,0 +1,63 @@ +// Which Claude ids name the same subagent, and which name no subagent at all. +// +// Claude re-announces a resumed task under a NEW `tool_use_id` while `task_id` +// stays put, so tool ids are aliases of a canonical task id — a store keyed on +// the tool id would show the child twice after every resume. +// +// The exclusions matter just as much: `task_updated` carries no `task_type` and +// child traffic carries no task metadata at all, so the one announcement that +// said "this is a backgrounded shell, not an agent" has to be remembered or a +// later frame re-admits it. + +import { isBoundedClaudeTaskId } from './claude-background-task-tracker' + +/** Both maps are event-accumulated and nothing prunes them, so both are bounded. */ +const MAX_TOOL_USE_ALIASES = 512 +const MAX_EXCLUDED_IDS = 512 + +export class ClaudeSubagentIds { + private readonly canonicalByToolUse = new Map() + private readonly excluded = new Set() + + /** The task id a tool id stands for, or the id itself when nothing aliases it. */ + canonical(id: string): string { + return this.canonicalByToolUse.get(id) ?? id + } + + alias(toolUseId: string, taskId: string): void { + if (!isBoundedClaudeTaskId(toolUseId) || !isBoundedClaudeTaskId(taskId)) { + return + } + this.canonicalByToolUse.set(toolUseId, taskId) + while (this.canonicalByToolUse.size > MAX_TOOL_USE_ALIASES) { + const oldest = this.canonicalByToolUse.keys().next() + if (oldest.done || oldest.value === toolUseId) { + break + } + this.canonicalByToolUse.delete(oldest.value) + } + } + + exclude(id: string): void { + if (!isBoundedClaudeTaskId(id)) { + return + } + this.excluded.add(id) + while (this.excluded.size > MAX_EXCLUDED_IDS) { + const oldest = this.excluded.values().next() + if (oldest.done || oldest.value === id) { + break + } + this.excluded.delete(oldest.value) + } + } + + isExcluded(...ids: (string | null)[]): boolean { + return ids.some((id) => id !== null && this.excluded.has(id)) + } + + clear(): void { + this.canonicalByToolUse.clear() + this.excluded.clear() + } +} diff --git a/src/main/claude/claude-subagent-roster-state.ts b/src/main/claude/claude-subagent-roster-state.ts new file mode 100644 index 00000000000..2fa8971d856 --- /dev/null +++ b/src/main/claude/claude-subagent-roster-state.ts @@ -0,0 +1,75 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames' + +const MAX_INVOCATIONS_PER_SUBAGENT = 16 + +export type TrackedEntry = { + entry: NativeChatSubagentEntry + /** The only signal separating a child that dies with its turn from one told to + * outlive it. A turn-end sweep must leave a backgrounded child alone. */ + backgrounded: boolean + toolUseId: string | null + invocationIds: Set | null + /** Label before its ordinal suffix, so a later announcement can tell a + * provisional row from one that already carries the provider's own name. */ + labelBase: string +} + +export type RosterGroup = { + groupId: string + identity: AgentJournalItemIdentity + /** Insertion order is the display order; the map holds the state. */ + entries: Map + /** Lifetime admissions bound retained labels even when entries are removed. */ + admittedEntries: number + /** Labels remain reserved after removal or provisional-name replacement. */ + claimedLabels: Set + /** Last body written, so an idempotent replay writes no new revision. */ + lastSerialized: string | null +} + +// Invocation history stays with the entry, independent of the evicting alias cache. +export function applyClaudeSubagentInvocation( + tracked: TrackedEntry, + frame: ClaudeSubagentTaskFrame, + now: () => number +): boolean { + if (tracked.invocationIds === null) { + return false + } + const newInvocation = + frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId) + if (newInvocation && frame.toolUseId) { + if (tracked.invocationIds.size >= MAX_INVOCATIONS_PER_SUBAGENT) { + tracked.invocationIds = null + tracked.entry = { ...tracked.entry, state: 'unverifiable', settledAt: now() } + return true + } + tracked.invocationIds.add(frame.toolUseId) + if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) { + tracked.backgrounded = frame.backgrounded ?? false + tracked.entry = { ...tracked.entry, state: frame.state ?? 'working', settledAt: undefined } + } + tracked.toolUseId = frame.toolUseId + } else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) { + return false + } + if (tracked.toolUseId === null) { + tracked.toolUseId = frame.toolUseId + } + return true +} + +/** Two children can share a description; the ordinal keeps their rows apart + * without inventing a name the provider never sent. The probe is over the + * labels actually rendered, not a per-base counter: a generated `Audit 2` + * must not collide with a provider that names its own child `Audit 2`. */ +export function claimClaudeSubagentLabel(group: RosterGroup, base: string): string { + let candidate = base + for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) { + candidate = `${base} ${ordinal}` + } + group.claimedLabels.add(candidate) + return candidate +} diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts new file mode 100644 index 00000000000..da1f16d0a0e --- /dev/null +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -0,0 +1,602 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + NativeChatSubagentEntry, + NativeChatSubagentGroupBlock +} from '../../shared/native-chat-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeSubagentRoster } from './claude-subagent-roster' + +const TURN_1 = 'claude-session:turn-1' + +function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] { + if (!body || body.kind !== 'message') { + return [] + } + const block = body.blocks.find( + (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' + ) + return block ? block.agents : [] +} + +function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean { + return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}` +} + +function harness(groupKey: string | null = TURN_1) { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const tombstones: AgentJournalItemIdentity[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: (identity) => tombstones.push(identity), + publish: vi.fn() + } + let clock = 1_000 + let key = groupKey + const roster = new ClaudeSubagentRoster({ + sink, + currentGroupKey: () => key, + now: () => (clock += 1) + }) + const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) + /** The last row written for one group, so a test can read a row that is no + * longer the newest one. */ + const rolesIn = (groupId: string): NativeChatSubagentEntry[] => + agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body) + return { + roster, + items, + tombstones, + roles, + rolesIn, + setGroupKey: (next: string | null) => { + key = next + } + } +} + +function system(subtype: string, fields: Record): Record { + return { type: 'system', subtype, session_id: 'claude-session', ...fields } +} + +function started(fields: Record): Record { + return system('task_started', { task_type: 'local_agent', ...fields }) +} + +describe('ClaudeSubagentRoster', () => { + it('builds the row from task_started, with the fallback sentence beside the block', () => { + const { roster, items, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' }) + ) + expect(items).toHaveLength(1) + expect(items[0]?.identity).toEqual({ + provider: 'orca', + clientMessageId: 'claude-subagents:claude-session:turn-1' + }) + const body = items[0]?.body + expect(body?.kind === 'message' && body.blocks[0]).toEqual({ + type: 'text', + text: 'Kicked off 1 subagent' + }) + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' }) + ]) + }) + + it('keeps a backgrounded shell task out of the roster', () => { + const { roster, items } = harness() + roster.observeSystemFrame( + system('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'sleep 20', + is_backgrounded: true + }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } }) + ) + // Its own frames carry a tool_use_id, so only the excluded-id memory stops it. + roster.observeChildActivity('toolu_bash') + expect(items).toHaveLength(0) + }) + + it('never renders a task marked skip_transcript', () => { + const { roster, items } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } }) + ) + roster.observeChildActivity('toolu_a') + expect(items).toHaveLength(0) + }) + + it('drops a provisional row once an announcement says the task is not a subagent', () => { + const { roster, items, tombstones, roles } = harness() + roster.observeChildActivity('toolu_bash') + expect(roles()).toHaveLength(1) + roster.observeSystemFrame( + system('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash' + }) + ) + expect(tombstones).toEqual([ + { provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' } + ]) + expect(items).toHaveLength(1) + }) + + it('does not duplicate a resumed task re-announced under a new tool_use_id', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' }) + ) + roster.observeChildActivity('toolu_first') + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' }) + ) + roster.observeChildActivity('toolu_second') + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' }) + ]) + }) + + it('adopts a row built from child traffic when the announcement finally names it', () => { + const { roster, roles } = harness() + roster.observeChildActivity('toolu_1') + expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })]) + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) + ) + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) + ]) + }) + + it('is idempotent: a repeated frame writes no new revision', () => { + const { roster, items } = harness() + const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' }) + roster.observeSystemFrame(frame) + roster.observeSystemFrame(frame) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) + ) + expect(items).toHaveLength(1) + }) + + it('latches a terminal state against a later live report', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) + ) + expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) + }) + + it('ignores an update for a task it never rostered', () => { + const { roster, items } = harness() + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } }) + ) + expect(items).toHaveLength(0) + }) + + it('disambiguates children that share a description', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' })) + expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2']) + }) + + describe('turn end', () => { + it('leaves a backgrounded child working and marks a foreground one unverifiable', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' })) + roster.observeSystemFrame( + started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) + ) + roster.settleTurn(TURN_1) + expect(roles()).toEqual([ + expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }), + expect.objectContaining({ label: 'Background', state: 'working' }) + ]) + }) + + it('never re-settles a child that already reported an outcome', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) + ) + roster.settleTurn(TURN_1) + expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('sweeps backgrounded children only when the provider itself is gone', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) + ) + roster.settleTurn(TURN_1) + roster.settleSession() + expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + }) + + describe('spawn tool result', () => { + it('settles a foreground child', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) + roster.observeToolResult('toolu_1', false) + expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('reports a failed spawn as failed', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) + roster.observeToolResult('toolu_1', true) + expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) + }) + + it('ignores the immediate result a backgrounded spawn returns', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true }) + ) + roster.observeToolResult('toolu_1', false) + expect(roles()).toEqual([expect.objectContaining({ state: 'working' })]) + }) + + it('ignores results for tools that are not spawn calls', () => { + const { roster, items } = harness() + roster.observeToolResult('toolu_read', false) + expect(items).toHaveLength(0) + }) + }) + + describe('label ordinals', () => { + it('never re-issues an ordinal a removed row gave up', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) + // task-1 is re-announced as a shell task, so its row goes; reclaiming the + // ordinal it held would print a second 'Audit 2' beside the one still shown. + roster.observeSystemFrame( + system('task_started', { task_id: 'task-1', task_type: 'local_bash' }) + ) + roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' })) + expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3']) + }) + + it('never generates a label a provider-supplied one already took', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) + // The provider's own name for the third child is the label the ordinal just + // generated for the second; a per-base counter would print it twice. + roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' })) + const labels = roles().map((agent) => agent.label) + expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2']) + expect(new Set(labels).size).toBe(labels.length) + }) + }) + + describe('child traffic for an id the CLI never declared', () => { + it('creates nothing once the CLI has announced any task at all', () => { + const { roster, items } = harness() + // A rejected announcement still proves this CLI declares what it spawns. + roster.observeSystemFrame( + system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) + ) + roster.observeChildActivity('toolu_never_announced') + expect(items).toHaveLength(0) + }) + + it('rejects an over-long provisional id instead of storing it as an entry id', () => { + const { roster, items } = harness() + // The announced path drops an id past `claudeTaskId`'s bound; the + // provisional one writes the same durable entry id, so it must too. + roster.observeChildActivity(`toolu_${'x'.repeat(512)}`) + expect(items).toHaveLength(0) + roster.observeChildActivity(`toolu_${'x'.repeat(500)}`) + expect(items).toHaveLength(1) + }) + + it('still rosters a subagent announced after a task the filter rejected', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) + ) + // The gate closes the child-traffic fallback, never the announcement path. + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) + ) + roster.observeChildActivity('toolu_1') + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) + ]) + }) + + it('leaves a grandchild parented inside the sidechain out of the roster', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) + ) + roster.observeChildActivity('toolu_1') + // A tool the subagent itself ran: never announced, so never excluded either. + roster.observeChildActivity('toolu_inner') + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) + ]) + }) + + it('still mints the provisional row for a release that announces no task', () => { + const { roster, roles } = harness() + roster.observeChildActivity('toolu_1') + // Not an announcement: the fallback path stays open for this release. + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-x', patch: { status: 'running' } }) + ) + roster.observeChildActivity('toolu_2') + expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2']) + }) + }) + + describe('groups that no later event can reach', () => { + it('loses contact with a group evicted past the bound', () => { + const { roster, rolesIn, setGroupKey } = harness('turn-0') + for (let index = 0; index < 33; index += 1) { + setGroupKey(`turn-${index}`) + roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' })) + } + expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })]) + }) + + it('loses contact with a live child when the translator is disposed without an end', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) + ) + roster.dispose() + expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('writes nothing on dispose when the session already settled', () => { + const { roster, items } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.settleSession() + const written = items.length + roster.dispose() + expect(items).toHaveLength(written) + }) + }) + + it('groups children outside any turn under their own row', () => { + const { roster, items } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + expect(items[0]?.identity).toEqual({ + provider: 'orca', + clientMessageId: 'claude-subagents:outside-turn' + }) + }) +}) + +describe('ClaudeSubagentRoster — the turn that is ending', () => { + it('leaves a child announced outside any turn alone when an unrelated turn ends', () => { + const { roster, rolesIn, setGroupKey } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) + setGroupKey(TURN_1) + roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) + roster.settleTurn(TURN_1) + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) + expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + // `unverifiable` latches, so sweeping it above would have swallowed this. + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) + ) + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('sweeps the outside-turn group when a turn with no key of its own ends', () => { + const { roster, rolesIn } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) + roster.settleTurn(null) + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('still settles an outside-turn child once the session itself ends', () => { + const { roster, rolesIn, setGroupKey } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) + setGroupKey(TURN_1) + roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) + roster.settleTurn(TURN_1) + roster.settleSession() + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('sweeps the turn that ended, not whichever turn is live now', () => { + const { roster, rolesIn, setGroupKey } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' })) + setGroupKey('claude-session:turn-2') + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' })) + // Turn 1's result lands after turn 2 has already begun. + roster.settleTurn(TURN_1) + expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + expect(rolesIn('claude-session:turn-2')).toEqual([ + expect.objectContaining({ state: 'working' }) + ]) + }) +}) + +describe('ClaudeSubagentRoster — through the real sink queue', () => { + it('lands every revision, not just the one that was already in flight', async () => { + const appended: AgentJournalItemBody[] = [] + let published = 0 + const journal = { + appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + appended.push(body) + return { cursor: { epoch: 'e', sequence: appended.length } } + }, + appendTombstone: async () => ({ epoch: 'e', sequence: 0 }) + } as unknown as AgentSessionJournal + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ + journal, + fence: 1, + publish: () => { + published += 1 + } + }) + const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 }) + + // The first append is in flight while the rest are submitted, so a publish + // sharing the row's coalescing key would evict them. + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' })) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) + ) + const drained = await deferred.drained() + + expect(drained).toEqual({ ok: true }) + expect(agentsOf(appended.at(-1))).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }), + expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' }) + ]) + expect(published).toBeGreaterThan(0) + }) +}) + +describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => { + it('accepts a notification after the foreground turn lost contact', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1' })) + roster.settleTurn(TURN_1) + roster.observeSystemFrame( + system('task_notification', { task_id: 'task-1', status: 'completed' }) + ) + expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('settles a background child from its notification without a task_updated', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true })) + roster.settleTurn(TURN_1) + roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' })) + expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) + }) + + it('bounds lifetime admissions when reclassification repeatedly removes entries', () => { + const { roster, items } = harness() + for (let i = 0; i < 100; i++) { + roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` })) + roster.observeSystemFrame( + system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' }) + ) + } + expect(items).toHaveLength(64) + }) +}) + +describe('ClaudeSubagentRoster — resumed invocation', () => { + it('reopens one canonical child on a new announcement without replaying old results', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + roster.observeToolResult('first', false) + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) + ) + expect(roles()).toEqual([expect.objectContaining({ id: 'task-1', state: 'working' })]) + expect(roles()[0].settledAt).toBeUndefined() + roster.observeSystemFrame( + system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' }) + ) + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + expect(roles()[0].state).toBe('working') + roster.observeSystemFrame( + system('task_notification', { + task_id: 'task-1', + tool_use_id: 'resumed', + status: 'completed' + }) + ) + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) + ) + expect(roles()[0].state).toBe('completed') + }) +}) + +describe('ClaudeSubagentRoster — invocation fences', () => { + it('ignores a previous invocation tool result even without a background flag', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + roster.observeToolResult('first', false) + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' })) + roster.observeToolResult('first', true) + expect(roles()[0].state).toBe('working') + roster.observeToolResult('next', false) + expect(roles()[0].state).toBe('completed') + }) + + it('does not treat an evicted alias as a new invocation', () => { + const { roster, rolesIn, setGroupKey } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + roster.observeToolResult('first', false) + setGroupKey('churn') + for (let i = 0; i < 513; i++) { + roster.observeSystemFrame( + system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` }) + ) + } + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + expect(rolesIn(TURN_1)[0].state).toBe('completed') + }) + + it('bounds invocation history and refuses to reopen beyond the retained budget', () => { + const { roster, roles } = harness() + for (let i = 0; i < 20; i++) { + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` })) + if (i >= 16) { + expect(roles()[0].state).toBe('unverifiable') + } + roster.observeToolResult(`tool-${i}`, false) + } + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' })) + expect(roles()[0].state).toBe('unverifiable') + }) +}) + +it('merges an explicit foreground patch without clearing on absent metadata', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } }) + ) + roster.observeToolResult('tool', false) + expect(roles()[0].state).toBe('working') + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } }) + ) + roster.observeToolResult('tool', false) + expect(roles()[0].state).toBe('completed') +}) diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts new file mode 100644 index 00000000000..34083f2ee8c --- /dev/null +++ b/src/main/claude/claude-subagent-roster.ts @@ -0,0 +1,388 @@ +// The Claude subagent roster: one journal row per turn that spawned children. +// +// Entries are built from `task_started`, never from child traffic: a +// BACKGROUNDED subagent emits no child frames at all, so a roster fed by +// `parent_tool_use_id` alone would leave every one of them an unlabelled row +// forever. Child traffic only creates an entry for CLI releases that announce +// no task frames. +// +// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is +// the key and tool ids are aliases; keying on the tool id would duplicate the +// child on every resume. Outcomes latch within an invocation; a new spawn +// alias can reopen it, and authoritative evidence can correct lost contact. + +import { + canReplaceSubagentState, + isTerminalSubagentState +} from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { isBoundedClaudeTaskId } from './claude-background-task-tracker' +import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' +import { ClaudeSubagentIds } from './claude-subagent-id-aliases' +import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' +import { + applyClaudeSubagentInvocation, + claimClaudeSubagentLabel, + type RosterGroup, + type TrackedEntry +} from './claude-subagent-roster-state' + +/** Spawn-group rows kept live per session, and children per row. Both bound an + * event-accumulated map that no provider snapshot ever prunes. */ +const MAX_SUBAGENT_GROUPS = 32 +const MAX_SUBAGENTS_PER_GROUP = 64 + +/** The turn a group belongs to when Claude reports a task outside any turn. */ +const OUTSIDE_TURN = 'outside-turn' + +const UNLABELLED_AGENT = 'subagent' + +export type ClaudeSubagentRosterDeps = { + sink: StructuredAgentSessionEventSink + /** The turn that owns children spawned right now; null outside any turn. */ + currentGroupKey: () => string | null + now?: () => number +} + +export class ClaudeSubagentRoster { + private readonly groups = new Map() + /** Canonical id → the group holding its entry, so a late update for a child + * from an earlier turn revises that turn's row instead of the live one. */ + private readonly groupIdByEntry = new Map() + private readonly ids = new ClaudeSubagentIds() + /** Set by ANY `task_started`, including one the subagent filter rejects. Once + * this CLI has proven it declares its tasks, child traffic for an id it never + * announced is a nested tool or a grandchild, not a subagent. */ + private announcesTasks = false + private readonly now: () => number + + constructor(private readonly deps: ClaudeSubagentRosterDeps) { + this.now = deps.now ?? (() => Date.now()) + } + + /** Consume a `message:system:task_*` frame. Returns false when it is not one. */ + observeSystemFrame(message: Record): boolean { + const frame = readClaudeSubagentTaskFrame(message) + if (!frame) { + return false + } + this.announcesTasks ||= frame.announcement + if (frame.excluded) { + // Child traffic may already have built a provisional row under the tool id; + // the announcement is the first frame that says it is not a subagent. + for (const id of [frame.taskId, frame.toolUseId]) { + if (id !== null) { + this.ids.exclude(id) + this.remove(id) + } + } + return true + } + if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) { + return true + } + if (frame.toolUseId) { + this.ids.alias(frame.toolUseId, frame.taskId) + } + const located = + this.locate(frame.taskId) ?? + (frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null) + if (!located) { + if (frame.announcesSubagent) { + this.create( + frame.taskId, + frame.label, + frame.state ?? 'working', + frame.backgrounded ?? false, + frame.toolUseId + ) + } + return true + } + const tracked = located.group.entries.get(frame.taskId) + if (tracked && !applyClaudeSubagentInvocation(tracked, frame, this.now)) { + return true + } + this.revise(located.group, frame.taskId, { + label: frame.label, + state: frame.state, + backgrounded: frame.backgrounded + }) + return true + } + + /** + * A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes + * nothing on an announced child; it exists so a CLI release that sends no task + * frames still shows the subagent it is running. + */ + observeChildActivity(parentToolUseId: string): void { + const canonical = this.ids.canonical(parentToolUseId) + if (this.ids.isExcluded(parentToolUseId, canonical)) { + return + } + if (this.locate(canonical)) { + return + } + if (this.announcesTasks) { + // A nested Task, a workflow child, or a grandchild parented to a tool id + // inside the sidechain all reach here. This CLI announces what it spawns, + // so an id it never declared cannot be a subagent — and a row invented for + // one is unlabelled forever and can only ever end `unverifiable`. The + // bounded exclusion set cannot cover an id that was never announced. + return + } + if (!isBoundedClaudeTaskId(canonical)) { + // `claudeTaskId` rejects an over-long announced id rather than truncating + // it; a provisional id becomes the same durable entry key, so it cannot + // enter under a looser rule. + return + } + this.create(canonical, null, 'working', false, parentToolUseId) + } + + /** + * The parent turn's tool result for a spawn call. It settles a foreground + * child, whose result IS the turn's evidence the child finished. A backgrounded + * child's spawn call returns immediately while the child keeps running, so its + * result proves nothing and is ignored. + */ + observeToolResult(toolUseId: string, failed: boolean): void { + const canonical = this.ids.canonical(toolUseId) + const located = this.locate(canonical) + if ( + !located || + located.tracked.invocationIds === null || + located.tracked.backgrounded || + (located.tracked.toolUseId !== null && located.tracked.toolUseId !== toolUseId) + ) { + return + } + this.revise(located.group, canonical, { + label: null, + state: failed ? 'failed' : 'completed', + backgrounded: false + }) + } + + /** + * The parent turn ended. A foreground child still reported as working will + * never be settled by an event, so it becomes `unverifiable`: contact was + * lost, which is NOT evidence the child exited. A backgrounded child was + * explicitly told to outlive the turn and is left alone. + */ + settleTurn(groupKey: string | null): void { + // Only the group this key names. `OUTSIDE_TURN` belongs to no turn, so an + // unrelated turn ending is no evidence about a child announced outside it. + // `settleSession` reaches what no turn does. + this.sweep(this.groups.get(groupKey ?? OUTSIDE_TURN), false) + } + + /** The provider is gone. Nothing more will arrive for any child, backgrounded + * or not, so every one of them loses contact at once. */ + settleSession(): void { + for (const group of this.groups.values()) { + this.sweep(group, true) + } + } + + dispose(): void { + // Teardown paths reach here without an `ended` event, so a row still + // reporting `working` would have nothing left to revise it. A session that + // did settle first leaves every child terminal, so this writes nothing. + this.settleSession() + this.groups.clear() + this.groupIdByEntry.clear() + this.ids.clear() + this.announcesTasks = false + } + + private sweep(group: RosterGroup | undefined, includeBackgrounded: boolean): void { + if (!group) { + return + } + let changed = false + for (const [id, tracked] of group.entries) { + if (isTerminalSubagentState(tracked.entry.state)) { + continue + } + if (tracked.backgrounded && !includeBackgrounded) { + continue + } + group.entries.set(id, { + ...tracked, + entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() } + }) + changed = true + } + if (changed) { + this.write(group) + } + } + + private create( + id: string, + label: string | null, + state: NativeChatSubagentEntry['state'], + backgrounded: boolean, + toolUseId: string | null + ): void { + const group = this.groupFor() + if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) { + return + } + group.admittedEntries += 1 + const now = this.now() + const labelBase = label ?? UNLABELLED_AGENT + group.entries.set(id, { + backgrounded, + toolUseId, + invocationIds: new Set(toolUseId ? [toolUseId] : []), + labelBase, + entry: { + id, + label: claimClaudeSubagentLabel(group, labelBase), + state, + startedAt: now, + ...(isTerminalSubagentState(state) ? { settledAt: now } : {}) + } + }) + this.groupIdByEntry.set(id, group.groupId) + this.write(group) + } + + private revise( + group: RosterGroup, + id: string, + change: { + label: string | null + state: NativeChatSubagentEntry['state'] | null + backgrounded: boolean | null + } + ): void { + const tracked = group.entries.get(id) + if (!tracked) { + return + } + const next: TrackedEntry = { + ...tracked, + backgrounded: change.backgrounded ?? tracked.backgrounded, + entry: { ...tracked.entry } + } + // A provisional row built from child traffic takes the real name the first + // announcement carries; an announced row keeps the name it was given. + if ( + change.label && + tracked.labelBase === UNLABELLED_AGENT && + change.label !== UNLABELLED_AGENT + ) { + next.labelBase = change.label + next.entry.label = claimClaudeSubagentLabel(group, change.label) + } + // Proven outcomes latch; lost contact can still receive a later verdict. + if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) { + next.entry.state = change.state + if (isTerminalSubagentState(change.state)) { + next.entry.settledAt = this.now() + } + } + group.entries.set(id, next) + this.write(group) + } + + /** Re-key a provisional entry from its tool id onto the canonical task id the + * announcement finally named, so the child does not appear twice. */ + private adopt(toolUseId: string, taskId: string): { group: RosterGroup } | null { + if (toolUseId === taskId) { + return null + } + const located = this.locate(toolUseId) + if (!located) { + return null + } + located.group.entries.delete(toolUseId) + located.group.entries.set(taskId, { + ...located.tracked, + entry: { ...located.tracked.entry, id: taskId } + }) + this.groupIdByEntry.delete(toolUseId) + this.groupIdByEntry.set(taskId, located.group.groupId) + return { group: located.group } + } + + private remove(id: string): void { + const located = this.locate(id) + if (!located) { + return + } + located.group.entries.delete(id) + this.groupIdByEntry.delete(id) + this.write(located.group) + } + + private locate(id: string): { group: RosterGroup; tracked: TrackedEntry } | null { + const groupId = this.groupIdByEntry.get(id) + const group = groupId === undefined ? undefined : this.groups.get(groupId) + const tracked = group?.entries.get(id) + return group && tracked ? { group, tracked } : null + } + + private groupFor(): RosterGroup { + const groupId = this.deps.currentGroupKey() ?? OUTSIDE_TURN + const existing = this.groups.get(groupId) + if (existing) { + return existing + } + const group: RosterGroup = { + groupId, + identity: claudeSubagentGroupIdentity(groupId), + entries: new Map(), + admittedEntries: 0, + claimedLabels: new Set(), + lastSerialized: null + } + this.groups.set(groupId, group) + while (this.groups.size > MAX_SUBAGENT_GROUPS) { + const oldest = this.groups.keys().next() + if (oldest.done || oldest.value === groupId) { + break + } + const evicted = this.groups.get(oldest.value) + // Once the group leaves the map nothing can reach its children again — + // not even a session sweep — so contact is lost here. + this.sweep(evicted, true) + for (const id of evicted?.entries.keys() ?? []) { + this.groupIdByEntry.delete(id) + } + this.groups.delete(oldest.value) + } + return group + } + + private write(group: RosterGroup): void { + const agents = [...group.entries.values()].map((tracked) => tracked.entry) + const options = { coalescingKey: `claude-subagents:${group.groupId}` } + if (agents.length === 0) { + // The row's last child turned out not to be a subagent. An empty roster is + // not a roster of nothing, so the row goes rather than reading "Ran 0". + if (group.lastSerialized !== null) { + group.lastSerialized = null + this.deps.sink.appendTombstone(group.identity, options) + this.deps.sink.publish() + } + return + } + const body = claudeSubagentGroupBody(group.groupId, agents) + const serialized = JSON.stringify(body) + if (serialized === group.lastSerialized) { + // Nothing changed — a duplicate delivery must not burn a revision. + return + } + group.lastSerialized = serialized + this.deps.sink.appendItem(group.identity, body, options) + // Publish keeps the sink's own coalescing slot: sharing the row's key makes + // each queued publish evict the append it was meant to flush. + this.deps.sink.publish() + } +} diff --git a/src/main/claude/claude-subagent-task-frames.test.ts b/src/main/claude/claude-subagent-task-frames.test.ts new file mode 100644 index 00000000000..230ef45e19c --- /dev/null +++ b/src/main/claude/claude-subagent-task-frames.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' + +function system(subtype: string, fields: Record): Record { + return { type: 'system', subtype, session_id: 'claude-session', ...fields } +} + +describe('readClaudeSubagentTaskFrame', () => { + it('ignores frames that are not task frames', () => { + expect(readClaudeSubagentTaskFrame({ type: 'assistant', subtype: 'task_started' })).toBeNull() + expect(readClaudeSubagentTaskFrame(system('init', { task_id: 'task-1' }))).toBeNull() + expect(readClaudeSubagentTaskFrame(system('task_started', {}))).toBeNull() + expect(readClaudeSubagentTaskFrame(system('task_started', { task_id: '' }))).toBeNull() + }) + + describe('task_type triage', () => { + it('announces a local_agent task', () => { + const frame = readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_agent', + subagent_type: 'code-reviewer', + description: 'Review the diff' + }) + ) + expect(frame).toMatchObject({ + taskId: 'task-1', + toolUseId: 'toolu_1', + label: 'Review the diff', + announcesSubagent: true, + excluded: false + }) + }) + + it('excludes a backgrounded shell command even though it carries a tool_use_id', () => { + const frame = readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'sleep 20', + is_backgrounded: true + }) + ) + expect(frame).toMatchObject({ + taskId: 'task-bash', + toolUseId: 'toolu_bash', + announcesSubagent: false, + excluded: true + }) + }) + + it('excludes workflows and monitors', () => { + for (const taskType of ['local_workflow', 'monitor']) { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { task_id: `task-${taskType}`, task_type: taskType }) + ) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + } + }) + + it('caps a subagent_type label the way a description is capped', () => { + const frame = readClaudeSubagentTaskFrame( + system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) }) + ) + // The roster stores this label verbatim, so nothing downstream bounds it. + expect(frame?.label).toHaveLength(512) + }) + + it('falls back to subagent_type only when the release sends no task_type', () => { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { task_id: 'task-old', subagent_type: 'explorer' }) + ) + ).toMatchObject({ announcesSubagent: true, label: 'explorer' }) + expect( + readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' })) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + // A type this build does not recognise is not an agent on subagent_type's word. + expect( + readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-new', + task_type: 'local_something_new', + subagent_type: 'explorer' + }) + ) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + }) + + it('excludes ambient housekeeping tasks', () => { + for (const suppression of [{ skip_transcript: true }, { ambient: true }]) { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-ambient', + task_type: 'local_agent', + subagent_type: 'watcher', + ...suppression + }) + ) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + } + }) + }) + + describe('status', () => { + it('collapses every in-flight status to working', () => { + for (const status of ['pending', 'running', 'paused']) { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { status } }) + ) + ).toMatchObject({ state: 'working' }) + } + }) + + it('maps the settled statuses onto the carrier vocabulary', () => { + const mapped: [string, string][] = [ + ['completed', 'completed'], + ['failed', 'failed'], + ['killed', 'stopped'], + ['stopped', 'stopped'] + ] + for (const [status, state] of mapped) { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { status } }) + ) + ).toMatchObject({ state }) + } + }) + + it('reports no state for a status it cannot map', () => { + for (const status of ['__proto__', 'toString', 'invented', 7, null]) { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { status } }) + ) + ).toMatchObject({ state: null }) + } + }) + + it('treats progress as no lifecycle verdict', () => { + for (const subtype of ['task_progress']) { + expect( + readClaudeSubagentTaskFrame( + system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } }) + ) + ).toMatchObject({ state: null }) + } + }) + }) + + it('reads the notification verdict from its top-level status', () => { + for (const state of ['completed', 'failed', 'stopped']) { + expect( + readClaudeSubagentTaskFrame( + system('task_notification', { + task_id: 'task-1', + status: state, + patch: { status: 'running' } + }) + ) + ).toMatchObject({ state }) + } + }) + + it('reads the backgrounded flag from the frame or its patch', () => { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + is_backgrounded: true + }) + ) + ).toMatchObject({ backgrounded: true }) + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } }) + ) + ).toMatchObject({ backgrounded: true }) + expect( + readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} })) + ).toMatchObject({ backgrounded: null }) + }) + + it('collapses a multi-line description into one bounded label', () => { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { + task_id: 'task-1', + patch: { description: ' audit\n the lockfile ' } + }) + ) + ).toMatchObject({ label: 'audit the lockfile' }) + }) +}) diff --git a/src/main/claude/claude-subagent-task-frames.ts b/src/main/claude/claude-subagent-task-frames.ts new file mode 100644 index 00000000000..e5f361fa8c4 --- /dev/null +++ b/src/main/claude/claude-subagent-task-frames.ts @@ -0,0 +1,123 @@ +// Claude's declarative task protocol, read as subagent roster events. +// +// `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same +// `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence +// discriminates nothing: filtering on it alone puts a backgrounded `sleep 20` in +// the subagent roster. `task_type` is the discriminator, with `subagent_type` +// covering CLI releases that predate it. + +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + classifyClaudeBackgroundTaskKind, + claudeTaskDescription, + claudeTaskId, + isBoundedClaudeTaskId +} from './claude-background-task-tracker' +import { claudeRecord, claudeText } from './claude-structured-item-translation' + +const TASK_SUBTYPES: ReadonlySet = new Set([ + 'task_started', + 'task_updated', + 'task_progress', + 'task_notification' +]) + +/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean + * the task was deliberately ended, which the carrier calls `stopped`; every + * in-flight status collapses to `working`. A Map, not an object, so a payload + * carrying `__proto__` as its status cannot resolve to an inherited value. */ +const TASK_STATES: ReadonlyMap = new Map([ + ['pending', 'working'], + ['running', 'working'], + ['paused', 'working'], + ['completed', 'completed'], + ['failed', 'failed'], + ['killed', 'stopped'], + ['stopped', 'stopped'] +] satisfies [string, NativeChatSubagentState][]) + +export type ClaudeSubagentTaskFrame = { + /** Canonical, resume-stable id — the roster key. */ + taskId: string + /** Re-minted when Claude re-announces a resumed task, so it is only an alias. */ + toolUseId: string | null + label: string | null + /** null when the frame reported no lifecycle status. */ + state: NativeChatSubagentState | null + backgrounded: boolean | null + /** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */ + announcement: boolean + /** `task_started` for a task the roster should show. Only an announcement + * creates an entry: an update carries no `task_type`, so honouring one for an + * unknown id would roster whatever else shares this channel. */ + announcesSubagent: boolean + /** Ambient housekeeping, or a task that is not a subagent at all. Its ids must + * never reach the roster, by this frame or by later child traffic. */ + excluded: boolean +} + +/** True when the task Claude announced is a subagent rather than a backgrounded + * shell command or a workflow. */ +export function isClaudeSubagentTask(message: Record): boolean { + if (classifyClaudeBackgroundTaskKind(message.task_type) === 'agent') { + return true + } + // Releases predating `task_type` still name the child in `subagent_type`. A + // task_type Orca does not recognise is NOT covered: it is a type this build + // has no reason to believe is an agent. + return ( + (message.task_type === undefined || message.task_type === null) && + claudeText(message.subagent_type) !== null + ) +} + +function taskState(value: unknown): NativeChatSubagentState | null { + return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null +} + +export function readClaudeSubagentTaskFrame( + message: Record +): ClaudeSubagentTaskFrame | null { + if (message.type !== 'system') { + return null + } + const subtype = claudeText(message.subtype) + if (!subtype || !TASK_SUBTYPES.has(subtype)) { + return null + } + const taskId = claudeTaskId(message) + if (!taskId) { + return null + } + const patch = claudeRecord(message.patch) + const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id) + const announcement = subtype === 'task_started' + // Housekeeping Claude runs for itself; the user never asked for it. + const suppressed = message.ambient === true || message.skip_transcript === true + const subagent = announcement && !suppressed && isClaudeSubagentTask(message) + return { + taskId, + toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null, + label: + claudeTaskDescription(message.description) ?? + claudeTaskDescription(patch?.description) ?? + // Bounded like a description: the roster stores whatever this returns. + (announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null), + // Notifications carry terminal evidence; progress carries usage only. + state: + subtype === 'task_notification' + ? taskState(message.status) + : announcement || subtype === 'task_updated' + ? taskState(patch?.status ?? message.status) + : null, + backgrounded: + typeof patch?.is_backgrounded === 'boolean' + ? patch.is_backgrounded + : typeof message.is_backgrounded === 'boolean' + ? message.is_backgrounded + : null, + announcement, + announcesSubagent: subagent, + excluded: announcement && !subagent + } +} diff --git a/src/main/codex/codex-background-command-tracker.test.ts b/src/main/codex/codex-background-command-tracker.test.ts new file mode 100644 index 00000000000..abccb4e4a35 --- /dev/null +++ b/src/main/codex/codex-background-command-tracker.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { CodexBackgroundCommandTracker } from './codex-background-command-tracker' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function notification( + method: string, + params: Record +): Extract { + return { + type: 'notification', + sessionId: 'session', + threadId: 'root', + method, + params: { threadId: 'root', turnId: 'turn', ...params } + } +} + +function command(method: string, id = 'exec', threadId = 'root') { + return { + ...notification(method, { + item: { + type: 'commandExecution', + id, + command: 'sleep 30', + source: 'unifiedExecStartup', + status: method === 'item/completed' ? 'completed' : 'inProgress', + exitCode: method === 'item/completed' ? 0 : null + } + }), + threadId + } +} + +describe('persistent command ownership', () => { + it('preflights finite metadata capacity and admits work again after process completion', () => { + const tracker = new CodexBackgroundCommandTracker('root', 700) + const first = command('item/started', 'first') + const second = command('item/started', 'second') + expect(tracker.canObserve(first)).toBe(true) + tracker.observe(first) + expect(tracker.canObserve(second)).toBe(false) + expect(() => tracker.observe(second)).toThrow('not admitted') + expect(tracker.tasks()).toHaveLength(1) + expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) + tracker.observe(command('item/completed', 'first')) + expect(tracker.canObserve(second)).toBe(true) + tracker.observe(second) + expect(tracker.tasks()).toHaveLength(1) + expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) + tracker.clear() + expect(tracker.retainedMetadataBytes).toBe(0) + }) + + it('keeps the journal running across turn completion and accepts late output and exit', () => { + const rows: { key: string; body: AgentJournalItemBody }[] = [] + const translator = createCodexJournalTranslator({ + primaryThreadId: () => 'root', + sink: { + appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => {}, + publish: () => {} + } + }) + const tracker = new CodexBackgroundCommandTracker('root') + const deliver = (event: Extract) => { + expect(translator.handle(event)).toEqual({ accepted: true }) + tracker.observe(event) + } + deliver(notification('turn/started', { turn: { id: 'turn' } })) + deliver(command('item/started')) + const originalKey = rows.find(({ body }) => body.kind === 'tool-call')?.key + deliver(notification('turn/completed', { turn: { id: 'turn' } })) + expect(rows.filter(({ body }) => body.kind === 'tool-call').map(({ body }) => body)).toEqual([ + expect.objectContaining({ state: 'running' }) + ]) + expect(tracker.tasks()).toHaveLength(1) + deliver( + notification('item/commandExecution/outputDelta', { itemId: 'exec', delta: 'late output' }) + ) + translator.flush() + expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'running' } }) + deliver(command('item/completed')) + expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'completed' } }) + expect(tracker.tasks()).toEqual([]) + translator.dispose() + }) + + it('counts child shells only after the child stops covering them, without resurrecting exits', () => { + const tracker = new CodexBackgroundCommandTracker('root') + tracker.observe(command('item/started', 'child-exec', 'child')) + tracker.observe( + notification('item/started', { + item: { + type: 'commandExecution', + id: 'poll', + source: 'unifiedExecInteraction', + status: 'inProgress' + } + }) + ) + expect(tracker.tasks(new Set(['child']))).toEqual([]) + expect(tracker.tasks()).toEqual([ + { id: 'codex-command:thread:child:child-exec', kind: 'command', description: 'sleep 30' } + ]) + tracker.observe(command('item/completed', 'child-exec', 'child')) + tracker.observe(command('item/started')) + tracker.observe(command('item/completed')) + tracker.observe(command('item/started')) + expect(tracker.tasks()).toEqual([]) + }) + + it('retains live commands while recycling bounded settled history', () => { + const tracker = new CodexBackgroundCommandTracker('root') + tracker.observe(command('item/started', 'long-lived')) + for (let index = 0; index < 300; index += 1) { + tracker.observe(command('item/started', `short-${index}`)) + tracker.observe(command('item/completed', `short-${index}`)) + } + expect(tracker.tasks()).toEqual([ + { id: 'codex-command:primary:long-lived', kind: 'command', description: 'sleep 30' } + ]) + tracker.clear() + expect(tracker.tasks()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts new file mode 100644 index 00000000000..becbb18a67c --- /dev/null +++ b/src/main/codex/codex-background-command-tracker.ts @@ -0,0 +1,150 @@ +import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' +import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' +import { readRecord, readString } from './codex-item-field-readers' +import { readCodexThreadItem } from './codex-structured-item-translation' +import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention' + +const MAX_SETTLED_COMMANDS = 128 +const MAX_DESCRIPTION_CHARS = 512 + +type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } + +/** Stays within the retained bound, so read-time qualification cannot outgrow admission. */ +function qualifiedDescription(label: string, description: string | undefined): string { + return (description ? `${label} — ${description}` : label).slice(0, MAX_DESCRIPTION_CHARS) +} + +export class CodexBackgroundCommandTracker { + private readonly commands = new Map() + private readonly settled = new Map() + private liveBytes = 0 + private settledBytes = 0 + + constructor( + private readonly primaryThreadId: string, + private readonly maxMetadataBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES + ) {} + + get retainedMetadataBytes(): number { + return this.liveBytes + this.settledBytes + } + + canObserve(event: CodexBackgroundTaskEvent): boolean { + const parsed = this.parse(event) + return ( + !parsed || + parsed.completed || + this.commands.has(parsed.key) || + this.settled.has(parsed.key) || + this.liveBytes + parsed.command.bytes <= this.maxMetadataBytes + ) + } + + observe(event: CodexBackgroundTaskEvent): void { + const parsed = this.parse(event) + if (!parsed || this.settled.has(parsed.key)) { + return + } + const { key, command, completed } = parsed + const existing = this.commands.get(key) + if (completed) { + if (existing) { + this.liveBytes -= existing.bytes + this.commands.delete(key) + } + const bytes = Buffer.byteLength(key, 'utf8') + 256 + if (this.liveBytes + bytes <= this.maxMetadataBytes) { + this.settled.set(key, bytes) + this.settledBytes += bytes + } + this.trimSettled() + return + } + if (existing) { + return + } + if (this.liveBytes + command.bytes > this.maxMetadataBytes) { + throw new Error('Codex command metadata was not admitted before observation') + } + this.commands.set(key, command) + this.liveBytes += command.bytes + this.trimSettled() + } + + tasks( + coveredThreads?: ReadonlySet, + childLabel?: (threadId: string) => string | null + ): AgentSessionBackgroundTask[] { + return [...this.commands.values()] + .filter((command) => !coveredThreads?.has(command.threadId)) + .map(({ threadId, task }) => { + // The agent row carrying the child's name is gone by the time this row shows; + // unqualified it reads as a bare shell string with no owner. Resolved on read so + // a label registered after the command still lands. + const label = threadId === this.primaryThreadId ? null : childLabel?.(threadId) + return label + ? { ...task, description: qualifiedDescription(label, task.description) } + : task + }) + } + + clear(): void { + this.commands.clear() + this.settled.clear() + this.liveBytes = 0 + this.settledBytes = 0 + } + + private trimSettled(): void { + while ( + this.settled.size > MAX_SETTLED_COMMANDS || + this.retainedMetadataBytes > this.maxMetadataBytes + ) { + const oldest = this.settled.entries().next().value + if (!oldest) { + break + } + this.settled.delete(oldest[0]) + this.settledBytes -= oldest[1] + } + } + + private parse( + event: CodexBackgroundTaskEvent + ): { key: string; command: Command; completed: boolean } | null { + if (event.method !== 'item/started' && event.method !== 'item/completed') { + return null + } + const item = readCodexThreadItem(readRecord(event.params).item) + if (!item || !codexCommandOutlivesTurn(item)) { + return null + } + const key = JSON.stringify([event.threadId, item.id]) + const completed = event.method === 'item/completed' || item.status !== 'inProgress' + const description = readString(item, 'command') + ?.slice(0, MAX_DESCRIPTION_CHARS) + .replace(/\s+/g, ' ') + .trim() + const value = { + threadId: event.threadId, + task: { + id: + event.threadId === this.primaryThreadId + ? `codex-command:primary:${encodeURIComponent(item.id)}` + : `codex-command:thread:${encodeURIComponent(event.threadId)}:${encodeURIComponent(item.id)}`, + kind: 'command' as const, + ...(description ? { description } : {}) + } + } + return { + key, + completed, + command: { + ...value, + bytes: + Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(value), 'utf8') + 256 + } + } + } +} diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts new file mode 100644 index 00000000000..a2e7a7e2151 --- /dev/null +++ b/src/main/codex/codex-background-task-frames.ts @@ -0,0 +1,72 @@ +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + codexSubagentLabel, + isCodexRootAgentActivity, + readCodexSubagentActivity +} from './codex-subagent-activity' +import { codexChildTurnState } from './codex-subagent-executions' +import { readRecord } from './codex-item-field-readers' +import { readCodexThreadItem } from './codex-structured-item-translation' +import { readCodexTurnId } from './codex-structured-thread-facts' + +export type CodexBackgroundTaskFrame = + | { + kind: 'subagent' + agentThreadId: string + label: string | null + parentTurnId: string | null | undefined + } + | { + kind: 'turn' + threadId: string + turnId: string + state: NativeChatSubagentState + } + +export type CodexBackgroundTaskEvent = { + method: string + threadId: string + params: unknown +} + +export function readCodexBackgroundTaskFrame( + event: CodexBackgroundTaskEvent, + primaryThreadId: string +): CodexBackgroundTaskFrame | null { + if (event.method === 'turn/started' || event.method === 'turn/completed') { + const turnId = readCodexTurnId(event.params) + if (turnId === null) { + return null + } + return { + kind: 'turn', + threadId: event.threadId, + turnId, + state: + event.method === 'turn/started' + ? 'working' + : codexChildTurnState(readRecord(readRecord(event.params).turn).status) + } + } + if (event.method !== 'item/started' && event.method !== 'item/completed') { + return null + } + const item = readCodexThreadItem(readRecord(event.params).item) + const activity = item && readCodexSubagentActivity(item) + if ( + !activity || + activity.agentThreadId === primaryThreadId || + isCodexRootAgentActivity(activity) + ) { + return null + } + return { + kind: 'subagent', + agentThreadId: activity.agentThreadId, + label: codexSubagentLabel(activity), + parentTurnId: + activity.kind === 'started' || activity.kind === 'interacted' + ? readCodexTurnId(event.params) + : undefined + } +} diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts new file mode 100644 index 00000000000..47987fe3fc0 --- /dev/null +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { + readCodexBackgroundTaskFrame, + type CodexBackgroundTaskEvent +} from './codex-background-task-frames' + +const PRIMARY = 'parent-thread' +const PARENT_TURN = 'parent-turn' +const CHILD = 'child-thread' +const CHILD_TURN = 'child-turn' + +function turn( + method: 'turn/started' | 'turn/completed', + threadId: string, + turnId: string, + status = 'completed' +): CodexBackgroundTaskEvent { + return { method, threadId, params: { threadId, turn: { id: turnId, status } } } +} + +function activity( + kind = 'started', + parentTurn = PARENT_TURN, + child = CHILD +): CodexBackgroundTaskEvent { + return { + method: 'item/started', + threadId: PRIMARY, + params: { + threadId: PRIMARY, + turnId: parentTurn, + item: { + type: 'subAgentActivity', + id: `activity-${kind}`, + kind, + agentThreadId: child, + agentPath: '/root/count_a' + } + } + } +} + +function runningChild(): CodexBackgroundTaskTracker { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(activity()) + return tracker +} + +function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTaskEvent { + return { + method, + threadId, + params: { + threadId, + turnId: PARENT_TURN, + item: { + type: 'commandExecution', + id: 'exec-1', + processId: '71831', + source: 'unifiedExecStartup', + command: 'sleep 90', + status: method === 'item/started' ? 'inProgress' : 'completed' + } + } + } +} + +describe('readCodexBackgroundTaskFrame', () => { + it('reads activity as child metadata without inferring execution state', () => { + expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({ + kind: 'subagent', + agentThreadId: CHILD, + label: 'count_a', + parentTurnId: PARENT_TURN + }) + }) + + it('reads a child turn with its own execution identity', () => { + expect(readCodexBackgroundTaskFrame(turn('turn/started', CHILD, CHILD_TURN), PRIMARY)).toEqual({ + kind: 'turn', + threadId: CHILD, + turnId: CHILD_TURN, + state: 'working' + }) + }) + + it('does not register the primary thread even when its activity path is missing', () => { + const event = activity('interacted', PARENT_TURN, PRIMARY) + ;(event.params as { item: { agentPath?: string } }).item.agentPath = undefined + expect(readCodexBackgroundTaskFrame(event, PRIMARY)).toBeNull() + }) +}) + +describe('CodexBackgroundTaskTracker child execution ownership', () => { + it('does not claim work from an activity item without a child turn', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(activity()) + tracker.observe(activity('interacted')) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state).toBeNull() + }) + + it('reports an executing child only after the foreground turn ends', () => { + const tracker = runningChild() + expect(tracker.state).toBeNull() + expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(true) + expect(tracker.state).toEqual({ + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }] + }) + }) + + it('never settles a child when a primary turn ends', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + for (let index = 0; index < 300; index++) { + expect(tracker.observe(turn('turn/completed', PRIMARY, `later-${index}`))).toBe(false) + } + expect(tracker.state?.tasks).toHaveLength(1) + }) + + it.each(['completed', 'interrupted', 'failed'])( + 'settles on the matching child turn %s', + (status) => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, status))).toBe(true) + expect(tracker.state).toBeNull() + } + ) + + it('does not mistake late activity completion for the current child execution', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity('completed')) + expect(tracker.state?.tasks).toHaveLength(1) + }) + + it.each([PARENT_TURN, 'followup-parent'])( + 'reports follow-up work in %s using the new child turn', + (parentTurn) => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', PRIMARY, parentTurn)) + tracker.observe(activity('interacted', parentTurn)) + expect(tracker.state).toBeNull() + tracker.observe(turn('turn/started', CHILD, 'followup-child-turn')) + tracker.observe(turn('turn/completed', PRIMARY, parentTurn)) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(activity('completed')) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, 'followup-child-turn')) + expect(tracker.state).toBeNull() + } + ) + + it('keeps idle send_message activity out of the strip', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity('interacted', 'message-parent')) + tracker.observe(turn('turn/completed', PRIMARY, 'message-parent')) + expect(tracker.state).toBeNull() + }) + + it('does not invent another execution for a message to a working child', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity('interacted', 'message-parent')) + tracker.observe(turn('turn/completed', PRIMARY, 'message-parent')) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + expect(tracker.state).toBeNull() + }) + + it('retains completion delivered before child registration', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(activity()) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state).toBeNull() + }) + + it('publishes no extra state for duplicate owner or metadata events', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.observe(turn('turn/started', CHILD, CHILD_TURN))).toBe(false) + expect(tracker.observe({ ...activity(), method: 'item/completed' })).toBe(false) + expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(true) + expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(false) + }) + + it('bounds retained child history while allowing repeated completed runs', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(activity()) + for (let index = 0; index < 300; index++) { + const id = `child-turn-${index}` + tracker.observe(turn('turn/started', CHILD, id)) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, id)) + expect(tracker.state).toBeNull() + } + }) + + it('clears the roster at session teardown', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.clear()).toBe(true) + expect(tracker.state).toBeNull() + expect(tracker.clear()).toBe(false) + }) +}) + +describe('CodexBackgroundTaskTracker command integration', () => { + it('keeps a primary shell visible after the turn until its own completion', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(command()) + expect(tracker.state).toBeNull() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state?.tasks).toEqual([ + { id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' } + ]) + tracker.observe(command(PRIMARY, 'item/completed')) + expect(tracker.state).toBeNull() + }) + + it('reveals a child shell only after the child execution finishes', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(command(CHILD)) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, 'interrupted')) + expect(tracker.state?.tasks).toEqual([ + { + id: `codex-command:thread:${CHILD}:exec-1`, + kind: 'command', + description: 'count_a — sleep 90' + } + ]) + tracker.observe(command(CHILD, 'item/completed')) + expect(tracker.state).toBeNull() + }) + + it('leaves a primary shell unqualified', () => { + const tracker = runningChild() + tracker.observe(command(PRIMARY)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state?.tasks).toContainEqual({ + id: 'codex-command:primary:exec-1', + kind: 'command', + description: 'sleep 90' + }) + }) + + it('names a child shell whose label only arrives after the command', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(command(CHILD)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity()) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + expect(tracker.state?.tasks).toEqual([ + { + id: `codex-command:thread:${CHILD}:exec-1`, + kind: 'command', + description: 'count_a — sleep 90' + } + ]) + }) +}) diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts new file mode 100644 index 00000000000..2918f087809 --- /dev/null +++ b/src/main/codex/codex-background-task-tracker.ts @@ -0,0 +1,96 @@ +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from '../../shared/agent-session-wire' +import { + readCodexBackgroundTaskFrame, + type CodexBackgroundTaskEvent +} from './codex-background-task-frames' +import { CodexSubagentExecutions } from './codex-subagent-executions' +import { CodexBackgroundCommandTracker } from './codex-background-command-tracker' +import { boundSubagentField } from './codex-subagent-group-body' + +/** Projects the same child execution facts the durable roster consumes. */ +export class CodexBackgroundTaskTracker { + private primaryTurnId: string | null = null + private publishedFingerprint = '[]' + private publishedState: AgentSessionBackgroundTaskState | null = null + private readonly commands: CodexBackgroundCommandTracker + + constructor( + private readonly primaryThreadId: string, + private readonly executions = new CodexSubagentExecutions() + ) { + this.commands = new CodexBackgroundCommandTracker(primaryThreadId) + } + + get state(): AgentSessionBackgroundTaskState | null { + // Journal admission precedes observe; readers must not see its pending facts. + return this.publishedState + } + + canObserve(event: CodexBackgroundTaskEvent): boolean { + return this.commands.canObserve(event) + } + + observe(event: CodexBackgroundTaskEvent): boolean { + const itemEvent = event.method === 'item/started' || event.method === 'item/completed' + if (itemEvent) { + this.commands.observe(event) + } + const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) + if (!frame) { + return itemEvent ? this.refresh() : false + } + if (frame.kind === 'subagent') { + this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId) + } else if (frame.threadId === this.primaryThreadId) { + if (frame.state === 'working') { + this.primaryTurnId = frame.turnId + } else if (frame.turnId === this.primaryTurnId) { + this.primaryTurnId = null + } + } else { + this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) + } + return this.refresh() + } + + clear(): boolean { + this.executions.clear() + this.commands.clear() + this.primaryTurnId = null + return this.refresh() + } + + private tasks(): AgentSessionBackgroundTask[] { + if (this.primaryTurnId !== null) { + return [] + } + const children = this.executions.workingChildren() + const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({ + id: `codex-agent:${child.agentThreadId}`, + kind: 'agent', + ...(child.label ? { description: boundSubagentField(child.label, index) } : {}) + })) + return [ + ...agents, + ...this.commands.tasks(new Set(children.map((child) => child.agentThreadId)), (threadId) => + this.executions.label(threadId) + ) + ] + } + + private refresh(): boolean { + const tasks = this.tasks() + const fingerprint = JSON.stringify(tasks) + if (fingerprint === this.publishedFingerprint) { + return false + } + this.publishedFingerprint = fingerprint + this.publishedState = tasks.length + ? { state: 'monitoring', tasks, supportsStopAll: false } + : null + return true + } +} diff --git a/src/main/codex/codex-command-lifecycle.ts b/src/main/codex/codex-command-lifecycle.ts new file mode 100644 index 00000000000..dac8d7ddf97 --- /dev/null +++ b/src/main/codex/codex-command-lifecycle.ts @@ -0,0 +1,6 @@ +import type { CodexThreadItem } from './codex-structured-item-translation' + +/** Persistent exec has its own process-exit notification, independent of a turn. */ +export function codexCommandOutlivesTurn(item: CodexThreadItem): boolean { + return item.type === 'commandExecution' && item.source === 'unifiedExecStartup' +} diff --git a/src/main/codex/codex-item-stream-retention.ts b/src/main/codex/codex-item-stream-retention.ts new file mode 100644 index 00000000000..ace12f34667 --- /dev/null +++ b/src/main/codex/codex-item-stream-retention.ts @@ -0,0 +1,107 @@ +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' +import { + MAX_CODEX_ITEM_STREAM_ITEM_BYTES, + MAX_CODEX_ITEM_STREAM_STATES +} from './codex-structured-item-stream-bounds' +import type { CodexItemStreamState } from './codex-structured-item-stream-contracts' + +// Preserve the previous metadata ceiling while letting small live commands share it. +export const MAX_CODEX_ITEM_STREAM_METADATA_BYTES = + MAX_CODEX_ITEM_STREAM_STATES * MAX_CODEX_ITEM_STREAM_ITEM_BYTES + +type RetainedState = { state: CodexItemStreamState; bytes: number; persistent: boolean } + +export class CodexItemStreamRetention { + private readonly states = new Map() + private bytes = 0 + private persistentBytes = 0 + private persistentCount = 0 + + constructor(private readonly maxBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES) {} + + get retainedBytes(): number { + return this.bytes + } + + get size(): number { + return this.states.size + } + + get persistentSize(): number { + return this.persistentCount + } + + get overCapacity(): boolean { + return ( + this.bytes > this.maxBytes || + this.states.size - this.persistentCount > MAX_CODEX_ITEM_STREAM_STATES + ) + } + + get(key: string): CodexItemStreamState | undefined { + return this.states.get(key)?.state + } + + isPersistent(key: string): boolean { + return this.states.get(key)?.persistent === true + } + + canRetain(key: string, state: CodexItemStreamState): boolean { + const previous = this.states.get(key) + return ( + this.persistentBytes - + (previous?.persistent ? previous.bytes : 0) + + this.stateBytes(key, state) <= + this.maxBytes + ) + } + + retain(key: string, state: CodexItemStreamState): boolean { + if (!this.canRetain(key, state)) { + return false + } + this.forget(key) + const bytes = this.stateBytes(key, state) + const persistent = codexCommandOutlivesTurn(state.item) + this.states.set(key, { state, bytes, persistent }) + this.bytes += bytes + if (persistent) { + this.persistentBytes += bytes + this.persistentCount += 1 + } + return true + } + + oldestEvictable(): string | undefined { + for (const [key, entry] of this.states) { + if (!entry.persistent) { + return key + } + } + return undefined + } + + forget(key: string): void { + const entry = this.states.get(key) + if (!entry) { + return + } + this.bytes -= entry.bytes + if (entry.persistent) { + this.persistentBytes -= entry.bytes + this.persistentCount -= 1 + } + this.states.delete(key) + } + + clear(): void { + this.states.clear() + this.bytes = 0 + this.persistentBytes = 0 + this.persistentCount = 0 + } + + private stateBytes(key: string, state: CodexItemStreamState): number { + return Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(state), 'utf8') + 256 + } +} diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts new file mode 100644 index 00000000000..2acb357cd71 --- /dev/null +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { CodexBackgroundCommandTracker } from './codex-background-command-tracker' +import { + CodexItemStreamRetention, + MAX_CODEX_ITEM_STREAM_METADATA_BYTES +} from './codex-item-stream-retention' +import { CodexJournalItems } from './codex-structured-journal-items' +import { settleCodexJournalTurn } from './codex-structured-journal-settlement' + +function command(threadId: string, id: string, method = 'item/started') { + return { + threadId, + method, + params: { + turnId: 'turn', + item: { + type: 'commandExecution', + id, + source: 'unifiedExecStartup', + command: `sleep 30 # ${threadId}/${id}`, + cwd: '/workspace', + status: method === 'item/completed' ? 'completed' : 'inProgress', + ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'BEFORE\nAFTER\n' } : {}) + } + } + } +} + +function fixture(maxMetadataBytes?: number) { + const rows = new Map() + const scheduled = new Set<() => void>() + const sink = { + appendItem: ( + identity: Parameters[0], + body: AgentJournalItemBody + ) => { + rows.set(agentJournalItemKey(identity), body) + }, + appendTombstone() {}, + publish() {} + } + const items = new CodexJournalItems( + { + sink, + maxMetadataBytes, + schedule: (run) => { + scheduled.add(run) + return () => { + scheduled.delete(run) + } + } + }, + () => 'turn', + () => {} + ) + return { items, sink, rows, scheduled } +} + +describe('persistent command retention', () => { + it('does not rebuild unchanged persistent output on every later lifecycle flush', () => { + const { items } = fixture() + const event = command('root', 'quiet') + items.handle(event) + items.streams.handle('root', 'item/commandExecution/outputDelta', { + itemId: 'quiet', + delta: 'retained-prefix' + }) + items.streams.flush() + const originalJoin = Array.prototype.join + let retainedJoins = 0 + const spy = vi + .spyOn(Array.prototype, 'join') + .mockImplementation(function (this: unknown[], separator) { + if (this[0] === 'retained-prefix') { + retainedJoins += 1 + } + return originalJoin.call(this, separator) + }) + try { + for (let index = 0; index < 100; index += 1) { + items.streams.flush() + } + } finally { + spy.mockRestore() + items.dispose() + } + expect(retainedJoins).toBe(0) + }) + + it('retains 448 live commands through completed turns, late output, and process completion', () => { + const { items, sink, rows, scheduled } = fixture() + const tracker = new CodexBackgroundCommandTracker('thread-0') + const events = Array.from({ length: 7 }, (_, thread) => + Array.from({ length: 64 }, (_, index) => command(`thread-${thread}`, `exec-${index}`)) + ).flat() + for (const event of events) { + expect(tracker.canObserve(event)).toBe(true) + expect(items.handle(event)).toMatchObject({ admission: { accepted: true } }) + tracker.observe(event) + expect( + items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', { + turnId: 'turn', + itemId: event.params.item.id, + delta: 'BEFORE\n' + }).admission + ).toEqual({ accepted: true }) + } + for (let thread = 0; thread < 7; thread += 1) { + expect( + settleCodexJournalTurn({ + sessionId: 'session', + threadId: `thread-${thread}`, + turnId: 'turn', + sink, + streams: items.streams, + activeItems: items.activeItems + }) + ).toEqual({ accepted: true }) + } + expect(items.activeItems.size).toBe(448) + expect(items.streams.persistentCount).toBe(448) + expect(tracker.tasks()).toHaveLength(448) + expect(tracker.retainedMetadataBytes).toBeLessThan(256 * 1024) + for (const event of events) { + items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', { + turnId: 'turn', + itemId: event.params.item.id, + delta: 'AFTER\n' + }) + } + expect(items.streams.flush()).toBe(true) + for (const event of events) { + const key = agentJournalItemKey({ + provider: 'orca', + clientMessageId: `codex-item:${event.threadId}:${event.params.item.id}` + }) + expect(rows.get(key)).toMatchObject({ + state: 'running', + input: { command: event.params.item.command, cwd: '/workspace' }, + output: { head: 'BEFORE\nAFTER\n' } + }) + const completed = command(event.threadId, event.params.item.id, 'item/completed') + expect(items.handle(completed)).toMatchObject({ admission: { accepted: true } }) + tracker.observe(completed) + expect(rows.get(key)).toMatchObject({ + state: 'completed', + output: { head: 'BEFORE\nAFTER\n' } + }) + expect(items.streams.snapshot(event.threadId, event.params.item.id)).toBeNull() + } + expect(items.activeItems.size).toBe(0) + expect(items.streams.persistentCount).toBe(0) + expect(tracker.tasks()).toEqual([]) + expect(tracker.retainedMetadataBytes).toBeLessThan(64 * 1024) + items.dispose() + tracker.clear() + expect(tracker.retainedMetadataBytes).toBe(0) + expect(scheduled.size).toBe(0) + }) + + it('rejects command metadata exhaustion before appending or evicting live state and frees it on completion', () => { + const { items, rows } = fixture(800) + const first = command('root', 'first') + const second = command('root', 'second') + expect(items.handle(first)).toMatchObject({ admission: { accepted: true } }) + const prior = [...rows] + expect(items.handle(second)).toMatchObject({ admission: { accepted: false, reason: 'failed' } }) + expect([...rows]).toEqual(prior) + expect(items.activeItems.size).toBe(1) + expect(items.handle(command('root', 'first', 'item/completed'))).toMatchObject({ + admission: { accepted: true } + }) + expect(items.handle(second)).toMatchObject({ admission: { accepted: true } }) + items.dispose() + }) + + it('accounts metadata bytes instead of interpreting the item count as liveness', () => { + const retention = new CodexItemStreamRetention() + for (let index = 0; index < 448; index += 1) { + const item = command('root', `exec-${index}`).params.item + expect( + retention.retain(item.id, { + item, + identity: { provider: 'orca', clientMessageId: item.id } + }) + ).toBe(true) + } + expect(retention.size).toBe(448) + expect(retention.retainedBytes).toBeLessThan(256 * 1024) + expect(retention.retainedBytes).toBeLessThan(MAX_CODEX_ITEM_STREAM_METADATA_BYTES) + expect(retention.overCapacity).toBe(false) + expect(retention.oldestEvictable()).toBeUndefined() + retention.clear() + expect(retention.retainedBytes).toBe(0) + expect(retention.persistentSize).toBe(0) + }) + + it('retains startup provenance when large command metadata is bounded', () => { + const { items, sink } = fixture() + const event = command('root', 'large') + event.params.item.command = 'x'.repeat(128 * 1024) + expect(items.handle(event)).toMatchObject({ admission: { accepted: true } }) + expect( + settleCodexJournalTurn({ + sessionId: 'session', + threadId: 'root', + turnId: 'turn', + sink, + streams: items.streams, + activeItems: items.activeItems + }) + ).toEqual({ accepted: true }) + expect(items.activeItems.size).toBe(1) + expect(items.streams.persistentCount).toBe(1) + items.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-item-stream-bounds.ts b/src/main/codex/codex-structured-item-stream-bounds.ts index 117b9b3a911..46104c54360 100644 --- a/src/main/codex/codex-structured-item-stream-bounds.ts +++ b/src/main/codex/codex-structured-item-stream-bounds.ts @@ -30,6 +30,7 @@ export function boundStreamItem(item: Record): Record void + readonly persistentCount: number + canTrack: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean + track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean handle: ( threadId: string, method: string, diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index a765f8339da..e67263f133d 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -1,5 +1,6 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import { CodexItemStreamRetention } from './codex-item-stream-retention' import { codexJournalItem, codexStreamingJournalItem, @@ -10,7 +11,6 @@ import { MAX_CODEX_ITEM_STREAM_PENDING_PATCHES, MAX_CODEX_ITEM_STREAM_PENDING_PATCH_BYTES, MAX_CODEX_ITEM_STREAM_RETAINED_BYTES, - MAX_CODEX_ITEM_STREAM_STATES, boundStreamItem, pendingPatchBytes } from './codex-structured-item-stream-bounds' @@ -47,8 +47,9 @@ export { export function createCodexStructuredItemStreams( deps: CodexItemStreamDeps ): CodexStructuredItemStreams { - const states = new Map() + const states = new CodexItemStreamRetention(deps.maxMetadataBytes) const checkpointLengths = new Map() + const pendingCheckpoints = new Set() // Patch updates are authoritative item snapshots. Keep the latest rejected // snapshot until the journal admits it; unlike streamed deltas, there is no // coalescer timer to retry these events for us. @@ -57,8 +58,9 @@ export function createCodexStructuredItemStreams( const forgetState = (key: string): void => { coalescer.forget(key) - states.delete(key) + states.forget(key) checkpointLengths.delete(key) + pendingCheckpoints.delete(key) const pending = pendingPatches.get(key) if (pending) { retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending)) @@ -67,8 +69,8 @@ export function createCodexStructuredItemStreams( } const trimStates = (): void => { - while (states.size > MAX_CODEX_ITEM_STREAM_STATES) { - const oldest = states.keys().next().value + while (states.overCapacity) { + const oldest = states.oldestEvictable() if (typeof oldest !== 'string') { break } @@ -124,6 +126,7 @@ export function createCodexStructuredItemStreams( const state = states.get(key) if (state && append(state, text)) { checkpointLengths.set(key, text.length) + pendingCheckpoints.delete(key) return true } return false @@ -133,6 +136,7 @@ export function createCodexStructuredItemStreams( windowMs: deps.coalesceMs, maxRetainedBytes: deps.maxRetainedBytes, maxTotalRetainedBytes: deps.maxTotalRetainedBytes, + isProtected: (key) => states.isPersistent(key), schedule: deps.schedule, emit: (key, text) => { return persist(key, text, false) @@ -144,7 +148,7 @@ export function createCodexStructuredItemStreams( itemId: string, type: string, params: unknown - ): CodexItemStreamState => { + ): CodexItemStreamState | null => { const key = codexStructuredItemKey(threadId, itemId) const existing = states.get(key) if (existing) { @@ -152,36 +156,25 @@ export function createCodexStructuredItemStreams( } const item = { type, id: itemId } const state = { item, identity: deps.identityFor(threadId, params, item) } - states.set(key, state) + if (!states.retain(key, state)) { + return null + } trimStates() return state } const flush = (): boolean => { let flushed = coalescer.flushAll() - for (const key of states.keys()) { + for (const key of pendingCheckpoints) { const snapshot = coalescer.snapshot(key) if (snapshot && checkpointLengths.get(key) !== snapshot.text.length) { flushed = persist(key, snapshot.text, true) && flushed + } else { + pendingCheckpoints.delete(key) } } - for (const [key, pending] of pendingPatches) { - const admission = deps.sink.tryAppendItem - ? deps.sink.tryAppendItem(pending.identity, pending.body) - : (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const }) - if (!admission.accepted) { - flushed = false - continue - } - const published = deps.sink.tryPublish - ? deps.sink.tryPublish() - : (deps.sink.publish(), { accepted: true as const }) - if (!published.accepted) { - flushed = false - continue - } - retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending)) - pendingPatches.delete(key) + for (const key of pendingPatches.keys()) { + flushed = flushPatch(key).accepted && flushed } return flushed } @@ -209,11 +202,21 @@ export function createCodexStructuredItemStreams( } return { + get persistentCount() { + return states.persistentSize + }, + canTrack: (threadId, item, identity) => + states.canRetain(codexStructuredItemKey(threadId, item.id), { + item: boundStreamItem(item) as CodexThreadItem, + identity + }), track: (threadId, item, identity) => { const key = codexStructuredItemKey(threadId, item.id) - states.delete(key) - states.set(key, { item: boundStreamItem(item) as CodexThreadItem, identity }) + if (!states.retain(key, { item: boundStreamItem(item) as CodexThreadItem, identity })) { + return false + } trimStates() + return true }, handle: (threadId, method, params) => { const paramsRecord = readCodexItemStreamRecord(params) @@ -230,6 +233,9 @@ export function createCodexStructuredItemStreams( const key = codexStructuredItemKey(threadId, itemId) const streamFlushed = coalescer.flush(key) const state = ensureState(threadId, itemId, 'fileChange', params) + if (!state) { + return { handled: true, admission: { accepted: false, reason: 'failed' } } + } state.item = { ...state.item, changes: paramsRecord.changes } const translated = codexJournalItem(state.item) if (translated.body) { @@ -269,9 +275,14 @@ export function createCodexStructuredItemStreams( return { handled: true, admission: { accepted: true } } } const state = ensureState(threadId, itemId, type ?? 'reasoning', params) + if (!state) { + return { handled: true, admission: { accepted: false, reason: 'failed' } } + } const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta if (typeof delta === 'string') { - const accepted = coalescer.append(codexStructuredItemKey(threadId, state.item.id), delta) + const key = codexStructuredItemKey(threadId, state.item.id) + pendingCheckpoints.add(key) + const accepted = coalescer.append(key, delta) if (!accepted) { return { handled: true, admission: { accepted: false, reason: 'backpressure' } } } @@ -287,6 +298,7 @@ export function createCodexStructuredItemStreams( coalescer.dispose() states.clear() checkpointLengths.clear() + pendingCheckpoints.clear() pendingPatches.clear() retainedPatchBytes = 0 }, diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 45afb0c9fa5..201df58bc90 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -584,6 +584,16 @@ describe('codex item bodies', () => { }) }) + it('preserves plan prose documents byte-for-byte as status text', () => { + const text = + ' # Implementation plan\r\n\r\n- [ ] Preserve prose\r\n- [x] Keep café → 日本語\r\n\r\n```ts\r\nconst task = "pending"\r\n```\r\n ' + + expect(codexJournalItem({ type: 'plan', id: 'plan-document', text })).toEqual({ + body: { kind: 'status', text, presentation: 'plan-document' }, + handled: true + }) + }) + it('renders reasoning as status and exposes an unknown item as a provider frame', () => { expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ kind: 'status', diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index acd04a4cf30..56a543fe001 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -1,11 +1,13 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import type { CodexSubagentExecutions } from './codex-subagent-executions' export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void primaryThreadId?: () => string | null + subagentExecutions?: CodexSubagentExecutions coalesceMs?: number maxRetainedBytes?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index f984bc1d9bf..62091580da3 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -11,7 +11,8 @@ import { type CodexThreadItem } from './codex-structured-item-translation' import { createCodexStructuredItemStreams } from './codex-structured-item-streams' -import { codexStructuredItemKey } from './codex-structured-item-stream-bounds' +import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds' +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import type { CodexItemTranslation, CodexJournalTranslationAdmission, @@ -40,7 +41,7 @@ export class CodexJournalItems { private readonly deps: Pick< CodexJournalTranslatorDeps, 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' - >, + > & { maxMetadataBytes?: number }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void ) { @@ -49,6 +50,7 @@ export class CodexJournalItems { coalesceMs: deps.coalesceMs, maxRetainedBytes: deps.maxRetainedBytes, schedule: deps.schedule, + maxMetadataBytes: deps.maxMetadataBytes, identityFor: (threadId, params, item) => { const turnId = readCodexTurnId(params) ?? this.activeTurn(threadId) return this.identityFor(threadId, turnId, item) @@ -81,6 +83,12 @@ export class CodexJournalItems { if (item.type === 'contextCompaction' && event.method === 'item/started') { return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } + if ( + event.method !== 'item/completed' && + !this.streams.canTrack(event.threadId, item, identity) + ) { + return { handled: true, admission: { accepted: false, reason: 'failed' } } + } const translated = codexJournalItem(item) const command = readCodexJournalString(item, 'command') if (command) { @@ -157,12 +165,15 @@ export class CodexJournalItems { item: CodexThreadItem, identity: AgentJournalItemIdentity ): void { - this.streams.track(threadId, item, identity) + const retainedItem = codexCommandOutlivesTurn(item) + ? (boundStreamItem(item) as CodexThreadItem) + : item + this.streams.track(threadId, retainedItem, identity) this.activeItems.set(codexStructuredItemKey(threadId, item.id), { threadId, turnId, identity, - item + item: retainedItem }) } @@ -194,8 +205,10 @@ export class CodexJournalItems { } private trimActiveState(): CodexJournalTranslationAdmission { - while (this.activeItems.size > MAX_CODEX_ACTIVE_ITEMS) { - const oldest = this.activeItems.keys().next().value + while (this.activeItems.size - this.streams.persistentCount > MAX_CODEX_ACTIVE_ITEMS) { + const oldest = [...this.activeItems].find( + ([, active]) => !codexCommandOutlivesTurn(active.item) + )?.[0] if (typeof oldest !== 'string') { break } diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 2b8eb627d4f..8c8bf091839 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -20,6 +20,7 @@ import { } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' export type CodexActiveJournalItem = { threadId: string @@ -118,6 +119,9 @@ export function settleCodexJournalTurn(input: { if (active.threadId !== input.threadId || active.turnId !== input.turnId) { continue } + if (codexCommandOutlivesTurn(active.item)) { + continue + } const streamed = input.streams.snapshot(active.threadId, active.item.id) const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) diff --git a/src/main/codex/codex-structured-journal-translation-frames.ts b/src/main/codex/codex-structured-journal-translation-frames.ts index 22dc516b210..d4cb2c825d9 100644 --- a/src/main/codex/codex-structured-journal-translation-frames.ts +++ b/src/main/codex/codex-structured-journal-translation-frames.ts @@ -6,6 +6,8 @@ * than as the shape checks each arm performs. */ +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import type { CodexJournalItems } from './codex-structured-journal-items' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' import { settleCodexOversizedNotification } from './codex-structured-journal-settlement' import { @@ -41,3 +43,23 @@ export function settleCodexOversizedNotificationFrame(input: { }) : null } + +export function createCodexOversizedNotificationSettler( + deps: { sink: OversizedInput['sink'] }, + items: Pick +) { + return settleOversizedNotification + + /** Settles the item a notification the transport refused to carry left + * mid-flight; null when the frame is not one. */ + function settleOversizedNotification( + event: Extract + ): CodexJournalTranslationAdmission | null { + return settleCodexOversizedNotificationFrame({ + ...event, + sink: deps.sink, + streams: items.streams, + activeItems: items.activeItems + }) + } +} diff --git a/src/main/codex/codex-structured-journal-translation-subagents.test.ts b/src/main/codex/codex-structured-journal-translation-subagents.test.ts index bf5cdffa5a9..8db30d31b21 100644 --- a/src/main/codex/codex-structured-journal-translation-subagents.test.ts +++ b/src/main/codex/codex-structured-journal-translation-subagents.test.ts @@ -59,6 +59,22 @@ function deliverActivity( translator: ReturnType, params: unknown ): void { + const item = (params as { item: { kind: string; agentThreadId: string } }).item + if (item.kind === 'started' || item.kind === 'completed') { + translator.handle({ + type: 'notification', + sessionId: SESSION_ID, + threadId: item.agentThreadId, + method: item.kind === 'started' ? 'turn/started' : 'turn/completed', + params: { + threadId: item.agentThreadId, + turn: { + id: `execution:${item.agentThreadId}`, + status: item.kind === 'started' ? 'inProgress' : 'completed' + } + } + }) + } translator.handle(notification('item/started', params)) translator.handle(notification('item/completed', params)) } diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 00b90d7ffc8..cea129e5cdf 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -19,7 +19,7 @@ import { settleCodexJournalSession, settleCodexJournalTurn } from './codex-structured-journal-settlement' -import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames' +import { createCodexOversizedNotificationSettler } from './codex-structured-journal-translation-frames' import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' @@ -58,13 +58,15 @@ export function createCodexJournalTranslator( (threadId) => activeTurns.current(threadId), (threadId, turnId) => genericFrames.suppress(threadId, turnId) ) + const settleOversizedNotification = createCodexOversizedNotificationSettler(deps, items) const prompts = new CodexJournalPrompts(deps, (threadId, itemId) => items.detailFor(threadId, itemId) ) const subagents = new CodexSubagentRoster({ sink: deps.sink, primaryThreadId: () => deps.primaryThreadId?.() ?? null, - activeTurn: (threadId) => activeTurns.current(threadId) + activeTurn: (threadId) => activeTurns.current(threadId), + ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) }) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } @@ -174,16 +176,17 @@ export function createCodexJournalTranslator( } return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } - if (event.method === 'turn/started') { - return startTurn(event) + if (event.method === 'turn/started' || event.method === 'turn/completed') { + const childAdmission = subagents.handleTurnEvent(event) + if (!childAdmission.accepted) { + return childAdmission + } + return event.method === 'turn/started' ? startTurn(event) : completeTurn(event) } const compaction = compactions.handle(event) if (compaction) { return publishActivity(event, compaction) } - if (event.method === 'turn/completed') { - return completeTurn(event) - } if (event.method === CODEX_TOKEN_USAGE_METHOD) { // Classified `status-chrome`, so the generic-frame path swallows it // before the journal. The roster consumes it as a typed notification. @@ -240,19 +243,6 @@ export function createCodexJournalTranslator( } } - /** Settles the item a notification the transport refused to carry left - * mid-flight; null when the frame is not one. */ - function settleOversizedNotification( - event: Extract - ): CodexJournalTranslationAdmission | null { - return settleCodexOversizedNotificationFrame({ - ...event, - sink: deps.sink, - streams: items.streams, - activeItems: items.activeItems - }) - } - function startTurn( event: Extract ): CodexJournalTranslationAdmission { diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 8c8b39ca48b..3f7f130e173 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -8,6 +8,8 @@ import { closeFailedCodexAcquisition, stopSupersededCodexAcquisition } from './codex-structured-acquisition-lifecycle' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openCodexAppServerConnection } from './codex-app-server-connection' import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' @@ -74,10 +76,12 @@ export async function acquireCodexStructuredSession(input: { acquireInput.identity.providerHandle.kind === 'codex' ? acquireInput.identity.providerHandle.threadId : null + const subagentExecutions = new CodexSubagentExecutions() const translator = acquireInput.events ? createCodexJournalTranslator({ sink: acquireInput.events, primaryThreadId: () => primaryThreadId, + subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey) => acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey) }) @@ -138,6 +142,7 @@ export async function acquireCodexStructuredSession(input: { connection: acquisition.connection, error, prompts: acquisition.prompts, + onBackgroundTasksChanged: deps.onBackgroundTasksChanged, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) }) } finally { @@ -199,6 +204,7 @@ export async function acquireCodexStructuredSession(input: { reportedOptions: reportedCodexThreadOptions(opened), turnIdWaiters: [], translator, + backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), forceCloseUnexpected: (reason) => input.forceCloseUnexpected( sessionId, diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index ebd7c3331bf..8b0e649b177 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -16,11 +16,7 @@ import type { CodexJournalTranslationAdmission } from './codex-structured-journa import { answerCodexPrompt } from './codex-structured-prompt-replies' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { supportsCodexStructuredLocation } from './codex-structured-location-support' -import { - closeAllCodexSessions, - closeCodexPublishedSession, - closeCodexSession -} from './codex-structured-session-close' +import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { applyCodexStructuredSessionOption, readLiveCodexSessionOptions @@ -54,6 +50,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap private readonly acquisitions = new CodexAcquisitionRegistry() private readonly turnCancellation: CodexStructuredTurnCancellation private readonly notificationRetries: ReturnType + private readonly teardown: CodexStructuredSessionTeardown constructor(private readonly deps: CodexStructuredSessionAdapterDeps) { this.notificationRetries = createCodexStructuredNotificationRetry({ @@ -61,6 +58,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap translate: (sessionId, session, method, params) => this.translateNotification(sessionId, session, method, params) }) + this.teardown = new CodexStructuredSessionTeardown({ + sessions: this.sessions, + acquisitions: this.acquisitions, + ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), + ...(deps.onBackgroundTasksChanged + ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } + : {}), + forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null) + }) this.turnCancellation = new CodexStructuredTurnCancellation({ captureTurnProcesses: deps.captureTurnProcesses, terminateTurnProcesses: deps.terminateTurnProcesses, @@ -92,7 +98,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap handleUnhandledFrame: (sessionId, kind, payload) => this.handleUnhandledFrame(sessionId, kind, payload), forceCloseUnexpected: (sessionId, fence, acquisitionGeneration, reason) => - this.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason) + this.teardown.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason) }) /** Buffers pre-publication events and drops events from superseded children. */ @@ -134,12 +140,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap session: CodexSession, event: CodexStructuredSessionEvent ): CodexJournalTranslationAdmission { + if (event.type === 'notification' && !session.backgroundTasks.canObserve(event)) { + return { accepted: false, reason: 'failed' } + } const admission = session.translator?.handle(event) ?? { accepted: true } if (!admission.accepted) { return admission } if (event.type === 'notification') { this.compactions.codex(event.sessionId, event.method, event.params) + // After the admission check, so a refused frame is observed by the strip + // only on the retry that also reaches the journal. + if (session.backgroundTasks.observe(event)) { + this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state) + } } if (event.type === 'ended') { this.compactions.ended(event.sessionId) @@ -167,6 +181,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } + backgroundTaskState: NonNullable = ( + sessionId + ) => this.sessions.get(sessionId)?.backgroundTasks.state + bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void => this.sessions .get(sessionId) @@ -267,59 +285,12 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap identity: AgentSessionJournalIdentity }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = async (sessionId: string): Promise => { - const closed = await closeCodexSession( - sessionId, - this.sessions, - this.acquisitions, - this.deps.onEvent - ) - if (closed) { - this.notificationRetries.clear(sessionId, null) - } - return closed - } - forceCloseSession = async (sessionId: string): Promise => { - const closed = await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, { - allowFailedSettlement: true, - requestedClose: false - }) - if (closed) { - this.notificationRetries.clear(sessionId, null) - } - return closed - } - - private forceCloseUnexpected( - sessionId: string, - fence: number, - acquisitionGeneration: string, - reason: Error - ): Promise { - const session = this.sessions.get(sessionId) - if ( - !session || - session.ended || - session.fence !== fence || - session.acquisitionGeneration !== acquisitionGeneration - ) { - return Promise.resolve(false) - } - return closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, { - allowFailedSettlement: true, - requestedClose: false, - expectedFence: fence, - expectedAcquisitionGeneration: acquisitionGeneration, - unexpectedReason: reason - }) - } - disposeSession = (sessionId: string): Promise => this.closeSession(sessionId) - closeAll = (): Promise => - closeAllCodexSessions(this.sessions, this.acquisitions, (sessionId) => - this.disposeSession(sessionId) - ) + closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) + disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => - this.closeSession(input.sessionId) + this.teardown.close(input.sessionId) private session(sessionId: string): CodexSession { return requireLiveCodexSession(this.sessions, sessionId) diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts new file mode 100644 index 00000000000..aa6156027e8 --- /dev/null +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +// Proves the strip is actually REACHED from provider traffic: the tracker is +// unit-tested separately, and a producer that is correct but unwired publishes +// nothing while every one of its own tests stays green. + +const THREAD_ID = '01a07d54-3785-71d0-b065-82c8ebbc572a' +const PARENT_TURN = '01a07d54-37be-72e1-8206-8f0c23dd2cef' +const CHILD_ID = '01a07d54-5523-78a3-91f5-e0acb1dab065' + +/** A three-route stand-in, deliberately smaller than the full adapter harness: + * this suite only needs a thread and a notification pipe. */ +function fakeCodex(close: () => Promise = async () => true): { + handlers: () => CodexAppServerConnectionHandlers + openConnection: typeof openCodexAppServerConnection +} { + let live: CodexAppServerConnectionHandlers = {} + const openConnection = (async (_launch, handlers = {}) => { + live = handlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => + method === 'thread/start' ? { thread: { id: THREAD_ID, path: null } } : {}, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close + } as unknown as CodexAppServerConnection + return connection + }) as typeof openCodexAppServerConnection + return { handlers: () => live, openConnection } +} + +function identity(sessionId: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +function subagentNotification(kind: string): { method: string; params: unknown } { + return { + method: 'item/started', + params: { + item: { + type: 'subAgentActivity', + id: 'call_1', + kind, + agentThreadId: CHILD_ID, + agentPath: '/root/count_a' + }, + threadId: THREAD_ID, + turnId: PARENT_TURN + } + } +} + +const TURN_COMPLETED = { + method: 'turn/completed', + params: { threadId: THREAD_ID, turn: { id: PARENT_TURN, status: 'completed' } } +} + +async function adapterWithSession( + published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[], + events?: StructuredAgentSessionEventSink, + onEvent?: (event: CodexStructuredSessionEvent) => void, + close?: () => Promise +): Promise<{ adapter: CodexStructuredSessionAdapter; codex: ReturnType }> { + const codex = fakeCodex(close) + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent, + onBackgroundTasksChanged: (sessionId, state) => published.push({ sessionId, state }) + }) + await adapter.acquire({ + identity: identity('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events + }) + codex.handlers().onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: PARENT_TURN, status: 'inProgress' } + }) + codex.handlers().onNotification?.('turn/started', { + threadId: CHILD_ID, + turn: { id: 'child-turn', status: 'inProgress' } + }) + return { adapter, codex } +} + +describe('codex background tasks reach the strip', () => { + it('clears natural-exit state before lifecycle observers can read it', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const onEvent = vi.fn() + const { adapter, codex } = await adapterWithSession(published, undefined, onEvent) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + expect(adapter.backgroundTaskState('session-1')?.tasks).toHaveLength(1) + published.length = 0 + onEvent.mockImplementation((event: CodexStructuredSessionEvent) => { + if (event.type === 'ended') { + expect(adapter.backgroundTaskState('session-1')).toBeNull() + } + }) + codex.handlers().onExit?.(new Error('provider exited')) + expect(adapter.backgroundTaskState('session-1')).toBeNull() + expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + await adapter.closeSession('session-1') + }) + + it('keeps live tasks when close is refused', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const close = vi.fn(async () => false) + const { adapter, codex } = await adapterWithSession(published, undefined, undefined, close) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + const before = adapter.backgroundTaskState('session-1') + published.length = 0 + expect(await adapter.closeSession('session-1')).toBe(false) + expect(adapter.backgroundTaskState('session-1')).toEqual(before) + expect(published).toEqual([]) + close.mockResolvedValue(true) + await adapter.closeSession('session-1') + }) + + it('does not let an old exit callback clear a replacement roster', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const { adapter, codex } = await adapterWithSession(published) + const oldExit = codex.handlers().onExit + await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' }) + codex.handlers().onNotification?.('turn/started', { + threadId: CHILD_ID, + turn: { id: 'replacement-child-turn' } + }) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + const before = adapter.backgroundTaskState('session-1') + expect(before?.tasks).toHaveLength(1) + published.length = 0 + oldExit?.(new Error('old provider exited late')) + expect(adapter.backgroundTaskState('session-1')).toEqual(before) + expect(published).toEqual([]) + await adapter.closeSession('session-1') + }) + + it('recovers the exact provider generation when command metadata cannot be admitted', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const observed: CodexStructuredSessionEvent[] = [] + const appendItem = vi.fn() + const { adapter, codex } = await adapterWithSession( + published, + { appendItem, appendTombstone: () => {}, publish: () => {} }, + (event) => observed.push(event) + ) + appendItem.mockClear() + observed.length = 0 + const admission = vi + .spyOn(CodexBackgroundTaskTracker.prototype, 'canObserve') + .mockReturnValue(false) + try { + codex.handlers().onNotification?.('item/started', { + threadId: THREAD_ID, + turnId: PARENT_TURN, + item: { + type: 'commandExecution', + id: 'over-budget', + command: 'sleep 1', + source: 'unifiedExecStartup', + status: 'inProgress' + } + }) + await vi.waitFor(() => expect(adapter.backgroundTaskState('session-1')).toBeUndefined()) + expect(appendItem.mock.calls.map((call) => call[1])).toEqual([ + { kind: 'status', text: 'Provider exited: notification admission failed (failed)' } + ]) + expect(observed).toEqual([ + expect.objectContaining({ + type: 'ended', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: expect.any(String), + reason: 'notification admission failed (failed)' + }) + ]) + expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + } finally { + admission.mockRestore() + await adapter.closeSession('session-1') + } + }) + + it('publishes the orphaned fan-out once the spawning turn completes', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const { adapter, codex } = await adapterWithSession(published) + + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + // The child is still inside the turn, so the strip stays silent. + expect(published).toEqual([]) + expect(adapter.backgroundTaskState('session-1')).toBeNull() + + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + + expect(published).toEqual([ + { + sessionId: 'session-1', + state: { + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }] + } + } + ]) + expect(adapter.backgroundTaskState('session-1')).toEqual(published[0].state) + }) + + it('clears the strip when the session closes', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const { adapter, codex } = await adapterWithSession(published) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + published.length = 0 + + expect(await adapter.closeSession('session-1')).toBe(true) + + // Explicit null, not silence: the reader answers `undefined` once the + // session is gone, which every channel treats as "unchanged". + expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + expect(adapter.backgroundTaskState('session-1')).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index b04e7bc2540..45bfbbf45a1 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -10,6 +10,7 @@ import { type CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { handleCodexSessionExit } from './codex-structured-session-close' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' @@ -90,6 +91,7 @@ describe('Codex structured session close lifecycle', () => { } as unknown as NonNullable const session = { connection, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, requestedClose: false, fence: 7, diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index db723096177..5b29dc6c056 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -4,6 +4,7 @@ import { cancelCodexAcquisitionAttempt, type CodexAcquisitionRegistry, type CodexSession, + type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -16,6 +17,7 @@ export function handleCodexSessionExit(input: { prompts?: CodexSession['prompts'] allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void + onBackgroundTasksChanged?: CodexStructuredSessionAdapterDeps['onBackgroundTasksChanged'] }): boolean { const session = input.sessions.get(input.sessionId) if (!session || session.connection !== input.connection || session.ended) { @@ -43,6 +45,8 @@ export function handleCodexSessionExit(input: { event.settlementRetryRequired = true } session.ended = true + session.backgroundTasks.clear() + input.onBackgroundTasksChanged?.(input.sessionId, null) session.unbindReadingControl?.() input.onEvent?.(event) session.prompts.clear() diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 1f28ff5e197..b081e52dd6a 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -7,6 +7,7 @@ import { reportedCodexThreadOptions, restoredCodexSessionOptions } from './codex-structured-session-options' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' function optionSession(request: CodexAppServerConnection['request']): CodexSession { @@ -20,6 +21,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi respondWithError: () => {}, close: async () => true }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, requestedClose: false, fence: 1, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 12ba28d712f..d004b0006e6 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -6,6 +6,8 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -44,6 +46,10 @@ export type CodexStructuredSessionAdapterDeps = { /** Host capability seam; production uses the native Windows process table. */ isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void + onBackgroundTasksChanged?: ( + sessionId: string, + state: AgentSessionBackgroundTaskState | null + ) => void openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise mintLinkId?: () => string @@ -73,6 +79,8 @@ export type CodexSession = { reportedOptions: { model?: string; effort?: string } turnIdWaiters: ((turnId: string) => void)[] translator: CodexJournalTranslator | null + /** Ephemeral roster behind the background-tasks strip; never durable state. */ + backgroundTasks: CodexBackgroundTaskTracker unbindReadingControl?: () => void /** Terminates this exact child as an unexpected death and enters host recovery. */ forceCloseUnexpected?: (reason: Error) => Promise diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts new file mode 100644 index 00000000000..dcd38eb82fe --- /dev/null +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -0,0 +1,94 @@ +// Stopping one Codex app-server child, in the four ways the host asks for it. +// +// Every path funnels through `settled` so the ephemeral surfaces a closed +// session owns are cleared exactly once, and only when the child was actually +// proven stopped — a refused close leaves the session indexed for a retry. + +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import { + closeAllCodexSessions, + closeCodexPublishedSession, + closeCodexSession +} from './codex-structured-session-close' +import type { + CodexAcquisitionRegistry, + CodexSession, + CodexStructuredSessionEvent +} from './codex-structured-session-state' + +export type CodexStructuredSessionTeardownDeps = { + sessions: Map + acquisitions: CodexAcquisitionRegistry + onEvent?: (event: CodexStructuredSessionEvent) => void + onBackgroundTasksChanged?: ( + sessionId: string, + state: AgentSessionBackgroundTaskState | null + ) => void + forgetNotificationRetries: (sessionId: string) => void +} + +export class CodexStructuredSessionTeardown { + constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} + + close = async (sessionId: string): Promise => { + const closed = await closeCodexSession( + sessionId, + this.deps.sessions, + this.deps.acquisitions, + this.deps.onEvent + ) + return this.settled(sessionId, closed) + } + + forceClose = async (sessionId: string): Promise => { + const closed = await closeCodexPublishedSession( + this.deps.sessions, + sessionId, + this.deps.onEvent, + { allowFailedSettlement: true, requestedClose: false } + ) + return this.settled(sessionId, closed) + } + + /** Terminates this exact child as an unexpected death. Every ownership check + * stays here so a stale caller cannot close a replacement child. */ + forceCloseUnexpected = ( + sessionId: string, + fence: number, + acquisitionGeneration: string, + reason: Error + ): Promise => { + const session = this.deps.sessions.get(sessionId) + if ( + !session || + session.ended || + session.fence !== fence || + session.acquisitionGeneration !== acquisitionGeneration + ) { + return Promise.resolve(false) + } + return closeCodexPublishedSession(this.deps.sessions, sessionId, this.deps.onEvent, { + allowFailedSettlement: true, + requestedClose: false, + expectedFence: fence, + expectedAcquisitionGeneration: acquisitionGeneration, + unexpectedReason: reason + }).then((closed) => this.settled(sessionId, closed)) + } + + closeAll = (): Promise => + closeAllCodexSessions(this.deps.sessions, this.deps.acquisitions, (sessionId) => + this.close(sessionId) + ) + + private settled(sessionId: string, closed: boolean): boolean { + if (closed) { + this.deps.forgetNotificationRetries(sessionId) + // Explicit null, not silence: the state reader answers `undefined` once + // the session leaves the map, which every channel reads as "unchanged" + // and would leave the last roster on screen. + this.deps.onBackgroundTasksChanged?.(sessionId, null) + } + return closed + } +} diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index f12e9dfb1b3..9761f8634f8 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -7,11 +7,10 @@ // segment is a semantic task name and the only label available. There is no // `thread/started` for a child, so nickname/role/depth do not exist. // * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the -// probe, so nothing here reads it — state comes from `kind` alone. +// probe, so nothing here reads it; child turn events own execution state. // * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the // latest frame replaces the previous one — it is never accumulated. -import type { NativeChatSubagentState } from '../../shared/native-chat-types' import type { CodexThreadItem } from './codex-structured-item-translation' export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity' @@ -48,24 +47,6 @@ export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentA } } -/** - * The state a `kind` implies for the child it names. - * - * An unrecognized kind means "this child exists and reported something we - * cannot classify" — `working`, which the session sweep will later settle to - * `unverifiable` if nothing better ever arrives. Claiming a terminal state from - * an unknown kind would assert an outcome the wire never gave us. - */ -export function codexSubagentStateForKind(kind: string): NativeChatSubagentState { - if (kind === 'completed') { - return 'completed' - } - if (kind === 'interrupted') { - return 'stopped' - } - return 'working' -} - /** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */ export function codexSubagentPathSegments(agentPath: string | null): string[] { return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0) diff --git a/src/main/codex/codex-subagent-execution-projection.test.ts b/src/main/codex/codex-subagent-execution-projection.test.ts new file mode 100644 index 00000000000..21d39f4f195 --- /dev/null +++ b/src/main/codex/codex-subagent-execution-projection.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexSubagentExecutions } from './codex-subagent-executions' + +const PRIMARY = 'primary' +const CHILD = 'child' + +function turn( + method: 'turn/started' | 'turn/completed', + threadId: string, + turnId: string, + status = 'completed' +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: 'session', + method, + threadId, + params: { threadId, turn: { id: turnId, status } } + } +} + +function activity(parentTurn: string, kind = 'started'): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: 'session', + threadId: PRIMARY, + method: 'item/started', + params: { + threadId: PRIMARY, + turnId: parentTurn, + item: { + type: 'subAgentActivity', + id: `activity-${parentTurn}-${kind}`, + kind, + agentThreadId: CHILD, + agentPath: '/root/task' + } + } + } +} + +function harness() { + const executions = new CodexSubagentExecutions() + const tracker = new CodexBackgroundTaskTracker(PRIMARY, executions) + const rows = new Map() + let refused = false + const translator = createCodexJournalTranslator({ + primaryThreadId: () => PRIMARY, + subagentExecutions: executions, + sink: { + appendItem: (identity, body) => rows.set(JSON.stringify(identity), body), + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body) => { + if (refused) { + return { accepted: false, reason: 'backpressure' } + } + rows.set(JSON.stringify(identity), body) + return { accepted: true } + } + }, + schedule: (run) => { + run() + return () => {} + } + }) + function send(event: CodexStructuredSessionEvent) { + const admission = translator.handle(event) + if (admission.accepted && event.type === 'notification') { + tracker.observe(event) + } + return admission + } + function state(parentTurn: string): string | undefined { + for (const body of rows.values()) { + if (body.kind !== 'message') { + continue + } + for (const block of body.blocks) { + if (block.type === 'subagent-group' && block.groupId === `${PRIMARY}:${parentTurn}`) { + return block.agents[0]?.state + } + } + } + return undefined + } + return { + send, + tracker, + state, + refuse: (value: boolean) => { + refused = value + }, + dispose: () => translator.dispose() + } +} + +function firstRun(h: ReturnType) { + h.send(turn('turn/started', PRIMARY, 'parent-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(activity('parent-1')) + h.send(turn('turn/completed', CHILD, 'child-1')) +} + +describe('shared child execution projection', () => { + it('creates no execution record from activity without an owner turn', () => { + const h = harness() + h.send(activity('parent-1')) + h.send(activity('parent-2', 'interacted')) + expect(h.state('parent-1')).toBeUndefined() + expect(h.state('parent-2')).toBeUndefined() + expect(h.tracker.state).toBeNull() + h.dispose() + }) + + it.each(['parent-1', 'parent-2'])( + 'reopens a child for real follow-up in %s and fences old execution events', + (parent) => { + const h = harness() + firstRun(h) + if (parent === 'parent-2') { + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(turn('turn/started', PRIMARY, parent)) + } + h.send(activity(parent, 'interacted')) + expect(h.tracker.state).toBeNull() + h.send(turn('turn/started', CHILD, 'child-2')) + h.send(turn('turn/completed', PRIMARY, parent)) + expect(h.state(parent)).toBe('working') + expect(h.tracker.state?.tasks).toHaveLength(1) + h.send(turn('turn/completed', CHILD, 'child-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(activity('parent-1', 'completed')) + expect(h.state(parent)).toBe('working') + expect(h.tracker.state?.tasks).toHaveLength(1) + if (parent === 'parent-2') { + expect(h.state('parent-1')).toBe('completed') + } + h.send(turn('turn/completed', CHILD, 'child-2')) + expect(h.state(parent)).toBe('completed') + expect(h.tracker.state).toBeNull() + h.dispose() + } + ) + + it('keeps an idle message from creating execution on either surface', () => { + const h = harness() + firstRun(h) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(activity('parent-2', 'interacted')) + h.send(turn('turn/completed', PRIMARY, 'parent-2')) + expect(h.state('parent-1')).toBe('completed') + expect(h.state('parent-2')).toBeUndefined() + expect(h.tracker.state).toBeNull() + h.dispose() + }) + + it('keeps a late completion activity from retargeting a queued follow-up', () => { + const h = harness() + firstRun(h) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(activity('parent-2', 'interacted')) + h.send(activity('parent-1', 'completed')) + h.send(turn('turn/started', CHILD, 'child-2')) + expect(h.state('parent-1')).toBe('completed') + expect(h.state('parent-2')).toBe('working') + expect(h.tracker.state?.tasks).toHaveLength(1) + h.dispose() + }) + + it('keeps a message to a running child in the original execution group', () => { + const h = harness() + h.send(turn('turn/started', PRIMARY, 'parent-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(activity('parent-1')) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(turn('turn/started', PRIMARY, 'parent-2')) + h.send(activity('parent-2', 'interacted')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(turn('turn/completed', PRIMARY, 'parent-2')) + expect(h.tracker.state?.tasks).toHaveLength(1) + expect(h.state('parent-2')).toBeUndefined() + h.send(turn('turn/completed', CHILD, 'child-1')) + expect(h.state('parent-1')).toBe('completed') + expect(h.state('parent-2')).toBeUndefined() + expect(h.tracker.state).toBeNull() + h.dispose() + }) + + it('does not expose pending child settlement before journal admission and retries the same fact', () => { + const h = harness() + h.send(turn('turn/started', PRIMARY, 'parent-1')) + h.send(activity('parent-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + const complete = turn('turn/completed', CHILD, 'child-1') + h.refuse(true) + expect(h.send(complete)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(h.tracker.state?.tasks).toHaveLength(1) + expect(h.state('parent-1')).toBe('working') + h.refuse(false) + expect(h.send(complete)).toEqual({ accepted: true }) + expect(h.state('parent-1')).toBe('completed') + expect(h.tracker.state).toBeNull() + h.dispose() + }) +}) diff --git a/src/main/codex/codex-subagent-executions.test.ts b/src/main/codex/codex-subagent-executions.test.ts new file mode 100644 index 00000000000..aceff7ab465 --- /dev/null +++ b/src/main/codex/codex-subagent-executions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { CodexSubagentExecutions } from './codex-subagent-executions' + +describe('CodexSubagentExecutions retention and identity', () => { + it('bounds settled history through repeated execution without evicting live children', () => { + const executions = new CodexSubagentExecutions() + executions.register('long-lived', 'long-lived', 'parent') + executions.observeTurn('long-lived', 'long-lived-turn', 'working') + for (let index = 0; index < 1_000; index++) { + const id = `child-${index}` + executions.observeTurn(id, id, 'working') + executions.register(id, id, 'parent') + executions.observeTurn(id, id, 'completed') + } + expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived']) + expect(Reflect.get(executions, 'children').size).toBeLessThanOrEqual(128) + expect(Reflect.get(executions, 'settledTurns').size).toBeLessThanOrEqual(256) + }) + + it('retains early live owner events at capacity and makes room only after settlement', () => { + const executions = new CodexSubagentExecutions() + for (let index = 0; index < 128; index++) { + executions.observeTurn(`child-${index}`, `turn-${index}`, 'working') + } + expect(executions.observeTurn('overflow', 'overflow', 'working')).toBeNull() + for (let index = 0; index < 128; index++) { + executions.register(`child-${index}`, `child-${index}`, 'parent') + } + expect(executions.workingChildren()).toHaveLength(128) + executions.observeTurn('child-0', 'turn-0', 'completed') + expect(executions.observeTurn('overflow', 'overflow', 'working')).not.toBeNull() + executions.register('overflow', 'overflow', 'parent') + expect(executions.workingChildren()).toHaveLength(128) + }) + + it('corrects an unverifiable execution with its own terminal event and ignores stale starts', () => { + const executions = new CodexSubagentExecutions() + executions.register('child', 'child', 'parent') + executions.observeTurn('child', 'turn', 'working') + executions.settleSession() + expect(executions.workingChildren()).toEqual([]) + expect(executions.observeTurn('child', 'turn', 'working')).toBeNull() + expect(executions.observeTurn('child', 'turn', 'completed')?.execution.state).toBe('completed') + executions.observeTurn('child', 'new-turn', 'working') + executions.observeTurn('child', 'turn', 'failed') + expect(executions.workingChildren()[0]?.execution?.turnId).toBe('new-turn') + executions.clear() + expect(Reflect.get(executions, 'children').size).toBe(0) + expect(Reflect.get(executions, 'settledTurns').size).toBe(0) + }) +}) diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts new file mode 100644 index 00000000000..cd33b4eb1d5 --- /dev/null +++ b/src/main/codex/codex-subagent-executions.ts @@ -0,0 +1,144 @@ +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' + +const MAX_CHILDREN = 128 +const MAX_SETTLED_TURNS = 256 + +export type CodexChildExecution = { + turnId: string + state: NativeChatSubagentState +} + +export type CodexExecutionChild = { + agentThreadId: string + registered: boolean + label: string | null + parentTurnId: string | null + execution: CodexChildExecution | null +} + +/** Child turn events own execution; activity items only identify the child. */ +export class CodexSubagentExecutions { + private readonly children = new Map() + private readonly settledTurns = new Map() + + register( + agentThreadId: string, + label: string | null, + parentTurnId: string | null | undefined + ): CodexExecutionChild | undefined { + const child = this.child(agentThreadId) + if (!child) { + return undefined + } + if (!child.registered || parentTurnId !== undefined) { + child.parentTurnId = parentTurnId ?? null + } + child.registered = true + // Retain one overflow unit so the journal can append its per-row truncation marker. + child.label ??= + label + ?.trim() + .replace(/\s+/g, ' ') + .slice(0, MAX_SUBAGENT_FIELD_CHARS + 1) || null + return child + } + + observeTurn( + agentThreadId: string, + turnId: string, + state: NativeChatSubagentState + ): { child: CodexExecutionChild; execution: CodexChildExecution } | null { + const key = JSON.stringify([agentThreadId, turnId]) + const settled = this.settledTurns.get(key) + if (state === 'working' && settled !== undefined) { + return null + } + const child = this.child(agentThreadId) + if (!child) { + return null + } + if ( + state === 'working' && + child.execution?.turnId === turnId && + child.execution.state !== 'working' + ) { + return null + } + const execution = { turnId, state: settled ?? state } + if (state !== 'working') { + this.settledTurns.set(key, execution.state) + while (this.settledTurns.size > MAX_SETTLED_TURNS) { + const oldest = this.settledTurns.keys().next().value + if (oldest === undefined) { + break + } + this.settledTurns.delete(oldest) + } + } + if (state === 'working' || !child.execution || child.execution.turnId === turnId) { + child.execution = execution + } + return { child, execution } + } + + /** Survives the child's turn, so a row outliving that turn can still name it. */ + label(agentThreadId: string): string | null { + return this.children.get(agentThreadId)?.label ?? null + } + + workingChildren(): CodexExecutionChild[] { + return [...this.children.values()].filter( + (child) => child.registered && child.execution?.state === 'working' + ) + } + + settleSession(): void { + for (const child of this.children.values()) { + if (child.execution?.state === 'working') { + child.execution = { ...child.execution, state: 'unverifiable' } + } + } + } + + clear(): void { + this.children.clear() + this.settledTurns.clear() + } + + private child(agentThreadId: string): CodexExecutionChild | undefined { + const existing = this.children.get(agentThreadId) + if (existing) { + return existing + } + if (this.children.size >= MAX_CHILDREN) { + const settled = [...this.children].find(([, child]) => child.execution?.state !== 'working') + if (!settled) { + return undefined + } + this.children.delete(settled[0]) + } + const child: CodexExecutionChild = { + agentThreadId, + registered: false, + label: null, + parentTurnId: null, + execution: null + } + this.children.set(agentThreadId, child) + return child + } +} + +export function codexChildTurnState(status: unknown): NativeChatSubagentState { + if (status === 'completed') { + return 'completed' + } + if (status === 'interrupted') { + return 'stopped' + } + if (status === 'failed') { + return 'failed' + } + return 'unverifiable' +} diff --git a/src/main/codex/codex-subagent-group-body.ts b/src/main/codex/codex-subagent-group-body.ts new file mode 100644 index 00000000000..c2157b0e7eb --- /dev/null +++ b/src/main/codex/codex-subagent-group-body.ts @@ -0,0 +1,51 @@ +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import { + MAX_SUBAGENT_FIELD_CHARS, + subagentGroupFallbackText +} from '../../shared/native-chat-subagent-summary' + +/** The roster row: the structured block plus the plain sentence an older client + * renders in its place. A message whose only block is the new variant would + * reach such a client with nothing it can draw. */ +export function codexSubagentGroupBody( + groupId: string, + agents: readonly NativeChatSubagentEntry[] +): AgentJournalItemBody { + const bounded = agents.map((agent, index) => ({ + ...agent, + id: boundSubagentField(agent.id, index), + label: boundSubagentField(agent.label, index) + })) + return { + kind: 'message', + role: 'system', + blocks: [ + { type: 'text', text: subagentGroupFallbackText(bounded) }, + { type: 'subagent-group', groupId, agents: bounded } + ] + } +} + +/** `id` and `label` are provider strings, so they take the bound both readers of + * this row already clip them to. A plain length check, not the tool-output + * bound: that one digests the whole value before it checks the length, and this + * runs twice per child on every streamed token-usage frame. + * + * A clip is not identity-preserving, so a clipped value carries the child's + * index: two ids sharing a long prefix collapse to one React key, and + * `claimLabel` writes its ordinal at the very tail the clip removes. The index + * is reserved out of the bound, not appended to it, because both readers + * re-clip to the same cap and would cut a suffix that overflowed it. */ +export function boundSubagentField(value: string, index: number): string { + if (value.length <= MAX_SUBAGENT_FIELD_CHARS) { + return value + } + const suffix = `…~${index}` + const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length + // Slicing UTF-16 units can split a surrogate pair; a lone surrogate is + // malformed in a durable row and lossy through any non-JSON UTF-8 hop. + const last = value.charCodeAt(keep - 1) + const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep + return `${value.slice(0, end)}${suffix}` +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 2f20c9df9bf..9d3e2c690c4 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -83,6 +83,22 @@ function deliver( item: CodexThreadItem, turnId: string | null = TURN ): void { + // The fixture includes the child's owner event separately from its activity metadata. + const state = + item.kind === 'started' + ? 'working' + : item.kind === 'completed' + ? 'completed' + : item.kind === 'interrupted' + ? 'stopped' + : null + if (state && typeof item.agentThreadId === 'string') { + roster.handleTurn({ + threadId: item.agentThreadId, + turnId: `execution:${item.agentThreadId}`, + state + }) + } // Every activity item reaches the wire twice: item/started, then item/completed. roster.handleItem({ threadId: THREAD, turnId, item }) roster.handleItem({ threadId: THREAD, turnId, item }) @@ -270,7 +286,7 @@ describe('CodexSubagentRoster', () => { expect(appended).toHaveLength(1) }) - it('rule 2 — a first event of any kind creates the entry in the state it implies', () => { + it('registers a child after its owner already reported completion', () => { const { roster, agents } = createHarness() deliver( @@ -374,7 +390,7 @@ describe('CodexSubagentRoster', () => { deliver( roster, - activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) ) roster.settleSession() const afterFirstSweep = appended.length @@ -676,6 +692,7 @@ describe('CodexSubagentRoster', () => { now: () => 1_000 }) const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal) @@ -724,6 +741,7 @@ describe('CodexSubagentRoster', () => { activeTurn: () => TURN, now: () => 1_000 }) + roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) roster.handleItem({ threadId: THREAD, turnId: TURN, @@ -758,6 +776,7 @@ describe('CodexSubagentRoster', () => { activeTurn: () => TURN }) + roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) expect( roster.handleItem({ threadId: THREAD, diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 257fe705764..a2b58e1a6ce 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -1,11 +1,6 @@ // The Codex subagent roster: one journal row per spawn group, revised in place. // -// There is no snapshot to read. `agentsStates` arrived empty in the live probe -// and children get no `thread/started`, so the roster is -// accumulated purely from `subAgentActivity` items — each of which arrives TWICE -// (`item/started` and `item/completed`). Every transition here is therefore -// idempotent, and a terminal state latches: duplicate and out-of-order delivery -// must not resurrect a settled child. +// Activity supplies membership; child turn events supply execution state. // // KNOWN LIMITATION: `groups` is process-local and is never seeded from the // journal, while the row's identity is keyed on the group id alone. So once a @@ -18,28 +13,32 @@ // thread, and a real turn id is assumed freshly minted per turn. Seeding from // the journal is the fix. +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import { - canReplaceSubagentState, - isTerminalSubagentState, - MAX_SUBAGENT_FIELD_CHARS, - subagentGroupFallbackText -} from '../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + NativeChatSubagentEntry, + NativeChatSubagentState +} from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { codexSubagentLabel, - codexSubagentStateForKind, isCodexRootAgentActivity, readCodexSubagentActivity, readCodexThreadTokenTotal } from './codex-subagent-activity' +import { + CodexSubagentExecutions, + codexChildTurnState, + type CodexChildExecution, + type CodexExecutionChild +} from './codex-subagent-executions' +import { readRecord } from './codex-item-field-readers' +import { readCodexTurnId } from './codex-structured-thread-facts' +import { codexSubagentGroupBody } from './codex-subagent-group-body' +export { codexSubagentGroupBody } from './codex-subagent-group-body' import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, @@ -51,15 +50,12 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } /** The turn a group belongs to when Codex reports activity outside any turn. * Mirrors the generic-frame bucket name so the two read alike in the journal. */ -const OUTSIDE_TURN = 'outside-turn' - -const UNLABELLED_AGENT = 'subagent' - type RosterGroup = { groupId: string identity: AgentJournalItemIdentity /** Insertion order is the display order; the map holds the state. */ entries: Map + executionTurns: Map /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ labelCounts: Map /** Last body written, so an idempotent replay writes no new revision. */ @@ -70,7 +66,7 @@ type RosterGroup = { * tree rooted at the parent thread, so every child of one turn shares a row * no matter which thread's stream carried its activity item. */ export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? OUTSIDE_TURN}` + return `${threadId}:${turnId ?? 'outside-turn'}` } /** Durable journal identity for the group's row — stable across revisions and @@ -85,6 +81,7 @@ export type CodexSubagentRosterDeps = { primaryThreadId: () => string | null activeTurn: (threadId: string) => string | null now?: () => number + executions?: CodexSubagentExecutions } export class CodexSubagentRoster { @@ -95,9 +92,11 @@ export class CodexSubagentRoster { * the map itself is LRU-capped in `handleTokenUsage`. */ private readonly tokensByThread = new Map() private readonly now: () => number + private readonly executions: CodexSubagentExecutions constructor(private readonly deps: CodexSubagentRosterDeps) { this.now = deps.now ?? (() => Date.now()) + this.executions = deps.executions ?? new CodexSubagentExecutions() } /** Consume a `subAgentActivity` item. Returns null when the item is not one. */ @@ -111,42 +110,81 @@ export class CodexSubagentRoster { return null } // The root node is the parent turn itself, not a child it spawned. - if (isCodexRootAgentActivity(activity)) { + if ( + activity.agentThreadId === this.deps.primaryThreadId() || + isCodexRootAgentActivity(activity) + ) { return ADMITTED } - const group = this.groupFor(input.threadId, input.turnId) - const existing = group.entries.get(activity.agentThreadId) - const state = codexSubagentStateForKind(activity.kind) - if (!existing) { - // Rule: the first event for a child may be ANY kind. An `interacted` or - // `completed` with no prior `started` creates the entry in the state its - // kind implies rather than being dropped for lacking a roster row. - if (group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) { - return ADMITTED - } - const now = this.now() - group.entries.set(activity.agentThreadId, { - id: activity.agentThreadId, - label: this.claimLabel(group, codexSubagentLabel(activity)), - state, - startedAt: now, - ...(isTerminalSubagentState(state) ? { settledAt: now } : {}) - }) - } else if (canReplaceSubagentState(existing.state, state)) { - // A child's own verdict latches. Re-applying the same non-terminal state - // is a no-op, which is what makes the duplicate `item/started` + - // `item/completed` delivery idempotent. `unverifiable` does not latch: a - // child swept when contact was lost can still report what it actually did - // if contact returns. - group.entries.set(activity.agentThreadId, { - ...existing, - state, - ...(isTerminalSubagentState(state) ? { settledAt: this.now() } : {}) - }) + const child = this.executions.register( + activity.agentThreadId, + codexSubagentLabel(activity), + activity.kind === 'started' || activity.kind === 'interacted' ? input.turnId : undefined + ) + if (!child?.execution) { + return ADMITTED + } + const group = + this.executionGroup(child.agentThreadId, child.execution.turnId) ?? + this.groupFor(input.threadId, input.turnId) + if (!group.entries.has(child.agentThreadId)) { + this.recordExecution(group, child, child.execution) } return this.write(group) } + handleTurnEvent(event: { + method: string + threadId: string + params: unknown + }): StructuredAgentSessionSinkAdmission { + const turnId = readCodexTurnId(event.params) + return turnId + ? this.handleTurn({ + threadId: event.threadId, + turnId, + state: + event.method === 'turn/started' + ? 'working' + : codexChildTurnState(readRecord(readRecord(event.params).turn).status) + }) + : ADMITTED + } + + handleTurn(input: { + threadId: string + turnId: string + state: NativeChatSubagentState + }): StructuredAgentSessionSinkAdmission { + if (input.threadId === this.deps.primaryThreadId()) { + return ADMITTED + } + const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state) + if (!observed || !observed.child.registered) { + return ADMITTED + } + const { child, execution } = observed + if (input.state === 'working') { + const parent = this.deps.primaryThreadId() ?? input.threadId + const group = + this.executionGroup(child.agentThreadId, execution.turnId) ?? + this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId) + this.recordExecution(group, child, execution) + return this.write(group) + } + for (const group of this.groups.values()) { + if (group.executionTurns.get(input.threadId) !== input.turnId) { + continue + } + this.recordExecution(group, child, execution) + const admission = this.write(group) + if (!admission.accepted) { + return admission + } + } + return ADMITTED + } + /** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */ handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null { const usage = readCodexThreadTokenTotal(params) @@ -187,6 +225,7 @@ export class CodexSubagentRoster { * routinely outlive their turn and keep reporting into the same group. */ settleSession(): StructuredAgentSessionSinkAdmission { + this.executions.settleSession() for (const group of this.groups.values()) { const admission = this.sweep(group) if (!admission.accepted) { @@ -233,6 +272,7 @@ export class CodexSubagentRoster { groupId, identity: codexSubagentGroupIdentity(groupId), entries: new Map(), + executionTurns: new Map(), labelCounts: new Map(), lastSerialized: null } @@ -247,15 +287,46 @@ export class CodexSubagentRoster { return group } + private executionGroup(threadId: string, turnId: string): RosterGroup | undefined { + return [...this.groups.values()].find((group) => group.executionTurns.get(threadId) === turnId) + } + /** Two children can share a trailing path segment; the ordinal keeps their * rows apart without inventing a name the provider never sent. */ private claimLabel(group: RosterGroup, label: string | null): string { - const base = label ?? UNLABELLED_AGENT + const base = label ?? 'subagent' const seen = group.labelCounts.get(base) ?? 0 group.labelCounts.set(base, seen + 1) return seen === 0 ? base : `${base} ${seen + 1}` } + private recordExecution( + group: RosterGroup, + child: CodexExecutionChild, + execution: CodexChildExecution | null + ): void { + const existing = group.entries.get(child.agentThreadId) + if (!existing && group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) { + return + } + const turnId = execution?.turnId ?? null + const state = execution?.state ?? 'unverifiable' + const sameTurn = existing && group.executionTurns.get(child.agentThreadId) === turnId + if (sameTurn && existing.state === state) { + return + } + const now = this.now() + group.executionTurns.set(child.agentThreadId, turnId) + group.entries.set(child.agentThreadId, { + id: child.agentThreadId, + label: existing?.label ?? this.claimLabel(group, child.label), + state, + startedAt: sameTurn ? existing.startedAt : now, + ...(isTerminalSubagentState(state) ? { settledAt: now } : {}), + ...(existing?.tokens !== undefined ? { tokens: existing.tokens } : {}) + }) + } + private write(group: RosterGroup): StructuredAgentSessionSinkAdmission { const agents = [...group.entries].map(([id, entry]) => { const tokens = this.tokensByThread.get(id) @@ -300,48 +371,3 @@ export class CodexSubagentRoster { return published } } - -/** The roster row: the structured block plus the plain sentence an older client - * renders in its place. A message whose only block is the new variant would - * reach such a client with nothing it can draw. */ -export function codexSubagentGroupBody( - groupId: string, - agents: readonly NativeChatSubagentEntry[] -): AgentJournalItemBody { - const bounded = agents.map((agent, index) => ({ - ...agent, - id: boundSubagentField(agent.id, index), - label: boundSubagentField(agent.label, index) - })) - return { - kind: 'message', - role: 'system', - blocks: [ - { type: 'text', text: subagentGroupFallbackText(bounded) }, - { type: 'subagent-group', groupId, agents: bounded } - ] - } -} - -/** `id` and `label` are provider strings, so they take the bound both readers of - * this row already clip them to. A plain length check, not the tool-output - * bound: that one digests the whole value before it checks the length, and this - * runs twice per child on every streamed token-usage frame. - * - * A clip is not identity-preserving, so a clipped value carries the child's - * index: two ids sharing a long prefix collapse to one React key, and - * `claimLabel` writes its ordinal at the very tail the clip removes. The index - * is reserved out of the bound, not appended to it, because both readers - * re-clip to the same cap and would cut a suffix that overflowed it. */ -function boundSubagentField(value: string, index: number): string { - if (value.length <= MAX_SUBAGENT_FIELD_CHARS) { - return value - } - const suffix = `…~${index}` - const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length - // Slicing UTF-16 units can split a surrogate pair; a lone surrogate is - // malformed in a durable row and lossy through any non-JSON UTF-8 hop. - const last = value.charCodeAt(keep - 1) - const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep - return `${value.slice(0, end)}${suffix}` -} diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 3901d8b1ffa..6237b8d040d 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' import { + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' @@ -80,6 +81,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] @@ -128,6 +130,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientKind: 'runtime', connectionId, clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] diff --git a/src/main/lazy-worker-thread-host.ts b/src/main/lazy-worker-thread-host.ts new file mode 100644 index 00000000000..01f15eb1c66 --- /dev/null +++ b/src/main/lazy-worker-thread-host.ts @@ -0,0 +1,102 @@ +import type { Worker } from 'node:worker_threads' + +export type WorkerThreadFactory = () => Worker + +/** + * Owns the lifetime of one lazily-spawned worker thread: spawn on demand, + * listener wiring, teardown, and idle expiry. It holds no request state, so + * every decision about which call a message belongs to stays with its client, + * and a failed spawn is reported rather than thrown so the client can fail its + * queued calls closed instead of moving the work back onto the main thread. + */ +export class LazyWorkerThreadHost { + private worker: Worker | null = null + private idleTimer: NodeJS.Timeout | null = null + private cleanupListeners: (() => void) | null = null + private reportedUnavailable = false + + constructor( + private readonly options: { + factory: WorkerThreadFactory + idleTeardownMs: number + onMessage: (response: TResponse) => void + onError: (error: Error) => void + onExit: (code: number) => void + /** Nothing active and nothing queued, checked again when the idle timer fires. */ + isIdle: () => boolean + /** First spawn failure only: a repeating one must not repeat the log. */ + onUnavailable: (error: unknown) => void + } + ) {} + + get current(): Worker | null { + return this.worker + } + + /** The live worker, spawning one if needed; null when no worker can be had. */ + ensure(): Worker | null { + if (this.worker) { + return this.worker + } + try { + const worker = this.options.factory() + const onMessage = (response: TResponse): void => this.options.onMessage(response) + const onError = (error: Error): void => this.options.onError(error) + const onExit = (code: number): void => this.options.onExit(code) + worker.on('message', onMessage) + worker.on('error', onError) + worker.on('exit', onExit) + this.cleanupListeners = () => { + worker.off('message', onMessage) + worker.off('error', onError) + worker.off('exit', onExit) + } + // Never keep the app alive for background work. + worker.unref?.() + this.worker = worker + return worker + } catch (err) { + if (!this.reportedUnavailable) { + this.reportedUnavailable = true + this.options.onUnavailable(err) + } + return null + } + } + + destroy(): void { + this.clearIdleTimer() + const worker = this.worker + this.worker = null + if (!worker) { + return + } + this.cleanupListeners?.() + this.cleanupListeners = null + worker.removeAllListeners() + void worker.terminate().catch(() => undefined) + } + + scheduleIdleTeardown(): void { + this.clearIdleTimer() + if (!this.worker) { + return + } + this.idleTimer = setTimeout(() => { + this.idleTimer = null + // Re-checked here: a request arriving as the timer fires must never be + // lost to a self-exiting worker. + if (this.options.isIdle()) { + this.destroy() + } + }, this.options.idleTeardownMs) + this.idleTimer.unref?.() + } + + clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer) + this.idleTimer = null + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index ed86d81861e..13673626ebc 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -432,6 +432,86 @@ describe('payload bounds on import', () => { expect(body.output.byteLength).toBe(64 * 1024) expect(body.output.head).toHaveLength(1_024) }) + + it('bounds an imported subagent roster by entry count, label and id', async () => { + // The import reads an untrusted file: nothing upstream bounded either string. + const oversized = 'z'.repeat(20 * 1024) + const journal = await open('claude', CLAUDE_SESSION) + await appendLegacyTranscriptMessages({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + messages: [ + { + id: 'legacy-roster', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: Array.from({ length: 80 }, (_, index) => ({ + id: index === 0 ? oversized : `task-${index}`, + label: index === 0 ? oversized : `label-${index}`, + state: 'working' as const + })) + } + ] + } + ] + }) + + const body = journal.snapshot().items[0]?.body + const block = body?.kind === 'message' ? body.blocks[0] : undefined + if (block?.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents).toHaveLength(64) + expect(block.agents[0]?.label.length).toBeLessThan(oversized.length) + expect(block.agents[0]?.id.length).toBeLessThan(oversized.length) + expect(block.agents[0]?.id.startsWith('z')).toBe(true) + }) + + it('bounds a roster id in the shared format, keeping a shared prefix distinct', async () => { + // The id is the roster key: it takes the same bounded-id format the wires + // use, so a later wire bound is a no-op instead of a second, merging clip. + const head = 'y'.repeat(512) + const journal = await open('claude', CLAUDE_SESSION) + await appendLegacyTranscriptMessages({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + messages: [ + { + id: 'legacy-roster-collision', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: [ + { id: `${head}-one`, label: 'Audit', state: 'working' as const }, + { id: `${head}-two`, label: 'Audit', state: 'working' as const } + ] + } + ] + } + ] + }) + + const body = journal.snapshot().items[0]?.body + const block = body?.kind === 'message' ? body.blocks[0] : undefined + if (block?.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id) + expect(block.agents[0]?.id).toHaveLength(512) + }) }) describe('import failures', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 0907ebd28f2..801c91fb534 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -28,6 +28,7 @@ import { decodeOmpTranscriptLine } from '../transcript-line-decoders' import { decodeTranscriptStream } from '../transcript-stream-lines' +import { boundSubagentEntryId } from '../subagent-entry-id-bounds' import { createLegacyIdentityTracker } from './journal-legacy-identity' import type { JournalReplacementItem } from './journal-epoch-replacement' import { @@ -47,6 +48,8 @@ export type LegacyImportOptions = ResolveSessionFileOptions & { } const MAX_LEGACY_IMPORT_SOURCE_BYTES = 16 * 1024 * 1024 +/** A roster is a status list; an imported one is as untrusted as any other block. */ +const MAX_LEGACY_IMPORT_SUBAGENTS = 64 export type LegacyImportResult = | { @@ -271,5 +274,17 @@ function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): Nativ if (block.type === 'tool-call') { return { ...block, input: boundToolInput(block.input, limits) } } + if (block.type === 'subagent-group') { + return { + ...block, + agents: block.agents.slice(0, MAX_LEGACY_IMPORT_SUBAGENTS).map((agent) => ({ + ...agent, + // The id is the roster key, so it is bounded with a digest rather than + // clipped to a prefix that two distinct children could share. + id: boundSubagentEntryId(agent.id), + label: boundInlineText(agent.label, limits).text + })) + } + } return block } diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 2dc0ac1aeb2..255e4ed184c 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -5,7 +5,10 @@ import { boundJournalKeyComponent, MAX_JOURNAL_KEY_COMPONENT_CHARS } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { applyJournalRow, @@ -14,6 +17,7 @@ import { renderJournalState, type JournalReducerState } from './journal-reducer' +import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -444,3 +448,49 @@ describe('bounded item-key collisions', () => { ]) }) }) + +describe('re-adding a tombstoned row', () => { + it('builds the rebuilt row above the tombstone that removed it', () => { + const identity: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'roster' } + const itemId = agentJournalItemKey(identity) + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + ) + applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) + expect(renderJournalState(state).items).toEqual([]) + + // Same identity, re-added later in the session: a revision built only from + // `items` would restart at 1 and lose to the tombstone forever. + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + ) + expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('second')]) + }) + + // `upsertItem` clearing the tombstone on a re-add is a map-state invariant: + // `items` and `tombstones` stay disjoint, so a re-added row is never both + // present and removed. Revision ordering is now independent of it — + // `buildJournalTombstoneRow` takes `max(itemRevision, tombstoneRevision) + 1` + // — so what this pins is the map state itself, not the ranking. + it('removes the row again after it was re-added', () => { + const identity: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'roster' } + const itemId = agentJournalItemKey(identity) + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + ) + applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + ) + expect(state.tombstones.get(itemId)).toBeUndefined() + + applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 4, fence: 1, ts: 1_004 })) + expect(renderJournalState(state).items).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 5c77c180c68..db82e86318f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -148,7 +148,13 @@ export function buildJournalItemRow(input: { }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId - const revision = (input.state.items.get(resolved)?.revision ?? 0) + 1 + // A tombstoned row keeps its revision in `tombstones`, and the reducer drops + // any item at or below it — so a re-add has to outrank the tombstone too. + const revision = + Math.max( + input.state.items.get(resolved)?.revision ?? 0, + input.state.tombstones.get(resolved) ?? 0 + ) + 1 return { kind: 'item', itemId, @@ -170,7 +176,15 @@ export function buildJournalTombstoneRow(input: { return { kind: 'tombstone', itemId: input.itemId, - revision: (input.state.items.get(resolved)?.revision ?? 0) + 1, + // Symmetric with the item builder: `upsertItem` clearing the tombstone on a + // re-add is what keeps the two maps disjoint, and that invariant lives in the + // reducer. Outranking both here means a repeat removal cannot be dropped as a + // stale revision if it ever stops holding. + revision: + Math.max( + input.state.items.get(resolved)?.revision ?? 0, + input.state.tombstones.get(resolved) ?? 0 + ) + 1, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts new file mode 100644 index 00000000000..ff82a8f63ed --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' + +describe('protected provider streams', () => { + it('preserves protected prefixes beyond the ordinary count cap and evicts only ordinary streams', () => { + const protectedKeys = new Set() + const coalescer = createAgentSessionDeltaCoalescer({ + maxStreams: 1, + isProtected: (key) => protectedKeys.has(key), + emit: () => true, + schedule: () => () => {} + }) + // A typed start can arrive after the first delta, before the next output. + coalescer.append('command-0', 'before') + protectedKeys.add('command-0') + for (let index = 1; index < 448; index += 1) { + const key = `command-${index}` + protectedKeys.add(key) + expect(coalescer.append(key, 'before')).toBe(true) + } + coalescer.append('ordinary-1', 'temporary') + coalescer.append('ordinary-2', 'latest') + expect(coalescer.snapshot('ordinary-1')).toBeNull() + expect(coalescer.snapshot('ordinary-2')?.text).toBe('latest') + for (const key of protectedKeys) { + coalescer.append(key, 'after') + expect(coalescer.snapshot(key)?.text).toBe('beforeafter') + coalescer.forget(key) + expect(coalescer.snapshot(key)).toBeNull() + } + coalescer.dispose() + }) + + it('keeps aggregate and per-stream output byte ceilings under protected-key pressure', () => { + const coalescer = createAgentSessionDeltaCoalescer({ + maxStreams: 1, + maxRetainedBytes: 80, + maxTotalRetainedBytes: 120, + isProtected: () => true, + emit: () => true, + schedule: () => () => {} + }) + coalescer.append('first', 'a'.repeat(80)) + coalescer.append('second', 'b'.repeat(80)) + coalescer.append('third', 'c'.repeat(80)) + const snapshots = ['first', 'second', 'third'].map((key) => coalescer.snapshot(key)!) + expect(snapshots[0].text).toBe('a'.repeat(80)) + expect(snapshots.map((snapshot) => snapshot.truncated)).toEqual([false, true, true]) + expect( + snapshots.reduce((total, snapshot) => total + Buffer.byteLength(snapshot.text), 0) + ).toBeLessThanOrEqual(120) + coalescer.forget('first') + coalescer.append('new', 'd'.repeat(80)) + expect(coalescer.snapshot('new')?.text).toBe('d'.repeat(80)) + coalescer.dispose() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index dbc63154b72..73dc08cc05c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -37,6 +37,8 @@ export type AgentSessionDeltaCoalescerDeps = { maxTotalRetainedBytes?: number /** Maximum distinct item streams retained at once. */ maxStreams?: number + /** The caller byte-bounds protected metadata; only ordinary streams use the count cap. */ + isProtected?: (key: string) => boolean /** Injected by tests so a window can be driven without real time. */ schedule?: (run: () => void, ms: number) => () => void } @@ -83,8 +85,7 @@ export function createAgentSessionDeltaCoalescer( >() let totalRetainedBytes = 0 let cancelTimer: (() => void) | null = null - const streamOrder = new Map() - let nextOrder = 0 + const evictable = new Set() const flushKey = (key: string): boolean => { const stream = streams.get(key) @@ -130,9 +131,13 @@ export function createAgentSessionDeltaCoalescer( if (!stream) { // Evict the oldest stream before admitting a new attacker-controlled // id. Flush first so the retained prefix is durably visible. - if (streams.size >= maxStreams) { - const oldest = [...streamOrder.entries()].sort((a, b) => a[1] - b[1])[0]?.[0] + while (!deps.isProtected?.(key) && evictable.size >= maxStreams) { + const oldest = evictable.values().next().value if (oldest) { + if (deps.isProtected?.(oldest)) { + evictable.delete(oldest) + continue + } // Under sink backpressure the oldest stream must remain available // for a later retry; dropping it would lose already-observed output. if (!flushKey(oldest)) { @@ -143,8 +148,9 @@ export function createAgentSessionDeltaCoalescer( totalRetainedBytes -= evicted.retainedBytes } streams.delete(oldest) - streamOrder.delete(oldest) + evictable.delete(oldest) } + break } stream = { chunks: [], @@ -153,7 +159,11 @@ export function createAgentSessionDeltaCoalescer( truncated: false, dirty: false } - streamOrder.set(key, nextOrder++) + if (!deps.isProtected?.(key)) { + evictable.add(key) + } + } else if (deps.isProtected?.(key)) { + evictable.delete(key) } stream.observedBytes += Buffer.byteLength(delta, 'utf8') if (!stream.truncated) { @@ -184,14 +194,14 @@ export function createAgentSessionDeltaCoalescer( if (stream) { totalRetainedBytes -= stream.retainedBytes streams.delete(key) - streamOrder.delete(key) + evictable.delete(key) } }, dispose: () => { cancelTimer?.() cancelTimer = null streams.clear() - streamOrder.clear() + evictable.clear() totalRetainedBytes = 0 }, snapshot: (key) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 412aa88025d..986fcefadbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -38,4 +38,5 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number + publishStatus?: (sessionId: string) => void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 5f9894c3280..728942b6347 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -22,6 +22,7 @@ export class StructuredAgentSessionEventRecovery { sessions: Map flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void + publishStatus?: (sessionId: string) => void hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index 7c8c65a5292..3495409f127 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -25,6 +25,7 @@ type HostHandoffAccess = { flush: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise subscribers: AgentSessionSubscribers + publishStatus?: (sessionId: string) => void now: () => number } @@ -82,6 +83,7 @@ export function createStructuredAgentSessionHostHandoff( return { state: 'live' } } host.session(sessionId).hasProviderChild = false + host.publishStatus?.(sessionId) try { await host.flush(sessionId) host.eventSink(sessionId).unbind() @@ -243,6 +245,7 @@ export async function acquireNativeHandoffOwner( return rethrowAfterAgentSessionAcquisitionCleanup(deps.adapter, input.sessionId, error) } session.hasProviderChild = true + host.publishStatus?.(input.sessionId) session.fence = proved.lease.runtimeFence session.acquisitionGeneration = acquired.acquisitionGeneration ?? null eventSink.bind({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index ba62db1704e..d1664bc985b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -93,13 +93,19 @@ export async function resumeStructuredAgentSessionForHold( export function createStructuredAgentSessionHolds( context: StructuredAgentSessionLifetimeContext, input: { - resume: (sessionId: string) => Promise - evict: (sessionId: string) => Promise + reconcileLeases: (sessionId: string) => Promise + attach: Parameters[0]['attach'] + close: (sessionId: string) => Promise } ): StructuredAgentSessionHolds { return new StructuredAgentSessionHolds({ - resume: input.resume, - evict: input.evict, + resume: (sessionId) => + resumeStructuredAgentSessionForHold( + { ...context, reconcileLeases: input.reconcileLeases }, + sessionId, + input.attach + ), + evict: input.close, hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), isTurnActive: (sessionId) => { const session = context.sessions.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 257c7a4e4f7..2df2fdf5412 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -27,7 +27,6 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { createStructuredAgentSessionHolds, evictHeldStructuredAgentSession, - resumeStructuredAgentSessionForHold, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { @@ -118,16 +117,13 @@ export class StructuredAgentSessionHost { flush: (sessionId) => this.flushStreamedEvents(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), subscribers: this.subscribers, + publishStatus: (sessionId) => this.statusFeed.publish(sessionId), now: this.now }) this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), { - resume: (sessionId) => - resumeStructuredAgentSessionForHold( - { ...this.lifetimeContext(), reconcileLeases: this.reconcileLeases }, - sessionId, - (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) - ), - evict: (sessionId) => this.close(sessionId) + reconcileLeases: this.reconcileLeases, + attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params), + close: (sessionId) => this.close(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { reconcile: this.reconcileLeases, @@ -149,6 +145,7 @@ export class StructuredAgentSessionHost { flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId), publishFence: (sessionId, session) => this.subscribers.snapshot(sessionId, session.journal, session.fence), + publishStatus: (sessionId) => this.statusFeed.publish(sessionId), hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now(), @@ -193,7 +190,8 @@ export class StructuredAgentSessionHost { subscribers: this.subscribers, tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), - serialize: (sessionId, task) => this.serialize(sessionId, task) + serialize: (sessionId, task) => this.serialize(sessionId, task), + publishStatus: (sessionId) => this.statusFeed.publish(sessionId) } } /** Releases a session's resources without ending the conversation: the record and journal stay @@ -202,6 +200,7 @@ export class StructuredAgentSessionHost { return this.serialize(sessionId, async () => { await this.handoffs.closeRetainedTuiOwner(sessionId) await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) + this.statusFeed.revokeLive(sessionId) // Whoever asked for the close, the surfaces that were holding this session are looking at a // session that no longer exists. A failed eviction throws above and keeps them. this.holds.forget(sessionId) @@ -213,8 +212,11 @@ export class StructuredAgentSessionHost { listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions) - getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => - this.deps.store.getVisibleSessionTabIndex() + /** Last projected status for every structured session this host still holds, for non-subscribing + * readers. The retained projections of forgotten sessions are deliberately not included. */ + readonly liveSessionStatusSummaries = () => this.statusFeed.liveSessionSummaries() + + getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => this.deps.store.setSessionTabVisibility(sessionId, visible) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index c1b52f879d4..1e3b9bb25a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -53,15 +53,24 @@ async function openJournal(sessionId = SESSION, now?: () => number) { }) } -function indexed(session: { journal: Awaited> }) { +function indexed(session: { + journal: Awaited> + hasProviderChild?: boolean +}) { return { journal: session.journal, + ...(session.hasProviderChild !== undefined + ? { hasProviderChild: session.hasProviderChild } + : {}), params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } } } function feedFor( - sessions: Map> }>, + sessions: Map< + string, + { journal: Awaited>; hasProviderChild?: boolean } + >, record: Partial | null = null, onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] ) { @@ -88,6 +97,41 @@ function feedFor( } describe('StructuredAgentSessionStatusFeed', () => { + it('publishes provider ownership transitions without changing journal time', async () => { + const journal = await openJournal() + const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]]) + const { feed, events, dispose } = feedFor(sessions) + events.length = 0 + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ hostExecutionOwned: true, updatedAt: expect.any(Number) }) + }) + const firstStatus = events.at(-1) + expect(firstStatus?.type).toBe('status') + if (firstStatus?.type !== 'status') { + throw new Error('status publication missing') + } + const journalTime = firstStatus.session.updatedAt + sessions.get(SESSION)!.hasProviderChild = false + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle', updatedAt: journalTime }) + }) + const secondStatus = events.at(-1) + expect(secondStatus?.type).toBe('status') + if (secondStatus?.type === 'status') { + expect(secondStatus.session).not.toHaveProperty('hostExecutionOwned') + } + dispose() + }) + it('opens with every readable session and reports no status before a persisted turn', async () => { const journal = await openJournal() const { events } = feedFor(new Map([[SESSION, { journal }]])) @@ -523,3 +567,37 @@ describe('StructuredAgentSessionStatusFeed', () => { }) }) }) + +/** + * `published` is a broadcast cache, not a roster. It deliberately never retracts — an evicted idle + * session is still idle, and a reloading renderer must not lose every settled row — so enumerating + * it lists every session this host has ever opened. Eviction's `forget-session` step deletes the + * session from the live map and touches nothing else, so a poller has to intersect with that map. + */ +describe('the polling reader answers from the live sessions, not the retained cache', () => { + it('drops an evicted session from the poll while a late subscriber still sees it', async () => { + const journal = await openJournal() + const sessions = new Map([[SESSION, { journal }]]) + const { feed } = feedFor(sessions) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + expect(feed.liveSessionSummaries().map((summary) => summary.sessionId)).toEqual([SESSION]) + + // Exactly what eviction's `forget-session` step does; nothing else touches the feed. + sessions.delete(SESSION) + + expect(feed.liveSessionSummaries()).toEqual([]) + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-2', emit: (event) => late.push(event) }) + expect(late).toEqual([ + { + type: 'snapshot', + sessions: [expect.objectContaining({ sessionId: SESSION, status: 'idle' })] + } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index cfd85ff4648..61d9649587e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -30,6 +30,7 @@ export type StructuredAgentSessionStatusSubscriber = { type StatusFeedSession = { journal: AgentSessionJournal params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } + hasProviderChild?: boolean } export type StructuredAgentSessionStatusFeedDeps = { @@ -46,6 +47,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.workspaceId === b.workspaceId && a.agent === b.agent && a.status === b.status && + a.hostExecutionOwned === b.hostExecutionOwned && a.rewindBlockedReason === b.rewindBlockedReason && // Settled activity changes ranking; streaming active turns must stay quiet. (a.status !== 'idle' || a.updatedAt === b.updatedAt) && @@ -76,6 +78,27 @@ export class StructuredAgentSessionStatusFeed { return () => this.unsubscribe(subscriber.id) } + /** + * Summaries for the sessions this host still holds, for readers that poll instead of subscribing. + * + * `published` never retracts, so it is a broadcast cache and not a roster: enumerating it lists + * every session ever opened here. A caller asking what is running gets the live intersection, + * while the retained view a subscriber opens on stays whole. + * + * Deliberately does NOT re-project: a subscriber's snapshot is the live read, and re-running the + * journal reduction per caller would make an enumerating command pay for every session it lists. + */ + liveSessionSummaries(): AgentSessionStatusSummary[] { + const summaries: AgentSessionStatusSummary[] = [] + for (const [sessionId] of this.deps.sessions) { + const summary = this.published.get(sessionId) + if (summary) { + summaries.push(summary) + } + } + return summaries + } + unsubscribe(id: string): void { const subscriber = this.subscribers.get(id) if (!subscriber) { @@ -89,6 +112,20 @@ export class StructuredAgentSessionStatusFeed { } } + /** Revoke live execution authority while retaining the last projection for reload history. */ + revokeLive(sessionId: string): void { + const previous = this.published.get(sessionId) + if (!previous) { + return + } + const { hostExecutionOwned: _hostExecutionOwned, ...retained } = previous + this.published.set(sessionId, retained) + this.broadcast({ + type: 'status', + session: retained + }) + } + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { const session = this.deps.sessions.get(sessionId) @@ -126,6 +163,7 @@ export class StructuredAgentSessionStatusFeed { sessionId, workspaceId: session.params.location.workspaceId, agent: session.params.provider, + ...(session.hasProviderChild ? { hostExecutionOwned: true as const } : {}), ...projectStructuredAgentSessionStatusSummary(items), ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 87fe0cbbeec..ddc4af9f2d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -32,6 +32,7 @@ export type StructuredAgentSessionUnexpectedExitContext = { sessions: Map flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void + publishStatus?: (sessionId: string) => void hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -59,6 +60,7 @@ export async function settleUnexpectedStructuredAgentSessionExit( if (!record || record.lease.handoffStage !== null) { // The handoff coordinator owns an already-started transition. session.hasProviderChild = false + context.publishStatus?.(unexpectedEvent.sessionId) return null } @@ -117,6 +119,7 @@ export async function settleUnexpectedStructuredAgentSessionExit( context.onBarrierError?.(unexpectedEvent.sessionId, error) } finally { session.hasProviderChild = false + context.publishStatus?.(unexpectedEvent.sessionId) if (released) { session.fence = released.lease.runtimeFence context.publishFence(unexpectedEvent.sessionId, session) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts new file mode 100644 index 00000000000..42d03f46b5b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import { conversationCommandBlocked } from './structured-conversation-command-admission' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +function contextWith( + backgroundTasks: AgentSessionBackgroundTaskState | null +): AgentSessionTurnContext { + return { + sessionId: 'session-1', + journal: { + snapshot: () => ({ items: [] }), + submissions: () => [] + }, + adapter: { backgroundTaskState: () => backgroundTasks } + } as unknown as AgentSessionTurnContext +} + +const RECORD = { lease: {} } as unknown as AgentSessionRecord + +describe('conversationCommandBlocked background tasks', () => { + it('admits the command when nothing is being monitored', () => { + expect(conversationCommandBlocked(contextWith(null), RECORD)).toBeNull() + }) + + it('asks for a stop when the host accepts targeted stops', () => { + const blocked = conversationCommandBlocked( + contextWith({ state: 'monitoring', supportsTaskStop: true }), + RECORD + ) + expect(blocked).toBe('Stop background tasks before using this command.') + }) + + it('asks for a stop on a host that predates the stop-capability field', () => { + const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) + expect(blocked).toBe('Stop background tasks before using this command.') + }) + + it('asks the user to wait when the provider exposes no stop at all', () => { + // Codex: an instruction to stop would name a control that does not exist. + const blocked = conversationCommandBlocked( + contextWith({ state: 'monitoring', supportsStopAll: false }), + RECORD + ) + expect(blocked).toBe('Wait for background tasks to finish before using this command.') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index a69a5163b67..78b9f4a59d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -38,8 +38,14 @@ export function conversationCommandBlocked( ) { return 'Resolve the pending question or approval before using this command.' } - if (ctx.adapter.backgroundTaskState?.(ctx.sessionId)?.state === 'monitoring') { - return 'Stop background tasks before using this command.' + const backgroundTasks = ctx.adapter.backgroundTaskState?.(ctx.sessionId) + if (backgroundTasks?.state === 'monitoring') { + // Only ask for a stop the host can actually perform. A provider that + // exposes neither a targeted nor an untargeted stop would otherwise leave + // the command refused behind an instruction nobody can follow. + return backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + ? 'Stop background tasks before using this command.' + : 'Wait for background tasks to finish before using this command.' } if ( ctx.journal diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts index f1108df181c..13d2b51f284 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts @@ -21,6 +21,7 @@ export async function replaceClaudeRewindOwner( return rewindRefusal('outcome-unknown') } session.hasProviderChild = false + context.publishStatus?.(sessionId) const head = agentSessionProviderHandleChainHead( context.deps.store.getRecord(sessionId)!.providerHandleChain )?.handle diff --git a/src/main/native-chat/subagent-entry-id-bounds.test.ts b/src/main/native-chat/subagent-entry-id-bounds.test.ts new file mode 100644 index 00000000000..56d0edece66 --- /dev/null +++ b/src/main/native-chat/subagent-entry-id-bounds.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { boundSubagentEntryId, MAX_SUBAGENT_ENTRY_ID_CHARS } from './subagent-entry-id-bounds' + +const SHARED_HEAD = 'a'.repeat(MAX_SUBAGENT_ENTRY_ID_CHARS) + +describe('boundSubagentEntryId', () => { + it('leaves an id that already fits untouched', () => { + const id = 'task-1' + expect(boundSubagentEntryId(id)).toBe(id) + expect(boundSubagentEntryId(SHARED_HEAD)).toBe(SHARED_HEAD) + }) + + it('keeps two ids sharing the whole cap-length head distinct', () => { + const first = boundSubagentEntryId(`${SHARED_HEAD}-one`) + const second = boundSubagentEntryId(`${SHARED_HEAD}-two`) + expect(first).not.toBe(second) + expect(first).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS) + expect(second).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS) + }) + + it('is deterministic and a no-op on an already bounded id', () => { + const bounded = boundSubagentEntryId(`${SHARED_HEAD}-one`) + expect(boundSubagentEntryId(`${SHARED_HEAD}-one`)).toBe(bounded) + expect(boundSubagentEntryId(bounded)).toBe(bounded) + }) +}) diff --git a/src/main/native-chat/subagent-entry-id-bounds.ts b/src/main/native-chat/subagent-entry-id-bounds.ts new file mode 100644 index 00000000000..3abf19a4ff6 --- /dev/null +++ b/src/main/native-chat/subagent-entry-id-bounds.ts @@ -0,0 +1,27 @@ +// Bounding a subagent entry's id — a KEY, not display text. +// +// `NativeChatSubagentEntry.id` keys the roster, so clipping it to a fixed prefix +// merges two distinct children whose ids agree that far and associates one's +// state with the other. An id long enough to need bounding only reaches us from +// an imported legacy transcript, but a bound is still required, so keep a head +// for readability plus a digest of the WHOLE id to keep distinct ids distinct. + +import { createHash } from 'node:crypto' + +/** Shared by every site that puts a roster entry on a wire, so a journal-bound + * id survives the RPC and transcript bounds untouched instead of being clipped + * a second time into a different string. */ +export const MAX_SUBAGENT_ENTRY_ID_CHARS = 512 + +const DIGEST_CHARS = 16 + +/** Returns `id` unchanged when it fits, else a head plus a digest suffix whose + * total length is exactly the cap — so bounding a bounded id is a no-op. */ +export function boundSubagentEntryId(id: string): string { + if (id.length <= MAX_SUBAGENT_ENTRY_ID_CHARS) { + return id + } + const digest = createHash('sha256').update(id, 'utf8').digest('base64url').slice(0, DIGEST_CHARS) + const suffix = `…#${digest}` + return `${id.slice(0, MAX_SUBAGENT_ENTRY_ID_CHARS - suffix.length)}${suffix}` +} diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts index 9c01b4dccda..27232ee8833 100644 --- a/src/main/ports/port-scan-command-client.ts +++ b/src/main/ports/port-scan-command-client.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { Worker } from 'node:worker_threads' +import { LazyWorkerThreadHost, type WorkerThreadFactory } from '../lazy-worker-thread-host' import { PORT_SCAN_COMMAND_TIMEOUT_MS, PortScanCommandTimeoutError, @@ -11,11 +12,10 @@ import { // Why (#11161): a lazily-spawned, unref'd worker runs the port scan's probe // spawns off the Electron main-process event loop, because libuv performs -// process creation inline on the calling thread. Lifecycle (FIFO one-at-a-time -// dispatch, per-call deadlines, respawn-on-fault, idle teardown, fail-closed) -// mirrors src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts; -// the duplicated ~150 lines are cheaper than a premature shared abstraction, so -// a third adopter should extract one. +// process creation inline on the calling thread. This module owns the request +// half (FIFO one-at-a-time dispatch, per-call deadlines, respawn-on-fault); the +// thread's own lifetime belongs to LazyWorkerThreadHost, shared with +// src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts. // // This module used to contain the literal text require('electron'), which fails the // plain-Node entry guard even inside a try/catch. It reads the AppEnvironment port @@ -36,7 +36,7 @@ export const MAX_CONSECUTIVE_DEATHS = 3 export const MAX_QUEUED_CALLS = 8 export type PortScanCommandResult = { stdout: string; spawnMs: number } -export type PortScanWorkerFactory = () => Worker +export type PortScanWorkerFactory = WorkerThreadFactory // Distinguishes "no worker at all" from a timeout or crash so the scanner can // log it once and callers never mistake it for a command timeout. @@ -62,20 +62,27 @@ type PendingCall = { * be spawned rather than moving process creation back onto the main thread. */ export class PortScanCommandClient { - private worker: Worker | null = null private active: PendingCall | null = null private queue: PendingCall[] = [] - private idleTimer: NodeJS.Timeout | null = null private consecutiveDeaths = 0 private nextId = 1 - private loggedWorkerUnavailable = false - private cleanupWorkerListeners: (() => void) | null = null - private readonly workerFactory: PortScanWorkerFactory - private readonly log: (message: string) => void + private readonly host: LazyWorkerThreadHost constructor(options: { workerFactory: PortScanWorkerFactory; log?: (message: string) => void }) { - this.workerFactory = options.workerFactory - this.log = options.log ?? ((message) => console.warn(message)) + const log = options.log ?? ((message: string) => console.warn(message)) + this.host = new LazyWorkerThreadHost({ + factory: options.workerFactory, + idleTeardownMs: IDLE_TEARDOWN_MS, + onMessage: (response) => this.onMessage(response), + onError: (error) => this.onWorkerFault(error), + onExit: (code) => this.onWorkerExit(code), + isIdle: () => !this.active && this.queue.length === 0, + // Why (#11161): never fall back to in-process execFile here; a missing + // bundle must report port scanning as unavailable rather than reintroduce + // the main-thread freeze this worker boundary exists to prevent. + onUnavailable: (err) => + log(`[workspace-ports] probe worker unavailable. ${errorMessage(err)}`) + }) } /** @@ -109,7 +116,7 @@ export class PortScanCommandClient { if (this.active || this.queue.length === 0) { return } - const worker = this.ensureWorker() + const worker = this.host.ensure() if (!worker) { this.failQueuedAsUnavailable() return @@ -119,7 +126,7 @@ export class PortScanCommandClient { return } this.active = call - this.clearIdleTimer() + this.host.clearIdleTimer() // Why (#11161): one at a time. uv_spawn blocks the worker's own loop, so a // second concurrent request would have its deadline armed while the first // spawn is still stalling the thread, producing a false timeout. @@ -128,39 +135,6 @@ export class PortScanCommandClient { worker.postMessage(call.request) } - private ensureWorker(): Worker | null { - if (this.worker) { - return this.worker - } - try { - const worker = this.workerFactory() - const onMessage = (response: PortScanCommandResponse): void => this.onMessage(response) - const onError = (error: Error): void => this.onWorkerFault(error) - const onExit = (code: number): void => this.onWorkerExit(code) - worker.on('message', onMessage) - worker.on('error', onError) - worker.on('exit', onExit) - this.cleanupWorkerListeners = () => { - worker.off('message', onMessage) - worker.off('error', onError) - worker.off('exit', onExit) - } - // Never keep the app alive for a port scan. - worker.unref?.() - this.worker = worker - return worker - } catch (err) { - // Why (#11161): never fall back to in-process execFile here; a missing - // bundle must report port scanning as unavailable rather than reintroduce - // the main-thread freeze this worker boundary exists to prevent. - if (!this.loggedWorkerUnavailable) { - this.loggedWorkerUnavailable = true - this.log(`[workspace-ports] probe worker unavailable. ${errorMessage(err)}`) - } - return null - } - } - private onMessage(response: PortScanCommandResponse): void { const call = this.active if (!call || call.request.id !== response.id) { @@ -191,7 +165,7 @@ export class PortScanCommandClient { // A clean self-exit is not a death, but the stale handle must be dropped or // the next dispatch would post into a dead worker and stall to its deadline. if (code === 0 && !this.active && this.queue.length === 0) { - this.destroyWorker() + this.host.destroy() return } this.onWorkerFault(new Error(`Port scan probe worker exited with code ${code}`)) @@ -199,7 +173,7 @@ export class PortScanCommandClient { private onWorkerFault(error: Error): void { const failed = this.active - this.destroyWorker() + this.host.destroy() this.consecutiveDeaths++ if (failed) { this.settle(failed, () => failed.reject(error)) @@ -248,50 +222,11 @@ export class PortScanCommandClient { if (this.queue.length > 0) { this.pump() } else { - this.scheduleIdleTeardown() + // Terminating can orphan a probe child mid-spawn; the worker reaps what it + // can on exit, and every probe here is short-lived. + this.host.scheduleIdleTeardown() } } - - private scheduleIdleTeardown(): void { - this.clearIdleTimer() - if (!this.worker) { - return - } - this.idleTimer = setTimeout(() => this.teardownIfIdle(), IDLE_TEARDOWN_MS) - this.idleTimer.unref?.() - } - - private teardownIfIdle(): void { - this.idleTimer = null - // Only tear down with nothing active AND nothing queued: a request arriving - // as the timer fires must never be lost to a self-exiting worker. - if (this.active || this.queue.length > 0) { - return - } - this.destroyWorker() - } - - private clearIdleTimer(): void { - if (this.idleTimer) { - clearTimeout(this.idleTimer) - this.idleTimer = null - } - } - - private destroyWorker(): void { - this.clearIdleTimer() - const worker = this.worker - this.worker = null - if (!worker) { - return - } - this.cleanupWorkerListeners?.() - this.cleanupWorkerListeners = null - worker.removeAllListeners() - // Terminating can orphan a probe child mid-spawn; the worker reaps what it - // can on exit, and every probe here is short-lived. - void worker.terminate().catch(() => undefined) - } } function errorMessage(error: unknown): string { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 240156d93c6..3dbd8809018 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner' import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude' import type { RuntimeWorktreePsResult } from '../../shared/runtime-types' @@ -9,6 +10,7 @@ import { applyRuntimeWorktreePsTerminalActivity } from './runtime-worktree-ps-activity' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { compareWorktreePs } from './runtime-worktree-status-projection' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' @@ -102,11 +104,15 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent summaries, pathIndex: runtimeWorktreeSummaryPathIndex, missingWorktreeIds: missingRuntimeWorktreeIds, - mirroredWorktreeIdByTabId, - connectedPtyEvidence, workingTerminalEvidenceByWorktreeId, - retainedSnapshots: this.agentRows.values(), - hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [], + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId, + connectedPtyEvidence, + retainedSnapshots: this.agentRows.values(), + hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [], + // Broadcast history outlives closed sessions; only the host roster is eligible. + structuredSummaries: getStructuredAgentSessionHost()?.liveSessionStatusSummaries() ?? [] + }), orchestrationByPaneKey: this.agentOrchestrationProjection.buildByPaneKey(), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts new file mode 100644 index 00000000000..58e7de1b674 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../../native-chat/agent-session-journal/journal-store' +import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' +import { + getStructuredAgentSessionHost, + setStructuredAgentSessionHost +} from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { StructuredAgentSessionStatusFeed } from '../../native-chat/agent-session-wire/structured-agent-session-status-feed' + +/** + * The production wiring, not the projection. Both structured-row suites call + * `attachRuntimeWorktreeAgentRows` directly with summaries they built themselves, so nothing + * executed `getWorktreePs`'s own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()` + * — and that file carries `@ts-nocheck`, so renaming the accessor stayed green in typecheck AND + * in the suite while `orca worktree ps` and mobile's poll would throw for every user. + */ + +const HELD_SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const FORGOTTEN_SESSION = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' +const OBSERVED_AT = 1_757_030_400_000 + +function runningTurn(prompt: string): AgentJournalRenderItem[] { + return [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: OBSERVED_AT, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: prompt }] } + }, + { + itemId: 'turn-1', + sequence: 2, + revision: 1, + observedAt: OBSERVED_AT, + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } + ] +} + +function journalWith(prompt: string): AgentSessionJournal { + return { + isReadOnly: false, + lastActivityAt: () => OBSERVED_AT, + snapshot: () => ({ items: runningTurn(prompt) }) + } as unknown as AgentSessionJournal +} + +/** A real feed the host still holds one session on, having forgotten the other. Its `published` + * cache never retracts, so the two views genuinely differ. */ +function statusFeed(): StructuredAgentSessionStatusFeed { + const session = (prompt: string) => ({ + journal: journalWith(prompt), + params: { location: { workspaceId: TEST_WORKTREE_ID }, provider: 'claude' as const } + }) + const sessions = new Map([ + [HELD_SESSION, session('ship the thing')], + [FORGOTTEN_SESSION, session('rm the branch')] + ]) + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => OBSERVED_AT + }) + feed.publish(HELD_SESSION) + feed.publish(FORGOTTEN_SESSION) + // `forget-session`, the last eviction step, drops the session and leaves the feed alone. + sessions.delete(FORGOTTEN_SESSION) + return feed +} + +/** Everything the feed retained, read through the snapshot a subscriber opens on. No production + * code reads this; it is here so swapping the call site back to a whole-cache read is a one-line + * edit that this suite must catch. */ +function retainedSummaries(feed: StructuredAgentSessionStatusFeed): AgentSessionStatusSummary[] { + let retained: AgentSessionStatusSummary[] = [] + feed.subscribe({ + id: 'retained-probe', + emit: (event: AgentSessionStatusEvent) => { + if (event.type === 'snapshot') { + retained = event.sessions + } + } + })() + return retained +} + +function installHost(feed: StructuredAgentSessionStatusFeed) { + const liveSessionStatusSummaries = vi.fn(() => feed.liveSessionSummaries()) + const retainedSessionStatusSummaries = vi.fn(() => retainedSummaries(feed)) + // Typed against the real host, so renaming the accessor on the class reddens `tc` here — the + // caller cannot, because `orca-runtime-get-worktree-ps.ts` is `@ts-nocheck`. + const host: Pick & { + retainedSessionStatusSummaries: () => AgentSessionStatusSummary[] + } = { liveSessionStatusSummaries, retainedSessionStatusSummaries } + setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost) + return { liveSessionStatusSummaries, retainedSessionStatusSummaries } +} + +describe('worktree ps reads the installed structured host', () => { + afterEach(() => { + setStructuredAgentSessionHost(null) + }) + + it('reports the held session and asks the host for its live summaries', async () => { + const feed = statusFeed() + const { liveSessionStatusSummaries } = installHost(feed) + + const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs() + + const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID) + expect(worktree).toBeDefined() + // Exactly one: the forgotten session is still in the feed's retained cache, so a call site + // that enumerated that cache instead would report two. + expect(worktree?.agents).toHaveLength(1) + expect(worktree?.agents[0]).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'ship the thing' + }) + // Pins the call site to the live-intersecting accessor, not merely to some accessor. + expect(liveSessionStatusSummaries).toHaveBeenCalledTimes(1) + }) + + it('succeeds with no structured rows when no host is installed', async () => { + // Guard the guard: these specs share one module registry, so state the premise. + expect(getStructuredAgentSessionHost()).toBeNull() + + const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs() + + const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID) + expect(worktree).toBeDefined() + expect(worktree?.agents).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 829c2ca321e..74098040c27 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -85,6 +85,7 @@ await import('./orca-runtime-tests/mobile-summaries.spec') await import('./orca-runtime-tests/mobile-summaries-part-02.spec') await import('./orca-runtime-tests/mobile-summaries-part-03.spec') await import('./orca-runtime-tests/mobile-summaries-part-04.spec') +await import('./orca-runtime-tests/worktree-ps-structured-host.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec') diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index 47e0cd6165a..56c2e2d542f 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -1,7 +1,17 @@ -import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import { + ORCHESTRATION_LEGACY_RUN_ID, + ORCHESTRATION_UNBOUND_RUN_ID +} from '../../../../shared/orchestration-rpc-contract' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID +export const UNBOUND_RUN_ID = ORCHESTRATION_UNBOUND_RUN_ID + +// Why: a v1.4.198 coordinator sends no Run id, so its remote workers file mail under a per-attachment stub Run. +export const FEDERATED_STUB_HOME_RUN_ID_PREFIX = 'run_federated_' +export function federatedStubHomeRunId(dispatchId: string): string { + return `${FEDERATED_STUB_HOME_RUN_ID_PREFIX}${dispatchId}` +} export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION diff --git a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts index 3584a2e59a6..ddb0e383143 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts @@ -109,9 +109,13 @@ export function settleWorkerReportInTransaction( (dispatch.status === 'pending' || dispatch.status === 'dispatched') && task.status === 'blocked' && reportingWorker?.state === 'start_unknown' + const reportingStart = + dispatch.status === 'pending' && + task.status === 'dispatched' && + reportingWorker?.state === 'starting' const previousDispatchStatus = settledByUnobservedPrompt ? 'failed' - : reconnectingStart + : reconnectingStart || reportingStart ? dispatch.status : 'dispatched' const previousTaskStatus = settledByUnobservedPrompt @@ -198,7 +202,7 @@ export function settleWorkerReportInTransaction( const dispatchTransition = transitionLifecycleWithDb(this.db, { entity: 'dispatch', id: params.dispatchId, - from: reconnectingStart ? ['pending', 'dispatched'] : 'dispatched', + from: reconnectingStart || reportingStart ? ['pending', 'dispatched'] : 'dispatched', to: expectedDispatchStatus, projection: { completed_at: new Date().toISOString(), @@ -234,11 +238,11 @@ export function settleWorkerReportInTransaction( projection: { stage: 'settled', updated_at: new Date().toISOString() }, correction: 'unobserved_prompt_report' }) - } else if (reconnectingStart && params.outcome === 'succeeded') { + } else if ((reconnectingStart || reportingStart) && params.outcome === 'succeeded') { transitionLifecycleWithDb(this.db, { entity: 'worker', id: params.dispatchId, - from: 'start_unknown', + from: reportingStart ? 'starting' : 'start_unknown', to: 'ready' }) transitionLifecycleWithDb(this.db, { @@ -253,7 +257,7 @@ export function settleWorkerReportInTransaction( entity: 'worker', id: params.dispatchId, // A start_unknown success report reconnects through 'ready' above; only failure settles here. - from: params.outcome === 'succeeded' ? 'ready' : ['ready', 'start_unknown'], + from: params.outcome === 'succeeded' ? 'ready' : ['ready', 'start_unknown', 'starting'], to: params.outcome === 'succeeded' ? 'succeeded' : 'failed', projection: { stage: 'settled', updated_at: new Date().toISOString() } }) diff --git a/src/main/runtime/orchestration/db/federation/federated-stub-home-run-backfill.ts b/src/main/runtime/orchestration/db/federation/federated-stub-home-run-backfill.ts new file mode 100644 index 00000000000..47c596e260d --- /dev/null +++ b/src/main/runtime/orchestration/db/federation/federated-stub-home-run-backfill.ts @@ -0,0 +1,16 @@ +import type Database from '../../../../sqlite/sync-database' +import { FEDERATED_STUB_HOME_RUN_ID_PREFIX } from '../contract-constants' + +// Why: a rolled-back v1.4.198 host inserts attachments with home_run_id='' after user_version is +// already 40, so this idempotent repair runs on every open, not only inside the v40 migration. +export function backfillFederatedStubHomeRuns(db: Database.Database): void { + db.exec(` + INSERT OR IGNORE INTO runs (id, objective, home_database, consumer_generation, legacy) + SELECT '${FEDERATED_STUB_HOME_RUN_ID_PREFIX}' || dispatch_id, + 'Coordinated from ' || home_peer_fingerprint, 'remote', 0, 0 + FROM remote_dispatch_attachments WHERE home_run_id = ''; + UPDATE remote_dispatch_attachments + SET home_run_id = '${FEDERATED_STUB_HOME_RUN_ID_PREFIX}' || dispatch_id + WHERE home_run_id = ''; + `) +} diff --git a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts index d927cf96838..5a6e89b65ca 100644 --- a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts +++ b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts @@ -2,13 +2,15 @@ import type { WorkerDispatchState, RemoteDispatchAttachmentRow } from '../../typ import { OrchestrationError } from '../../orchestration-error' import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity' import type { OrchestrationDb } from '../orchestration-db' +import { federatedStubHomeRunId } from '../contract-constants' import { insertRemoteDispatchAttachmentRow } from '../dispatch-row-writer' export function createRemoteDispatchAttachment( this: OrchestrationDb, params: { dispatchId: string - runId: string + /** Absent from a v1.4.198 coordinator; replaced by a per-attachment stub Run. */ + runId?: string taskId: string homePeerFingerprint: string protocolVersion: number @@ -44,7 +46,8 @@ export function createRemoteDispatchAttachment( `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } - if (!params.runId?.trim()) { + const runId = params.runId ?? federatedStubHomeRunId(params.dispatchId) + if (!runId.trim()) { throw new OrchestrationError('invalid_argument', 'Missing Run ID') } this.db @@ -52,8 +55,8 @@ export function createRemoteDispatchAttachment( `INSERT OR IGNORE INTO runs (id, objective, home_database, consumer_generation, legacy) VALUES (?, ?, 'remote', 0, 0)` ) - .run(params.runId, `Coordinated from ${params.homePeerFingerprint}`) - this.requireRun(params.runId) + .run(runId, `Coordinated from ${params.homePeerFingerprint}`) + this.requireRun(runId) ensureMutationReceiptCapacity(this.db) this.db .prepare( @@ -70,7 +73,7 @@ export function createRemoteDispatchAttachment( ) insertRemoteDispatchAttachmentRow(this.db, { dispatchId: params.dispatchId, - runId: params.runId, + runId, taskId: params.taskId, homePeerFingerprint: params.homePeerFingerprint, protocolVersion: params.protocolVersion, diff --git a/src/main/runtime/orchestration/db/messages/message-insert.ts b/src/main/runtime/orchestration/db/messages/message-insert.ts index 82573305479..51ce7aaaa50 100644 --- a/src/main/runtime/orchestration/db/messages/message-insert.ts +++ b/src/main/runtime/orchestration/db/messages/message-insert.ts @@ -3,6 +3,7 @@ import { generateId } from '../generated-id' import { exposeMessageTimestamps } from '../utc-timestamp' import type { OrchestrationDb } from '../orchestration-db' import { runLifecycleWriteTransaction } from '../lifecycle-write-transaction-runner' +import { UNBOUND_RUN_ID } from '../contract-constants' // ── Messages ── @@ -25,9 +26,17 @@ export type MessageInsert = { } export function insertMessage(this: OrchestrationDb, msg: MessageInsert): MessageRow { - const runId = msg.runId - if (!runId) { - throw new Error('Run is required') + // A sender in no Run (two plain terminals, `send --to `) still gets durable mail. It is + // filed under the unbound Run, never the legacy one, which the schema-skew probe reads as pre-Runs. + // Created on first use so `run list` shows it only to a user who has such mail. + const runId = msg.runId ?? UNBOUND_RUN_ID + if (msg.runId == null) { + this.db + .prepare( + `INSERT OR IGNORE INTO runs (id, objective, home_database, consumer_generation, legacy) + VALUES (?, 'Mail from terminals in no Run', 'this_database', 0, 0)` + ) + .run(UNBOUND_RUN_ID) } const deliveryContract = msg.deliveryContract ?? 'current_delivery' this.requireRun(runId) diff --git a/src/main/runtime/orchestration/db/orchestration-db.ts b/src/main/runtime/orchestration/db/orchestration-db.ts index 2a970841a38..1ce52e96c4a 100644 --- a/src/main/runtime/orchestration/db/orchestration-db.ts +++ b/src/main/runtime/orchestration/db/orchestration-db.ts @@ -1,6 +1,7 @@ import Database from '../../../sqlite/sync-database' import { attachOrchestrationDbMethods } from './attach-orchestration-db-methods' import { hardenOrchestrationDatabaseFiles } from './database-file-permissions' +import { backfillFederatedStubHomeRuns } from './federation/federated-stub-home-run-backfill' import type { OrchestrationDbMethods } from './orchestration-db-methods' import { createCoordinatorMailRoutingTrigger, @@ -28,6 +29,7 @@ class OrchestrationDbCore { this.db.pragma('busy_timeout = 5000') createTables.call(this as unknown as OrchestrationDb) migrate.call(this as unknown as OrchestrationDb) + backfillFederatedStubHomeRuns(this.db) createCoordinatorMailRoutingTrigger.call(this as unknown as OrchestrationDb) rememberCurrentRunCoordinatorHandles.call(this as unknown as OrchestrationDb) hardenOrchestrationDatabaseFiles(dbPath) diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index 0897cb852de..d674a5298e7 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS federated_dispatches ( ); CREATE TABLE IF NOT EXISTS remote_dispatch_attachments ( - home_run_id TEXT NOT NULL, + -- DEFAULT: a rolled-back v1.4.198 host still inserts here without a home Run. + home_run_id TEXT NOT NULL DEFAULT '', dispatch_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, home_peer_fingerprint TEXT NOT NULL, diff --git a/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts b/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts index b970435223a..4fcd78634f3 100644 --- a/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts +++ b/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts @@ -1,26 +1,105 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../shared/protocol-version' import { OrchestrationDb } from '../orchestration-db' +import { SCHEMA_VERSION, federatedStubHomeRunId } from '../contract-constants' import { migrateV40 } from './migrate-v40' import { importFederatedControlMessage } from '../../federation-control-message' describe('federated home Run migration', () => { - const db = new OrchestrationDb(':memory:') + let db: OrchestrationDb + beforeEach(() => { + db = new OrchestrationDb(':memory:') + }) afterEach(() => db.close()) - it('adds the home Run column and refuses mail for a development placeholder', () => { + function importInstruction(target: OrchestrationDb, dispatchId: string, messageId: string): void { + expect( + importFederatedControlMessage(target, { + dispatchId, + messageId, + payload: JSON.stringify({ from: 'home', subject: 'Instruction', body: '', type: 'status' }) + }) + ).toEqual({ imported: true, type: 'status' }) + } + + function attachWithoutRunId(target: OrchestrationDb, dispatchId: string, runId?: string): void { + target.createRemoteDispatchAttachment({ + dispatchId, + runId, + taskId: `task_${dispatchId}`, + homePeerFingerprint: 'home', + protocolVersion: ORCHESTRATION_CONTRACT_VERSION, + runtimeEpoch: 'epoch', + mutationReceipt: { + callerFingerprint: 'home', + requestId: `request_${dispatchId}`, + method: 'orchestration.federationAttachStart', + payloadHash: `payload_${dispatchId}` + } + }) + } + + it('backfills a pre-upgrade attachment with a stub home Run that keeps its mailbox', () => { db.db.exec('ALTER TABLE remote_dispatch_attachments DROP COLUMN home_run_id') db.db.exec(`INSERT INTO remote_dispatch_attachments (dispatch_id, task_id, home_peer_fingerprint, runtime_epoch) VALUES ('ctx_old', 'task_old', 'home', 'epoch')`) migrateV40.call(db, 39) - expect(db.getRemoteDispatchAttachment('ctx_old')?.home_run_id).toBe('') - expect(() => - importFederatedControlMessage(db, { - dispatchId: 'ctx_old', - messageId: 'message_old', - payload: JSON.stringify({ from: 'home', subject: 'Instruction', body: '', type: 'message' }) - }) - ).toThrow('Run not found:') - expect(db.getMessageById('message_old')).toBeUndefined() + const stubRunId = federatedStubHomeRunId('ctx_old') + expect(db.getRemoteDispatchAttachment('ctx_old')?.home_run_id).toBe(stubRunId) + expect(db.getRunRaw(stubRunId)).toMatchObject({ home_database: 'remote', legacy: 0 }) + importInstruction(db, 'ctx_old', 'message_old') + expect(db.getMessageById('message_old')?.run_id).toBe(stubRunId) + }) + + it('mints a stub home Run when a v1.4.198 coordinator attaches without a Run id', () => { + attachWithoutRunId(db, 'ctx_legacy_home') + const stubRunId = federatedStubHomeRunId('ctx_legacy_home') + expect(db.getRemoteDispatchAttachment('ctx_legacy_home')?.home_run_id).toBe(stubRunId) + importInstruction(db, 'ctx_legacy_home', 'message_legacy_home') + expect(db.getMessageById('message_legacy_home')?.run_id).toBe(stubRunId) + }) + + it('rejects a whitespace-only Run id instead of minting a stub', () => { + expect(() => attachWithoutRunId(db, 'ctx_blank', ' ')).toThrow('Missing Run ID') + expect(db.getRemoteDispatchAttachment('ctx_blank')).toBeUndefined() + }) + + it('repairs rows a rolled-back v1.4.198 host inserted after user_version reached 40', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-federated-home-run-')) + const dbPath = join(dir, 'orchestration.db') + try { + const upgraded = new OrchestrationDb(dbPath) + expect(upgraded.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + // v1.4.198's insert shape: no home_run_id column, so the DEFAULT '' lands. + upgraded.db.exec(`INSERT INTO remote_dispatch_attachments + (dispatch_id, task_id, home_peer_fingerprint, runtime_epoch) + VALUES ('ctx_rolled_back', 'task_rolled_back', 'home', 'epoch')`) + expect(upgraded.getRemoteDispatchAttachment('ctx_rolled_back')?.home_run_id).toBe('') + expect(() => + importFederatedControlMessage(upgraded, { + dispatchId: 'ctx_rolled_back', + messageId: 'message_refused', + payload: JSON.stringify({ from: 'home', subject: 'x', body: '', type: 'status' }) + }) + ).toThrow(/Run not found/) + upgraded.close() + + const reopened = new OrchestrationDb(dbPath) + try { + const stubRunId = federatedStubHomeRunId('ctx_rolled_back') + expect(reopened.getRemoteDispatchAttachment('ctx_rolled_back')?.home_run_id).toBe(stubRunId) + expect(reopened.getRunRaw(stubRunId)).toMatchObject({ home_database: 'remote', legacy: 0 }) + importInstruction(reopened, 'ctx_rolled_back', 'message_rolled_back') + expect(reopened.getMessageById('message_rolled_back')?.run_id).toBe(stubRunId) + } finally { + reopened.close() + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } }) }) diff --git a/src/main/runtime/orchestration/db/schema/migrate-v40.ts b/src/main/runtime/orchestration/db/schema/migrate-v40.ts index 50ef46f82cc..e3cd46ca30d 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v40.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v40.ts @@ -1,11 +1,15 @@ import type { OrchestrationDb } from '../orchestration-db' +import { backfillFederatedStubHomeRuns } from '../federation/federated-stub-home-run-backfill' export function migrateV40(this: OrchestrationDb, current: number): void { - if (current >= 40 || this.hasColumn('remote_dispatch_attachments', 'home_run_id')) { + if (current >= 40) { return } - // Federation is unreleased; any development-only rows fail Run validation until reattached. - this.db.exec( - "ALTER TABLE remote_dispatch_attachments ADD COLUMN home_run_id TEXT NOT NULL DEFAULT ''" - ) + if (!this.hasColumn('remote_dispatch_attachments', 'home_run_id')) { + this.db.exec( + "ALTER TABLE remote_dispatch_attachments ADD COLUMN home_run_id TEXT NOT NULL DEFAULT ''" + ) + } + // Why: workers attached by v1.4.198 keep a mailbox; without a Run their control mail is refused. + backfillFederatedStubHomeRuns(this.db) } diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index 5e0f5f5b142..57ec354a8cc 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -15,9 +15,9 @@ export function prepareStartingWorkerAuthority( effects: unknown[] setupState: string hostScope?: string | null - // 'created': this worker-start operation created the agent terminal (including agent-first - // worktree creation, whose effects receipt says 'reused_agent_terminal'). 'external': an - // explicit --terminal reuse; ownership transfers only from an exact owned settled resource. + // 'created': this worker-start operation created the agent terminal (agent-first worktree + // creation included; its pre-rename effects rows said 'reused_agent_terminal'). 'external': + // an explicit --terminal reuse; ownership transfers only from an exact owned settled resource. terminalOwnership?: 'created' | 'external' } ): string { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts index 6544f519497..99873949f92 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts @@ -110,7 +110,8 @@ export function markWorkerStartUnknown( this: OrchestrationDb, dispatchId: string, stage: string, - reason: string + reason: string, + effects?: unknown[] ): WorkerDispatchRow { this.db.exec('BEGIN IMMEDIATE') try { @@ -124,7 +125,12 @@ export function markWorkerStartUnknown( id: dispatchId, from: 'starting', to: 'start_unknown', - projection: { stage, last_error: reason, updated_at: new Date().toISOString() } + projection: { + stage, + last_error: reason, + updated_at: new Date().toISOString(), + ...(effects ? { effects: JSON.stringify(effects) } : {}) + } }) transitionLifecycleWithDb(this.db, { entity: 'dispatch', @@ -138,7 +144,7 @@ export function markWorkerStartUnknown( from: 'dispatched', to: 'blocked' }) - this.closeQuestionsForDispatch(dispatchId) + // Authority survives uncertainty, so its outstanding questions must remain answerable. this.db.exec('COMMIT') return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow } catch (error) { diff --git a/src/main/runtime/orchestration/db/writer-run-required.test.ts b/src/main/runtime/orchestration/db/writer-run-required.test.ts index 21fcbeb28d6..682fabb1b60 100644 --- a/src/main/runtime/orchestration/db/writer-run-required.test.ts +++ b/src/main/runtime/orchestration/db/writer-run-required.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationDb } from './orchestration-db' +import { UNBOUND_RUN_ID } from './contract-constants' describe('writers require a Run', () => { let db: OrchestrationDb @@ -8,11 +9,11 @@ describe('writers require a Run', () => { }) afterEach(() => db.close()) - it('rejects a message without a Run instead of using the legacy Run', () => { - expect(() => db.insertMessage({ from: 'sender', to: 'worker', subject: 'mail' })).toThrow( - 'Run is required' - ) - expect(db.db.prepare('SELECT id FROM messages').all()).toEqual([]) + it('files a message without a Run under the unbound Run, never the legacy one', () => { + const message = db.insertMessage({ from: 'sender', to: 'worker', subject: 'mail' }) + expect(message.run_id).toBe(UNBOUND_RUN_ID) + expect(db.getRun(UNBOUND_RUN_ID)).toMatchObject({ legacy: 0 }) + expect(db.getUnreadMessages('worker').map((row) => row.id)).toEqual([message.id]) }) it('rejects a Task without a Run instead of using the legacy Run', () => { diff --git a/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts b/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts index ad08a7383f0..5eff7569d27 100644 --- a/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts +++ b/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { LEGACY_RUN_ID, OrchestrationDb } from './db' -import { SCHEMA_VERSION } from './db/contract-constants' +import { federatedStubHomeRunId, SCHEMA_VERSION, UNBOUND_RUN_ID } from './db/contract-constants' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' describe('federated mailbox legacy-adoption probe', () => { @@ -17,15 +17,22 @@ describe('federated mailbox legacy-adoption probe', () => { } }) - function seedMailbox(handle: string, kind: 'message' | 'delivery'): string { + function seedMailbox( + handle: string, + kind: 'message' | 'delivery', + homeRunId = 'run_home', + mailRunId = LEGACY_RUN_ID + ): string { directory = mkdtempSync(join(tmpdir(), 'orca-federated-legacy-probe-')) const path = join(directory, 'orchestration.db') db = new OrchestrationDb(path) - db.db.exec(` - INSERT INTO remote_dispatch_attachments ( - dispatch_id, task_id, home_peer_fingerprint, home_run_id, runtime_epoch, state - ) VALUES ('ctx_remote', 'task_remote', 'peer_home', 'run_home', 'epoch', 'ready'); - `) + db.db + .prepare( + `INSERT INTO remote_dispatch_attachments ( + dispatch_id, task_id, home_peer_fingerprint, home_run_id, runtime_epoch, state + ) VALUES ('ctx_remote', 'task_remote', 'peer_home', ?, 'epoch', 'ready')` + ) + .run(homeRunId) if (kind === 'message') { db.db .prepare( @@ -33,14 +40,14 @@ describe('federated mailbox legacy-adoption probe', () => { id, run_id, delivery_contract, from_handle, to_handle, subject, type ) VALUES ('msg_probe', ?, 'current_delivery', 'term_home', ?, 'continue', 'dispatch')` ) - .run(LEGACY_RUN_ID, handle) + .run(mailRunId, handle) } else { db.db .prepare( `INSERT INTO deliveries (id, run_id, mailbox_handle, consumer_generation, message_ids) VALUES ('delivery_probe', ?, ?, 0, '[]')` ) - .run(LEGACY_RUN_ID, handle) + .run(mailRunId, handle) } return path } @@ -70,6 +77,27 @@ describe('federated mailbox legacy-adoption probe', () => { } ) + it.each(['message', 'delivery'] as const)( + 'does not treat a stub-home-Run attachment %s as pre-Runs evidence', + (kind) => { + const stubRunId = federatedStubHomeRunId('ctx_remote') + const path = seedMailbox('dispatch:ctx_remote', kind, stubRunId, stubRunId) + db!.db + .prepare( + `INSERT INTO runs (id, objective, home_database, consumer_generation, legacy) + VALUES (?, 'Coordinated from peer_home', 'remote', 0, 0)` + ) + .run(stubRunId) + expect( + resolveOrchestrationMigrationStartVersion(db!.db, SCHEMA_VERSION, SCHEMA_VERSION) + ).toBe(SCHEMA_VERSION) + db!.close() + db = new OrchestrationDb(path) + expect(db.getLegacyAdoption()).toBeUndefined() + expect(db.getRemoteDispatchAttachment('ctx_remote')?.home_run_id).toBe(stubRunId) + } + ) + it.each(['message', 'delivery'] as const)( 'still replays adoption for a genuine legacy %s', (kind) => { @@ -94,4 +122,19 @@ describe('federated mailbox legacy-adoption probe', () => { } } ) + + it('keeps mail from a terminal in no Run across a reopen without replaying adoption', () => { + directory = mkdtempSync(join(tmpdir(), 'orca-unbound-mail-probe-')) + const path = join(directory, 'orchestration.db') + db = new OrchestrationDb(path) + const sent = db.insertMessage({ from: 'term_a', to: 'term_b', subject: 'hi' }) + expect(sent.run_id).toBe(UNBOUND_RUN_ID) + expect(resolveOrchestrationMigrationStartVersion(db.db, SCHEMA_VERSION, SCHEMA_VERSION)).toBe( + SCHEMA_VERSION + ) + db.close() + db = new OrchestrationDb(path) + expect(db.getLegacyAdoption()).toBeUndefined() + expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([sent.id]) + }) }) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index f47a46605e1..aaa3fdcfcc1 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -146,6 +146,36 @@ describe('worker transcript wire bounds', () => { expect(result).toMatchObject({ limited: false, warnings: [] }) }) + it('keeps two roster ids sharing a 512-char prefix distinct', () => { + // The id is the roster key: a plain prefix clip would merge the two children. + const head = 'a'.repeat(512) + const result = boundWorkerTranscriptMessages([ + { + id: 'message-1', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'g', + agents: [ + { id: `${head}-one`, label: 'Audit', state: 'working' }, + { id: `${head}-two`, label: 'Audit', state: 'working' } + ] + } + ] + } + ]) + + const block = result.messages[0]?.blocks[0] + if (block?.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id) + expect(block.agents[0]?.id).toHaveLength(512) + }) + it('keeps fallback identifiers stable without exposing the transcript path', () => { const transcriptPath = 'C:\\Users\\worker\\.codex\\session.jsonl' const message = { diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index f9d83e20c62..0f50aa8309c 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -5,6 +5,7 @@ import type { NativeChatMessage, NativeChatSubagentState } from '../../../shared/native-chat-types' +import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds' export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40 export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50 @@ -153,7 +154,7 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native groupId: clipMetadata(block.groupId, state), agents: agents.map((agent) => ({ ...agent, - id: clipMetadata(agent.id, state), + id: boundEntryId(agent.id, state), label: clipMetadata(agent.label, state), state: clipSubagentState(agent.state, state) })) @@ -194,6 +195,18 @@ function isLocalFileLocator(value: string): boolean { ) } +/** A roster entry's id is the roster KEY, so it is redacted like other metadata + * but bounded with a digest rather than clipped: two ids sharing a 512-char + * head must not collapse onto one entry. */ +function boundEntryId(value: string, state: TranscriptBoundState): string { + const redacted = redactSensitiveText(value, state.warnings) + const bounded = boundSubagentEntryId(redacted) + if (bounded !== redacted) { + markClipped(state, 'Oversized transcript metadata was clipped.') + } + return bounded +} + function clipMetadata(value: string, state: TranscriptBoundState): string { const redacted = redactSensitiveText(value, state.warnings) if (redacted.length <= MAX_WORKER_TRANSCRIPT_METADATA_CHARS) { diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts new file mode 100644 index 00000000000..de25ea7e45d --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' + +const SHARED_HEAD = 'a'.repeat(512) + +function rosterBlock(ids: readonly string[]): NativeChatBlock { + return { + type: 'subagent-group', + groupId: 'group-1', + agents: ids.map((id) => ({ id, label: 'l'.repeat(900), state: 'working' as const })) + } +} + +describe('mobile subagent roster bounds', () => { + it('keeps two ids sharing a 512-char prefix distinct', () => { + const block = sanitizeNativeChatRpcBlock( + rosterBlock([`${SHARED_HEAD}-one`, `${SHARED_HEAD}-two`]), + 'mobile' + ) + + if (block.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id) + expect(block.agents[0]?.id).toHaveLength(512) + // The label is display text and still clips. + expect(block.agents[0]?.label).toContain('… (truncated)') + }) + + it('leaves a short id alone', () => { + const block = sanitizeNativeChatRpcBlock(rosterBlock(['task-1']), 'mobile') + expect(block.type === 'subagent-group' && block.agents[0]?.id).toBe('task-1') + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts index fc19273d5ca..79f1da4fc53 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts @@ -3,6 +3,7 @@ import { normalizeSubagentState } from '../../../../shared/native-chat-subagent-summary' import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types' +import { boundSubagentEntryId } from '../../../native-chat/subagent-entry-id-bounds' import type { RpcContext } from '../core' import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' @@ -63,7 +64,9 @@ export function sanitizeNativeChatRpcBlock( groupId: clip(block.groupId, MAX_SUBAGENT_FIELD_CHARS), agents: block.agents.slice(0, MOBILE_SUBAGENT_CAP).map((agent) => ({ ...agent, - id: clip(agent.id, MAX_SUBAGENT_FIELD_CHARS), + // The id is the roster KEY: a prefix clip would merge two children, so + // it takes the shared digest bound the other wires use. + id: boundSubagentEntryId(agent.id), label: clip(agent.label, MAX_SUBAGENT_FIELD_CHARS), state: clipSubagentState(agent.state) })) diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 1417e716770..47a6d9e855e 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../../shared/native-chat-types' import type { RpcContext } from '../core' // Stub the bounded tail reader so the handler returns a deterministic transcript with @@ -88,6 +91,7 @@ vi.mock('../../../native-chat/transcript-watch', () => ({ } })) +import { boundSubagentEntryId } from '../../../native-chat/subagent-entry-id-bounds' import { NATIVE_CHAT_METHODS } from './native-chat' function makeMessage(text: string): NativeChatMessage { @@ -191,6 +195,35 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(block.text).toBe(text) }) + it('bounds a subagent roster before it reaches mobile', async () => { + const agents: NativeChatSubagentEntry[] = Array.from({ length: 100 }, (_, index) => ({ + id: `task-${index}-${'i'.repeat(600)}`, + label: 'l'.repeat(600), + state: 'working' + })) + cachedResult.value = { + messages: [ + { + ...makeMessage(''), + blocks: [{ type: 'subagent-group', groupId: 'g', agents }] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] as { + agents: { id: string; label: string }[] + } + expect(block.agents).toHaveLength(64) + expect(block.agents[0].label).toBe(`${'l'.repeat(512)}\n… (truncated)`) + // The id is as untrusted as the label on an imported roster, but it is the + // roster key: it is bounded with a digest, never clipped to a bare prefix. + expect(block.agents[0].id).toHaveLength(512) + expect(block.agents[0].id).toBe(boundSubagentEntryId(`task-0-${'i'.repeat(600)}`)) + }) + it('clips a pathological text block at the safety ceiling for mobile clients', async () => { const text = 'y'.repeat(70_000) cachedResult.value = { messages: [makeTextMessage(text)] } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts index da0bcf92894..36d25552d80 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts @@ -29,7 +29,9 @@ export function appendFederationTerminalEffects( : terminal.handle === setupHandle ? 'setup' : 'configured_tab', - action: terminal.handle === agentHandle ? 'reused_agent_terminal' : 'created', + // Remote agent-first worktree creation made every listed terminal, the agent one included; + // the verb is a lifecycle fact, not a role marker. + action: 'created', id: terminal.handle, tabId: terminal.tabId, leafId: terminal.leafId diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts new file mode 100644 index 00000000000..f00e5102d6d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { FederationAttachStartParams } from './federation-start-schema' + +// The request shape a v1.4.198 coordinator sends: no runId field at all. +const legacyRequest = { + dispatchId: 'ctx_legacy', + taskId: 'task_legacy', + taskSpec: 'Do the thing', + protocolVersion: 3, + worktree: 'feature-branch' +} + +describe('FederationAttachStartParams', () => { + it('parses a v1.4.198 request that carries no runId', () => { + const result = FederationAttachStartParams.safeParse(legacyRequest) + expect(result.success, result.success ? undefined : JSON.stringify(result.error.issues)).toBe( + true + ) + expect(result.success && result.data.runId).toBeUndefined() + }) + + it('keeps a v1.4.199 runId verbatim', () => { + const result = FederationAttachStartParams.parse({ ...legacyRequest, runId: 'run_home' }) + expect(result.runId).toBe('run_home') + }) + + // Pins OptionalString: '' and non-strings drop to undefined (a stub Run is minted downstream); + // whitespace-only passes the schema and is refused by createRemoteDispatchAttachment. + it('maps an empty or non-string runId to undefined but passes whitespace through', () => { + expect(FederationAttachStartParams.parse({ ...legacyRequest, runId: '' }).runId).toBeUndefined() + expect(FederationAttachStartParams.parse({ ...legacyRequest, runId: 7 }).runId).toBeUndefined() + expect(FederationAttachStartParams.parse({ ...legacyRequest, runId: ' ' }).runId).toBe(' ') + }) + + it('still requires the dispatch, task, spec, and worktree fields', () => { + for (const field of ['dispatchId', 'taskId', 'taskSpec', 'worktree'] as const) { + const { [field]: _dropped, ...rest } = legacyRequest + expect(FederationAttachStartParams.safeParse(rest).success, field).toBe(false) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts index 1e7257df27d..84ed57d58cc 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts @@ -3,7 +3,8 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../s import { OptionalWorkerLaunchPreference } from '../worker/worker-start-schema' export const FederationAttachStartParams = z.object({ - runId: requiredString('Missing Run ID'), + /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */ + runId: OptionalString, dispatchId: requiredString('Missing Dispatch ID'), taskId: requiredString('Missing Task ID'), taskSpec: requiredString('Missing Task spec'), diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts new file mode 100644 index 00000000000..84cb250a19e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +describe('orchestration.send between terminals in no Run', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { + // Two plain panes and `send --to `: the first command the guide teaches. #19542 + // refused this with a bare "Run is required"; it files under the unbound Run instead. + const { db, runtime, ctx } = h.setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await h.call( + 'orchestration.send', + { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, + ctx + )) as { message: { id: string; run_id: string; to_handle: string } } + + expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) + expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) + expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) + const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { + messages: { id: string }[] + } + expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index 32863c09371..aee45e25259 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -572,7 +572,7 @@ describe('orchestration RPC methods', () => { ) expect(result.effects).toEqual( expect.arrayContaining([ - expect.objectContaining({ role: 'agent', action: 'reused_agent_terminal' }), + expect.objectContaining({ role: 'agent', action: 'created' }), expect.objectContaining({ role: 'setup', action: 'created' }), expect.objectContaining({ role: 'configured_tab', action: 'created' }) ]) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 71370a4b3e9..ec188695f5c 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -19,11 +19,11 @@ import { import { failWorkerStartWithReceipt } from './worker-start-receipt' import { parseTaskDeps } from './task-deps-argument' import { assertExplicitWorkerTerminalUsable } from './explicit-worker-terminal-validation' -import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' import { recordCreatedWorkerTerminalCustody } from './created-worker-terminal-custody' import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' -import { monitorWorkerSetup, requireWorkerAuthority, type WorkerEffect } from './worker-topology' +import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' +import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' type WorkerStartMutation = { callerFingerprint: string @@ -204,50 +204,30 @@ export async function startLocalWorker(args: { terminalOwnership: params.terminal ? 'external' : 'created' }) - failedStage = 'dispatch_input' - const promptDelivery = await deliverWorkerDispatchPreamble({ + return await deliverAndSettleWorkerStartReadiness({ runtime, - structuredSession, - terminalHandle, + db, + run, + task, dispatchId: started.dispatch.id, dispatchDepth: started.dispatch.depth, - taskId: task.id, - taskSpec: task.spec, + structuredSession, + terminalHandle, coordinatorHandle: params.from, dispatchCapability: capability, devMode: params.devMode, - requestId: orchestrationMutation?.requestId ?? started.dispatch.id - }) - effects.push({ - kind: 'dispatch_input', - role: 'agent', - id: terminalHandle, - state: 'accepted' - }) - const worker = db.markWorkerDispatchReady(started.dispatch.id, effects) - monitorWorkerSetup({ - runtime, - db, - runId: run.id, - dispatchId: started.dispatch.id, + requestId: orchestrationMutation?.requestId ?? started.dispatch.id, + agent: agent ?? null, setupReceipt, - effects - }) - return { - runId: run.id, - taskId: task.id, - dispatchId: started.dispatch.id, - state: worker.state, - stage: worker.stage, - setup: setupReceipt, - launch: launch.receipt, + launchReceipt: launch.receipt, mode, timeoutMs: params.timeoutMs ?? 60_000, effects, - ...(promptDelivery ? { prompt: promptDelivery } : {}), - residualResources: [], - ...(placed.warning ? { warning: placed.warning } : {}) - } + terminalRevealWarning: placed.warning, + onStage: (stage) => { + failedStage = stage + } + }) } catch (error) { await tearDownFailedWorkerStart({ runtime, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts index 98407cdb1b4..52a70433867 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts @@ -6,6 +6,8 @@ import { } from './worker-topology' function residualWorkerEffects(effects: WorkerEffect[]): WorkerEffect[] { + // 'reused_agent_terminal' is the retired verb agent-first creation used for its own agent + // terminal; rows persisted before the rename still carry it. return effects.filter( (effect) => effect.action?.startsWith('created') || effect.action === 'reused_agent_terminal' ) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index dc645c97c88..593d097580f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -41,6 +41,8 @@ const openDatabases: OrchestrationDb[] = [] const temporaryRoots: string[] = [] type PromptContractHarness = { + runtime: Awaited>['runtime'] + handle: string db: OrchestrationDb dbPath: string dispatcher: RpcDispatcher @@ -131,6 +133,8 @@ async function createPromptContractHarness( vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') return { + runtime, + handle, db, dbPath, dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }), @@ -227,22 +231,100 @@ describe('orchestration worker-start prompt contract', () => { }) }) - it('keeps a swallowed Enter queued without revoking the worker or retrying input', async () => { + it.each([ + ['succeeded', false], + ['succeeded', true], + ['failed', false], + ['failed', true] + ] as const)('preserves an early %s report with turn evidence=%s', async (outcome, observed) => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + vi.spyOn(harness.runtime, 'observeTerminalAgentPrompt').mockImplementation( + async (_handle, prompt) => { + const dispatch = harness.db.findActiveDispatchForAssignee(harness.handle) + expect(dispatch).toBeDefined() + expect( + harness.db.settleWorkerReport({ + taskId: harness.taskId, + dispatchId: dispatch!.id, + outcome, + result: 'Finished before the hook arrived' + }) + ).toMatchObject({ action: 'settled', outcome }) + return observed ? { ...prompt, stages: ['input_accepted', 'turn_started'] } : prompt + } + ) + const pending = harness.dispatcher.dispatch(harness.request) + await vi.runAllTimersAsync() + expect(await pending).toMatchObject({ + ok: true, + result: { state: 'ready', stage: 'settled', workerOutcome: outcome } + }) + expect(harness.db.getTask(harness.taskId)?.status).toBe( + outcome === 'succeeded' ? 'completed' : 'failed' + ) + }) + + it('retains accepted authority when the observation binding becomes stale', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + vi.spyOn(harness.runtime, 'observeTerminalAgentPrompt').mockRejectedValue( + new Error('terminal_handle_stale') + ) + const pending = harness.dispatcher.dispatch(harness.request) + await vi.runAllTimersAsync() + expect(await pending).toMatchObject({ ok: true, result: { state: 'outcome_unknown' } }) + expect(harness.db.findActiveDispatchForAssignee(harness.handle)).toMatchObject({ + status: 'pending', + capability_hash: expect.any(String), + capability_revoked_at: null + }) + expect(harness.submittedTurns()).toBe(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a worker question answerable after turn observation times out', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + let questionId = '' + vi.spyOn(harness.runtime, 'observeTerminalAgentPrompt').mockImplementation( + async (_handle, prompt) => { + const dispatch = harness.db.findActiveDispatchForAssignee(harness.handle)! + questionId = harness.db.createQuestion({ + runId: dispatch.run_id!, + dispatchId: dispatch.id, + askerHandle: harness.handle, + question: 'Which target should I use?' + }).question.message_id + return prompt + } + ) + const pending = harness.dispatcher.dispatch(harness.request) + await vi.runAllTimersAsync() + expect(await pending).toMatchObject({ ok: true, result: { state: 'outcome_unknown' } }) + expect(harness.db.getQuestion(questionId)?.status).toBe('pending') + }) + + it('reports a swallowed Enter as start_unknown while keeping the worker and its capability', async () => { vi.useFakeTimers() const harness = await createPromptContractHarness('swallowed') const pending = harness.dispatcher.dispatch(harness.request) await vi.runAllTimersAsync() const response = await pending + // Codex supports turn-start observation and no turn started, so ready would be a lie: the + // paste can sit unsent in the composer while the receipt looks like a healthy dispatch. expect(response).toMatchObject({ ok: true, result: { - state: 'ready', - stage: 'input_accepted', + state: 'outcome_unknown', + stage: 'turn_start_unobserved', + turnStart: 'unobserved', prompt: { requestId: harness.requestId, stages: ['input_accepted'] }, + nextCommands: expect.arrayContaining([expect.stringContaining('worker-show')]), mutation: { requestId: harness.requestId, replayed: false } } }) @@ -251,29 +333,42 @@ describe('orchestration worker-start prompt contract', () => { } const dispatchId = (response.result as { dispatchId: string }).dispatchId await vi.advanceTimersByTimeAsync(20_000) + // Unverifiable is not failure: exactly one submit, no blind retry, nothing torn down. expect(harness.submittedTurns()).toBe(1) expect(harness.startedTurns()).toBe(0) expect(harness.prematureSubmits()).toBe(0) expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1) const persisted = reopenPromptContractDb(harness) - expect(persisted.getTask(harness.taskId)?.status).toBe('dispatched') + expect(persisted.getTask(harness.taskId)?.status).toBe('blocked') expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ - status: 'dispatched', + status: 'pending', last_failure: null, + // The capability survives so a worker that recovers can still report; worker-report + // settlement reconnects a start_unknown worker through 'ready'. + capability_hash: expect.any(String), capability_revoked_at: null }) expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ - state: 'ready', - stage: 'input_accepted', - last_error: null + state: 'start_unknown', + stage: 'turn_start_unobserved', + last_error: expect.stringContaining('turn start could not be verified') }) + const persistedEffects = JSON.parse( + persisted.getWorkerDispatch(dispatchId)?.effects ?? '[]' + ) as { kind?: string; state?: string }[] + expect(persistedEffects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }), + expect.objectContaining({ kind: 'dispatch_input', state: 'turn_unobserved' }) + ]) + ) const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint() const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId) expect(receipt).toMatchObject({ state: 'completed' }) expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({ dispatchId, - state: 'ready', - stage: 'input_accepted', + state: 'outcome_unknown', + stage: 'turn_start_unobserved', prompt: { requestId: harness.requestId, stages: ['input_accepted'] diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts new file mode 100644 index 00000000000..a3c57a357d2 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -0,0 +1,156 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { RunRow, TaskRow } from '../../../../orchestration/types' +import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' +import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' +import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' +import { + describeUnobservedWorkerTurnStart, + observeWorkerTurnStart, + type WorkerTurnStartObservation +} from './worker-start-turn-observation' +import { + monitorWorkerSetup, + type createStructuredWorkerSessionForWorktree, + type WorkerEffect, + type WorkerSetupReceipt +} from './worker-topology' + +/** + * Delivers the dispatch preamble and settles the worker's start state on the strongest + * evidence available: `ready` only with a positive turn-start (or a provider that cannot + * prove one), `start_unknown` when observation is supported and nothing started. + */ +export async function deliverAndSettleWorkerStartReadiness(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + run: RunRow + task: TaskRow + dispatchId: string + dispatchDepth: number + structuredSession: Awaited> | null + terminalHandle: string + coordinatorHandle: string + dispatchCapability: string + devMode: boolean | undefined + requestId: string + agent: string | null + setupReceipt: WorkerSetupReceipt + launchReceipt: OrchestrationWorkerLaunchReceipt + mode: WorkerStartModeReceipt + timeoutMs: number + effects: WorkerEffect[] + terminalRevealWarning: string | undefined + /** Keeps the caller's failure receipt naming the stage that actually failed. */ + onStage: (stage: 'dispatch_input' | 'turn_observation') => void +}): Promise { + const { runtime, db, run, task, structuredSession, terminalHandle, effects } = args + + args.onStage('dispatch_input') + const promptDelivery = await deliverWorkerDispatchPreamble({ + runtime, + structuredSession, + terminalHandle, + dispatchId: args.dispatchId, + dispatchDepth: args.dispatchDepth, + taskId: task.id, + taskSpec: task.spec, + coordinatorHandle: args.coordinatorHandle, + dispatchCapability: args.dispatchCapability, + devMode: args.devMode, + requestId: args.requestId + }) + effects.push({ + kind: 'dispatch_input', + role: 'agent', + id: terminalHandle, + state: 'accepted' + }) + + args.onStage('turn_observation') + // The write above was accepted without waiting on provider hooks; now demand the positive + // evidence the receipt claims is observable. A worker whose turn never starts must not be + // reported ready — a wedged agent and a working one looked identical before this gate. + // A structured preamble send is acknowledged by the provider or throws, so it is already + // positive evidence. + const turnStart: WorkerTurnStartObservation = structuredSession + ? { verdict: 'observed' } + : await observeWorkerTurnStart({ runtime, terminalHandle, prompt: promptDelivery }) + const deliveredPrompt = turnStart.prompt ?? promptDelivery + monitorWorkerSetup({ + runtime, + db, + runId: run.id, + dispatchId: args.dispatchId, + setupReceipt: args.setupReceipt, + effects + }) + // A worker report can settle the dispatch while turn observation is outstanding. + const currentWorker = db.getWorkerDispatch(args.dispatchId) + const alreadySettled = currentWorker && currentWorker.state !== 'starting' + if (turnStart.verdict === 'unobserved' && !alreadySettled) { + // Honest `unverifiable`: keep the dispatch capability and the terminal — the worker may + // still recover and report (worker-report settlement reconnects a start_unknown worker) — + // but never claim ready for a turn nobody observed. + effects.push({ + kind: 'dispatch_input', + role: 'agent', + id: terminalHandle, + state: 'turn_unobserved' + }) + const reason = describeUnobservedWorkerTurnStart(args.agent) + const worker = db.markWorkerStartUnknown( + args.dispatchId, + 'turn_start_unobserved', + reason, + effects + ) + return { + runId: run.id, + taskId: task.id, + dispatchId: args.dispatchId, + state: 'outcome_unknown', + stage: worker.stage, + turnStart: turnStart.verdict, + lastError: reason, + setup: args.setupReceipt, + launch: args.launchReceipt, + mode: args.mode, + timeoutMs: args.timeoutMs, + effects, + ...(deliveredPrompt ? { prompt: deliveredPrompt } : {}), + residualResources: JSON.parse(worker.residual_resources) as unknown[], + nextCommands: [ + `orca orchestration worker-show --dispatch ${args.dispatchId} --json`, + `orca terminal read --terminal ${terminalHandle} --screen`, + `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` + ], + ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) + } + } + const worker = alreadySettled + ? currentWorker + : db.markWorkerDispatchReady(args.dispatchId, effects) + // A completed task proves start succeeded; older callers use only 'ready' as start success. + const reportedOutcome = + worker.stage === 'settled' && (worker.state === 'succeeded' || worker.state === 'failed') + ? worker.state + : undefined + return { + runId: run.id, + taskId: task.id, + dispatchId: args.dispatchId, + state: reportedOutcome ? 'ready' : worker.state, + stage: worker.stage, + ...(reportedOutcome ? { workerOutcome: reportedOutcome } : {}), + turnStart: turnStart.verdict, + setup: args.setupReceipt, + launch: args.launchReceipt, + mode: args.mode, + timeoutMs: args.timeoutMs, + effects, + ...(deliveredPrompt ? { prompt: deliveredPrompt } : {}), + residualResources: [], + ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts new file mode 100644 index 00000000000..f1992eb9b9c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-contracts' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { observeWorkerTurnStart } from './worker-start-turn-observation' + +function delivery( + overrides: Partial = {} +): RuntimeTerminalPromptDelivery { + return { + requestId: 'req-1', + stages: ['input_accepted'], + provider: 'codex', + observation: 'supported', + processIncarnation: 'inc-1', + generation: 1, + baselineWorkingSequence: 0, + ...overrides + } +} + +function runtimeObserving(result: RuntimeTerminalPromptDelivery): { + runtime: OrcaRuntimeService + observe: ReturnType +} { + const observe = vi.fn().mockResolvedValue(result) + return { + runtime: { observeTerminalAgentPrompt: observe } as unknown as OrcaRuntimeService, + observe + } +} + +describe('observeWorkerTurnStart', () => { + it('treats a missing receipt as unsupported observation, never as failure', async () => { + const { runtime, observe } = runtimeObserving(delivery()) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt: undefined }) + ).resolves.toEqual({ verdict: 'unsupported' }) + expect(observe).not.toHaveBeenCalled() + }) + + it('accepts a first-stage turn_started without a second observation pass', async () => { + const prompt = delivery({ stages: ['input_accepted', 'turn_started'] }) + const { runtime, observe } = runtimeObserving(prompt) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt }) + ).resolves.toEqual({ verdict: 'observed', prompt }) + expect(observe).not.toHaveBeenCalled() + }) + + it('reports observed when the second-stage observer sees the turn start', async () => { + const observed = delivery({ stages: ['input_accepted', 'turn_started'] }) + const { runtime, observe } = runtimeObserving(observed) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toEqual({ verdict: 'observed', prompt: observed }) + expect(observe).toHaveBeenCalledWith('term_w', delivery(), 5) + }) + + it('reports unobserved — not dead — when a supported observation stalls', async () => { + const { runtime } = runtimeObserving(delivery()) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toMatchObject({ verdict: 'unobserved' }) + }) + + it('reports permission as positive liveness', async () => { + const observed = delivery({ observation: 'permission' }) + const { runtime } = runtimeObserving(observed) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toEqual({ verdict: 'permission', prompt: observed }) + }) + + it('treats a replaced incarnation as unobserved rather than unsupported', async () => { + const observed = delivery({ observation: 'incarnation_replaced' }) + const { runtime } = runtimeObserving(observed) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toEqual({ verdict: 'unobserved', prompt: observed }) + }) + + it('leaves an unsupported provider on the accepted receipt', async () => { + const prompt = delivery({ provider: 'unsupported', observation: 'unsupported' }) + const { runtime, observe } = runtimeObserving(prompt) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt }) + ).resolves.toEqual({ verdict: 'unsupported', prompt }) + expect(observe).not.toHaveBeenCalled() + }) + + it('preserves uncertainty when observation loses the terminal binding', async () => { + const prompt = delivery() + const { runtime, observe } = runtimeObserving(prompt) + observe.mockRejectedValue(new Error('terminal_handle_stale')) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt }) + ).resolves.toEqual({ verdict: 'unobserved', prompt }) + expect(observe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts new file mode 100644 index 00000000000..d1c9e59adfa --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts @@ -0,0 +1,86 @@ +import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../../../../../shared/orchestration-timing-budgets' +import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-contracts' +import type { OrcaRuntimeService } from '../../../../orca-runtime' + +/** + * Turn-start verdict for a dispatched worker prompt, in the execution-boundary vocabulary: + * + * - 'observed': the provider proved a turn started for this request. Positive liveness. + * - 'permission': the agent rendered an approval prompt after the write. Positive liveness, + * but the turn is blocked on a human. + * - 'unsupported': this provider exposes no turn-start signal; the accepted write is the + * strongest receipt that can exist. Never treated as failure. + * - 'unobserved': observation IS supported and no turn started within the window. This is + * `unverifiable`, never evidence of death — the bytes were written, but the agent may be + * wedged at startup or holding the spec unsent in its composer. + */ +export type WorkerTurnStartVerdict = 'observed' | 'permission' | 'unsupported' | 'unobserved' + +export type WorkerTurnStartObservation = { + verdict: WorkerTurnStartVerdict + prompt?: RuntimeTerminalPromptDelivery +} + +function classifyPromptDelivery(prompt: RuntimeTerminalPromptDelivery): WorkerTurnStartVerdict { + if (prompt.stages.includes('turn_started')) { + return 'observed' + } + if (prompt.observation === 'permission') { + return 'permission' + } + if (prompt.observation === 'supported') { + return 'unobserved' + } + // 'unsupported' (and an old host's missing observation) leaves acceptance as the best receipt. + return 'unsupported' +} + +/** + * Second-stage turn-start observation for a worker prompt that was accepted without waiting on + * provider hooks. Reuses the same observer that terminal.send receipts replay through, so the + * evidence rules (lifecycle edge or hook turn-start, never output bytes) stay in one place. + * + * The observation window is `AGENT_PROMPT_EFFECT_TIMEOUT_MS`, which worker-start's client RPC + * grace already budgets for (see orchestration-worker-start-prompt-budget.ts). + */ +export async function observeWorkerTurnStart(args: { + runtime: OrcaRuntimeService + terminalHandle: string + prompt: RuntimeTerminalPromptDelivery | undefined + timeoutMs?: number +}): Promise { + if (!args.prompt) { + return { verdict: 'unsupported' } + } + const verdict = classifyPromptDelivery(args.prompt) + if (verdict !== 'unobserved') { + return { verdict, prompt: args.prompt } + } + let observed: RuntimeTerminalPromptDelivery + try { + observed = await args.runtime.observeTerminalAgentPrompt( + args.terminalHandle, + args.prompt, + args.timeoutMs ?? AGENT_PROMPT_EFFECT_TIMEOUT_MS + ) + } catch { + // Observation failure cannot revoke authority for input that was already accepted. + return { verdict: 'unobserved', prompt: args.prompt } + } + if (observed.observation === 'incarnation_replaced') { + // The PTY under this handle changed mid-observation; the accepted write is unproven. + return { verdict: 'unobserved', prompt: observed } + } + return { verdict: classifyPromptDelivery(observed), prompt: observed } +} + +export function describeUnobservedWorkerTurnStart(agent: string | null): string { + const name = agent ?? 'the agent' + return ( + `Dispatch input was written and submitted, but ${name}'s turn start could not be verified ` + + `during observation (up to ${Math.round(AGENT_PROMPT_EFFECT_TIMEOUT_MS / 1000)}s). This is unverifiable, not proof the ` + + 'worker is dead: the agent may still be starting, may be wedged (for example waiting on ' + + 'network), or may be holding the task unsent in its composer. If the worker recovers and ' + + 'reports, this Dispatch settles normally.' + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index 0f9435bb018..fe0c11e374d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -108,7 +108,10 @@ export async function createWorkerWorktree(args: { : terminal.handle === setupTerminalHandle ? 'setup' : 'configured_tab', - action: terminal.handle === terminalHandle ? 'reused_agent_terminal' : 'created', + // Every terminal listed here — the agent terminal included — was created by this call's + // agent-first worktree creation. The old 'reused_agent_terminal' verb on the agent row + // conflated the role test with a lifecycle claim and misdiagnosed at least one incident. + action: 'created', id: terminal.handle, tabId: terminal.tabId, leafId: terminal.leafId diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index a0d74031b72..5164ac0b22f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -157,7 +157,7 @@ describe('orchestration new-worktree workers', () => { expect.objectContaining({ kind: 'terminal', role: 'agent', - action: 'reused_agent_terminal', + action: 'created', id: 'term_worker' }) ]) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts new file mode 100644 index 00000000000..cc5a7282080 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' +import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' +import { + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(installStructuredHostStub) +afterEach(clearStructuredHostStub) + +const TASKS: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: 'child', kind: 'agent' }] +} +const CURRENT_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities) +} + +describe('background-task stop capability at the RPC boundary', () => { + it('advertises reader support on remote requests and subscriptions', () => { + expect(CURRENT_CLIENT.clientCapabilities).toContain( + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY + ) + }) + + it.each([ + ['legacy reader', STRUCTURED_CLIENT, null], + ['current reader', CURRENT_CLIENT, TASKS], + ['in-process reader', undefined, TASKS] + ] as const)('projects history for a %s', async (_label, client, expected) => { + hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: TASKS } }) + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: expected } } }) + }) + + it.each(['snapshot', 'batch', 'reset'] as const)( + 'gates the %s stream without changing the provider state', + async (type) => { + hostCalls.hold = vi.fn(async () => undefined) + hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { + const base = { sessionId: SESSION, fence: 1, backgroundTasks: TASKS } + if (type === 'batch') { + input.emit({ + ...base, + type, + batch: { + cursor: { epoch: 'a', sequence: 0 }, + items: [], + removedItemIds: [], + submissions: [] + } + }) + } else { + const page = { + sessionId: SESSION, + epoch: 'a', + direction: 'tail' as const, + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'a', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } + input.emit( + type === 'snapshot' + ? { ...base, type, page } + : { ...base, type, page, reset: 'epoch_changed' } + ) + } + return () => {} + }) + for (const [client, expected] of [ + [STRUCTURED_CLIENT, null], + [CURRENT_CLIENT, TASKS] + ] as const) { + expect(await call('agentSession.subscribe', { sessionId: SESSION }, client)).toMatchObject({ + ok: true, + result: { type, backgroundTasks: expected } + }) + } + expect(TASKS.supportsStopAll).toBe(false) + } + ) + + it('preserves legacy stoppable state for both readers', async () => { + const stoppable = { state: 'monitoring', tasks: TASKS.tasks } + hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: stoppable } }) + for (const client of [STRUCTURED_CLIENT, CURRENT_CLIENT]) { + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: stoppable } } }) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts new file mode 100644 index 00000000000..02afc5569bf --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts @@ -0,0 +1,47 @@ +import type { + AgentSessionBackgroundTaskState, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' + +type BackgroundTaskReader = Pick + +function supportsReadOnlyTasks(ctx: BackgroundTaskReader): boolean { + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY) === true + ) +} + +function projectState( + state: AgentSessionBackgroundTaskState | null | undefined, + ctx: BackgroundTaskReader +): AgentSessionBackgroundTaskState | null | undefined { + // Legacy readers always offer a stop; retain their pre-producer empty strip. + return state?.supportsStopAll === false && !state.supportsTaskStop && !supportsReadOnlyTasks(ctx) + ? null + : state +} + +export function projectBackgroundTaskHistory( + result: AgentSessionHistoryResult, + ctx: BackgroundTaskReader +): AgentSessionHistoryResult { + const state = projectState(result.page.backgroundTasks, ctx) + return state === result.page.backgroundTasks + ? result + : { ...result, page: { ...result.page, backgroundTasks: state } } +} + +export function projectBackgroundTaskEvent( + event: AgentSessionSubscribeEvent, + ctx: BackgroundTaskReader +): AgentSessionSubscribeEvent { + if (!('backgroundTasks' in event)) { + return event + } + const state = projectState(event.backgroundTasks, ctx) + return state === event.backgroundTasks ? event : { ...event, backgroundTasks: state } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 3078c9fff61..d629b1c29a1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -10,6 +10,10 @@ import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { z } from 'zod' +import { + projectBackgroundTaskEvent, + projectBackgroundTaskHistory +} from './structured-agent-session-background-task-capability' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, @@ -251,7 +255,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'agentSession.history', params: HistoryParams, - handler: async (params, ctx) => requireHost(ctx).history(params) + handler: async (params, ctx) => + projectBackgroundTaskHistory(requireHost(ctx).history(params), ctx) }), defineStreamingMethod({ name: 'agentSession.subscribe', @@ -278,7 +283,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ dispose = host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit, + emit: (event) => emit(projectBackgroundTaskEvent(event, ctx)), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts new file mode 100644 index 00000000000..84966846be7 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -0,0 +1,120 @@ +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +import { describe, expect, it } from 'vitest' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' + +/** + * A structured session has no PTY, so it reaches none of the hook or retained snapshots that every + * other row comes from. Before this, `worktree ps` reported a worktree running one as idle while + * the sidebar showed it working — the CLI, which is the agent-facing surface, was the blind one. + */ +const WORKTREE_ID = 'repo-1::/workspace/app' + +function summary(over: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + workspaceId: WORKTREE_ID, + agent: 'claude', + status: 'working', + latestPrompt: 'ship the thing', + updatedAt: 1_757_030_400_000, + hostExecutionOwned: true, + ...over + } as AgentSessionStatusSummary +} + +function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummary { + const row = { + worktreeId: WORKTREE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } as unknown as RuntimeWorktreePsSummary + const summariesById = new Map([[WORKTREE_ID, row]]) + attachRuntimeWorktreeAgentRows({ + summaries: summariesById, + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, + retainedSnapshots: [], + hookSnapshots: [], + structuredSummaries: summaries + }), + orchestrationByPaneKey: null, + getSummary: (map, _p, _m, id) => map.get(id) ?? null + }) + return row +} + +describe('worktree ps reports structured sessions', () => { + it('a busy structured session is not reported idle', () => { + const row = attach([summary()]) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('working') + expect(row.agents[0]?.agentType).toBe('claude') + expect(row.agents[0]?.prompt).toBe('ship the thing') + }) + + // The same projection the sidebar applies, so the two surfaces cannot disagree about one session. + it('maps attention to blocked and idle to done', () => { + expect(attach([summary({ status: 'attention' })]).agents[0]?.state).toBe('blocked') + expect(attach([summary({ status: 'idle' })]).agents[0]?.state).toBe('done') + }) + + it('does not turn a completed host-held session into permission', () => { + const row = attach([summary({ status: 'idle' })]) + expect(row.status).toBe('inactive') + expect(row.hasHostSidebarActivity).toBe(false) + }) + + it('reports the DERIVED pane key, never an orchestration credential', () => { + const row = attach([summary()]) + const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + expect(row.agents[0]?.paneKey).toBe( + structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) + ) + }) + + // Null status means no turn has been persisted; the chat itself shows nothing, so neither does this. + it('omits a session with no projected status', () => { + expect(attach([summary({ status: null })]).agents).toHaveLength(0) + }) +}) + +/** + * The deliberate non-goal. Adding structured rows to `terminal list` was investigated and rejected: + * mobile mounts a terminal WebView per row that can never receive a frame, a `connected`-keyed + * refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and + * the plugin projection has no field that can carry `writable: false`. Every SAFE consumer of a + * terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence, + * which no added field can qualify. A separate change publishes an honest partial-listing count + * there instead. This pins that only `worktree ps` gained the enumerator. + */ +describe('terminal listing is deliberately left alone', () => { + it('only worktree ps consumes the structured status summaries', async () => { + const { readFile } = await import('node:fs/promises') + // orca-runtime-subscribe-to-terminal-resize.ts owns listTerminals. + const listing = await readFile( + new URL('./orca-runtime-subscribe-to-terminal-resize.ts', import.meta.url), + 'utf8' + ) + // Guard the guard: an empty read would make every assertion below vacuously true. + expect(listing).toContain('async listTerminals(') + expect(listing).not.toContain('liveSessionStatusSummaries') + expect(listing).not.toContain('structuredSummaries') + + const worktreePs = await readFile( + new URL('./orca-runtime-get-worktree-ps.ts', import.meta.url), + 'utf8' + ) + expect(worktreePs).toContain('liveSessionStatusSummaries') + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 1853f704471..80802cf11a3 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -1,34 +1,10 @@ -import { - AGENT_STATUS_STALE_AFTER_MS, - isFreshNonDoneAgentStatus, - pickParsedAgentStatusPayload, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' +import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-types' import type { RuntimeWorktreeAgentRow, RuntimeWorktreePsSummary } from '../../shared/runtime-types' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection' import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity' - -export type RuntimeAgentRowSnapshot = { - paneKey: string - ptyId: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - stateStartedAt: number - updatedAt: number -} - -type ConnectedPtyEvidence = { - tabIds: ReadonlySet - paneKeys: ReadonlySet - ptyIds: ReadonlySet -} +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' +export type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' type OrchestrationDisplay = { taskTitle?: string | null @@ -36,38 +12,15 @@ type OrchestrationDisplay = { parentPaneKey?: string | null } -type RuntimeWorktreeAgentSource = { - paneKey: string - ptyId?: string - tabId?: string - worktreeId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - state: ParsedAgentStatusPayload['state'] - workingMode?: ParsedAgentStatusPayload['workingMode'] - agentType: string | null - prompt: string - lastAssistantMessage: string | null - toolName: string | null - toolInput: string | null - interrupted: boolean - stateStartedAt: number - updatedAt: number - restoredUnconfirmed?: boolean -} - export function attachRuntimeWorktreeAgentRows(args: { summaries: Map pathIndex: RuntimeWorktreeSummaryPathIndex missingWorktreeIds: Set - mirroredWorktreeIdByTabId: ReadonlyMap - connectedPtyEvidence: ConnectedPtyEvidence + rowSources: ReadonlyMap workingTerminalEvidenceByWorktreeId: ReadonlyMap< string, readonly RuntimeWorkingTerminalEvidence[] > - retainedSnapshots: Iterable - hookSnapshots: readonly AgentStatusIpcPayload[] orchestrationByPaneKey: Record | null | undefined getSummary: ( summaries: Map, @@ -76,88 +29,11 @@ export function attachRuntimeWorktreeAgentRows(args: { worktreeId: string ) => RuntimeWorktreePsSummary | null }): void { - const rowSources = new Map() + const { rowSources } = args const now = Date.now() - for (const snapshot of args.retainedSnapshots) { - const { payload } = snapshot - rowSources.set(snapshot.paneKey, { - paneKey: snapshot.paneKey, - ptyId: snapshot.ptyId, - tabId: snapshot.tabId, - worktreeId: snapshot.worktreeId, - connectionId: snapshot.connectionId, - payload, - state: payload.state, - ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), - agentType: payload.agentType ?? null, - prompt: payload.prompt, - lastAssistantMessage: payload.lastAssistantMessage ?? null, - toolName: payload.toolName ?? null, - toolInput: payload.toolInput ?? null, - interrupted: payload.interrupted ?? false, - stateStartedAt: snapshot.stateStartedAt, - updatedAt: snapshot.updatedAt - }) - } - for (const entry of args.hookSnapshots) { - if (entry.restoredUnconfirmed === true) { - continue - } - const existing = rowSources.get(entry.paneKey) - const hookPayload = pickParsedAgentStatusPayload(entry) - if (existing && existing.updatedAt > entry.receivedAt) { - if ( - entry.workingMode === 'monitoring' && - now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && - terminalStatusPayloadMatchesHook(hookPayload, existing.payload) - ) { - existing.workingMode = 'monitoring' - if (existing.payload.workingMode === undefined) { - existing.payload = { ...existing.payload, workingMode: 'monitoring' } - } - } - continue - } - rowSources.set(entry.paneKey, { - paneKey: entry.paneKey, - ptyId: existing?.ptyId, - tabId: entry.tabId, - worktreeId: entry.worktreeId, - connectionId: entry.connectionId, - payload: hookPayload, - state: entry.state, - ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), - agentType: entry.agentType ?? null, - prompt: entry.prompt, - lastAssistantMessage: entry.lastAssistantMessage ?? null, - toolName: entry.toolName ?? null, - toolInput: entry.toolInput ?? null, - interrupted: entry.interrupted ?? false, - stateStartedAt: entry.stateStartedAt, - updatedAt: entry.receivedAt - }) - } - if (rowSources.size === 0) { - return - } const rowsByWorktree = new Map() for (const source of rowSources.values()) { - const tabId = - source.tabId ?? - parsePaneKey(source.paneKey)?.tabId ?? - parseLegacyNumericPaneKey(source.paneKey)?.tabId - const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined - if ( - tabId !== undefined && - mirroredWorktreeId === undefined && - (source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) && - !args.connectedPtyEvidence.tabIds.has(tabId) && - !args.connectedPtyEvidence.paneKeys.has(source.paneKey) && - (source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId)) - ) { - continue - } - const worktreeId = mirroredWorktreeId ?? source.worktreeId + const { worktreeId } = source if (!worktreeId) { continue } @@ -204,13 +80,15 @@ export function attachRuntimeWorktreeAgentRows(args: { let hasForegroundWorkingAgent = false const monitoringSources: RuntimeWorktreeAgentSource[] = [] for (const row of rows) { - if (!isFreshNonDoneAgentStatus(row, now)) { + const source = rowSources.get(row.paneKey) + const hostHeldStructuredSession = + source?.authority === 'structured-host' && row.state !== 'done' + if (!hostHeldStructuredSession && !isFreshNonDoneAgentStatus(row, now)) { continue } summary.hasHostSidebarActivity = true if (row.state === 'working') { if (row.workingMode === 'monitoring') { - const source = rowSources.get(row.paneKey) if (source) { monitoringSources.push(source) } diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts new file mode 100644 index 00000000000..2e8bc833b23 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -0,0 +1,21 @@ +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' + +export type RuntimeWorktreeAgentSource = { + paneKey: string + ptyId?: string + tabId?: string + worktreeId?: string + connectionId: string | null + state: ParsedAgentStatusPayload['state'] + workingMode?: ParsedAgentStatusPayload['workingMode'] + agentType: string | null + prompt: string + lastAssistantMessage: string | null + toolName: string | null + toolInput: string | null + interrupted: boolean + stateStartedAt: number + updatedAt: number + /** Structured host projections remain authoritative after PTY freshness expiry. */ + authority?: 'structured-host' +} diff --git a/src/main/runtime/runtime-worktree-agent-sources.test.ts b/src/main/runtime/runtime-worktree-agent-sources.test.ts new file mode 100644 index 00000000000..c0395cd6831 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-sources.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +import type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +const paneKey = 'worktree:tab:0' +const now = Date.now() +const retained: RuntimeAgentRowSnapshot = { + paneKey, + ptyId: 'pty', + tabId: 'tab', + worktreeId: 'worktree', + connectionId: null, + payload: { state: 'working', prompt: 'implement', agentType: 'codex' }, + stateStartedAt: now, + updatedAt: now +} +const base = { + retainedSnapshots: [retained], + hookSnapshots: [] as AgentStatusIpcPayload[], + structuredSummaries: [], + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIds: new Set() + } +} + +describe('worktree agent source admission', () => { + it('rejects a disconnected local terminal before row assembly', () => { + expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) + const connected = { + ...base, + connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } + } + expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.state).toBe('working') + }) + + it('keeps remote evidence and resolves mirrored workspace ownership', () => { + const remote = { ...retained, connectionId: 'ssh-connection' } + expect(collectRuntimeWorktreeAgentSources({ ...base, retainedSnapshots: [remote] }).size).toBe( + 1 + ) + const sources = collectRuntimeWorktreeAgentSources({ + ...base, + mirroredWorktreeIdByTabId: new Map([['tab', 'remote-worktree']]) + }) + expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree') + }) + + it('preserves fresh monitoring enrichment on a newer retained report', () => { + const hook: AgentStatusIpcPayload = { + ...retained.payload, + paneKey, + tabId: 'tab', + worktreeId: 'worktree', + connectionId: null, + stateStartedAt: now - 1, + receivedAt: now - 1, + workingMode: 'monitoring' + } + const sources = collectRuntimeWorktreeAgentSources({ + ...base, + hookSnapshots: [hook], + connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } + }) + expect(sources.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-sources.ts b/src/main/runtime/runtime-worktree-agent-sources.ts new file mode 100644 index 00000000000..44ce7848960 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-sources.ts @@ -0,0 +1,22 @@ +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { collectRuntimeWorktreePtyAgentSources } from './runtime-worktree-pty-agent-sources' +import { structuredRuntimeWorktreeAgentSources } from './runtime-worktree-structured-agent-rows' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' + +/** One admitted roster for row and worktree-status projection. */ +export function collectRuntimeWorktreeAgentSources( + args: Parameters[0] & { + structuredSummaries: readonly AgentSessionStatusSummary[] + } +): ReadonlyMap { + const sources = new Map() + for (const source of collectRuntimeWorktreePtyAgentSources(args)) { + sources.set(source.paneKey, source) + } + for (const source of structuredRuntimeWorktreeAgentSources(args.structuredSummaries)) { + if (!sources.has(source.paneKey)) { + sources.set(source.paneKey, source) + } + } + return sources +} diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts new file mode 100644 index 00000000000..04e8dffc04e --- /dev/null +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -0,0 +1,121 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + pickParsedAgentStatusPayload, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' + +export type RuntimeAgentRowSnapshot = { + paneKey: string + ptyId: string + worktreeId?: string + tabId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + stateStartedAt: number + updatedAt: number +} + +export type ConnectedPtyEvidence = { + tabIds: ReadonlySet + paneKeys: ReadonlySet + ptyIds: ReadonlySet +} + +/** Reconcile terminal status, then admit rows using their execution-host evidence. */ +export function collectRuntimeWorktreePtyAgentSources(args: { + retainedSnapshots: Iterable + hookSnapshots: readonly AgentStatusIpcPayload[] + mirroredWorktreeIdByTabId: ReadonlyMap + connectedPtyEvidence: ConnectedPtyEvidence +}): RuntimeWorktreeAgentSource[] { + const rowSources = new Map< + string, + RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload } + >() + const now = Date.now() + for (const snapshot of args.retainedSnapshots) { + const { payload } = snapshot + rowSources.set(snapshot.paneKey, { + paneKey: snapshot.paneKey, + ptyId: snapshot.ptyId, + tabId: snapshot.tabId, + worktreeId: snapshot.worktreeId, + connectionId: snapshot.connectionId, + payload, + state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), + agentType: payload.agentType ?? null, + prompt: payload.prompt, + lastAssistantMessage: payload.lastAssistantMessage ?? null, + toolName: payload.toolName ?? null, + toolInput: payload.toolInput ?? null, + interrupted: payload.interrupted ?? false, + stateStartedAt: snapshot.stateStartedAt, + updatedAt: snapshot.updatedAt + }) + } + for (const entry of args.hookSnapshots) { + if (entry.restoredUnconfirmed === true) { + continue + } + const existing = rowSources.get(entry.paneKey) + const hookPayload = pickParsedAgentStatusPayload(entry) + if (existing && existing.updatedAt > entry.receivedAt) { + if ( + entry.workingMode === 'monitoring' && + now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && + terminalStatusPayloadMatchesHook(hookPayload, existing.payload) + ) { + existing.workingMode = 'monitoring' + if (existing.payload.workingMode === undefined) { + existing.payload = { ...existing.payload, workingMode: 'monitoring' } + } + } + continue + } + rowSources.set(entry.paneKey, { + paneKey: entry.paneKey, + ptyId: existing?.ptyId, + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + payload: hookPayload, + state: entry.state, + ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), + agentType: entry.agentType ?? null, + prompt: entry.prompt, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interrupted: entry.interrupted ?? false, + stateStartedAt: entry.stateStartedAt, + updatedAt: entry.receivedAt + }) + } + const sources: RuntimeWorktreeAgentSource[] = [] + for (const source of rowSources.values()) { + const tabId = + source.tabId ?? + parsePaneKey(source.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(source.paneKey)?.tabId + const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined + if ( + tabId !== undefined && + mirroredWorktreeId === undefined && + (source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) && + !args.connectedPtyEvidence.tabIds.has(tabId) && + !args.connectedPtyEvidence.paneKeys.has(source.paneKey) && + (source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId)) + ) { + continue + } + const worktreeId = mirroredWorktreeId ?? source.worktreeId + sources.push({ ...source, tabId, worktreeId }) + } + return sources +} diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts new file mode 100644 index 00000000000..063ee0b36cf --- /dev/null +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -0,0 +1,157 @@ +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' + +/** + * The whole chain `worktree ps` walks: journal -> status feed -> agent rows -> worktree status. + * + * The feed's `published` map never retracts, so reading it as a roster reports every session the + * app has ever opened. A closed chat that was waiting on an approval is the sharp edge: deliberate + * close does not settle a pending prompt, so the retained summary stays `attention`, which maps to + * a `blocked` row and merges the worktree to `permission` for the 30-minute freshness window. + */ +const WORKTREE_ID = 'repo-1::/workspace/app' +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-ps-liveness-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** A session parked on an approval nobody answered — the state a deliberate close leaves behind. */ +async function awaitingApproval() { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: WORKTREE_ID, + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) + await journal.appendItem( + { ...IDENTITY, ordinal: 1 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] }, + { fence: 1 } + ) + await journal.appendItem( + { ...IDENTITY, ordinal: 2 }, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + const sessions = new Map([ + [ + SESSION, + { journal, params: { location: { workspaceId: WORKTREE_ID }, provider: 'codex' as const } } + ] + ]) + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => Date.now() + }) + feed.publish(SESSION, journal) + return { feed, sessions } +} + +function worktreeFor( + feed: StructuredAgentSessionStatusFeed, + summaries = feed.liveSessionSummaries() +): RuntimeWorktreePsSummary { + const row = { + worktreeId: WORKTREE_ID, + status: 'inactive', + agents: [] + } as unknown as RuntimeWorktreePsSummary + attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKTREE_ID, row]]), + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, + retainedSnapshots: [], + hookSnapshots: [], + structuredSummaries: summaries + }), + orchestrationByPaneKey: null, + getSummary: (map, _paths, _missing, id) => map.get(id) ?? null + }) + return row +} + +describe('worktree ps and a closed structured chat', () => { + it('reports the blocked row while the session is still held', async () => { + const { feed } = await awaitingApproval() + const row = worktreeFor(feed) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('blocked') + expect(row.status).toBe('permission') + }) + + it('stops reporting it once eviction forgets the session', async () => { + const { feed, sessions } = await awaitingApproval() + // `forget-session`, the last eviction step, does exactly this and nothing to the feed. + sessions.delete(SESSION) + + const row = worktreeFor(feed) + expect(row.agents).toHaveLength(0) + expect(row.status).toBe('inactive') + }) + + it('keeps an aged host-held working state authoritative', async () => { + const { feed } = await awaitingApproval() + const aged = feed.liveSessionSummaries().map((summary) => ({ + ...summary, + hostExecutionOwned: true as const, + updatedAt: Date.now() - 30 * 60 * 1000 - 1, + status: 'working' as const + })) + const row = worktreeFor(feed, aged) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('working') + expect(row.status).toBe('working') + expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt) + }) + + it('keeps an aged host-held approval state authoritative', async () => { + const { feed } = await awaitingApproval() + const aged = feed.liveSessionSummaries().map((summary) => ({ + ...summary, + hostExecutionOwned: true as const, + updatedAt: Date.now() - 30 * 60 * 1000 - 1 + })) + const row = worktreeFor(feed, aged) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('blocked') + expect(row.status).toBe('permission') + expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt) + }) +}) diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows.ts b/src/main/runtime/runtime-worktree-structured-agent-rows.ts new file mode 100644 index 00000000000..c4d075fbd9c --- /dev/null +++ b/src/main/runtime/runtime-worktree-structured-agent-rows.ts @@ -0,0 +1,48 @@ +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionStatusState, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' + +/** + * Row sources for the structured (non-PTY) sessions a host still holds. + * + * A structured session reaches none of the hook or retained snapshots every other row comes from, + * so `worktree ps` projects it from the host's status feed instead. The feed's retained + * projections are not a roster — the caller passes only sessions the host still holds. + */ +export function structuredRuntimeWorktreeAgentSources( + summaries: readonly AgentSessionStatusSummary[] +): RuntimeWorktreeAgentSource[] { + const sources: RuntimeWorktreeAgentSource[] = [] + for (const summary of summaries) { + // No turn has been persisted yet, so there is nothing to report - the same read the chat shows. + if (!summary.status) { + continue + } + const tabId = structuredAgentSessionTabId(summary.sessionId) + // The DERIVED pane key the renderer already publishes, never the orchestration bearer handle + // or the minted worker pane key: both of those are credentials. + sources.push({ + paneKey: structuredAgentSessionPaneKey(tabId, summary.sessionId), + tabId, + worktreeId: summary.workspaceId, + connectionId: null, + // The shared mapping the sidebar applies, so the CLI and the GUI cannot disagree about one + // session. No hook payload: nothing reads one off a structured row. + state: structuredAgentSessionStatusState(summary.status), + agentType: summary.agent, + prompt: summary.latestPrompt, + lastAssistantMessage: summary.lastAssistantMessage ?? null, + toolName: summary.toolName ?? null, + toolInput: summary.toolInput ?? null, + interrupted: false, + stateStartedAt: summary.updatedAt, + updatedAt: summary.updatedAt, + ...(summary.hostExecutionOwned ? { authority: 'structured-host' as const } : {}) + }) + } + return sources +} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d9b3e59186a..0245f15054b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -225,6 +225,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + host?.publishBackgroundTaskState(sessionId, state), onEvent: (event) => { if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') { return diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx new file mode 100644 index 00000000000..e137220dd66 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' +import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' + +afterEach(cleanup) + +const TASKS: AgentSessionBackgroundTask[] = [ + { id: 'codex-agent:child-1', kind: 'agent', description: 'count_a' }, + { id: 'codex-command:exec-1', kind: 'command', description: 'sleep 90' } +] + +function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolean }): { + onStop: ReturnType +} { + const onStop = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { expanded: false })) + return { onStop } +} + +describe('NativeChatBackgroundTasksStatus stop affordances', () => { + it('offers a per-task stop on a host that accepts targeted stops', () => { + renderStrip({ supportsTaskStop: true, supportsStopAll: true }) + expect(screen.getByLabelText('Stop count_a')).toBeInTheDocument() + expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() + }) + + it('falls back to a stop-all on a host that only accepts an untargeted stop', () => { + renderStrip({ supportsTaskStop: false, supportsStopAll: true }) + expect(screen.getByLabelText('Stop background tasks')).toBeInTheDocument() + }) + + it('offers no stop at all when the provider exposes none', () => { + // Codex: a Stop button here would be a control that cannot act. + renderStrip({ supportsTaskStop: false, supportsStopAll: false }) + expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Stop count_a')).not.toBeInTheDocument() + expect(screen.getByText('count_a')).toBeInTheDocument() + expect(screen.getByText('sleep 90')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index 9f8f08e1efd..c52b73842a9 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -26,6 +26,9 @@ function backgroundTaskLabel(task: AgentSessionBackgroundTask): string { export function NativeChatBackgroundTasksStatus(props: { tasks: readonly AgentSessionBackgroundTask[] supportsTaskStop: boolean + /** False when the provider exposes no honest stop at all; the fallback + * control is hidden rather than offering a button that cannot act. */ + supportsStopAll: boolean stoppingTaskIds: ReadonlySet stoppingAll: boolean onStop: (taskId?: string) => void @@ -49,7 +52,7 @@ export function NativeChatBackgroundTasksStatus(props: { - + {translate( 'components.native-chat.backgroundTasks.monitoring', 'Monitoring background tasks' @@ -115,7 +118,7 @@ export function NativeChatBackgroundTasksStatus(props: { )}

)} - {!props.supportsTaskStop ? ( + {!props.supportsTaskStop && props.supportsStopAll ? (
0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}> -
- ) : null} - {messages.map((message, index) => { - const turnKey = turnKeys[index] - const isCurrentTurn = currentTurnKey - ? turnKey === currentTurnKey - : turnKey === undefined - const status = - index === latestUserIndex - ? turnStatuses.active - : message.role === 'user' && turnKey - ? turnStatuses.completedByTurn[turnKey] - : undefined - const receipt = receipts.get(message.id) - const turnDiff = - turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined - return ( - - {receipt ? ( - - ) : ( - - )} - {showTurnStatus && - status && - (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( - toggleExpandedTurn(turnKey) - : undefined - } - /> - ) : null} - {turnDiff ? ( - - ) : null} - - ) - })} - {showTurnStatus && - latestUserIndex === -1 && - turnStatuses.active && - showTypingIndicator ? ( - - ) : null} - {showTurnStatus && isWorking ? ( - - ) : null} - {!showTurnStatus && showTypingIndicator ? : null} +
+ {hasMore ? ( +
+ +
+ ) : null} + {messages.map((message, index) => { + const turnKey = turnKeys[index] + const isCurrentTurn = currentTurnKey + ? turnKey === currentTurnKey + : turnKey === undefined + const status = + index === latestUserIndex + ? turnStatuses.active + : message.role === 'user' && turnKey + ? turnStatuses.completedByTurn[turnKey] + : undefined + const receipt = receipts.get(message.id) + const turnDiff = + turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined + return ( + + {receipt ? ( + + ) : ( + + )} + {showTurnStatus && + status && + (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( + toggleExpandedTurn(turnKey) + : undefined + } + /> + ) : null} + {turnDiff ? ( + + ) : null} + + ) + })} + {showTurnStatus && + latestUserIndex === -1 && + turnStatuses.active && + showTypingIndicator ? ( + + ) : null} + {showTurnStatus && isWorking ? ( + + ) : null} + {!showTurnStatus && showTypingIndicator ? : null} +
+ {showJump ? ( + + ) : null} - {showJump ? ( - + {taskListState.list && taskListState.list.tasks.length > 0 ? ( +
+
+ +
+
) : null} ) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index e3a4e78efb4..773e6f2c2ff 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -8,7 +8,11 @@ import { isSubagentGroupFallbackText, subagentGroupBlocks } from '../../../../shared/native-chat-subagent-summary' -import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' +import { + isSubagentGroupBlock, + type NativeChatMessage, + type NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' import { splitNativeChatBlocks } from './native-chat-tool-fold' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatNoticeRow } from './NativeChatNoticeRow' @@ -29,6 +33,8 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' * keep their block identity, so only the changed row re-renders. */ export const MessageRow = memo(function MessageRow({ message, + previousTodoWrite, + previousUpdatePlan, revealedDiff, expandSignal, activeTurnIsWorking, @@ -41,6 +47,8 @@ export const MessageRow = memo(function MessageRow({ runtimeContext }: { message: NativeChatMessage + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock revealedDiff?: NativeChatDiffReveal expandSignal: boolean activeTurnIsWorking?: boolean @@ -202,6 +210,8 @@ export const MessageRow = memo(function MessageRow({ {tools.length > 0 || subagentGroups.length > 0 ? ( { diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx index af3bf468011..cbb25e624cd 100644 --- a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx @@ -214,9 +214,7 @@ export function NativeChatSubagentRun({ > - - {headline} - + {headline} {verdict} {alert === null ? null : ` +${alert}`} diff --git a/src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx b/src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx new file mode 100644 index 00000000000..588f392f713 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { NativeChatTaskList } from './NativeChatTaskList' +import type { NativeChatTaskList as TaskList } from '../../../../shared/native-chat-task-list' + +afterEach(cleanup) +const previous: TaskList = { + tasks: [ + { content: 'Read', status: 'in_progress', activeForm: 'Reading' }, + { content: 'Write', status: 'pending', activeForm: 'Writing' }, + { content: 'Test', status: 'pending' } + ] +} +const current: TaskList = { + tasks: [ + { content: 'Read', status: 'completed', activeForm: 'Reading' }, + { content: 'Write', status: 'in_progress', activeForm: 'Writing' }, + { content: 'Test', status: 'pending' } + ] +} + +describe('NativeChatTaskList', () => { + it('shows tri-state glyphs, progress, and activeForm in the first checklist', () => { + const { container } = render() + expect(screen.getByText('Read')).toHaveClass('line-through') + expect(screen.getByText('Writing').closest('li')).toHaveClass('text-foreground') + expect(screen.getByText('Test')).toBeInTheDocument() + expect(screen.getByLabelText('1 of 3 tasks completed')).toHaveTextContent('1/3') + for (const glyph of ['circle', 'circle-dot', 'circle-check']) { + expect(container.querySelector(`.lucide-${glyph}`)).not.toBeNull() + } + expect(screen.getByText('In progress:')).toHaveClass('sr-only') + }) + + it('leads with the diff and expands the complete checklist on demand', () => { + render() + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect(screen.getByText('Started Write')).toBeInTheDocument() + expect(screen.queryByText('Test')).toBeNull() + const disclosure = screen.getByRole('button', { name: 'Full task list' }) + expect(disclosure).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(disclosure) + expect(disclosure).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Writing')).toBeInTheDocument() + expect(screen.getByText('Test')).toBeInTheDocument() + }) + + it('shows unchanged feedback and the current explanation', () => { + render( + + ) + expect(screen.getByText('Tasks unchanged')).toBeInTheDocument() + expect(screen.getByText('Continuing verification')).toBeInTheDocument() + expect(screen.queryByText('Test')).toBeNull() + }) + + it('renders empty lists without claiming any task completed', () => { + render() + expect(screen.getByText('No tasks')).toBeInTheDocument() + expect(screen.getByLabelText('0 of 0 tasks completed')).toHaveTextContent('0/0') + }) + + it('switches from full list to diff when earlier history supplies a predecessor', () => { + const { rerender } = render() + expect(screen.getByText('Test')).toBeInTheDocument() + rerender() + expect(screen.queryByText('Test')).toBeNull() + expect(screen.getByText('Started Write')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTaskList.tsx b/src/renderer/src/components/native-chat/NativeChatTaskList.tsx new file mode 100644 index 00000000000..3ddaa8959e8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTaskList.tsx @@ -0,0 +1,183 @@ +import { Circle, CircleCheck, CircleDot, ChevronRight, ListChecks } from 'lucide-react' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + diffNativeChatTaskLists, + nativeChatTaskLabel, + type NativeChatTask, + type NativeChatTaskChange, + type NativeChatTaskList as TaskList +} from '../../../../shared/native-chat-task-list' + +function statusLabel(task: NativeChatTask): string { + if (task.status === 'completed') { + return translate('components.native-chat.taskList.completed', 'Completed') + } + if (task.status === 'in_progress') { + return translate('components.native-chat.taskList.inProgress', 'In progress') + } + return translate('components.native-chat.taskList.pending', 'Pending') +} + +function changeLabel(change: NativeChatTaskChange): string { + const values = { task: change.task.content } + switch (change.kind) { + case 'added': + return translate('components.native-chat.taskList.added', 'Added {{task}}', values) + case 'removed': + return translate('components.native-chat.taskList.removed', 'Removed {{task}}', values) + case 'started': + return translate('components.native-chat.taskList.started', 'Started {{task}}', values) + case 'completed': + return translate('components.native-chat.taskList.finished', 'Completed {{task}}', values) + case 'pending': + return translate('components.native-chat.taskList.reset', 'Marked pending: {{task}}', values) + case 'updated': + return translate('components.native-chat.taskList.updated', 'Updated {{task}}', { + task: nativeChatTaskLabel(change.task) + }) + } +} + +function TaskRow({ task, label }: { task: NativeChatTask; label?: string }): React.JSX.Element { + const Icon = + task.status === 'completed' ? CircleCheck : task.status === 'in_progress' ? CircleDot : Circle + return ( +
  • + + {statusLabel(task)}: + + {label ?? nativeChatTaskLabel(task)} + +
  • + ) +} + +function Checklist({ list }: { list: TaskList }): React.JSX.Element { + return list.tasks.length === 0 ? ( +

    + {translate('components.native-chat.taskList.empty', 'No tasks')} +

    + ) : ( +
      + {list.tasks.map((task, index) => ( + + ))} +
    + ) +} + +export function NativeChatTaskList({ + list, + previous, + presentation = 'inline' +}: { + list: TaskList + previous?: TaskList + presentation?: 'inline' | 'composer' +}): React.JSX.Element { + const completed = list.tasks.filter((task) => task.status === 'completed').length + if (presentation === 'composer') { + return ( + + + + + {translate('components.native-chat.taskList.title', 'Tasks')} + + + {completed}/{list.tasks.length} + + + + +
    + + {list.explanation ? ( +

    + {list.explanation} +

    + ) : null} +
    +
    +
    + ) + } + const changes = previous ? diffNativeChatTaskLists(previous, list) : null + return ( +
    +
    + + + {translate('components.native-chat.taskList.title', 'Tasks')} + + + {completed}/{list.tasks.length} + +
    + {changes ? ( + <> + {changes.length > 0 ? ( +
      + {changes.map((change, index) => ( + + ))} +
    + ) : ( +

    + {translate('components.native-chat.taskList.unchanged', 'Tasks unchanged')} +

    + )} + + + + {translate('components.native-chat.taskList.showAll', 'Full task list')} + + + + + + + ) : ( + + )} + {list.explanation ? ( +

    + {list.explanation} +

    + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx b/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx index 7da3345fae4..25c0cd14d89 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx @@ -41,7 +41,7 @@ export function NativeChatCommandMetadata({ return null } return ( - + {exitCode !== undefined ? ( {translate('components.native-chat.tool.exitCode', 'exit {{value0}}', { diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index cd819c5ced1..d5cf4ceb1eb 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -486,18 +486,28 @@ describe('NativeChatToolRun', () => { expect(runHeader(container)).toHaveTextContent('shell git log -1') }) - it('settles an orphaned running call when its turn lifecycle has ended', () => { + it('keeps a post-turn running call neutral until the item itself settles', () => { const blocks: NativeChatBlock[] = [ { type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' } ] - const { container } = render( + const { container, rerender } = render( ) expect(screen.queryByText('Running sleep 1')).toBeNull() - expect(container.querySelector('.lucide-check')).toBeInTheDocument() + expect(container.querySelector('.lucide-check')).toBeNull() expect(container.querySelector('.lucide-circle-alert')).toBeNull() + rerender( + + ) + expect(container.querySelector('.lucide-check')).toBeInTheDocument() }) it('shows the category glyph beside the word a classified row is named by', () => { @@ -730,3 +740,58 @@ describe('NativeChatToolRun', () => { expect(screen.getByTitle('ls')).toHaveTextContent('ls') }) }) + +describe('NativeChatToolRun task lists', () => { + it('renders task updates instead of JSON and consumes successful results', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'update_plan', + input: { + plan: [ + { step: 'Read', status: 'in_progress' }, + { step: 'Test', status: 'pending' } + ] + } + }, + { type: 'tool-result', output: 'Plan updated' }, + { + type: 'tool-call', + name: 'update_plan', + input: { + plan: [ + { step: 'Read', status: 'completed' }, + { step: 'Test', status: 'in_progress' } + ] + } + } + ] + const { container } = render() + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect(screen.getByText('Started Test')).toBeInTheDocument() + expect(screen.getByText('1/2')).toBeInTheDocument() + expect(screen.queryByText('Plan updated')).toBeNull() + expect(container.querySelector('pre')).toBeNull() + }) + + it('keeps malformed calls and failed results visible in the generic view', () => { + render( + + ) + expect(screen.getByText('Invalid arguments', { selector: 'pre' })).toBeInTheDocument() + expect(screen.getByText('Update rejected', { selector: 'pre' })).toBeInTheDocument() + expect(screen.queryByText('1/1')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 5f579a2778b..ad68b79e2d4 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -12,7 +12,8 @@ import { isToolCallBlock, isToolResultBlock, type NativeChatBlock, - type NativeChatSubagentGroupBlock + type NativeChatSubagentGroupBlock, + type NativeChatToolCallBlock } from '../../../../shared/native-chat-types' import { isRenderableSubagentGroup } from '../../../../shared/native-chat-subagent-summary' import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff' @@ -31,6 +32,8 @@ import { selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' +import { NativeChatTaskList } from './NativeChatTaskList' +import { buildNativeChatTaskListRows } from './native-chat-task-list-history' import { NativeChatDiffView } from './NativeChatDiffView' import { NativeChatSubagentRun } from './NativeChatSubagentRun' import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon' @@ -158,6 +161,8 @@ function ToolLine({ * toolbar toggle drive every run at once while still allowing per-run override. */ export function NativeChatToolRun({ blocks, + previousTodoWrite, + previousUpdatePlan, revealedDiff, onRevealDiff, subagentGroups = NO_SUBAGENT_GROUPS, @@ -168,6 +173,8 @@ export function NativeChatToolRun({ onLinkClick }: { blocks: NativeChatBlock[] + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock revealedDiff?: NativeChatDiffReveal onRevealDiff?: (element: HTMLElement) => void /** Spawn-group rosters that belong with this run's activity, one row each. */ @@ -228,9 +235,22 @@ export function NativeChatToolRun({ ? selectActiveToolCall(blocks, { activeTurnIsWorking }) : null const isSettled = latestActiveCall == null + const hasRunningCall = blocks.some((block) => isToolCallBlock(block) && block.state === 'running') // The turn caret opens the activity group, while each child tool remains // collapsed. The global expand toolbar still opens child details together. const expandToolLines = expandOverride === undefined ? open : false + // Diffing every edit is the run's most expensive work, so a collapsed run — + // which renders none of it — never pays for it. + const taskLists = useMemo( + () => + open + ? buildNativeChatTaskListRows(blocks, { + todowrite: previousTodoWrite, + update_plan: previousUpdatePlan + }) + : null, + [open, blocks, previousTodoWrite, previousUpdatePlan] + ) // Rollups cache counts only; detailed diff rows are built when the run opens. const { editCards, consumedResults } = useMemo( () => (open ? buildEditCards(blocks) : NO_EDIT_CARDS), @@ -297,7 +317,7 @@ export function NativeChatToolRun({ rowWord={latestActiveCall.name} className="text-muted-foreground" /> - + {nativeChatToolActivityLabel(latestActiveCall)} {open ? : null} @@ -361,8 +381,8 @@ export function NativeChatToolRun({ {fallbackLabel} )} - {/* Completion reads as a trailing mark so the leading glyph can stay fixed. */} - {structuredActivityUi ? ( + {/* A running item cannot inherit completion from its turn. */} + {structuredActivityUi && !hasRunningCall ? ( ) : null} {/* Chevron is revealed on hover when collapsed and points down when open. */} @@ -381,7 +401,14 @@ export function NativeChatToolRun({
    {(() => { const seen = new Map() - return blocks.map((block) => { + return blocks.map((block, blockIndex) => { + const taskList = taskLists?.rows.get(block) + if (taskList) { + return + } + if (taskLists?.consumedResults.has(block)) { + return null + } const edit = editCards.get(block) if (edit) { return ( diff --git a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx index 8050710a177..6a7359ce4a6 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx @@ -29,7 +29,7 @@ export function NativeChatTurnDiffRollup({ ) : null} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index c4376dbf66e..1276287aca0 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -7,6 +7,7 @@ import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { resolveAttention } from '../sidebar/smart-attention' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' import type { AppState } from '@/store/types' @@ -88,6 +89,7 @@ function summary(overrides: Partial = {}): AgentSessi workspaceId: 'wt-1', agent: 'codex', status: 'working', + hostExecutionOwned: true, latestPrompt: 'hello', providerSession, updatedAt: 1, @@ -180,6 +182,26 @@ describe('StructuredAgentSessionStatusBridge', () => { // Hiddenness is the host's side of this: see structured-agent-session-subscribers.test.ts, // which drives an unsubscribed journal through the feed. Here the transport is a mock, so // only the summary-to-store mapping is under test. + it('keeps host-held working evidence active past the normal freshness window', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const updatedAt = Date.now() - 30 * 60 * 1000 - 1 + act(() => feed().emit({ type: 'status', session: summary({ updatedAt }) })) + const entry = statuses()[0] + expect(entry).toEqual(expect.objectContaining({ state: 'working', structuredHostOwned: true })) + expect(isExplicitAgentStatusFresh(entry, Date.now(), 30 * 60 * 1000)).toBe(true) + }) + + it('clears host-held evidence when the status stream disconnects', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary() })) + expect(statuses()).toHaveLength(1) + act(() => feed().emit({ type: 'end' })) + expect(statuses()).toHaveLength(1) + expect(statuses()[0]).not.toHaveProperty('structuredHostOwned') + }) + it('maps each host status onto the sidebar agent state', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index a72f28d7c08..47d6aec460f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -2,7 +2,10 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' -import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionStatusState +} from '../../../../shared/structured-agent-session-projection' import type { Tab } from '../../../../shared/tab-types' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -67,12 +70,8 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | return } const desired = { - state: - summary.status === 'working' - ? 'working' - : summary.status === 'attention' - ? 'blocked' - : 'done', + // Shared with `worktree ps`, so the CLI and this row cannot disagree about one session. + state: structuredAgentSessionStatusState(summary.status), prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: @@ -99,6 +98,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | current.tabId === tab.id && current.worktreeId === tab.worktreeId && current.terminalResumeEligible === false && + current.structuredHostOwned === summary.hostExecutionOwned && agentProviderSessionsEqual( tab.agentSessionAgent, current.providerSession, @@ -119,12 +119,13 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | desired.state !== 'done' && current?.state === desired.state ? current.stateStartedAt : summary.updatedAt, - evidenceObservedAt: Date.now() + evidenceObservedAt: summary.updatedAt }, { tabId: tab.id, worktreeId: tab.worktreeId }, { ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), - terminalResumeEligible: false + terminalResumeEligible: false, + ...(summary.hostExecutionOwned ? { structuredHostOwned: true as const } : {}) } ) } diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts b/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts new file mode 100644 index 00000000000..f58c4a2bbdd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts @@ -0,0 +1,36 @@ +import { normalizeNativeChatTaskList } from '../../../../shared/native-chat-task-list' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +const projectedFrames = new WeakMap() + +/** Project after tool folding so a notification never takes another call's result. */ +export function projectNativeChatTaskListFrames( + messages: readonly NativeChatMessage[] +): NativeChatMessage[] { + return messages.map((message) => { + const cached = projectedFrames.get(message) + if (cached) { + return cached + } + const block = message.blocks.length === 1 ? message.blocks[0] : undefined + const frame = block?.type === 'text' ? block.providerFrame : undefined + if ( + message.role !== 'system' || + frame?.provider !== 'codex' || + frame.kind !== 'notification:turn/plan/updated' || + frame.payload.truncated || + !normalizeNativeChatTaskList('update_plan', frame.payload.head) + ) { + return message + } + const projected: NativeChatMessage = { + ...message, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'update_plan', input: frame.payload.head, state: 'completed' } + ] + } + projectedFrames.set(message, projected) + return projected + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts b/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts new file mode 100644 index 00000000000..ef8eae291ad --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' +import { + buildNativeChatTaskListRows, + nativeChatTaskListPredecessors +} from './native-chat-task-list-history' + +function call(name = 'TodoWrite', status = 'pending'): NativeChatToolCallBlock { + return { + type: 'tool-call', + name, + input: + name === 'TodoWrite' + ? { todos: [{ content: 'Test', status }] } + : { plan: [{ step: 'Test', status }] } + } +} +function message( + id: string, + blocks: NativeChatBlock[], + role: NativeChatMessage['role'] = 'assistant' +): NativeChatMessage { + return { id, blocks, role, timestamp: 1, source: 'transcript' } +} + +describe('native chat task list history', () => { + it('carries predecessors across prose, ordinary tools, and user turns', () => { + const first = call() + const next = call('TodoWrite', 'completed') + const history = nativeChatTaskListPredecessors([ + message('a', [first]), + message('b', [{ type: 'text', text: 'Continue' }], 'user'), + message('c', [{ type: 'tool-call', name: 'Read', input: {} }]), + message('d', [next]) + ]) + expect(history.get('d')?.todowrite).toBe(first) + expect( + buildNativeChatTaskListRows([next], history.get('d')).rows.get(next)?.previous?.tasks[0] + .status + ).toBe('pending') + }) + + it('keeps interleaved tool families separate and ignores MCP lookalikes', () => { + const claude = call() + const codex = call('update_plan') + const next = call('TodoWrite', 'completed') + const model = buildNativeChatTaskListRows([claude, codex, call('mcp__x__TodoWrite'), next]) + expect(model.rows.get(codex)?.previous).toBeUndefined() + expect(model.rows.get(next)?.previous).toEqual(model.rows.get(claude)?.list) + const history = nativeChatTaskListPredecessors([ + message('a', [claude]), + message('b', [codex]), + message('c', [next]) + ]) + expect(history.get('c')).toEqual({ todowrite: claude, update_plan: codex }) + }) + + it('skips failed and malformed calls and keeps errors unconsumed', () => { + const first = call() + const failed = { ...call(), state: 'failed' as const } + const rejected = call('TodoWrite', 'completed') + const error: NativeChatBlock = { type: 'tool-result', output: 'Rejected', isError: true } + const next = call('TodoWrite', 'in_progress') + const blocks: NativeChatBlock[] = [ + first, + { type: 'tool-result', output: 'ok' }, + failed, + { type: 'tool-result', output: 'failed' }, + rejected, + error, + { ...call(), input: '{' }, + next + ] + const model = buildNativeChatTaskListRows(blocks) + expect(model.rows.has(failed)).toBe(false) + expect(model.rows.has(rejected)).toBe(false) + expect(model.consumedResults.has(error)).toBe(false) + expect(model.rows.get(next)?.previous).toEqual(model.rows.get(first)?.list) + const history = nativeChatTaskListPredecessors([ + message('a', blocks.slice(0, -1)), + message('b', [next]) + ]) + expect(history.get('b')?.todowrite).toBe(first) + }) + + it('updates predecessor identity after pagination and remains stable on rerender', () => { + const first = call() + const second = call('TodoWrite', 'in_progress') + const tail = message('b', [second]) + expect(nativeChatTaskListPredecessors([tail]).get('b')?.todowrite).toBeUndefined() + const history = nativeChatTaskListPredecessors([message('a', [first]), tail]) + expect(history.get('b')?.todowrite).toBe(first) + expect(nativeChatTaskListPredecessors([message('a', [first]), tail]).get('b')?.todowrite).toBe( + history.get('b')?.todowrite + ) + expect(nativeChatTaskListPredecessors([tail]).get('b')?.todowrite).toBeUndefined() + }) + + it('diffs a running call before its result arrives and consumes a successful result', () => { + const first = call() + const running = { ...call('TodoWrite', 'in_progress'), state: 'running' as const } + const result: NativeChatBlock = { type: 'tool-result', output: 'ok' } + const model = buildNativeChatTaskListRows([running, result], { + todowrite: first, + update_plan: undefined + }) + expect(model.rows.get(running)?.previous).toBeDefined() + expect(model.consumedResults.has(result)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.ts b/src/renderer/src/components/native-chat/native-chat-task-list-history.ts new file mode 100644 index 00000000000..62582e2a7cf --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-history.ts @@ -0,0 +1,83 @@ +import { + nativeChatTaskListTool, + normalizeNativeChatTaskList, + type NativeChatTaskList, + type NativeChatTaskListTool +} from '../../../../shared/native-chat-task-list' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' +import { pairToolBlocks } from './native-chat-tool-fold' + +export type NativeChatTaskListPredecessors = Partial< + Record +> +export type NativeChatTaskListRow = { list: NativeChatTaskList; previous?: NativeChatTaskList } + +function taskListFromCall(call: NativeChatToolCallBlock): NativeChatTaskList | null { + return call.state === 'failed' ? null : normalizeNativeChatTaskList(call.name, call.input) +} + +/** Store call identities so unchanged rows stay memoized, while prepends replace their context. */ +export function nativeChatTaskListPredecessors( + messages: readonly NativeChatMessage[] +): Map { + const history = new Map() + const previous: NativeChatTaskListPredecessors = {} + for (const message of messages) { + history.set(message.id, { ...previous }) + if (message.role === 'user') { + continue + } + for (const { call, result } of pairToolBlocks(message.blocks)) { + if (!call || result?.isError) { + continue + } + const tool = nativeChatTaskListTool(call.name) + if (tool && taskListFromCall(call)) { + previous[tool] = call + } + } + } + return history +} + +export function buildNativeChatTaskListRows( + blocks: readonly NativeChatBlock[], + predecessors: NativeChatTaskListPredecessors = {} +): { + rows: Map + consumedResults: Set +} { + const rows = new Map() + const consumedResults = new Set() + const previous = new Map() + for (const call of Object.values(predecessors)) { + if (!call) { + continue + } + const tool = nativeChatTaskListTool(call.name) + const list = taskListFromCall(call) + if (tool && list) { + previous.set(tool, list) + } + } + for (const { call, result } of pairToolBlocks(blocks)) { + if (!call || result?.isError) { + continue + } + const tool = nativeChatTaskListTool(call.name) + const list = taskListFromCall(call) + if (!tool || !list) { + continue + } + rows.set(call, { list, previous: previous.get(tool) }) + previous.set(tool, list) + if (result) { + consumedResults.add(result) + } + } + return { rows, consumedResults } +} diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts b/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts new file mode 100644 index 00000000000..c282c4d4532 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import { nativeChatTaskListState } from './native-chat-task-list-state' + +function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { + return { id, role: 'assistant', timestamp: 1, source: 'transcript', blocks } +} +function call(content: string, status = 'pending'): NativeChatBlock { + return { type: 'tool-call', name: 'TodoWrite', input: { todos: [{ content, status }] } } +} + +describe('nativeChatTaskListState', () => { + it('projects one latest snapshot, preserves prose and leaves source messages unchanged', () => { + const first = message('first', [call('Read')]) + const last = message('last', [ + { type: 'text', text: 'Here is the result' }, + call('Read', 'completed'), + { type: 'tool-result', output: 'Updated todos' } + ]) + const result = nativeChatTaskListState([first, last]) + expect(result.list?.tasks).toEqual([{ content: 'Read', status: 'completed' }]) + expect(result.messages[0]).toBe(first) + expect(result.messages[1]).toBe(last) + expect(first.blocks).toHaveLength(1) + expect(last.blocks).toHaveLength(3) + expect(nativeChatTaskListState([first, last]).messages[1]).toBe(result.messages[1]) + }) + + it('preserves latest state across user follow-ups and clears it on an explicit empty list', () => { + const first = message('first', [call('Read')]) + const user = { ...message('user', [{ type: 'text', text: 'Continue' }]), role: 'user' as const } + const empty = message('empty', [{ type: 'tool-call', name: 'TodoWrite', input: { todos: [] } }]) + expect(nativeChatTaskListState([first, user]).list?.tasks).toHaveLength(1) + expect(nativeChatTaskListState([first, user, empty]).list?.tasks).toEqual([]) + expect(nativeChatTaskListState([]).list).toBeNull() + }) + + it('does not replace valid state with malformed or failed calls, and retains their diagnostics', () => { + const first = message('first', [call('Read')]) + const malformed = message('malformed', [ + { type: 'tool-call', name: 'TodoWrite', input: '{' }, + { type: 'tool-result', output: 'Invalid arguments', isError: true } + ]) + const failed = message('failed', [ + call('Wrong', 'completed'), + { type: 'tool-result', output: 'Update rejected', isError: true } + ]) + const failedCall = message('failed-call', [ + { type: 'tool-call', name: 'TodoWrite', state: 'failed', input: { todos: [] } } + ]) + const result = nativeChatTaskListState([first, malformed, failed, failedCall]) + expect(result.list?.tasks[0].content).toBe('Read') + expect(result.messages.slice(1)).toEqual([malformed, failed, failedCall]) + }) + + it('retains task history and unrelated errors while selecting the paired snapshot', () => { + const tasks: NativeChatBlock = call('Read') + const shell: NativeChatBlock = { + type: 'tool-call', + name: 'shell', + input: {} + } + const error: NativeChatBlock = { + type: 'tool-result', + output: 'Failed', + isError: true + } + const success: NativeChatBlock = { type: 'tool-result', output: 'Updated' } + const result = nativeChatTaskListState([message('mixed', [tasks, success, shell, error])]) + expect(result.list?.tasks[0].content).toBe('Read') + expect(result.messages[0].blocks).toEqual([tasks, success, shell, error]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-state.ts b/src/renderer/src/components/native-chat/native-chat-task-list-state.ts new file mode 100644 index 00000000000..f45d6531600 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-state.ts @@ -0,0 +1,43 @@ +import { + normalizeNativeChatTaskList, + type NativeChatTaskList +} from '../../../../shared/native-chat-task-list' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { pairToolBlocks } from './native-chat-tool-fold' + +const snapshots = new WeakMap() + +function latestSnapshot(message: NativeChatMessage): NativeChatTaskList | null { + if (snapshots.has(message)) { + return snapshots.get(message) ?? null + } + let list: NativeChatTaskList | null = null + if (message.role === 'assistant') { + for (const { call, result } of pairToolBlocks(message.blocks)) { + if (!call || call.state === 'failed' || result?.isError) { + continue + } + const snapshot = normalizeNativeChatTaskList(call.name, call.input) + if (snapshot) { + list = snapshot + } + } + } + snapshots.set(message, list) + return list +} + +/** Select composer progress without consuming historical transcript updates. */ +export function nativeChatTaskListState(messages: readonly NativeChatMessage[]): { + messages: readonly NativeChatMessage[] + list: NativeChatTaskList | null +} { + let list: NativeChatTaskList | null = null + for (const message of messages) { + const snapshot = latestSnapshot(message) + if (snapshot) { + list = snapshot + } + } + return { messages, list } +} diff --git a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts new file mode 100644 index 00000000000..d347c161f97 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts @@ -0,0 +1,31 @@ +// The background-tasks strip's view of one session's wire state. +// +// The strip stands for work that OUTLIVED a turn, not work in flight: a running +// turn already has the working status, the turn activity line, and its own +// durable rows, so the strip is suppressed while one is open. + +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from '../../../../shared/agent-session-wire' + +export type StructuredSessionBackgroundTasksView = { + isMonitoringBackgroundTasks: boolean + backgroundTasks: readonly AgentSessionBackgroundTask[] + supportsBackgroundTaskStop: boolean + supportsBackgroundTaskStopAll: boolean +} + +export function structuredSessionBackgroundTasksView( + state: AgentSessionBackgroundTaskState | null | undefined, + turnId: string | null +): StructuredSessionBackgroundTasksView { + return { + isMonitoringBackgroundTasks: turnId === null && state?.state === 'monitoring', + backgroundTasks: state?.tasks ?? [], + supportsBackgroundTaskStop: state?.supportsTaskStop === true, + // Absent means the host predates the field and does accept an untargeted + // stop; only a host that says `false` has none to offer. + supportsBackgroundTaskStopAll: state?.supportsStopAll !== false + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts new file mode 100644 index 00000000000..6f071e1a16b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -0,0 +1,96 @@ +// One structured-session mutation, fenced and idempotent. +// +// The client operation id is keyed on (session, method, payload) so a retry of +// the same request reuses it and the host upserts one row instead of two, and +// every result is discarded unless the runtime fence it was issued against is +// still the current one. + +import { useCallback, useRef, useState } from 'react' +import * as conversationCommands from './structured-conversation-command-send' +import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire' +import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' +import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { structuredSessionOperationId } from './use-structured-agent-session-outbox' + +export type StructuredAgentSessionMutate = ( + method: string, + fingerprintMethod: string, + fields: Record, + operationIdOverride?: string | null +) => Promise + +export function useStructuredAgentSessionMutate(args: { + sessionId: string + target: RuntimeClientTarget + /** Read at settle time, not at call time: the fence can move while a request + * is in flight, and a result from the previous fence is not this session's. */ + stateRef: { current: { fence: number | null } } +}): { mutate: StructuredAgentSessionMutate; writeError: string | null } { + const { sessionId, stateRef, target } = args + const [writeError, setWriteError] = useState(null) + const operationIds = useRef(new Map()) + + const mutate = useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record, + operationIdOverride?: string | null + ): Promise => { + if (stateRef.current.fence === null) { + return null + } + const targetFence = stateRef.current.fence + const key = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = + operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId() + operationIds.current.set(key, clientOperationId) + let result: AgentSessionMutationResult + try { + result = await callStructuredAgentSession>(target, method, { + envelope: { + sessionId, + clientOperationId, + expectedRuntimeFence: targetFence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: fingerprintMethod, + sessionId, + fields + }) + }, + ...fields + }) + } catch (error) { + if (stateRef.current.fence === targetFence) { + setWriteError(error instanceof Error ? error.message : 'Request was not sent') + } + return null + } + if (!result.ok) { + if ( + agentSessionRefusalOperationState(fingerprintMethod, result.refusal.code) === + 'settled-rejected' + ) { + operationIds.current.delete(key) + } + if (stateRef.current.fence === targetFence) { + setWriteError(result.refusal.message) + } + return null + } + if (stateRef.current.fence !== targetFence) { + return null + } + if (!conversationCommands.isUnconfirmedConversationCommand(fingerprintMethod, result.value)) { + operationIds.current.delete(key) + } + setWriteError(null) + return result.value + }, + [sessionId, stateRef, target] + ) + + return { mutate, writeError } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index d5980fc90e0..3befefa5edd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -1,20 +1,19 @@ -import * as conversationCommands from './structured-conversation-command-send' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as conversationCommands from './structured-conversation-command-send' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult, + AgentSessionPromptResult +} from '../../../../shared/agent-session-wire' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import type { AgentSessionConversationCommand, AgentSessionConversationCommandResult } from '../../../../shared/agent-session-conversation-command' import type { AgentType } from '../../../../shared/agent-status-types' -import type { - AgentSessionMutationResult, - AgentSessionOptionResult, - AgentSessionOptionsResult, - AgentSessionPromptResult -} from '../../../../shared/agent-session-wire' import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' -import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' -import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, @@ -26,16 +25,13 @@ import { import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { - structuredSessionOperationId, - useStructuredAgentSessionOutbox -} from './use-structured-agent-session-outbox' import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' import { pendingStructuredSessionPrompts, type StructuredPromptItem } from './structured-agent-session-message-projection' +import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' @@ -54,8 +50,7 @@ export function useStructuredAgentSession(args: { useStructuredAgentSessionHold({ sessionId, target, surface: 'desktop-chat', enabled: isVisible }) const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead(args) const stateRef = useRef(state) - const [writeError, setWriteError] = useState(null) - const operationIds = useRef(new Map()) + const { mutate, writeError } = useStructuredAgentSessionMutate({ sessionId, target, stateRef }) const [conversationSupport, setConversationSupport] = useState<{ sessionId: string commands: readonly AgentSessionConversationCommand[] @@ -83,74 +78,13 @@ export function useStructuredAgentSession(args: { setOptionState(next) }, [agent, sessionId, state.fence]) - const mutate = useCallback( - async ( - method: string, - fingerprintMethod: string, - fields: Record, - operationIdOverride?: string | null - ): Promise => { - if (stateRef.current.fence === null) { - return null - } - const targetFence = stateRef.current.fence - const key = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = - operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId() - operationIds.current.set(key, clientOperationId) - let result: AgentSessionMutationResult - try { - result = await callStructuredAgentSession>(target, method, { - envelope: { - sessionId, - clientOperationId, - expectedRuntimeFence: targetFence, - payloadFingerprint: structuredAgentSessionPayloadFingerprint({ - method: fingerprintMethod, - sessionId, - fields - }) - }, - ...fields - }) - } catch (error) { - if (stateRef.current.fence === targetFence) { - setWriteError(error instanceof Error ? error.message : 'Request was not sent') - } - return null - } - if (!result.ok) { - if ( - agentSessionRefusalOperationState(fingerprintMethod, result.refusal.code) === - 'settled-rejected' - ) { - operationIds.current.delete(key) - } - if (stateRef.current.fence === targetFence) { - setWriteError(result.refusal.message) - } - return null - } - if (stateRef.current.fence !== targetFence) { - return null - } - if (!conversationCommands.isUnconfirmedConversationCommand(fingerprintMethod, result.value)) { - operationIds.current.delete(key) - } - setWriteError(null) - return result.value - }, - [sessionId, target] - ) - // Refresh options each turn to confirm which model the provider actually selected. const turnId = activeStructuredAgentSessionTurnId(state.items) const turnActivity = useMemo( () => selectStructuredAgentTurnActivity(state.items, turnId, state.activity), [state.activity, state.items, turnId] ) - const isMonitoringBackgroundTasks = - turnId === null && state.backgroundTasks?.state === 'monitoring' + const backgroundTasksView = structuredSessionBackgroundTasksView(state.backgroundTasks, turnId) useEffect(() => { if (!isVisible || !optionCatalog) { @@ -250,7 +184,12 @@ export function useStructuredAgentSession(args: { conversationCommands.sendStructuredConversationCommand({ command, pending: commandPending, - blocked: Boolean(turnId || prompts.length || isMonitoringBackgroundTasks || outbox.length), + blocked: Boolean( + turnId || + prompts.length || + backgroundTasksView.isMonitoringBackgroundTasks || + outbox.length + ), send: (command) => mutate( 'agentSession.conversationCommand', @@ -273,9 +212,7 @@ export function useStructuredAgentSession(args: { retry: outboxController.retry, isWorking: turnId !== null, turnActivity, - isMonitoringBackgroundTasks, - backgroundTasks: state.backgroundTasks?.tasks ?? [], - supportsBackgroundTaskStop: state.backgroundTasks?.supportsTaskStop === true, + ...backgroundTasksView, turnId, cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), stopBackgroundTask: (taskId?: string) => diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f33a2d9745..4f283dc4f47 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16994,6 +16994,22 @@ "empty": "No users found" }, "native-chat": { + "taskList": { + "title": "Tasks", + "completed": "Completed", + "inProgress": "In progress", + "pending": "Pending", + "empty": "No tasks", + "progress": "{{completed}} of {{total}} tasks completed", + "added": "Added {{task}}", + "removed": "Removed {{task}}", + "started": "Started {{task}}", + "finished": "Completed {{task}}", + "reset": "Marked pending: {{task}}", + "updated": "Updated {{task}}", + "unchanged": "Tasks unchanged", + "showAll": "Full task list" + }, "turnDiff": { "one": "1 changed file", "many": "{{count}} changed files", diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 51e306becca..67dbbf85c6f 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -18,14 +18,20 @@ import { export function isExplicitAgentStatusFresh( entry: Pick< AgentStatusEntry, - 'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'restoredUnconfirmed' + | 'updatedAt' + | 'evidenceObservedAt' + | 'mirroredEvidenceReceivedAt' + | 'restoredUnconfirmed' + | 'structuredHostOwned' >, now: number, staleAfterMs: number ): boolean { // Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh. return ( - entry.restoredUnconfirmed !== true && now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs + entry.restoredUnconfirmed !== true && + (entry.structuredHostOwned === true || + now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs) ) } diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts new file mode 100644 index 00000000000..4f98700d806 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) +vi.mock('./structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: mocks.subscribe +})) +vi.mock('./runtime-rpc-client', () => ({ runtimeEnvironmentSupportsCapability: vi.fn() })) + +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from './structured-agent-session-status-feed' + +type Subscription = { + emit: (event: AgentSessionStatusEvent) => void + unsubscribe: ReturnType +} +const subscriptions: Subscription[] = [] +const owned: AgentSessionStatusSummary = { + sessionId: 'running', + workspaceId: 'workspace', + agent: 'codex', + status: 'working', + latestPrompt: 'work', + updatedAt: 1, + hostExecutionOwned: true +} +const done: AgentSessionStatusSummary = { + ...owned, + sessionId: 'completed', + status: 'idle', + hostExecutionOwned: undefined +} + +function subscription(index = 0): Subscription { + const value = subscriptions[index] + if (!value) { + throw new Error('missing subscription') + } + return value +} + +describe('structured status feed execution authority lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + resetStructuredAgentSessionStatusFeedsForTests() + subscriptions.length = 0 + mocks.subscribe.mockReset() + mocks.subscribe.mockImplementation((_target, emit: Subscription['emit']) => { + const unsubscribe = vi.fn(() => emit({ type: 'end' })) + subscriptions.push({ emit, unsubscribe }) + return Promise.resolve({ unsubscribe }) + }) + }) + + afterEach(() => { + resetStructuredAgentSessionStatusFeedsForTests() + vi.useRealTimers() + }) + + it('revokes on end before reentrant unsubscribe and ignores late frames', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'snapshot', sessions: [owned, done] }) + subscription().emit({ type: 'end' }) + expect(subscription().unsubscribe).toHaveBeenCalledOnce() + expect(feed.getSnapshot().get('running')).toEqual({ ...owned, hostExecutionOwned: undefined }) + expect(feed.getSnapshot().get('completed')).toBe(done) + subscription().emit({ type: 'status', session: owned }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(250) + subscription(1).emit({ type: 'snapshot', sessions: [done] }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + subscription(1).emit({ type: 'status', session: owned }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true) + }) + + it('retains history without ownership while stopped and until remount receives fresh evidence', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const deactivate = feed.activate() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'snapshot', sessions: [owned, done] }) + deactivate() + expect(vi.getTimerCount()).toBe(0) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + expect(feed.getSnapshot().get('running')?.updatedAt).toBe(owned.updatedAt) + expect(feed.getSnapshot().get('completed')).toBe(done) + feed.activate() + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'status', session: owned }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + subscription(1).emit({ type: 'snapshot', sessions: [owned] }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true) + }) + + it('does not notify or reallocate already unowned historical rows on teardown', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const deactivate = feed.activate() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'snapshot', sessions: [done] }) + const previous = feed.getSnapshot() + const listener = vi.fn() + feed.subscribe(listener) + deactivate() + expect(feed.getSnapshot()).toBe(previous) + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 2b550679315..8308ac8729a 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -82,6 +82,32 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { handle?.unsubscribe() handle = null } + const revokeSnapshotOwnership = (): void => { + let next: Map | null = null + for (const [sessionId, summary] of snapshot) { + if (!summary.hostExecutionOwned) { + continue + } + if (!next) { + next = new Map(snapshot) + } + const { hostExecutionOwned: _owned, ...retained } = summary + next.set(sessionId, retained) + } + if (next) { + snapshot = next + emit() + } + } + const fenceCandidateAndReconnect = (candidate: number): void => { + if (candidate !== generation) { + return + } + generation += 1 + revokeSnapshotOwnership() + dropHandle() + scheduleReconnect(generation) + } let open = (): void => {} const scheduleReconnect = (candidate: number): void => { if (!active(candidate) || reconnectTimer) { @@ -104,22 +130,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return } if (event.type === 'end') { - dropHandle() - scheduleReconnect(candidate) + fenceCandidateAndReconnect(candidate) return } applyEvent(event) }, () => { if (active(candidate)) { - dropHandle() - scheduleReconnect(candidate) + fenceCandidateAndReconnect(candidate) } }, () => { if (active(candidate)) { - dropHandle() - scheduleReconnect(candidate) + fenceCandidateAndReconnect(candidate) } } ) @@ -130,7 +153,13 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { opened.unsubscribe() } }) - .catch(() => scheduleReconnect(candidate)) + .catch(() => { + if (active(candidate)) { + fenceCandidateAndReconnect(candidate) + } else { + scheduleReconnect(candidate) + } + }) } open = (): void => { const candidate = ++generation @@ -163,6 +192,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { generation += 1 clearReconnect() dropHandle() + revokeSnapshotOwnership() reconnectAttempt = 0 } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 39bbde535d1..453fef02139 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -108,6 +108,8 @@ export type AgentStatusRouting = { } export type AgentStatusMetadata = { + /** Structured status rows remain fresh while the host owns the session; cleared on feed loss. */ + structuredHostOwned?: true providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 5c3ef89bdaf..63ba0a5afc9 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -227,6 +227,7 @@ export function buildAgentStatusLiveEntry( ...(timing?.evidenceObservedAt !== undefined ? { evidenceObservedAt: timing.evidenceObservedAt } : {}), + ...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}), stateStartedAt, agentType: identity.agentType, model: diff --git a/src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts b/src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts index 35e9397c1a5..405007067d7 100644 --- a/src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-activation-and-activity.test.ts @@ -47,6 +47,29 @@ function makeFolderWorkspace(overrides: Partial = {}): FolderWo } } +function rememberedBrowserSurface(workspaceKey: string): Partial { + return { + browserTabsByWorktree: { + [workspaceKey]: [ + { + id: 'remembered', + worktreeId: workspaceKey, + url: 'about:blank', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + activeBrowserTabIdByWorktree: { [workspaceKey]: 'remembered' }, + activeTabTypeByWorktree: { [workspaceKey]: 'browser' } + } +} + type FolderWorkspaceUpdateArgs = { folderWorkspaceId: string updates: Partial @@ -121,6 +144,100 @@ describe('folder workspace generic activation and activity', () => { }) }) + it.each([ + ['simulator', 'local'], + ['agent-session', 'local'], + ['simulator', 'ssh:test-host'], + ['agent-session', 'ssh:test-host'] + ] as const)( + 'restores a folder %s tab on %s using its concrete visible type', + (contentType, executionHostId) => { + const folder = makeFolderWorkspace({ executionHostId }) + const workspaceKey = folderWorkspaceKey(folder.id) + const store = seedLocalFolderStore(folder) + store.getState().createUnifiedTab(workspaceKey, contentType, { id: 'selected' }) + store.setState({ activeTabTypeByWorktree: { [workspaceKey]: 'editor' } }) + + store.getState().setActiveFolderWorkspace(folder.id, executionHostId) + + expect(store.getState().activeWorkspaceExecutionHostId).toBe(executionHostId) + expect(store.getState().activeTabType).toBe(contentType) + expect(store.getState().activeTabTypeByWorktree[workspaceKey]).toBe(contentType) + expect(store.getState().getActiveTab(workspaceKey)?.id).toBe('selected') + } + ) + + it('does not let remembered browser state select content in an empty folder group', () => { + const folder = makeFolderWorkspace() + const workspaceKey = folderWorkspaceKey(folder.id) + const store = seedLocalFolderStore(folder) + store.setState({ + ...rememberedBrowserSurface(workspaceKey), + groupsByWorktree: { + [workspaceKey]: [ + { + id: 'empty', + worktreeId: workspaceKey, + activeTabId: null, + tabOrder: [] + } + ] + }, + activeGroupIdByWorktree: { [workspaceKey]: 'empty' } + } as Partial) + + store.getState().setActiveFolderWorkspace(folder.id) + + expect(store.getState().activeTabType).toBe('terminal') + expect(store.getState().activeBrowserTabId).toBe('remembered') + }) + + it('keeps layout-only folder ownership above remembered browser state', () => { + const folder = makeFolderWorkspace() + const workspaceKey = folderWorkspaceKey(folder.id) + const store = seedLocalFolderStore(folder) + store.setState({ + ...rememberedBrowserSurface(workspaceKey), + groupsByWorktree: {}, + layoutByWorktree: { [workspaceKey]: { type: 'leaf', groupId: 'pending' } } + } as Partial) + + store.getState().setActiveFolderWorkspace(folder.id) + + expect(store.getState().activeTabType).toBe('terminal') + expect(store.getState().activeBrowserTabId).toBe('remembered') + }) + + it('falls back to an open file when nothing else owns the folder surface', () => { + const folder = makeFolderWorkspace() + const workspaceKey = folderWorkspaceKey(folder.id) + const store = seedLocalFolderStore(folder) + store.setState({ + groupsByWorktree: {}, + layoutByWorktree: {}, + // Why: the remembered browser tab is gone, so only the open file is left to show. + activeBrowserTabIdByWorktree: { [workspaceKey]: 'closed' }, + browserTabsByWorktree: { [workspaceKey]: [] }, + activeTabTypeByWorktree: { [workspaceKey]: 'browser' }, + openFiles: [ + { + id: 'fallback-file', + worktreeId: workspaceKey, + filePath: '/workspace/folder/file', + relativePath: 'file', + language: 'plaintext', + isDirty: false, + mode: 'edit' + } + ] + } as Partial) + + store.getState().setActiveFolderWorkspace(folder.id) + + expect(store.getState().activeTabType).toBe('editor') + expect(store.getState().activeFileId).toBe('fallback-file') + }) + it('coalesces repeated activity persistence while keeping local activity current', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts b/src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts new file mode 100644 index 00000000000..3100301b50a --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tab-selection-contract.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest' +import type { Tab, TabContentType } from '../../../../../shared/tab-types' +import { buildHydratedTabState } from '../tabs-hydration' +import { resolveActivatedWorktreeSurface } from '../worktrees/session/active-worktree-surface' +import { deriveActiveSurfaceForWorktree } from './tabs-surface' + +type SelectionState = Parameters[0] + +const workspace = 'repo::/workspace' +function selectedTab(contentType: TabContentType): Tab { + return { + id: 'selected', + entityId: 'selected-entity', + groupId: 'group', + worktreeId: workspace, + contentType, + label: 'Selected', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function selectionState(tab: Tab | null): SelectionState { + return { + rightSidebarExplorerViewByWorktree: {}, + activeGroupIdByWorktree: { [workspace]: 'group' }, + groupsByWorktree: { + [workspace]: [ + { + id: 'group', + worktreeId: workspace, + activeTabId: tab?.id ?? null, + tabOrder: tab ? [tab.id] : [] + } + ] + }, + unifiedTabsByWorktree: { [workspace]: tab ? [tab] : [] }, + layoutByWorktree: {}, + activeTabIdByWorktree: { [workspace]: 'remembered-terminal' }, + activeFileIdByWorktree: { [workspace]: 'remembered-file' }, + activeBrowserTabIdByWorktree: { [workspace]: 'remembered-browser' }, + activeTabTypeByWorktree: { [workspace]: 'browser' }, + tabsByWorktree: { + [workspace]: [ + { + id: 'remembered-terminal', + worktreeId: workspace, + ptyId: null, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + browserTabsByWorktree: { + [workspace]: [ + { + id: 'remembered-browser', + worktreeId: workspace, + url: 'about:blank', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + openFiles: [ + { + id: 'remembered-file', + worktreeId: workspace, + filePath: '/workspace/file', + relativePath: 'file', + language: 'plaintext', + isDirty: false, + mode: 'edit' + } + ] + } +} + +function activate(state: SelectionState) { + const { restoredRightSidebarExplorerView: _view, ...surface } = resolveActivatedWorktreeSurface( + state, + workspace, + undefined, + null + ) + return surface +} + +describe('tab selection and hydration ownership', () => { + it.each([ + ['terminal', 'terminal'], + ['editor', 'editor'], + ['diff', 'editor'], + ['conflict-review', 'editor'], + ['check-details', 'editor'], + ['browser', 'browser'], + ['simulator', 'simulator'], + ['agent-session', 'agent-session'] + ] as const)( + 'projects %s selection while retaining other remembered surfaces', + (kind, visible) => { + const state = selectionState(selectedTab(kind)) + const expected = { + activeTabType: visible, + activeTabId: kind === 'terminal' ? 'selected-entity' : 'remembered-terminal', + activeFileId: visible === 'editor' ? 'selected-entity' : 'remembered-file', + activeBrowserTabId: kind === 'browser' ? 'selected-entity' : 'remembered-browser' + } + expect(deriveActiveSurfaceForWorktree(state, workspace)).toEqual(expected) + expect(activate(state)).toEqual(expected) + } + ) + + it('keeps empty groups authoritative over remembered browser/editor surfaces', () => { + const state = selectionState(null) + expect(activate(state).activeTabType).toBe('terminal') + expect(deriveActiveSurfaceForWorktree(state, workspace).activeTabType).toBe('terminal') + }) + + it('keeps layout-only ownership authoritative during staged hydration', () => { + const state = selectionState(null) + state.groupsByWorktree = {} + state.layoutByWorktree = { [workspace]: { type: 'leaf', groupId: 'pending' } } + expect(activate(state).activeTabType).toBe('terminal') + expect(deriveActiveSurfaceForWorktree(state, workspace).activeTabType).toBe('terminal') + }) + + it('distinguishes workspace restoration from group-focus legacy fallback', () => { + const state = selectionState(null) + state.groupsByWorktree = {} + state.activeTabTypeByWorktree[workspace] = 'terminal' + expect(activate(state).activeTabType).toBe('terminal') + expect(deriveActiveSurfaceForWorktree(state, workspace).activeTabType).toBe('browser') + }) + + it('does not select a preferred tab owned by another group', () => { + const state = selectionState(selectedTab('browser')) + state.unifiedTabsByWorktree[workspace].push({ + ...selectedTab('editor'), + id: 'foreign', + groupId: 'other' + }) + expect(resolveActivatedWorktreeSurface(state, workspace, 'foreign', null).activeTabType).toBe( + 'terminal' + ) + }) + + it('resolves stale active-group IDs to the first group consistently', () => { + const state = selectionState(selectedTab('simulator')) + state.activeGroupIdByWorktree[workspace] = 'removed' + expect(activate(state).activeTabType).toBe('simulator') + expect(deriveActiveSurfaceForWorktree(state, workspace).activeTabType).toBe('simulator') + }) + + it('requires group ownership even for an explicit preferred tab during hydration', () => { + const state = selectionState(selectedTab('simulator')) + state.groupsByWorktree = {} + state.activeTabTypeByWorktree[workspace] = 'terminal' + expect(resolveActivatedWorktreeSurface(state, workspace, 'selected', null).activeTabType).toBe( + 'terminal' + ) + }) + + it('honors a preferred tab in the selected group without mutating selection', () => { + const state = selectionState(selectedTab('browser')) + const preferred = { ...selectedTab('simulator'), id: 'preferred' } + state.unifiedTabsByWorktree[workspace].push(preferred) + state.groupsByWorktree[workspace][0].tabOrder.push(preferred.id) + expect( + resolveActivatedWorktreeSurface(state, workspace, preferred.id, null).activeTabType + ).toBe('simulator') + expect(state.groupsByWorktree[workspace][0].activeTabId).toBe('selected') + }) + + it.each([ + ['terminal', 'terminal', 'remembered-file'], + ['editor', 'editor', 'remembered-file'], + ['browser', 'browser', 'remembered-file'], + // Why: nothing renders a remembered agent-session/simulator once its tab is gone, so the browser + // surface takes over and must not leave the remembered file selected underneath it. + ['agent-session', 'browser', null], + ['simulator', 'browser', null] + ] as const)( + 'projects legacy %s memory as %s when unified groups are absent', + (activeTabType, visible, activeFileId) => { + const state = selectionState(null) + state.groupsByWorktree = {} + state.activeTabTypeByWorktree[workspace] = activeTabType + expect(activate(state)).toEqual({ + activeTabType: visible, + activeTabId: 'remembered-terminal', + activeFileId, + activeBrowserTabId: 'remembered-browser' + }) + } + ) + + it('hydrates unified selection without allowing conflicting legacy memories to choose it', () => { + const tab = selectedTab('simulator') + const state = selectionState(tab) + const session = { + activeRepoId: null, + activeWorktreeId: workspace, + activeTabId: 'remembered-terminal', + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: state.unifiedTabsByWorktree, + tabGroups: state.groupsByWorktree, + activeGroupIdByWorktree: state.activeGroupIdByWorktree, + activeTabTypeByWorktree: { [workspace]: 'browser' as const }, + activeTabIdByWorktree: state.activeTabIdByWorktree + } + const before = structuredClone(session) + const hydrated = buildHydratedTabState(session, new Set([workspace])) + expect(activate({ ...state, ...hydrated }).activeTabType).toBe('simulator') + expect(session).toEqual(before) + }) +}) diff --git a/src/renderer/src/store/slices/tabs/tabs-surface.ts b/src/renderer/src/store/slices/tabs/tabs-surface.ts index b870ca7bb32..793d0437fca 100644 --- a/src/renderer/src/store/slices/tabs/tabs-surface.ts +++ b/src/renderer/src/store/slices/tabs/tabs-surface.ts @@ -2,22 +2,26 @@ import type { AppState } from '../../types' import { toVisibleTabType } from '../../../../../shared/tab-types' import type { WorkspaceVisibleTabType } from '../../../../../shared/tab-types' +export type ActiveSurfaceSourceState = Pick< + AppState, + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' +> + export function deriveActiveSurfaceForWorktree( - state: Pick< - AppState, - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' - >, + state: ActiveSurfaceSourceState, worktreeId: string, - preferredGroupId?: string | null + preferredGroupId?: string | null, + options?: { preferredTabId?: string; legacySelection?: 'remembered-type' } ): { activeBrowserTabId: string | null activeFileId: string | null @@ -28,10 +32,12 @@ export function deriveActiveSurfaceForWorktree( const activeGroupId = preferredGroupId ?? state.activeGroupIdByWorktree[worktreeId] ?? null const activeGroup = (activeGroupId ? groups.find((group) => group.id === activeGroupId) : null) ?? groups[0] ?? null + const activeUnifiedTabId = options?.preferredTabId ?? activeGroup?.activeTabId const activeUnifiedTab = - activeGroup?.activeTabId != null + activeUnifiedTabId != null ? ((state.unifiedTabsByWorktree[worktreeId] ?? []).find( - (tab) => tab.id === activeGroup.activeTabId && tab.groupId === activeGroup.id + (tab) => + tab.id === activeUnifiedTabId && activeGroup != null && tab.groupId === activeGroup.id ) ?? null) : null const restoredFileId = state.activeFileIdByWorktree[worktreeId] ?? null @@ -50,6 +56,14 @@ export function deriveActiveSurfaceForWorktree( : false const hasGroupOwnedSurface = groups.length > 0 || Boolean(state.layoutByWorktree[worktreeId]) + const restoreLegacyType = options?.legacySelection === 'remembered-type' + const restoredTabType = restoreLegacyType + ? (state.activeTabTypeByWorktree[worktreeId] ?? 'terminal') + : null + // Why: only a remembered browser type — or group focus, which remembers no type at all — may keep + // the remembered file selected under the browser surface; a stale agent-session/simulator clears it. + const keepRememberedFileUnderBrowser = restoredTabType === null || restoredTabType === 'browser' + let activeFileId: string | null let activeBrowserTabId: string | null let activeTabType: WorkspaceVisibleTabType @@ -76,8 +90,16 @@ export function deriveActiveSurfaceForWorktree( activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) // Why: focusing an empty split should target its default terminal area, not the previously active browser/editor in another group. activeTabType = 'terminal' - } else if (browserTabStillOpen) { + } else if (restoredTabType === 'terminal') { activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) + activeTabType = 'terminal' + } else if (restoredTabType === 'editor' && fileStillOpen) { + activeFileId = restoredFileId + activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) + activeTabType = 'editor' + } else if (browserTabStillOpen) { + activeFileId = keepRememberedFileUnderBrowser && fileStillOpen ? restoredFileId : null activeBrowserTabId = restoredBrowserTabId activeTabType = 'browser' } else if (fileStillOpen) { @@ -106,20 +128,7 @@ export function deriveActiveSurfaceForWorktree( } export function buildActiveSurfacePatch( - state: Pick< - AppState, - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'activeTabTypeByWorktree' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' - >, + state: ActiveSurfaceSourceState, worktreeId: string, preferredGroupId?: string | null ): Pick< diff --git a/src/renderer/src/store/slices/worktrees/session/active-worktree-surface.ts b/src/renderer/src/store/slices/worktrees/session/active-worktree-surface.ts index 6dcfc62d155..3e185435551 100644 --- a/src/renderer/src/store/slices/worktrees/session/active-worktree-surface.ts +++ b/src/renderer/src/store/slices/worktrees/session/active-worktree-surface.ts @@ -1,9 +1,10 @@ import type { AppState } from '../../../types' import type { WorkspaceVisibleTabType } from '../../../../../../shared/tab-types' -import { toVisibleTabType } from '../../../../../../shared/tab-types' +import type { ActiveSurfaceSourceState } from '../../tabs/tabs-surface' +import { deriveActiveSurfaceForWorktree } from '../../tabs/tabs-surface' export function resolveActivatedWorktreeSurface( - s: AppState, + s: ActiveSurfaceSourceState & Pick, worktreeId: string, preferredActiveUnifiedTabId: string | undefined, reconciledActiveTabId: string | null @@ -16,107 +17,11 @@ export function resolveActivatedWorktreeSurface( activeTabType: WorkspaceVisibleTabType activeTabId: string | null } { - // Why: Search lives under Explorer, so the files/search sub-route must switch with the worktree, not leak the prior one. - const restoredRightSidebarExplorerView = - s.rightSidebarExplorerViewByWorktree?.[worktreeId] ?? 'files' - const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null - const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[worktreeId] ?? null - const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal' - const activeGroupId = - s.activeGroupIdByWorktree[worktreeId] ?? s.groupsByWorktree[worktreeId]?.[0]?.id ?? null - const activeGroup = activeGroupId - ? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ?? null) - : null - const activeUnifiedTabId = - preferredActiveUnifiedTabId ?? reconciledActiveTabId ?? activeGroup?.activeTabId ?? null - const activeUnifiedTab = - activeUnifiedTabId != null - ? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find( - (tab) => tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id) - ) ?? null) - : null - // Verify the restored file still exists in openFiles - const fileStillOpen = restoredFileId - ? s.openFiles.some((f) => f.id === restoredFileId && f.worktreeId === worktreeId) - : false - const browserTabs = s.browserTabsByWorktree[worktreeId] ?? [] - const browserTabStillOpen = restoredBrowserTabId - ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) - : false - const hasGroupOwnedSurface = - (s.groupsByWorktree[worktreeId]?.length ?? 0) > 0 || Boolean(s.layoutByWorktree[worktreeId]) - - // Why: restore from the reconciled tab-group model first; preferring legacy fallbacks can show a blank worktree. - let activeFileId: string | null - let activeBrowserTabId: string | null - let activeTabType: WorkspaceVisibleTabType - if (activeUnifiedTab) { - activeFileId = - activeUnifiedTab.contentType === 'editor' || - activeUnifiedTab.contentType === 'diff' || - activeUnifiedTab.contentType === 'conflict-review' || - activeUnifiedTab.contentType === 'check-details' - ? activeUnifiedTab.entityId - : fileStillOpen - ? restoredFileId - : null - activeBrowserTabId = - activeUnifiedTab.contentType === 'browser' - ? activeUnifiedTab.entityId - : browserTabStillOpen - ? restoredBrowserTabId - : (browserTabs[0]?.id ?? null) - activeTabType = toVisibleTabType(activeUnifiedTab.contentType) - } else if (hasGroupOwnedSurface) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - activeTabType = 'terminal' - } else if (restoredTabType === 'terminal') { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - activeTabType = 'terminal' - } else if (restoredTabType === 'browser' && browserTabStillOpen) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = restoredBrowserTabId - activeTabType = 'browser' - } else if (restoredTabType === 'editor' && fileStillOpen) { - activeFileId = restoredFileId - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - activeTabType = 'editor' - } else if (browserTabStillOpen) { - activeFileId = null - activeBrowserTabId = restoredBrowserTabId - activeTabType = 'browser' - } else if (fileStillOpen) { - activeFileId = restoredFileId - activeBrowserTabId = browserTabs[0]?.id ?? null - activeTabType = 'editor' - } else { - const fallbackFile = s.openFiles.find((f) => f.worktreeId === worktreeId) - const fallbackBrowserTab = browserTabs[0] ?? null - activeFileId = fallbackFile?.id ?? null - activeBrowserTabId = browserTabStillOpen - ? restoredBrowserTabId - : (fallbackBrowserTab?.id ?? null) - activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' - } - - // Why: restore the last-active terminal tab so the user returns to where they left, not tab 0. - const restoredTabId = s.activeTabIdByWorktree[worktreeId] ?? null - const worktreeTabs = s.tabsByWorktree[worktreeId] ?? [] - const tabStillExists = restoredTabId ? worktreeTabs.some((t) => t.id === restoredTabId) : false - const activeTabId = - activeUnifiedTab?.contentType === 'terminal' - ? activeUnifiedTab.entityId - : tabStillExists - ? restoredTabId - : (worktreeTabs[0]?.id ?? null) - return { - restoredRightSidebarExplorerView, - activeFileId, - activeBrowserTabId, - activeTabType, - activeTabId + restoredRightSidebarExplorerView: s.rightSidebarExplorerViewByWorktree?.[worktreeId] ?? 'files', + ...deriveActiveSurfaceForWorktree(s, worktreeId, undefined, { + legacySelection: 'remembered-type', + preferredTabId: preferredActiveUnifiedTabId ?? reconciledActiveTabId ?? undefined + }) } } diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts index 1ca9f11ee5a..d2b5af79114 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts @@ -8,6 +8,7 @@ import { folderWorkspaceMatchesHost } from '../listing/detected-worktree-meta' import { shouldDeferActivationTerminalPrep } from './activation-terminal-prep' +import { deriveActiveSurfaceForWorktree } from '../../tabs/tabs-surface' export function createSetActiveFolderWorkspace( set: WorktreeSliceSet, @@ -28,72 +29,11 @@ export function createSetActiveFolderWorkspace( const reconciledActiveTabId = get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId set((s) => { - const restoredFileId = s.activeFileIdByWorktree[workspaceKey] ?? null - const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[workspaceKey] ?? null - const restoredTabType = s.activeTabTypeByWorktree[workspaceKey] ?? 'terminal' - const activeGroupId = - s.activeGroupIdByWorktree[workspaceKey] ?? s.groupsByWorktree[workspaceKey]?.[0]?.id ?? null - const activeGroup = activeGroupId - ? ((s.groupsByWorktree[workspaceKey] ?? []).find((group) => group.id === activeGroupId) ?? - null) - : null - const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null - const activeUnifiedTab = - activeUnifiedTabId != null - ? ((s.unifiedTabsByWorktree[workspaceKey] ?? []).find( - (tab) => - tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id) - ) ?? null) - : null - const fileStillOpen = restoredFileId - ? s.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === workspaceKey) - : false - const browserTabs = s.browserTabsByWorktree[workspaceKey] ?? [] - const browserTabStillOpen = restoredBrowserTabId - ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) - : false - const worktreeTabs = s.tabsByWorktree[workspaceKey] ?? [] - const restoredTabId = s.activeTabIdByWorktree[workspaceKey] ?? null - const tabStillExists = restoredTabId - ? worktreeTabs.some((tab) => tab.id === restoredTabId) - : false - const activeFileId = - activeUnifiedTab?.contentType === 'editor' || - activeUnifiedTab?.contentType === 'diff' || - activeUnifiedTab?.contentType === 'conflict-review' || - activeUnifiedTab?.contentType === 'check-details' - ? activeUnifiedTab.entityId - : fileStillOpen - ? restoredFileId - : null - const activeBrowserTabId = - activeUnifiedTab?.contentType === 'browser' - ? activeUnifiedTab.entityId - : browserTabStillOpen - ? restoredBrowserTabId - : (browserTabs[0]?.id ?? null) - const activeTabType = - activeUnifiedTab?.contentType === 'terminal' - ? 'terminal' - : activeUnifiedTab?.contentType === 'browser' - ? 'browser' - : activeUnifiedTab - ? 'editor' - : restoredTabType === 'browser' && browserTabStillOpen - ? 'browser' - : restoredTabType === 'editor' && fileStillOpen - ? 'editor' - : fileStillOpen - ? 'editor' - : browserTabs.length > 0 - ? 'browser' - : 'terminal' - const activeTabId = - activeUnifiedTab?.contentType === 'terminal' - ? activeUnifiedTab.entityId - : tabStillExists - ? restoredTabId - : (worktreeTabs[0]?.id ?? null) + const { activeFileId, activeBrowserTabId, activeTabType, activeTabId } = + deriveActiveSurfaceForWorktree(s, workspaceKey, undefined, { + legacySelection: 'remembered-type', + preferredTabId: reconciledActiveTabId ?? undefined + }) const nextEverActivated = s.everActivatedWorktreeIds.has(workspaceKey) ? s.everActivatedWorktreeIds : new Set([...s.everActivatedWorktreeIds, workspaceKey]) diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index ac7dafacfd2..93b2b460bb0 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -33,7 +33,15 @@ const CANONICAL_BODIES: AgentJournalItemBody[] = [ }, { type: 'tool-call', name: 'Read', input: { path: 'a' } }, { type: 'tool-result', output: 'ok', isError: false }, - { type: 'image-ref', path: '/tmp/a.png', alt: 'screenshot' } + { type: 'image-ref', path: '/tmp/a.png', alt: 'screenshot' }, + { + type: 'subagent-group', + groupId: 'claude-session:turn-1', + agents: [ + { id: 'task-1', label: 'Explore', state: 'working', startedAt: 1_000 }, + { id: 'task-2', label: 'Review', state: 'completed', tokens: 42, settledAt: 2_000 } + ] + } ] }, { kind: 'tool-call', name: 'Read', input: undefined, state: 'running' }, @@ -143,6 +151,41 @@ describe('nested corruption is rejected', () => { ).toBe(false) }) + it('rejects a subagent roster whose entries are malformed', () => { + // A KNOWN block type stays a known block: it must not fall through to the + // forward-tolerant arm just because its payload is wrong. + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'system', + blocks: [{ type: 'subagent-group', groupId: 'g', agents: [{ id: 'a', label: 'x' }] }] + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'system', + blocks: [{ type: 'subagent-group', groupId: 'g', agents: 'not-a-roster' }] + }) + ).toBe(false) + }) + + it('keeps a state string a newer build might write admissible', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'system', + blocks: [ + { + type: 'subagent-group', + groupId: 'g', + agents: [{ id: 'a', label: 'x', state: 'some-future-state' }] + } + ] + }) + ).toBe(true) + }) + it('rejects shallow render items and submissions', () => { expect( isAdmissibleAgentJournalRenderItem({ diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 70d464d5392..2b5c0e5ae07 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -68,6 +68,11 @@ export type AgentSessionBackgroundTaskState = { tasks?: AgentSessionBackgroundTask[] /** Optional so clients only send targeted stops to hosts that accept them. */ supportsTaskStop?: boolean + /** Whether an untargeted "stop everything" is available at all. Absent means + * yes: every host that predates this field accepted one, and a client that + * read absence as "no stop" would hide a working control on those hosts. + * A host whose provider exposes no honest stop sends `false`. */ + supportsStopAll?: boolean } export type AgentSessionTurnActivity = { @@ -196,6 +201,8 @@ export type AgentSessionStatusSummary = { agent: AgentSessionRecord['provider'] /** Null until the journal holds a persisted user or assistant message. */ status: StructuredAgentSessionProjectedStatus | null + /** Present only while this host has the provider child executing the session. */ + hostExecutionOwned?: true latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string diff --git a/src/shared/agent-status-freshness.ts b/src/shared/agent-status-freshness.ts index 84a3218e3dd..616879b93e9 100644 --- a/src/shared/agent-status-freshness.ts +++ b/src/shared/agent-status-freshness.ts @@ -37,6 +37,7 @@ export function isFreshNonDoneAgentStatus( | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'restoredUnconfirmed' + | 'structuredHostOwned' > | undefined, now = Date.now(), @@ -47,6 +48,7 @@ export function isFreshNonDoneAgentStatus( entry && entry.state !== 'done' && entry.restoredUnconfirmed !== true && - now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs + (entry.structuredHostOwned === true || + now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs) ) } diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 128e2f80f82..cc0ad487565 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -114,6 +114,8 @@ export type AgentStatusEntry = { * which is the delivery/ordering clock a relay reconnect must restamp to stay monotonic. * Absent for locally derived rows and old hosts; freshness falls back to `updatedAt`. */ evidenceObservedAt?: number + /** True only while a host-held structured session is represented by its live status feed. */ + structuredHostOwned?: true /** Timestamp (ms) when the current `state` was first reported. * Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */ stateStartedAt: number diff --git a/src/shared/native-chat-task-list.test.ts b/src/shared/native-chat-task-list.test.ts new file mode 100644 index 00000000000..0bbb7077f3c --- /dev/null +++ b/src/shared/native-chat-task-list.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + diffNativeChatTaskLists, + nativeChatTaskLabel, + normalizeNativeChatTaskList, + type NativeChatTask, + type NativeChatTaskList +} from './native-chat-task-list' + +const task = (content: string, status: NativeChatTask['status'] = 'pending'): NativeChatTask => ({ + content, + status +}) +const list = (...tasks: NativeChatTask[]): NativeChatTaskList => ({ tasks }) + +describe('normalizeNativeChatTaskList', () => { + it('normalizes Claude tasks and uses activeForm only while in progress', () => { + const result = normalizeNativeChatTaskList('TodoWrite', { + todos: [ + { content: 'Read', status: 'completed', activeForm: 'Reading' }, + { content: 'Write', status: 'in_progress', activeForm: 'Writing' }, + { content: 'Test', status: 'pending', activeForm: 'Testing' } + ] + })! + expect(result.tasks.map(nativeChatTaskLabel)).toEqual(['Read', 'Writing', 'Test']) + expect(result.tasks.map((entry) => entry.status)).toEqual([ + 'completed', + 'in_progress', + 'pending' + ]) + }) + + it('normalizes Codex JSON-string arguments and explanation', () => { + expect( + normalizeNativeChatTaskList( + 'update_plan', + JSON.stringify({ + explanation: 'Proceed with verification', + plan: [{ step: 'Test', status: 'in_progress' }] + }) + ) + ).toEqual({ explanation: 'Proceed with verification', tasks: [task('Test', 'in_progress')] }) + }) + + it('defaults unknown/missing statuses and ignores invalid entries', () => { + expect( + normalizeNativeChatTaskList(' TodoWrite ', { + todos: [ + null, + [], + 4, + {}, + { content: ' ' }, + { content: 7 }, + { content: ' One ', status: 'unknown', activeForm: 4 }, + { content: 'Two' } + ] + }) + ).toEqual(list(task('One'), task('Two'))) + }) + + it.each([undefined, null, 42, [], '{', '{}', { todos: null }, { todos: [{}] }])( + 'returns null for malformed input %j', + (input) => { + expect(normalizeNativeChatTaskList('TodoWrite', input)).toBeNull() + } + ) + + it('keeps empty lists valid and recognizes only exact tool families', () => { + expect(normalizeNativeChatTaskList('update_plan', { plan: [] })).toEqual(list()) + expect(normalizeNativeChatTaskList('TodoWrite', { todos: [] })).toEqual(list()) + expect(normalizeNativeChatTaskList('mcp__server__TodoWrite', { todos: [] })).toBeNull() + expect(normalizeNativeChatTaskList('ExitPlanMode', { plan: [] })).toBeNull() + expect(normalizeNativeChatTaskList('update_plan', { todos: [] })).toBeNull() + }) +}) + +describe('diffNativeChatTaskLists', () => { + it('reports completions and starts, omitting unchanged tasks', () => { + expect( + diffNativeChatTaskLists( + list(task('Read', 'in_progress'), task('Write'), task('Test')), + list(task('Read', 'completed'), task('Write', 'in_progress'), task('Test')) + ) + ).toEqual([ + { kind: 'completed', task: task('Read', 'completed') }, + { kind: 'started', task: task('Write', 'in_progress') } + ]) + }) + + it('ignores reorder-only updates and explanation changes', () => { + expect( + diffNativeChatTaskLists(list(task('A'), task('B')), { + tasks: [task('B'), task('A')], + explanation: 'Reordered' + }) + ).toEqual([]) + }) + + it('matches duplicate contents by occurrence', () => { + expect( + diffNativeChatTaskLists( + list(task('A'), task('A', 'in_progress')), + list(task('A', 'completed'), task('A', 'in_progress')) + ) + ).toEqual([{ kind: 'completed', task: task('A', 'completed') }]) + }) + + it('reports renamed content as an addition and removal', () => { + expect(diffNativeChatTaskLists(list(task('Old')), list(task('New')))).toEqual([ + { kind: 'added', task: task('New') }, + { kind: 'removed', task: task('Old') } + ]) + }) + + it('reports resets, reopening, and activeForm-only edits', () => { + const changed = { ...task('C', 'in_progress'), activeForm: 'Checking C' } + expect( + diffNativeChatTaskLists( + list(task('A', 'completed'), task('B', 'completed'), task('C', 'in_progress')), + list(task('A'), task('B', 'in_progress'), changed) + ) + ).toEqual([ + { kind: 'pending', task: task('A') }, + { kind: 'started', task: task('B', 'in_progress') }, + { kind: 'updated', task: changed } + ]) + }) + + it('reports clearing a list and removing a duplicate', () => { + expect(diffNativeChatTaskLists(list(task('A')), list())).toEqual([ + { kind: 'removed', task: task('A') } + ]) + expect(diffNativeChatTaskLists(list(task('A'), task('A')), list(task('A')))).toEqual([ + { kind: 'removed', task: task('A') } + ]) + }) +}) diff --git a/src/shared/native-chat-task-list.ts b/src/shared/native-chat-task-list.ts new file mode 100644 index 00000000000..4d4d470b246 --- /dev/null +++ b/src/shared/native-chat-task-list.ts @@ -0,0 +1,122 @@ +export type NativeChatTaskStatus = 'pending' | 'in_progress' | 'completed' +export type NativeChatTask = { + content: string + status: NativeChatTaskStatus + activeForm?: string +} +export type NativeChatTaskList = { tasks: NativeChatTask[]; explanation?: string } +export type NativeChatTaskChange = { + kind: 'added' | 'removed' | 'started' | 'completed' | 'pending' | 'updated' + task: NativeChatTask +} +export type NativeChatTaskListTool = 'todowrite' | 'update_plan' + +export function nativeChatTaskListTool(name: string): NativeChatTaskListTool | null { + const normalized = name.trim().toLowerCase() + return normalized === 'todowrite' || normalized === 'update_plan' ? normalized : null +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function nonemptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +export function normalizeNativeChatTaskList( + name: string, + input: unknown +): NativeChatTaskList | null { + const tool = nativeChatTaskListTool(name) + if (!tool) { + return null + } + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch { + return null + } + } + const value = record(input) + const entries = tool === 'todowrite' ? value?.todos : value?.plan + if (!Array.isArray(entries)) { + return null + } + const tasks: NativeChatTask[] = [] + for (const entry of entries) { + const item = record(entry) + const content = nonemptyString(tool === 'todowrite' ? item?.content : item?.step) + if (!item || !content) { + continue + } + const status = + item.status === 'in_progress' || (tool === 'update_plan' && item.status === 'inProgress') + ? 'in_progress' + : item.status === 'completed' + ? 'completed' + : 'pending' + const activeForm = tool === 'todowrite' ? nonemptyString(item.activeForm) : undefined + tasks.push({ content, status, ...(activeForm ? { activeForm } : {}) }) + } + if (entries.length > 0 && tasks.length === 0) { + return null + } + const explanation = tool === 'update_plan' ? nonemptyString(value?.explanation) : undefined + return { tasks, ...(explanation ? { explanation } : {}) } +} + +export function nativeChatTaskLabel(task: NativeChatTask): string { + return task.status === 'in_progress' && task.activeForm ? task.activeForm : task.content +} + +/** Content plus occurrence is the only identity the providers give these entries. */ +export function diffNativeChatTaskLists( + previous: NativeChatTaskList, + current: NativeChatTaskList +): NativeChatTaskChange[] { + const byContent = new Map() + for (const task of previous.tasks) { + const matches = byContent.get(task.content) + if (matches) { + matches.push(task) + } else { + byContent.set(task.content, [task]) + } + } + const occurrences = new Map() + const consumed = new Set() + const changes: NativeChatTaskChange[] = [] + for (const task of current.tasks) { + const occurrence = occurrences.get(task.content) ?? 0 + occurrences.set(task.content, occurrence + 1) + const before = byContent.get(task.content)?.[occurrence] + if (!before) { + changes.push({ kind: 'added', task }) + continue + } + consumed.add(before) + if (before.status !== task.status) { + changes.push({ + kind: + task.status === 'completed' + ? 'completed' + : task.status === 'in_progress' + ? 'started' + : 'pending', + task + }) + } else if (before.activeForm !== task.activeForm) { + changes.push({ kind: 'updated', task }) + } + } + for (const task of previous.tasks) { + if (!consumed.has(task)) { + changes.push({ kind: 'removed', task }) + } + } + return changes +} diff --git a/src/shared/native-chat-tool-icon.test.ts b/src/shared/native-chat-tool-icon.test.ts index 695400c94ca..f8cabed950c 100644 --- a/src/shared/native-chat-tool-icon.test.ts +++ b/src/shared/native-chat-tool-icon.test.ts @@ -50,6 +50,9 @@ describe('native chat tool icons', () => { expect(nativeChatToolCategory('list')).toBe('listFiles') expect(nativeChatToolCategory('shell')).toBe('unknown') expect(nativeChatToolCategory('apply_patch')).toBe('fileChange') + expect(nativeChatToolCategory('update_plan')).toBe('todoList') + expect(nativeChatToolIconName('update_plan')).toBe('list-checks') + expect(nativeChatToolRunIconName([{ name: 'update_plan' }])).toBe('list-checks') expect(nativeChatToolCategory('web search')).toBe('webSearch') }) diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index d546a76e865..d52df9e52e9 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -81,6 +81,7 @@ const CATEGORY_BY_ROW_WORD = new Map([ ['task', 'subAgentActivity'], ['webfetch', 'webSearch'], ['todowrite', 'todoList'], + ['update_plan', 'todoList'], ['web search', 'webSearch'], ['websearch', 'webSearch'], ['web_search', 'webSearch'] diff --git a/src/shared/orchestration-rpc-contract.ts b/src/shared/orchestration-rpc-contract.ts index 3067d5ad6b0..fec4d4ed4e7 100644 --- a/src/shared/orchestration-rpc-contract.ts +++ b/src/shared/orchestration-rpc-contract.ts @@ -14,6 +14,9 @@ export const ORCHESTRATION_SKILL_COMMAND_ARGS = [ ] as const export const ORCHESTRATION_LEGACY_RUN_ID = 'run_legacy_local' +/** Files mail sent from a terminal in no Run. Distinct from the legacy Run on purpose: rows under + * the legacy id read as a pre-Runs database and trigger adoption on the next open. */ +export const ORCHESTRATION_UNBOUND_RUN_ID = 'run_unbound' const ORCHESTRATION_MUTATION_METHODS = new Set([ 'orchestration.runCreate', diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 3aaeeb747f4..74317dd28c1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -163,6 +163,9 @@ export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY = export const AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY = 'agent-session.status-feed.v1' as const // The RPC is registered unconditionally; per-session rewind support is a separate check. export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const +// Readers must understand a monitoring roster with no available stop control. +export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = + 'agent-session.background-task-stop.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -269,6 +272,7 @@ export const RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 3797f08ffb9..456f90e25ad 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,4 +1,5 @@ import { + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, @@ -16,6 +17,7 @@ export function remoteRuntimeClientCapabilities( ): RuntimeCapability[] { return Array.from( new Set([ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 02ad872c211..1301f766e2b 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -299,6 +299,14 @@ export function projectStructuredAgentSessionStatusSummary( } } +/** The agent-status state one projected session status stands for. Shared across the process + * boundary so `worktree ps` and the sidebar cannot disagree about the same session. */ +export function structuredAgentSessionStatusState( + status: StructuredAgentSessionProjectedStatus +): 'working' | 'blocked' | 'done' { + return status === 'working' ? 'working' : status === 'attention' ? 'blocked' : 'done' +} + export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string { const bytes = sha256(new TextEncoder().encode(sessionId)) const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('') diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 6283df7d0a3..10897c3f61f 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -59,7 +59,8 @@ function backgroundTaskStatesEqual( !left || !right || left.state !== right.state || - left.supportsTaskStop !== right.supportsTaskStop + left.supportsTaskStop !== right.supportsTaskStop || + left.supportsStopAll !== right.supportsStopAll ) { return false } diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index f679bdba608..cffa6415208 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -350,6 +350,9 @@ test.describe('orchestration push-on-idle mail delivery', () => { await expectSubmitted(pane) }) + // #19542 deleted the legacy-Run write fallback, so a sender in no Run has + // nowhere to file mail to a bare handle: the send is refused outright, which + // is what keeps an unsafe pointer out of the pane on the next idle frame. test('keeps unbound direct mail durable without pointing to an unsafe check', async ({ orcaPage, electronApp @@ -359,6 +362,8 @@ test.describe('orchestration push-on-idle mail delivery', () => { const pane = await openAgentPane() await driveToLiveIdle(client, pane) + // Two plain terminals, neither in a Run: `send --to ` must still land durably. It + // files under the unbound Run, so a reopen never reads it as pre-Runs state (#19542 regression). const stdinBeforeScan = pane.agent.readStdin() const messageId = await sendMail(client, pane.handle, { subject: 'Unbound direct mail' }) pane.agent.setTitle(CODEX_WORKING_TITLE) @@ -366,8 +371,10 @@ test.describe('orchestration push-on-idle mail delivery', () => { pane.agent.setTitle(CODEX_IDLE_TITLE) await waitForObservedTitle(client, pane.handle, CODEX_IDLE_TITLE) + await orcaPage.waitForTimeout(NO_DELIVERY_SETTLE_MS) expect(readMailRow(userDataDir, messageId)).toMatchObject({ to_handle: pane.handle, + run_id: 'run_unbound', read: 0, delivered_at: null })