diff --git a/.github/workflows/packaged-browser-e2e.yml b/.github/workflows/packaged-browser-e2e.yml new file mode 100644 index 00000000000..2a23ac58988 --- /dev/null +++ b/.github/workflows/packaged-browser-e2e.yml @@ -0,0 +1,74 @@ +name: Packaged browser compatibility +on: + workflow_dispatch: + inputs: + ref: + description: Commit SHA or ref to validate (defaults to the selected revision) + type: string + required: false + schedule: + - cron: '20 8 * * 1' + workflow_call: + inputs: + ref: + type: string + required: false +permissions: + contents: read +jobs: + compatibility: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - name: Install headless tools + run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 ripgrep xvfb zsh openbox x11-utils + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + - name: Download pinned old release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v1.4.188 --repo stablyai/orca --pattern orca-ide_1.4.188_amd64.deb --dir "$RUNNER_TEMP/old-orca" + python3 - <<'PYVERIFY' + import base64,hashlib,os,pathlib,subprocess + root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca' + package=root/'orca-ide_1.4.188_amd64.deb' + expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g==' + assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected + extracted=root/'extracted' + subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True) + executable=extracted/'opt'/'Orca'/'orca-ide' + assert executable.is_file() and os.access(executable,os.X_OK) + with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n') + print('Verified old package:',executable) + PYVERIFY + - name: Build current Electron app + env: + VITE_EXPOSE_STORE: 'true' + run: | + pnpm run build:relay + pnpm exec electron-vite build --mode e2e + pnpm run build:web-from-renderer + - name: Run both mixed-version directions + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/packaged-browser-results.json + run: >- + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 + pnpm exec playwright test --config tests/playwright.config.ts + tests/e2e/packaged-mixed-version-browser-placement.spec.ts + --project=electron-headless --workers=1 --retries=0 --repeat-each=3 --reporter=list,json + - name: Require all six compatibility executions + if: always() + run: node config/scripts/verify-packaged-browser-participation.mjs test-results/packaged-browser-results.json + - uses: actions/upload-artifact@v7 + if: always() + with: + name: packaged-mixed-version-audit + path: test-results/ + retention-days: 3 diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 73ea28a08e0..ede7c46c751 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -18472,6 +18472,107 @@ "The new PR lane is outside verify until reliability is established." ], "demotionRule": "Keep experimental if provisioning or an execution flakes; never promote by skipping a case, raising timeouts, or retrying until green." + }, + { + "id": "browser.packaged-mixed-version-placement", + "title": "Packaged browser placement across versions", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "electron-packaged", + "surfaces": [ + "paired browser placement" + ], + "platforms": [ + "linux", + "macos", + "windows" + ], + "providers": [ + "paired-runtime" + ], + "coveredPlatforms": [ + "linux" + ], + "coveredProviders": [ + "paired-runtime" + ], + "coverageNotes": "Published Linux 1.4.188 desktop against current source in both directions; scheduled weekly and manually runnable. No required PR check.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/actions/runs/34069063016" + ], + "invariant": "A paired client and host without client-hosted browser capabilities retain server-hosted browser placement across supported version skew.", + "oracle": "Require both existing named browser placement scenarios to pass three times with one attempt, zero skips, zero failures, and no report errors.", + "commands": [ + "gh workflow run packaged-browser-e2e.yml", + "pnpm exec playwright test tests/e2e/packaged-mixed-version-browser-placement.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --repeat-each=3 --retries=0", + "node_modules/.bin/vitest run --config config/vitest.config.ts config/scripts/packaged-browser-lane-contract.test.mjs config/scripts/verify-packaged-browser-participation.test.mjs", + "gh run view 34069063016 --log" + ], + "testFiles": [ + "tests/e2e/packaged-mixed-version-browser-placement.spec.ts", + "config/scripts/packaged-browser-lane-contract.test.mjs", + "config/scripts/verify-packaged-browser-participation.test.mjs" + ], + "assertionRefs": [ + { + "file": "tests/e2e/packaged-mixed-version-browser-placement.spec.ts", + "assertions": [ + "old client and old host lack client-host and browser-tunnel capabilities", + "browser contents remain owned by the server and the expected snapshot marker is readable" + ] + }, + { + "file": "config/scripts/verify-packaged-browser-participation.test.mjs", + "assertions": [ + "reject missing, substituted, skipped and retried scenarios" + ] + }, + { + "file": "config/scripts/packaged-browser-lane-contract.test.mjs", + "assertions": [ + "verify pinned package checksum before extraction", + "require both directions three times and run report verification even on failure" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-07", + "runner": "ci", + "platform": "linux", + "result": "passed", + "command": "gh run view 34069063016 --log", + "durationSeconds": 120, + "summary": "Both unmodified compatibility cases passed three times at 5a99f935 with published1.4.188 and main f7d52160162; retries0. Final workflow34069429156 also passed6/6; its downloaded JSON passed the same participation verifier." + } + ], + "runtimeBudget": { + "p95Seconds": 1500, + "scope": "CI job timeout; not a measured p95" + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Initial executable discovery matched CLI and desktop and was corrected before any tests ran. Corrected baseline2/2 and repeat6/6 pass." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Participation unit tests reject missing and retried scenarios; no application mutation proof." + }, + "performanceBudget": { + "required": false, + "evidence": "Compatibility assertions, not a performance benchmark." + }, + "promotionCriteria": [ + "Final workflow JSON report proves all six executions.", + "Collect repeated scheduled history before making this required." + ], + "knownGaps": [ + "Linux1.4.188 only; no macOS or Windows packaged coverage.", + "No folder workspace, SSH execution host or live-service coverage.", + "Other released version pairs remain untested; not a required PR check." + ], + "demotionRule": "Keep experimental if any direction skips or fails; do not extend timeouts or retry to green." } ] } diff --git a/config/scripts/packaged-browser-lane-contract.test.mjs b/config/scripts/packaged-browser-lane-contract.test.mjs new file mode 100644 index 00000000000..bac077d57d4 --- /dev/null +++ b/config/scripts/packaged-browser-lane-contract.test.mjs @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse( + readFileSync(new URL('../../.github/workflows/packaged-browser-e2e.yml', import.meta.url), 'utf8') +) +const steps = workflow.jobs.compatibility.steps + +describe('packaged browser compatibility lane', () => { + it('runs weekly and supports immutable manual or reusable revisions', () => { + expect(workflow.on.schedule).toHaveLength(1) + for (const trigger of ['workflow_dispatch', 'workflow_call']) { + expect(workflow.on[trigger].inputs.ref).toMatchObject({ type: 'string', required: false }) + } + expect(steps[0].with.ref).toBe('${{ inputs.ref || github.sha }}') + expect(workflow.permissions).toEqual({ contents: 'read' }) + }) + + it('verifies the pinned package before selecting the desktop executable', () => { + const download = steps.find((step) => step.name === 'Download pinned old release').run + expect(download).toContain('gh release download v1.4.188') + expect(download).toContain('hashlib.sha512(package.read_bytes())') + expect(download).toContain("extracted/'opt'/'Orca'/'orca-ide'") + expect(download).toContain('assert base64.') + expect(download).toContain('decode()==expected') + expect(download).toContain("['dpkg-deb'") + expect(download.indexOf('assert base64.')).toBeLessThan(download.indexOf("['dpkg-deb'")) + }) + + it('requires both directions three times and rejects silent skips', () => { + const run = steps.find((step) => step.name === 'Run both mixed-version directions') + expect(run.run).toContain('tests/e2e/packaged-mixed-version-browser-placement.spec.ts') + expect(run.run).toContain('--repeat-each=3') + expect(run.run).toContain('--retries=0') + expect(run.run).toContain('--reporter=list,json') + const verify = steps.find((step) => step.name === 'Require all six compatibility executions') + expect(verify.if).toBe('always()') + expect(verify.run).toBe( + `node config/scripts/verify-packaged-browser-participation.mjs ${run.env.PLAYWRIGHT_JSON_OUTPUT_FILE}` + ) + expect(steps.at(-1).if).toBe('always()') + expect(steps.at(-1).with.path).toBe('test-results/') + }) +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index bda022159d4..308f1dfdaa3 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -41,7 +41,7 @@ export const PR_E2E_SOURCE_ROUTES = [ ], matches: (file) => isProductSource(file) && - /^(?:config\/scripts\/verify-wsl-e2e-participation\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test( + /^(?:config\/scripts\/(?:verify-wsl-e2e-participation|verify-playwright-participation)\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test( file ) }, diff --git a/config/scripts/verify-packaged-browser-participation.mjs b/config/scripts/verify-packaged-browser-participation.mjs new file mode 100644 index 00000000000..c165ab46f19 --- /dev/null +++ b/config/scripts/verify-packaged-browser-participation.mjs @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { verifyPlaywrightParticipation } from './verify-playwright-participation.mjs' + +export const PACKAGED_BROWSER_TEST_TITLES = [ + 'keeps an old packaged client on the current server-hosted path', + 'keeps a current client on an old packaged server-hosted path' +] + +export function verifyPackagedBrowserParticipation(report) { + verifyPlaywrightParticipation(report, { + titles: PACKAGED_BROWSER_TEST_TITLES, + label: 'Packaged browser' + }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + verifyPackagedBrowserParticipation(JSON.parse(readFileSync(process.argv[2], 'utf8'))) + console.log('Both packaged browser directions passed three times without skips or retries.') +} diff --git a/config/scripts/verify-packaged-browser-participation.test.mjs b/config/scripts/verify-packaged-browser-participation.test.mjs new file mode 100644 index 00000000000..6508777e19a --- /dev/null +++ b/config/scripts/verify-packaged-browser-participation.test.mjs @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + verifyPackagedBrowserParticipation, + PACKAGED_BROWSER_TEST_TITLES +} from './verify-packaged-browser-participation.mjs' + +function report() { + return { + stats: { expected: 6, skipped: 0, unexpected: 0, flaky: 0 }, + suites: [ + { + suites: [ + { + specs: PACKAGED_BROWSER_TEST_TITLES.map((title) => ({ + title, + tests: Array.from({ length: 3 }, () => ({ + expectedStatus: 'passed', + results: [{ status: 'passed' }] + })) + })) + } + ] + } + ] + } +} + +describe('Packaged browser participation', () => { + it('accepts both named scenarios executed three times', () => { + expect(() => verifyPackagedBrowserParticipation(report())).not.toThrow() + }) + it.each(['skipped', 'unexpected', 'flaky'])('rejects a nonzero %s result', (key) => { + const value = report() + value.stats[key] = 1 + expect(() => verifyPackagedBrowserParticipation(value)).toThrow('participation failed') + }) + it('rejects missing scenarios even when aggregate counts claim six passes', () => { + const value = report() + value.suites[0].suites[0].specs.pop() + expect(() => verifyPackagedBrowserParticipation(value)).toThrow('requires three executions') + }) + it('rejects an unrelated scenario substituted for an expected scenario', () => { + const value = report() + value.suites[0].suites[0].specs[0].title = 'native shell passes' + expect(() => verifyPackagedBrowserParticipation(value)).toThrow( + 'Unexpected Packaged browser scenario' + ) + }) + it('rejects a pass obtained after a failed attempt', () => { + const value = report() + value.suites[0].suites[0].specs[0].tests[0].results.unshift({ status: 'failed' }) + expect(() => verifyPackagedBrowserParticipation(value)).toThrow('without retries') + }) + it('rejects missing report content', () => { + expect(() => verifyPackagedBrowserParticipation({})).toThrow('participation failed') + }) +}) diff --git a/config/scripts/verify-playwright-participation.mjs b/config/scripts/verify-playwright-participation.mjs new file mode 100644 index 00000000000..d78f2757f1e --- /dev/null +++ b/config/scripts/verify-playwright-participation.mjs @@ -0,0 +1,42 @@ +export function verifyPlaywrightParticipation(report, { titles, label, repetitions = 3 }) { + const stats = report?.stats + if ( + !stats || + stats.expected !== titles.length * repetitions || + stats.skipped !== 0 || + stats.unexpected !== 0 || + stats.flaky !== 0 || + report.errors?.length + ) { + throw new Error(`${label} participation failed: ${JSON.stringify(stats)}`) + } + const counts = new Map(titles.map((title) => [title, 0])) + const visit = (suites) => { + for (const suite of suites ?? []) { + for (const spec of suite.specs ?? []) { + if (!counts.has(spec.title)) { + throw new Error(`Unexpected ${label} scenario: ${spec.title}`) + } + for (const test of spec.tests ?? []) { + if ( + test.expectedStatus !== 'passed' || + test.results?.length !== 1 || + test.results[0].status !== 'passed' + ) { + throw new Error(`${label} scenario did not pass without retries: ${spec.title}`) + } + counts.set(spec.title, counts.get(spec.title) + 1) + } + } + visit(suite.suites) + } + } + visit(report.suites) + for (const [title, count] of counts) { + if (count !== repetitions) { + throw new Error( + `${label} scenario requires ${repetitions === 3 ? 'three' : repetitions} executions: ${title} (${count})` + ) + } + } +} diff --git a/config/scripts/verify-wsl-e2e-participation.mjs b/config/scripts/verify-wsl-e2e-participation.mjs index 21570ef7689..9e8c9252b7f 100644 --- a/config/scripts/verify-wsl-e2e-participation.mjs +++ b/config/scripts/verify-wsl-e2e-participation.mjs @@ -1,3 +1,4 @@ +import { verifyPlaywrightParticipation } from './verify-playwright-participation.mjs' import { readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' @@ -8,44 +9,7 @@ export const WSL_TEST_TITLES = [ ] export function verifyWslParticipation(report) { - const stats = report?.stats - if ( - !stats || - stats.expected !== 9 || - stats.skipped !== 0 || - stats.unexpected !== 0 || - stats.flaky !== 0 || - report.errors?.length - ) { - throw new Error(`WSL participation failed: ${JSON.stringify(stats)}`) - } - const counts = new Map(WSL_TEST_TITLES.map((title) => [title, 0])) - const visit = (suites) => { - for (const suite of suites ?? []) { - for (const spec of suite.specs ?? []) { - if (!counts.has(spec.title)) { - throw new Error(`Unexpected WSL scenario: ${spec.title}`) - } - for (const test of spec.tests ?? []) { - if ( - test.expectedStatus !== 'passed' || - test.results?.length !== 1 || - test.results[0].status !== 'passed' - ) { - throw new Error(`WSL scenario did not pass without retries: ${spec.title}`) - } - counts.set(spec.title, counts.get(spec.title) + 1) - } - } - visit(suite.suites) - } - } - visit(report.suites) - for (const [title, count] of counts) { - if (count !== 3) { - throw new Error(`WSL scenario requires three executions: ${title} (${count})`) - } - } + verifyPlaywrightParticipation(report, { titles: WSL_TEST_TITLES, label: 'WSL' }) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/config/scripts/wsl-e2e-lane-contract.test.mjs b/config/scripts/wsl-e2e-lane-contract.test.mjs index 0369eb7c0c4..6790e19e5fe 100644 --- a/config/scripts/wsl-e2e-lane-contract.test.mjs +++ b/config/scripts/wsl-e2e-lane-contract.test.mjs @@ -8,6 +8,7 @@ const read = (path) => readFileSync(new URL(`../../${path}`, import.meta.url), ' describe('real WSL terminal lane', () => { it.each([ 'config/scripts/verify-wsl-e2e-participation.mjs', + 'config/scripts/verify-playwright-participation.mjs', 'src/main/wsl-availability.ts', 'src/main/wsl/wsl-runner.ts', 'src/main/pty/wsl-orca-env.ts', diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index cb1107a8622..fbe0dca909f 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from './first-work-structured-session-rename' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' const { @@ -83,6 +87,130 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { ) }) + it.each([ + ['claude', WORKTREE_ID], + ['codex', WORKTREE_ID], + ['claude', FOLDER_WORKTREE_ID], + ['codex', FOLDER_WORKTREE_ID] + ] as const)( + 'renames %s workspace %s on live work without a subscriber, preserving replay, dedupe and retries', + async (agent, workspaceId) => { + const { deps, setDisplayName } = makeDeps({ + getFolderWorkspacePath: () => '/workspace/platform', + isPendingFirstAgentMessageRename: () => true + }) + const items: AgentJournalRenderItem[] = [] + const journal = { + snapshot: () => ({ items }), + isReadOnly: false + } as unknown as AgentSessionJournal + const pending: Promise[] = [] + const observe = vi.fn((summary, options) => { + const work = maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary, options, deps) + if (work) { + pending.push(work) + } + }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + ['session', { journal, params: { location: { workspaceId }, provider: agent } }] + ]), + getRecord: () => null, + now: () => 1, + onStatusChanged: observe + }) + const user = { + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } + } as AgentJournalRenderItem + const turn = { + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } as AgentJournalRenderItem + items.push(user, turn) + feed.publish('session', journal, { replay: true }) + await Promise.all(pending) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + + items.pop() + feed.publish('session', journal) + items.push(turn) + generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'temporary failure' }) + feed.publish('session', journal) + await Promise.all(pending) + expect(generateBranchNameMock).toHaveBeenCalledOnce() + expect(setDisplayName).not.toHaveBeenCalled() + const callsBeforeOutput = observe.mock.calls.length + for (let index = 0; index < 100; index++) { + feed.publish('session', journal) + } + expect(observe).toHaveBeenCalledTimes(callsBeforeOutput) + + items.pop() + feed.publish('session', journal) + items.push(turn) + feed.publish('session', journal) + await Promise.all(pending) + expect(generateBranchNameMock).toHaveBeenCalledTimes(2) + expect(setDisplayName).toHaveBeenCalledWith(workspaceId, 'Fix auth') + if (workspaceId === FOLDER_WORKTREE_ID) { + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + } else { + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.anything() + ) + } + } + ) + + it('does not probe git for a folder-project structured session with a synthetic worktree id', async () => { + const workspaceId = `${REPO_ID}::/workspace/platform::workspace:123e4567-e89b-12d3-a456-426614174000` + const { deps, setDisplayName, setRenameError } = makeDeps({ + getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo + }) + const journal = { + isReadOnly: false, + snapshot: () => ({ + items: [ + { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } }, + { + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } + ] + }) + } as unknown as AgentSessionJournal + const location = { workspaceId, workspaceKind: 'git-worktree' as const } + const pending: Promise[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]), + getRecord: () => null, + now: () => 1, + onStatusChanged: (summary, options) => { + expect(summary.workspaceId).toBe(workspaceId) + const work = maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary, options, deps) + if (work) { + pending.push(work) + } + } + }) + + feed.publish('session', journal) + await Promise.all(pending) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(setDisplayName).not.toHaveBeenCalled() + expect(setRenameError).toHaveBeenCalledWith(workspaceId, null) + }) + it('keeps incidental work-item markers from overriding the generated display name', async () => { const { deps, onRenamed, setDisplayName } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: 'Fix auth from note #1' }), deps) @@ -233,6 +361,30 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID) }) + it.each([true, false])( + 'preserves a manual folder name during generation (pending=%s)', + async (pendingAfterRename) => { + let name = 'Platform workspace' + let pending = true + const { deps, setDisplayName } = makeDeps({ + resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID, + getFolderWorkspacePath: () => '/workspace/platform', + isPendingFirstAgentMessageRename: () => pending, + getCurrentDisplayName: () => name + }) + generateBranchNameMock.mockImplementationOnce(async () => { + name = 'My manual title' + pending = pendingAfterRename + return { success: true, slug: 'fix-auth' } + }) + + await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + + expect(generateBranchNameMock).toHaveBeenCalledOnce() + expect(setDisplayName).not.toHaveBeenCalled() + } + ) + it('does not rename folder workspace titles without the pending marker', async () => { const { deps, setDisplayName } = makeDeps({ resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID, diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 45f78e75e8a..355a0a35dc9 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -1,6 +1,7 @@ // On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name. import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' +import { isFolderRepo } from '../../shared/repo-kind' import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' @@ -170,6 +171,9 @@ async function runAutoRename( if (!repo || !parsed) { return stop('unresolved repo or worktree id') } + if (isFolderRepo(repo)) { + return stop('folder project has no branch to rename', true) + } const worktreePath = parsed.worktreePath const provider = repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts new file mode 100644 index 00000000000..ebc9484071b --- /dev/null +++ b/src/main/agent-hooks/first-work-rename-runtime.ts @@ -0,0 +1,119 @@ +import { existsSync } from 'node:fs' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import type { FirstWorkBranchRenameDeps } from './first-work-branch-rename' +import { rememberBranchRenameFailureOutput } from './branch-rename-failure-output' +import { renameWorktreeFolderOnFirstWork } from './first-work-folder-rename' +import { moveWorktree } from '../git/worktree' +import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +const ENABLE_FIRST_WORK_FOLDER_RENAME = false + +export function firstWorkRenameDeps( + store: Store, + runtime: Pick< + OrcaRuntimeService, + | 'getCommitMessageAgentEnvironmentResolvers' + | 'notifyFolderWorkspaceChanged' + | 'notifyBranchRenamed' + | 'notifyWorktreeFolderRenamed' + > +): FirstWorkBranchRenameDeps { + return { + getSettings: () => store.getSettings(), + getRepo: (repoId) => store.getRepo(repoId), + getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), + getCurrentDisplayName: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name + : store.getWorktreeMeta(worktreeId)?.displayName + }, + getFolderWorkspacePath: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath + : undefined + }, + isPendingFirstAgentMessageRename: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === true + : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true + }, + canRenameOrcaCreatedBranch: (worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. + return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true + }, + setDisplayName: (worktreeId, displayName) => { + rememberBranchRenameFailureOutput(worktreeId, null) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + store.updateFolderWorkspace(scope.folderWorkspaceId, { + name: displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + runtime.notifyFolderWorkspaceChanged() + return + } + store.setWorktreeMeta(worktreeId, { + displayName, + // The first-agent title is an intentional user-facing label; keep it stable after the + // generated branch is renamed and across subsequent catalog refreshes. + displayNameIsPinned: true, + pendingFirstAgentMessageRename: false, + // Success clears the failure badge (redundant with the explicit setRenameError(null)). + firstAgentMessageRenameError: null + }) + }, + renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME + ? (worktreeId, newLeaf) => + renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { + getRepo: (repoId) => store.getRepo(repoId), + getSettings: () => store.getSettings(), + migrateWorktreeIdentity: (oldId, newId) => store.migrateWorktreeIdentity(oldId, newId), + notifyWorktreeRenamed: (repoId, oldId, newId) => + runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), + pathExists: async (candidate) => existsSync(candidate), + moveWorktree + }) + : undefined, + setRenameError: (worktreeId, error, failureOutput) => { + // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. + rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) + // Skip the write + push when unchanged — most settled worktrees never had an error to clear. + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const current = store.getFolderWorkspace( + scope.folderWorkspaceId + )?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.updateFolderWorkspace(scope.folderWorkspaceId, { + firstAgentMessageRenameError: error + }) + runtime.notifyFolderWorkspaceChanged() + return + } + const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. + runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) + }, + resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), + onRenamed: (repoIdOrWorktreeId) => { + if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { + runtime.notifyFolderWorkspaceChanged() + return + } + runtime.notifyBranchRenamed(repoIdOrWorktreeId) + } + } +} diff --git a/src/main/agent-hooks/first-work-structured-session-rename.ts b/src/main/agent-hooks/first-work-structured-session-rename.ts new file mode 100644 index 00000000000..637dc1e9c3c --- /dev/null +++ b/src/main/agent-hooks/first-work-structured-session-rename.ts @@ -0,0 +1,28 @@ +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { + maybeAutoRenameBranchOnFirstWork, + type FirstWorkBranchRenameDeps +} from './first-work-branch-rename' + +export function maybeAutoRenameWorkspaceOnFirstStructuredTurn( + summary: AgentSessionStatusSummary, + options: { replay: boolean }, + deps: FirstWorkBranchRenameDeps +): Promise | undefined { + if (summary.status !== 'working') { + return + } + return maybeAutoRenameBranchOnFirstWork( + { + // No pane: a structured session is resolved by its workspace id, not by a terminal tab. + paneKey: '', + tabId: undefined, + worktreeId: summary.workspaceId, + state: 'working', + prompt: summary.latestPrompt, + assistantMessage: undefined, + isReplay: options.replay + }, + deps + ) +} diff --git a/src/main/agent-hooks/first-work-workspace-title-rename.ts b/src/main/agent-hooks/first-work-workspace-title-rename.ts index fb682c86cf3..063c6f15aec 100644 --- a/src/main/agent-hooks/first-work-workspace-title-rename.ts +++ b/src/main/agent-hooks/first-work-workspace-title-rename.ts @@ -27,6 +27,7 @@ export async function runFolderWorkspaceTitleAutoRename( return stop('folder workspace path unavailable') } + const originalDisplayName = deps.getCurrentDisplayName(worktreeId) const settings = deps.getSettings() const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null) if (!resolvedParams.ok) { @@ -49,6 +50,13 @@ export async function runFolderWorkspaceTitleAutoRename( resolvedParams.params, target ) + // Generation may outlive a manual rename or workspace removal. + if ( + deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true || + deps.getCurrentDisplayName(worktreeId) !== originalDisplayName + ) { + return stop('folder workspace changed during generation', true) + } if (!generated.success) { if (!generated.canceled) { deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index b844d156205..df8e8e67f53 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -113,7 +113,10 @@ export function createClaudeJournalTranslator( } else { deps.sink.appendTombstone(identity) } - deps.sink.publish() + // Preserve first-work evidence when completion arrives before the journal drains. + deps.sink.publish({ + coalescingKey: running ? `turn-start:${sessionId}:${turnId}` : 'publish' + }) } const publishActivity = (kind: string, payload: unknown): void => { diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index dc1cfd35356..1f2bc480a22 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -212,7 +212,7 @@ describe('codex journal translation', () => { expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ queuedOperations: 4, backpressured: true }) + expect(deferred.state()).toMatchObject({ queuedOperations: 5, backpressured: true }) deferred.bind(deferredTarget(bodies, publishes)) await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) @@ -225,7 +225,7 @@ describe('codex journal translation', () => { expect.objectContaining({ kind: 'tool-call', state: 'running' }), expect.objectContaining({ kind: 'tool-call', state: 'failed' }) ]) - expect(publishes).toHaveLength(1) + expect(publishes).toHaveLength(2) }) it('admits terminal session settlement publication across the hard watermark', async () => { @@ -257,7 +257,7 @@ describe('codex journal translation', () => { acquisitionGeneration: 'generation-1' }) ).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ queuedOperations: 4, backpressured: true }) + expect(deferred.state()).toMatchObject({ queuedOperations: 5, backpressured: true }) deferred.bind(deferredTarget(bodies, publishes)) await expect(deferred.lifecycleBarrier()).resolves.toEqual({ ok: true }) @@ -277,7 +277,7 @@ describe('codex journal translation', () => { }), { kind: 'status', text: 'Provider exited: lost child' } ]) - expect(publishes).toHaveLength(1) + expect(publishes).toHaveLength(2) }) it('retries a rejected terminal admission without losing tool, prompt, turn, or session truth', () => { diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 06bb28f85f9..7313946a53e 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -56,9 +56,16 @@ export function publishCodexTurnLifecycle(input: { return admission } } - if (input.sink.tryPublish) { - return input.sink.tryPublish({ lifecycle: true }) + // Preserve first-work evidence when completion arrives before the journal drains. + const publishOptions = { + lifecycle: true, + ...(input.state === 'running' + ? { coalescingKey: `turn-start:${input.sessionId}:${input.turnId}` } + : {}) } - input.sink.publish({ lifecycle: true }) + if (input.sink.tryPublish) { + return input.sink.tryPublish(publishOptions) + } + input.sink.publish(publishOptions) return ADMITTED } diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index b6988ec0e6f..41625792aa0 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -26,6 +26,7 @@ export type JournalReducerState = { sessionId: string epoch: string lastSequence: number + lastActivityAt: number /** Lowest sequence still individually replayable; rows below it were compacted. */ oldestSequence: number highestFence: number @@ -45,6 +46,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou sessionId, epoch, lastSequence: 0, + lastActivityAt: 0, oldestSequence: 1, highestFence: 0, items: new Map(), @@ -62,6 +64,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo if (row.kind === 'epoch') { return } + state.lastActivityAt = Math.max(state.lastActivityAt, row.ts) if (row.kind === 'item') { const itemId = resolveJournalItemId(state, row.itemId, row.body) upsertItem(state, itemId, row.revision, { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index ab2715d0d86..e2936b2553d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -162,6 +162,9 @@ export class AgentSessionJournal { snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) + /** Includes revisions and completion tombstones, whose timestamps disappear from render items. */ + lastActivityAt = (): number => this.state.lastActivityAt + submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] pendingSubmissions = (): AgentJournalSubmission[] => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index ea4b594ac44..7a321668c46 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,6 +1,7 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -62,5 +63,11 @@ export type StructuredAgentSessionHostDeps = { /** How long a session outlives its last surface. Tests drive this; production takes the default. */ releaseGraceMs?: number onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + /** Every status projection this host publishes. `replay` marks a re-projection of state the host + * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ + onSessionStatusChanged?: ( + summary: AgentSessionStatusSummary, + options: { replay: boolean } + ) => void handoffTransport?: StructuredAgentSessionHandoffTransport } 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 c13fd4f4051..1e2dcfeb0d9 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 @@ -61,7 +61,8 @@ export class StructuredAgentSessionHost { private readonly statusFeed = new StructuredAgentSessionStatusFeed({ sessions: this.sessions, getRecord: (sessionId) => this.deps.store.getRecord(sessionId), - now: () => this.now() + now: () => this.now(), + onStatusChanged: (summary, options) => this.deps.onSessionStatusChanged?.(summary, options) }) private readonly subscribers = new AgentSessionSubscribers({ onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal) @@ -129,7 +130,7 @@ export class StructuredAgentSessionHost { // `hasSession` inside the same serialized step as this `set`. onReadable: (sessionId, restored) => { this.sessions.set(sessionId, restored) - this.statusFeed.publish(sessionId) + this.statusFeed.publish(sessionId, undefined, { replay: true }) }, restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) }) @@ -180,14 +181,11 @@ export class StructuredAgentSessionHost { /** The host's half of attaching, named so it cannot grow dependencies unnoticed. */ private attachContext(): StructuredAgentSessionAttachContext { return { - deps: this.deps, - runtimeState: this.runtimeState, - sessions: this.sessions, + ...this.lifetimeContext(), subscribers: this.subscribers, tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), - serialize: (sessionId, task) => this.serialize(sessionId, task), - now: () => this.now() + serialize: (sessionId, task) => this.serialize(sessionId, task) } } /** Releases a session's resources without ending the conversation: the record and journal stay 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 d44e3c07eb8..c1b52f879d4 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 @@ -4,8 +4,14 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' +import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' +import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusFeedDeps +} from './structured-agent-session-status-feed' const SESSION = 'status-session' const TURN_IDENTITY = { @@ -33,7 +39,7 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -async function openJournal(sessionId = SESSION) { +async function openJournal(sessionId = SESSION, now?: () => number) { return journals.open({ identity: { sessionId, @@ -42,6 +48,7 @@ async function openJournal(sessionId = SESSION) { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, + now, journalDir: join(root, sessionId) }) } @@ -55,10 +62,12 @@ function indexed(session: { journal: Awaited> }) function feedFor( sessions: Map> }>, - record: Partial | null = null + record: Partial | null = null, + onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] ) { let now = 1_000 const feed = new StructuredAgentSessionStatusFeed({ + ...(onStatusChanged ? { onStatusChanged } : {}), sessions: { get: (sessionId: string) => { const session = sessions.get(sessionId) @@ -136,6 +145,108 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(events).toHaveLength(3) }) + it('preserves the completion tombstone time when the journal and host reopen', async () => { + let now = 100 + const journal = await openJournal(SESSION, () => now) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + now = 200 + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', updatedAt: 200 } + }) + await journal.close() + now = 900 + const reopened = await openJournal(SESSION, () => now) + const restored = feedFor(new Map([[SESSION, { journal: reopened }]])) + expect(restored.events[0]).toMatchObject({ + type: 'snapshot', + sessions: [{ status: 'idle', updatedAt: 200 }] + }) + }) + + it('publishes settled activity revisions and restores the same age after reopening', async () => { + let now = 100 + const journal = await openJournal(SESSION, () => now) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + const assistant = { ...USER_IDENTITY, ordinal: 2 } + await journal.appendItem( + assistant, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }, + { fence: 1 } + ) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + now = 200 + await journal.appendItem( + assistant, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'finished' }] }, + { fence: 1 } + ) + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', updatedAt: 200 } + }) + feed.publish(SESSION) + expect(events).toHaveLength(2) + await journal.close() + const reopened = await openJournal(SESSION, () => 900) + const restored = feedFor(new Map([[SESSION, { journal: reopened }]])) + expect(restored.events[0]).toMatchObject({ + type: 'snapshot', + sessions: [{ status: 'idle', updatedAt: 200 }] + }) + }) + + it('does not publish timestamp-only revisions while a turn is working', async () => { + let now = 100 + const journal = await openJournal(SESSION, () => now) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + for (let revision = 1; revision <= 20; revision += 1) { + now += 1 + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + feed.publish(SESSION) + } + expect(events).toHaveLength(1) + now = 200 + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(events).toHaveLength(2) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', updatedAt: 200 } + }) + }) + it('carries the record model and the running tool line the sidebar row shows', async () => { const journal = await openJournal() const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), { @@ -280,4 +391,135 @@ describe('StructuredAgentSessionStatusFeed', () => { session: expect.objectContaining({ status: 'idle' }) }) }) + it('reports each projection change to the host observer, marking re-projections as replay', async () => { + const journal = await openJournal() + const seen: { status: string | null; prompt: string; replay: boolean }[] = [] + const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary, options) => + seen.push({ status: summary.status, prompt: summary.latestPrompt, replay: options.replay }) + ) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix the auth bug' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + + feed.publish(SESSION, journal) + // A second identical publication is deduped, so the observer only ever sees changes. + feed.publish(SESSION, journal) + // seen[0] is the opening projection the harness's own subscriber triggered. + expect(seen.slice(1)).toEqual([ + { status: 'working', prompt: 'fix the auth bug', replay: false } + ]) + + // An arriving subscriber re-projects state the host already knew. + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.subscribe({ id: 'list-2', emit: () => undefined }) + expect(seen.at(-1)).toEqual({ status: 'idle', prompt: 'fix the auth bug', replay: true }) + }) + + it.each(['claude', 'codex'] as const)( + 'observes a fast %s turn even when start and finish queue before persistence', + async (agent) => { + const journal = await openJournal() + await journal.appendItem( + USER_IDENTITY, + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fix auth' }] + }, + { fence: 1 } + ) + const seen: (string | null)[] = [] + const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) => + seen.push(summary.status) + ) + const deferred = createDeferredStructuredAgentSessionEventSink() + if (agent === 'claude') { + const translator = createClaudeJournalTranslator({ sink: deferred.sink }) + translator.handle({ + type: 'message', + sessionId: SESSION, + startsTurn: true, + message: { + type: 'user', + uuid: 'prompt-1', + session_id: 'claude-session', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'Fix auth' }] } + } + }) + translator.handle({ + type: 'message', + sessionId: SESSION, + message: { + type: 'result', + subtype: 'success', + session_id: 'claude-session', + uuid: 'result-1', + result: 'Done' + } + }) + translator.dispose() + } else { + for (const state of ['running', 'completed'] as const) { + publishCodexTurnLifecycle({ + sink: deferred.sink, + primaryThreadId: 'thread-1', + sessionId: SESSION, + threadId: 'thread-1', + turnId: 'turn-1', + state + }) + } + } + for (let index = 0; index < 100; index++) { + deferred.sink.publish() + } + // This queue is also reached while a previous asynchronous journal write is pending. + let publications = 0 + let activityPublications = 0 + deferred.bind({ + journal, + fence: 1, + publish: (activity) => { + if (activity === undefined) { + publications += 1 + } else { + activityPublications += 1 + } + feed.publish(SESSION, journal) + } + }) + expect(await deferred.drained()).toEqual({ ok: true }) + expect(seen).toEqual(['idle', 'working', 'idle']) + expect(publications).toBe(2) + expect(activityPublications).toBe(agent === 'claude' ? 1 : 0) + expect(deferred.state()).toMatchObject({ queuedBytes: 0, queuedOperations: 0 }) + deferred.close() + } + ) + + it('keeps publishing to subscribers when the host observer throws', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, () => { + throw new Error('observer exploded') + }) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + + expect(() => feed.publish(SESSION, journal)).not.toThrow() + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) + }) + }) }) 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 902acbc6112..348b5aebc21 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 @@ -36,6 +36,9 @@ export type StructuredAgentSessionStatusFeedDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null now: () => number + /** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection + * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ + onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void } function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { @@ -43,6 +46,8 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.workspaceId === b.workspaceId && a.agent === b.agent && a.status === b.status && + // Settled activity changes ranking; streaming active turns must stay quiet. + (a.status !== 'idle' || a.updatedAt === b.updatedAt) && a.latestPrompt === b.latestPrompt && a.model === b.model && a.toolName === b.toolName && @@ -63,7 +68,7 @@ export class StructuredAgentSessionStatusFeed { // Re-project before registering: a change found here has to reach the subscribers that // already read the old value, and the arriving one carries it in its snapshot instead. for (const [sessionId] of this.deps.sessions) { - this.publish(sessionId) + this.publish(sessionId, undefined, { replay: true }) } this.subscribers.set(subscriber.id, subscriber) this.emit(subscriber, { type: 'snapshot', sessions: [...this.published.values()] }) @@ -84,7 +89,7 @@ export class StructuredAgentSessionStatusFeed { } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ - publish(sessionId: string, journal?: AgentSessionJournal): void { + publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { const session = this.deps.sessions.get(sessionId) if (!session) { return @@ -96,6 +101,12 @@ export class StructuredAgentSessionStatusFeed { } this.published.set(sessionId, summary) this.broadcast({ type: 'status', session: summary }) + try { + this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) + } catch (error) { + // An observer must never cost the subscribers their status event. + console.warn('[structured-session-status] status observer failed', error) + } } private summaryFor( @@ -117,7 +128,7 @@ export class StructuredAgentSessionStatusFeed { ...projectStructuredAgentSessionStatusSummary(items), ...(model ? { model } : {}), ...(providerSession ? { providerSession } : {}), - updatedAt: this.deps.now() + updatedAt: journal.lastActivityAt() || this.deps.now() } } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 06391e2ae83..240156d93c6 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -14,6 +14,8 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' +import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' @@ -156,6 +158,15 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent claudeStructuredAuthPolicyForSettings(this.requireStore().getSettings()), // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), + // Structured chat has no agent CLI hooks, so this projection is what the first-work + // workspace rename listens to instead of `agentStatus:set`. + onSessionStatusChanged: (summary, options) => { + void maybeAutoRenameWorkspaceOnFirstStructuredTurn( + summary, + options, + firstWorkRenameDeps(this.requireStore(), this) + ) + }, handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 8defafb4433..f6c9d274142 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -98,6 +98,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { { journal: { isReadOnly: false, + lastActivityAt: () => 2, snapshot: () => ({ items: STATUS_ITEMS }) } as unknown as AgentSessionJournal, params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } @@ -815,7 +816,8 @@ describe('agentSession.subscribeStatus', () => { workspaceId: 'workspace-1', agent: 'codex', status: 'working', - latestPrompt: 'write a poem' + latestPrompt: 'write a poem', + updatedAt: 2 } ] } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 51640fb0cb0..d9b3e59186a 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -16,7 +16,10 @@ import { type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' -import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + StructuredAgentSessionHost, + type StructuredAgentSessionHostDeps +} from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' @@ -76,6 +79,9 @@ export type StructuredAgentSessionRuntimeDeps = { resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void + /** Every structured-session status projection, for host-side reactions such as the first-work + * workspace rename that CLI agents get from their hooks. */ + onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] handoffTransport?: StructuredAgentSessionHandoffTransport reapOrphanChildren?: typeof stopOrphanAgentSessionChildren } @@ -289,6 +295,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + ...(deps.onSessionStatusChanged + ? { onSessionStatusChanged: deps.onSessionStatusChanged } + : {}), persistTuiProviderHandle: async ({ sessionId, link, now }) => { await store.transitionHandoff(sessionId, (record) => recordAgentSessionProviderHandle({ record, fence: record.lease.runtimeFence, link, now }) diff --git a/src/main/startup/branch-rename-hook-structured-session.test.ts b/src/main/startup/branch-rename-hook-structured-session.test.ts new file mode 100644 index 00000000000..de85e824db6 --- /dev/null +++ b/src/main/startup/branch-rename-hook-structured-session.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' + +// Why the mocks: this file only proves the structured-session seam, and the real orchestrator's +// import graph reaches git, electron, and the agent-hook installers. +const { renameCalls } = vi.hoisted(() => ({ renameCalls: [] as unknown[][] })) +vi.mock('../agent-hooks/first-work-branch-rename', () => ({ + maybeAutoRenameBranchOnFirstWork: (...args: unknown[]) => { + renameCalls.push(args) + return Promise.resolve() + } +})) +vi.mock('../agent-hooks/branch-rename-failure-output', () => ({ + rememberBranchRenameFailureOutput: vi.fn() +})) +vi.mock('../agent-hooks/first-work-folder-rename', () => ({ + renameWorktreeFolderOnFirstWork: vi.fn() +})) +vi.mock('../git/worktree', () => ({ moveWorktree: vi.fn() })) +vi.mock('electron', () => ({ app: { getPath: () => '', on: vi.fn(), isReady: () => true } })) + +import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' +import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' +import { mainProcessState } from './main-process-state' + +let renameDeps: ReturnType + +const WORKSPACE_ID = 'repo1::/repo/wt' + +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: WORKSPACE_ID, + agent: 'claude', + status: 'working', + latestPrompt: 'Fix the auth bug', + updatedAt: 1, + ...overrides + } +} + +beforeEach(() => { + renameCalls.length = 0 + mainProcessState.store = { + getSettings: () => ({}), + getRepo: () => undefined, + getWorktreeMeta: () => undefined, + getWorktreeIdForTab: () => undefined + } as unknown as typeof mainProcessState.store + mainProcessState.runtime = { + getCommitMessageAgentEnvironmentResolvers: () => undefined + } as unknown as typeof mainProcessState.runtime + renameDeps = firstWorkRenameDeps(mainProcessState.store!, mainProcessState.runtime!) +}) + +describe('maybeAutoRenameWorkspaceOnFirstStructuredTurn', () => { + it('drives the first-work rename from the session workspace, with no pane to resolve', () => { + maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: false }, renameDeps) + + expect(renameCalls).toHaveLength(1) + expect(renameCalls[0]?.[0]).toEqual({ + paneKey: '', + tabId: undefined, + worktreeId: WORKSPACE_ID, + state: 'working', + prompt: 'Fix the auth bug', + assistantMessage: undefined, + isReplay: false + }) + }) + + it('marks a re-projected summary as a replay so restore cannot rename on old state', () => { + maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: true }, renameDeps) + + expect(renameCalls[0]?.[0]).toMatchObject({ isReplay: true }) + }) + + it('ignores every status that is not a running turn', () => { + for (const status of ['idle', 'attention', null] as const) { + maybeAutoRenameWorkspaceOnFirstStructuredTurn( + summary({ status }), + { replay: false }, + renameDeps + ) + } + + expect(renameCalls).toEqual([]) + }) + + it('uses the owning runtime even when desktop singletons do not exist', () => { + mainProcessState.store = null + mainProcessState.runtime = null + maybeAutoRenameWorkspaceOnFirstStructuredTurn(summary(), { replay: false }, renameDeps) + + expect(renameCalls).toHaveLength(1) + expect(renameCalls[0]?.[1]).toBe(renameDeps) + }) +}) diff --git a/src/main/startup/branch-rename-hook.ts b/src/main/startup/branch-rename-hook.ts index 578935132fb..e2d5dcfcd19 100644 --- a/src/main/startup/branch-rename-hook.ts +++ b/src/main/startup/branch-rename-hook.ts @@ -1,15 +1,7 @@ -import { existsSync } from 'node:fs' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename' -import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output' -import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename' -import { moveWorktree } from '../git/worktree' +import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { mainProcessState as state } from './main-process-state' -// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. -const ENABLE_FIRST_WORK_FOLDER_RENAME = false - // Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { paneKey: string @@ -33,103 +25,6 @@ export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { assistantMessage: event.payload.lastAssistantMessage, isReplay: event.isReplay }, - { - getSettings: () => store.getSettings(), - getRepo: (repoId) => store.getRepo(repoId), - getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), - getCurrentDisplayName: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name - : store.getWorktreeMeta(worktreeId)?.displayName - }, - getFolderWorkspacePath: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath - : undefined - }, - isPendingFirstAgentMessageRename: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === - true - : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true - }, - canRenameOrcaCreatedBranch: (worktreeId) => { - const meta = store.getWorktreeMeta(worktreeId) - // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. - return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true - }, - setDisplayName: (worktreeId, displayName) => { - rememberBranchRenameFailureOutput(worktreeId, null) - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - store.updateFolderWorkspace(scope.folderWorkspaceId, { - name: displayName, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - }) - runtime.notifyFolderWorkspaceChanged() - return - } - store.setWorktreeMeta(worktreeId, { - displayName, - // The first-agent title is an intentional user-facing label; keep it stable after the - // generated branch is renamed and across subsequent catalog refreshes. - displayNameIsPinned: true, - pendingFirstAgentMessageRename: false, - // Success clears the failure badge (redundant with the explicit setRenameError(null)). - firstAgentMessageRenameError: null - }) - }, - renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME - ? (worktreeId, newLeaf) => - renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { - getRepo: (repoId) => store.getRepo(repoId), - getSettings: () => store.getSettings(), - migrateWorktreeIdentity: (oldId, newId) => - store.migrateWorktreeIdentity(oldId, newId), - notifyWorktreeRenamed: (repoId, oldId, newId) => - runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), - pathExists: async (candidate) => existsSync(candidate), - moveWorktree - }) - : undefined, - setRenameError: (worktreeId, error, failureOutput) => { - // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. - rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) - // Skip the write + push when unchanged — most settled worktrees never had an error to clear. - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const current = store.getFolderWorkspace( - scope.folderWorkspaceId - )?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - store.updateFolderWorkspace(scope.folderWorkspaceId, { - firstAgentMessageRenameError: error - }) - runtime.notifyFolderWorkspaceChanged() - return - } - const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) - // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. - runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) - }, - resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), - onRenamed: (repoIdOrWorktreeId) => { - if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { - runtime.notifyFolderWorkspaceChanged() - return - } - runtime.notifyBranchRenamed(repoIdOrWorktreeId) - } - } + firstWorkRenameDeps(store, runtime) ) } diff --git a/src/main/windows-descendant-exit-verification.test.ts b/src/main/windows-descendant-exit-verification.test.ts index 392c44399e7..d1944f5ef86 100644 --- a/src/main/windows-descendant-exit-verification.test.ts +++ b/src/main/windows-descendant-exit-verification.test.ts @@ -19,6 +19,101 @@ function snapshot( } describe('captureWindowsDescendantSnapshot', () => { + it('does not claim an older process whose former parent PID was reused by the root', async () => { + const olderProcess = { pid: 50244, ppid: 36084, creationTimeMs: 1788659167395 } + const captured = await captureWindowsDescendantSnapshot(36084, { + readTable: async () => [ + { pid: 36084, ppid: 60976, creationTimeMs: 1788733587893 }, + olderProcess + ] + }) + + expect(captured?.descendants).toEqual([]) + await expect( + verifyWindowsDescendantSnapshotExit(captured!, { readTable: async () => [olderProcess] }) + ).resolves.toBe('exited') + }) + + it('prunes a stale parent link and its subtree at any depth', async () => { + const captured = await captureWindowsDescendantSnapshot(100, { + readTable: async () => [ + { pid: 100, ppid: 1, creationTimeMs: 5 }, + { pid: 200, ppid: 100, creationTimeMs: 10 }, + { pid: 300, ppid: 200, creationTimeMs: 7 }, + { pid: 400, ppid: 300, creationTimeMs: 12 }, + { pid: 500, ppid: 100, creationTimeMs: 4 }, + { pid: 600, ppid: 500, creationTimeMs: 13 }, + { pid: 700, ppid: 200, creationTimeMs: 10 } + ] + }) + + expect(captured?.descendants).toEqual([ + { pid: 700, creationTimeMs: 10 }, + { pid: 200, creationTimeMs: 10 } + ]) + }) + + it('keeps the root when its own parent PID was reused by a newer process', async () => { + // The root's retained ppid now names a process created after it. Pruning the + // root drops the whole snapshot, so its own link is never evidence about it. + const captured = await captureWindowsDescendantSnapshot(100, { + readTable: async () => [ + { pid: 100, ppid: 900, creationTimeMs: 5 }, + { pid: 900, ppid: 1, creationTimeMs: 50 }, + { pid: 200, ppid: 100, creationTimeMs: 7 } + ], + now: () => 42 + }) + + expect(captured).toEqual({ + root: { pid: 100, creationTimeMs: 5 }, + descendants: [{ pid: 200, creationTimeMs: 7 }], + unidentifiedCount: 0, + capturedAtMs: 42 + }) + }) + + it('bounds a link by the root when the claimed parent denied its creation time', async () => { + // 300 has no creation time for a child to be compared against, so the root's + // start is the only bound left: 350 ties with it, which a same-millisecond + // spawn does routinely, while 360 predates the whole tree. + const captured = await captureWindowsDescendantSnapshot(100, { + readTable: async () => [ + { pid: 100, ppid: 1, creationTimeMs: 5 }, + { pid: 300, ppid: 100 }, + { pid: 350, ppid: 300, creationTimeMs: 5 }, + { pid: 360, ppid: 300, creationTimeMs: 2 } + ], + now: () => 42 + }) + + expect(captured).toEqual({ + root: { pid: 100, creationTimeMs: 5 }, + descendants: [{ pid: 350, creationTimeMs: 5 }], + unidentifiedCount: 1, + capturedAtMs: 42 + }) + }) + + it('drops an unidentified row whose parent link was pruned', async () => { + // 250 denied its creation time, but 200's claim on the root is impossible, so + // 250 was never in this tree: counting it would cap the verdict at + // unverifiable over a process the root does not own. + const captured = await captureWindowsDescendantSnapshot(100, { + readTable: async () => [ + { pid: 100, ppid: 1, creationTimeMs: 10 }, + { pid: 200, ppid: 100, creationTimeMs: 5 }, + { pid: 250, ppid: 200 } + ] + }) + + expect(captured?.descendants).toEqual([]) + expect(captured?.unidentifiedCount).toBe(0) + await expect( + verifyWindowsDescendantSnapshotExit(captured!, { readTable: async () => [] }) + ).resolves.toBe('exited') + }) + it('walks the whole subtree and keeps only rows a later read can re-identify', async () => { const captured = await captureWindowsDescendantSnapshot(100, { // 400 is a grandchild; 300 denied a creation-time query, so no later read diff --git a/src/main/windows-descendant-exit-verification.ts b/src/main/windows-descendant-exit-verification.ts index 079833a2bd6..5e365a57e5a 100644 --- a/src/main/windows-descendant-exit-verification.ts +++ b/src/main/windows-descendant-exit-verification.ts @@ -1,3 +1,4 @@ +import { getProcessTableIndex } from '../shared/process-table-index' import type { DescendantTreeVerdict } from './pty-descendant-exit-verification' import { windowsDescendantsFromRows } from './providers/windows-foreground-process-rows' import { readWindowsProcessTableFresh } from './windows/windows-process-table' @@ -57,6 +58,9 @@ function delay(ms: number): Promise { * Snapshot a Windows root's descendants while it is still alive. Resolves null * (never rejects) when the table is unreadable or the root is absent — the same * contract as the POSIX walk, because "cannot see" is never "nothing is there". + * + * Stale parent links are pruned by creation time, so a backwards clock step + * between two spawns can drop a live descendant — accepted over a certain stall. */ export async function captureWindowsDescendantSnapshot( rootPid: number, @@ -69,9 +73,35 @@ export async function captureWindowsDescendantSnapshot( // One table read, not a walk plus an identity read: each is bounded in // seconds, and this runs inside the close ladder's budget. const table = await (deps.readTable ?? readWindowsProcessTableFresh)().catch(() => null) - const descendants = table && windowsDescendantsFromRows(table, rootPid) - const root = table?.find((row) => row.pid === rootPid) - if (!descendants || typeof root?.creationTimeMs !== 'number') { + if (!table) { + return null + } + // One index for both lookups, so a repeated pid resolves to the same row for + // the root and for a parent link: `byPid` is first-wins, a Map is not. + const rowsByPid = getProcessTableIndex(table).byPid + const root = rowsByPid.get(rootPid) + if (typeof root?.creationTimeMs !== 'number') { + return null + } + const rootCreationTimeMs = root.creationTimeMs + // Windows keeps a process's original parent PID after that parent exits, so a + // reused PID is not ancestry: no real child predates the parent it claims. + // The root's start backstops the undefined-time bypass, which admits a row + // unchecked and leaves its children no parent time to compare against. Ties + // pass -- FILETIMEs truncated to ms make a same-millisecond parent and child + // collide exactly, so `>` would drop true descendants. + const currentRows = table.filter((row) => { + const parentCreationTimeMs = rowsByPid.get(row.ppid)?.creationTimeMs + return ( + // Its own ppid can be recycled too, and a pruned root loses the snapshot. + row.pid === rootPid || + row.creationTimeMs === undefined || + (row.creationTimeMs >= rootCreationTimeMs && + (parentCreationTimeMs === undefined || row.creationTimeMs >= parentCreationTimeMs)) + ) + }) + const descendants = windowsDescendantsFromRows(currentRows, rootPid) + if (!descendants) { return null } return { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index bfa522e4b83..c4376dbf66e 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -6,15 +6,18 @@ import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import { resolveAttention } from '../sidebar/smart-attention' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' const mocks = vi.hoisted(() => ({ removeAgentStatus: vi.fn(), setAgentStatus: vi.fn(), store: null as null | { - getState: () => Record - setState: (state: Record) => void + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void }, subscribeStatus: vi.fn(), subscribeTranscript: vi.fn(), @@ -23,53 +26,19 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/store', async () => { - const { create } = await import('zustand') - const useAppStore = create<{ - agentStatusByPaneKey: Record> - removeAgentStatus: (paneKey: string) => void - setAgentStatus: (...args: unknown[]) => void - testRuntimeOwner: string | null - unifiedTabsByWorktree: Record - }>((set, get) => ({ - agentStatusByPaneKey: {}, - removeAgentStatus: (paneKey) => { - mocks.removeAgentStatus(paneKey) - if (!get().agentStatusByPaneKey[paneKey]) { - return - } - const next = { ...get().agentStatusByPaneKey } - delete next[paneKey] - set({ agentStatusByPaneKey: next }) - }, + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + const { setAgentStatus, removeAgentStatus } = useAppStore.getState() + useAppStore.setState({ setAgentStatus: (...args) => { mocks.setAgentStatus(...args) - const [paneKey, payload, terminalTitle, , routing, metadata] = args as [ - string, - Record, - string, - unknown, - Record, - Record - ] - set((state) => ({ - agentStatusByPaneKey: { - ...state.agentStatusByPaneKey, - [paneKey]: { - ...payload, - ...routing, - ...metadata, - paneKey, - terminalTitle, - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] - } - } - })) + setAgentStatus(...args) }, - testRuntimeOwner: null, - unifiedTabsByWorktree: {} - })) + removeAgentStatus: (paneKey) => { + mocks.removeAgentStatus(paneKey) + removeAgentStatus(paneKey) + } + }) mocks.store = useAppStore return { useAppStore } }) @@ -126,7 +95,7 @@ function summary(overrides: Partial = {}): AgentSessi } } -function statuses(): Record[] { +function statuses(): AgentStatusEntry[] { return Object.values(mocks.store?.getState().agentStatusByPaneKey ?? {}) } @@ -218,7 +187,9 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()).toEqual([expect.objectContaining({ state: 'working' })]) act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) - expect(statuses()).toEqual([expect.objectContaining({ state: 'done', sessionBoundary: true })]) + expect(statuses()).toEqual([ + expect.objectContaining({ state: 'done', sessionBoundary: false, stateStartedAt: 2 }) + ]) act(() => feed().emit({ type: 'status', session: summary({ status: 'attention', updatedAt: 3 }) }) @@ -309,8 +280,8 @@ describe('StructuredAgentSessionStatusBridge', () => { const before = mocks.store?.getState().agentStatusByPaneKey act(() => { - for (let updatedAt = 2; updatedAt <= 12; updatedAt += 1) { - feed().emit({ type: 'status', session: summary({ updatedAt }) }) + for (let repeat = 0; repeat < 10; repeat += 1) { + feed().emit({ type: 'status', session: summary() }) } }) @@ -318,6 +289,84 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) }) + it.each(['claude', 'codex'] as const)( + 'sorts restored %s completions by host time and advances identical turns', + async (agent) => { + const now = Date.now() + mocks.store?.setState({ + unifiedTabsByWorktree: { 'wt-1': [{ ...structuredTab, agentSessionAgent: agent }] } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => + feed().emit({ + type: 'snapshot', + sessions: [summary({ status: 'idle', updatedAt: now - 100 })] + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'done', + sessionBoundary: false, + stateStartedAt: now - 100, + updatedAt: now - 100 + }) + ]) + act(() => + feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: now - 50 }) }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ stateStartedAt: now - 50, updatedAt: now - 50 }) + ]) + expect( + resolveAttention([{ kind: 'hook', entry: statuses()[0], hasLivePty: false }], now) + ).toEqual({ cls: 2, attentionTimestamp: now - 50 }) + } + ) + + it('preserves the working age when host metadata advances during the same turn', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 100 }) })) + act(() => + feed().emit({ + type: 'status', + session: summary({ updatedAt: 200, providerSession: { ...providerSession, id: 'new-id' } }) + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ state: 'working', updatedAt: 200, stateStartedAt: 100 }) + ]) + }) + + it('accepts an authoritative older journal age after a host upgrade reconnect', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 800 }) })) + act(() => + feed().emit({ type: 'snapshot', sessions: [summary({ status: 'idle', updatedAt: 900 })] }) + ) + const paneKey = statuses()[0].paneKey + const history = statuses()[0].stateHistory + const acknowledged = { [paneKey]: 950 } + mocks.store?.setState({ acknowledgedAgentsByPaneKey: acknowledged }) + act(() => + feed().emit({ type: 'snapshot', sessions: [summary({ status: 'idle', updatedAt: 200 })] }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ state: 'done', updatedAt: 200, stateStartedAt: 200 }) + ]) + const before = mocks.store?.getState().agentStatusByPaneKey + const calls = mocks.setAgentStatus.mock.calls.length + expect(statuses()[0].stateHistory).toBe(history) + expect(mocks.store?.getState().acknowledgedAgentsByPaneKey).toBe(acknowledged) + act(() => + feed().emit({ type: 'snapshot', sessions: [summary({ status: 'idle', updatedAt: 200 })] }) + ) + expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) + expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) + }) + it('drops the status and the feed when the last structured tab closes', 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 601592a11a7..a72f28d7c08 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -81,7 +81,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | ...(summary.toolName ? { toolName: summary.toolName } : {}), ...(summary.toolInput ? { toolInput: summary.toolInput } : {}), ...(summary.lastAssistantMessage ? { lastAssistantMessage: summary.lastAssistantMessage } : {}), - sessionBoundary: summary.status === 'idle' + sessionBoundary: false } as const const current = store.agentStatusByPaneKey?.[paneKey] if ( @@ -94,6 +94,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | current.toolInput === summary.toolInput && current.lastAssistantMessage === summary.lastAssistantMessage && current.sessionBoundary === desired.sessionBoundary && + current.updatedAt === summary.updatedAt && current.terminalTitle === tab.label && current.tabId === tab.id && current.worktreeId === tab.worktreeId && @@ -110,7 +111,16 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | paneKey, desired, tab.label, - undefined, + { + updatedAt: summary.updatedAt, + // This ordered host feed can correct a legacy publication clock after upgrade. + allowOlderTimestamp: true, + stateStartedAt: + desired.state !== 'done' && current?.state === desired.state + ? current.stateStartedAt + : summary.updatedAt, + evidenceObservedAt: Date.now() + }, { tabId: tab.id, worktreeId: tab.worktreeId }, { ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index b6f1a1654f3..ca685dded64 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -182,9 +182,7 @@ export async function submitFolderWorkspaceCreate({ linkedTask: toFolderWorkspaceLinkedTask(linkedWorkItem), ...(linkedTaskSourceContext ? { linkedTaskSourceContext } : {}), ...(quickAgent ? { createdWithAgent: quickAgent } : {}), - ...(pendingFirstAgentMessageRename && !structuredLaunch - ? { pendingFirstAgentMessageRename: true } - : {}) + ...(pendingFirstAgentMessageRename ? { pendingFirstAgentMessageRename: true } : {}) }) if (!workspace) { return false diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index 7cb5f88f17d..f199ca66f0c 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -175,7 +175,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { smartGitHubResolution.kind === 'none' ? (linkedGitLabMR ?? undefined) : undefined, smartGitHubResolution.kind === 'none' ? (linkedGitLabIssue ?? undefined) : undefined, effectiveBackendStartup, - structuredLaunch ? false : pendingFirstAgentMessageRename, + pendingFirstAgentMessageRename, undefined, linkedLinearIssueWorkspaceId, linkedLinearIssueOrganizationUrlKey, diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 297ebc378a5..27ee8da4827 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -77,7 +77,7 @@ export async function executeWorktreeCreation( preparedRequest.linkedGitLabMR, preparedRequest.linkedGitLabIssue, backendStartup, - structuredLaunch ? false : preparedRequest.pendingFirstAgentMessageRename, + preparedRequest.pendingFirstAgentMessageRename, creationId, preparedRequest.linkedLinearIssueWorkspaceId, preparedRequest.linkedLinearIssueOrganizationUrlKey, diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 64dcb7f6919..39bbde535d1 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -92,6 +92,8 @@ export type AgentStatusPayload = ParsedAgentStatusPayload & { } export type AgentStatusTiming = { + /** Ordered authoritative sources may correct a prior publication clock. */ + allowOlderTimestamp?: boolean updatedAt?: number /** Observation clock for staleness; see `AgentStatusEntry.evidenceObservedAt`. */ evidenceObservedAt?: number 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 12812b380c7..5c3ef89bdaf 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 @@ -74,7 +74,7 @@ export function buildAgentStatusLiveEntry( ): AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection { const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args const existing = state.agentStatusByPaneKey[paneKey] - if (existing && updatedAt < existing.updatedAt) { + if (existing && updatedAt < existing.updatedAt && !timing?.allowOlderTimestamp) { return { entry: null, reason: 'stale' } } const effectiveTitle = terminalTitle ?? existing?.terminalTitle