Merge remote-tracking branch 'origin/main' into brennanb2025/structured-chat-restore-settle-2

This commit is contained in:
Merge Sim
2026-09-06 17:49:31 -07:00
37 changed files with 1318 additions and 230 deletions
@@ -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
+101
View File
@@ -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."
}
]
}
@@ -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/')
})
})
+1 -1
View File
@@ -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
)
},
@@ -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.')
}
@@ -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')
})
})
@@ -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})`
)
}
}
}
@@ -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) {
@@ -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',
@@ -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<void>[] = []
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<void>[] = []
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,
@@ -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
@@ -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)
}
}
}
@@ -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<void> | 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
)
}
@@ -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)
@@ -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 => {
@@ -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', () => {
@@ -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
}
@@ -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, {
@@ -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[] =>
@@ -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
}
@@ -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
@@ -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<ReturnType<typeof openJournal>> })
function feedFor(
sessions: Map<string, { journal: Awaited<ReturnType<typeof openJournal>> }>,
record: Partial<AgentSessionRecord> | null = null
record: Partial<AgentSessionRecord> | 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' })
})
})
})
@@ -36,6 +36,9 @@ export type StructuredAgentSessionStatusFeedDeps = {
sessions: ReadonlyMap<string, StatusFeedSession>
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()
}
}
@@ -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()
})
}
@@ -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
}
]
}
@@ -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<NodeJS.ProcessEnv>
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<Install
: {}),
onEventSinkError: ({ sessionId, error }) =>
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 })
@@ -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<typeof firstWorkRenameDeps>
const WORKSPACE_ID = 'repo1::/repo/wt'
function summary(overrides: Partial<AgentSessionStatusSummary> = {}): 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)
})
})
+2 -107
View File
@@ -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)
)
}
@@ -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
@@ -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<void> {
* 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 {
@@ -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<string, unknown>
setState: (state: Record<string, unknown>) => void
getState: () => AppState
setState: (state: Partial<AppState> & { 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<string, Record<string, unknown>>
removeAgentStatus: (paneKey: string) => void
setAgentStatus: (...args: unknown[]) => void
testRuntimeOwner: string | null
unifiedTabsByWorktree: Record<string, Tab[]>
}>((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>,
string,
unknown,
Record<string, unknown>,
Record<string, unknown>
]
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<AgentSessionStatusSummary> = {}): AgentSessi
}
}
function statuses(): Record<string, unknown>[] {
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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
@@ -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 } : {}),
@@ -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
@@ -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,
@@ -77,7 +77,7 @@ export async function executeWorktreeCreation(
preparedRequest.linkedGitLabMR,
preparedRequest.linkedGitLabIssue,
backendStartup,
structuredLaunch ? false : preparedRequest.pendingFirstAgentMessageRename,
preparedRequest.pendingFirstAgentMessageRename,
creationId,
preparedRequest.linkedLinearIssueWorkspaceId,
preparedRequest.linkedLinearIssueOrganizationUrlKey,
@@ -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
@@ -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