mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/structured-chat-restore-settle-2
This commit is contained in:
@@ -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
|
||||
@@ -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/')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
+245
-3
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
+99
-50
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user