Files
orca/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts
Jinwoo Hong f341558b82 feat(cli): match folder workspaces by path: and name:, keep same-id ps rows apart (#39637)
* feat(cli): match folder workspaces by path: and name:, and keep same-id ps rows apart

Adds one runtime matcher for worktrees and folder workspaces. With the new
matchFolderWorkspaces opt-in (advertised as workspace-selector.folder-match.v1),
path: and name: also match folder workspaces; branch:, issue: and identity:
stay repo-backed. Several matches refuse with each choice's kind, host, path
and selector; a folder id shared by two folder workspaces refuses with the
repair step. The CLI resolves path:/name: through it, so terminal, file and
ports commands reach a folder workspace, and create/set parents record a
folder parent. Explicit ambiguous parents report LINEAGE_PARENT_AMBIGUOUS.

worktree ps now keeps rows that share an id on different hosts apart, with
their own host, identity, metadata and activity; activity whose host can't
be proven attaches to neither.

* test: let the cross-version ps harness pass the host-qualified summary lookup

* fix(cli): read each ps row's own host session; qualify folder selector help

* fix(runtime): refuse unscoped duplicate folder ids; hand lineage the matcher's typed result

Unscoped folder:<id> refuses a duplicated id even when only one copy's host
is derivable; a host-scoped lookup still picks that host's copy. A wsl: hook
stamp counts as local host evidence. The lineage controller receives the
worktree-or-folder candidate directly instead of re-deriving the kind.

* docs(cli): guide the ambiguity fallback through truncated ps and id: selectors

* fix(cli): report an ambiguous parent by its own choices; cover per-host sessions

* fix(cli): send parents to the runtime parent resolver as typed; translate WSL paths for folders too

Parent selectors skip the CLI's show-based folder lookup, so worktree.create
and worktree.set classify them with parent rules: a folder spanning hosts is
a valid parent, and ambiguity reports LINEAGE_PARENT_AMBIGUOUS. Caller-distro
Linux path translation now also matches folder workspaces.

* chore(rpc): import the computer-use recovery data directly instead of through an alias

* fix(runtime): report a missing set parent as LINEAGE_PARENT_NOT_FOUND, as create does

worktree set and worktree create now share one explicit-parent error
conversion: several matches give LINEAGE_PARENT_AMBIGUOUS and none give
LINEAGE_PARENT_NOT_FOUND, so a missing parent is no longer reported as a
bare selector_not_found that could mean the child.
2026-11-07 23:35:33 -05:00

180 lines
7.7 KiB
TypeScript

import { beforeAll, describe, expect, it } from 'vitest'
import {
importReleaseCheckoutModule,
importWorkingTreeModuleCopy,
materializeReleaseCheckout
} from './release-checkout'
// Pin pre-Stopping readers: optional status/mainAgent flags must remain plain working rows to old desktop/mobile clients.
// Current clients must not infer Stopping from an old host that sends no flag.
const PRE_CHANGE_REF = 'v1.4.214'
const SUITE_TIMEOUT_MS = 180_000
const STATUS_FOLD = 'src/shared/structured-agent-session-agent-status.ts'
// Both builds load a copy in the checkout cache, out of reach of `mobile/tsconfig.json`.
const PHONE_ROW_READER = 'mobile/src/worktree/agent-row-display.ts'
const WORKTREE_ID = 'repo::/worktree'
const NOW = 1_000_000
type Summary = Record<string, unknown>
type Row = Record<string, unknown>
type Fold = (summary: Summary) => { state: string; mainAgent: Record<string, unknown> }
type HostRowModules = {
collectRuntimeWorktreeAgentSources: (args: Record<string, unknown>) => unknown
attachRuntimeWorktreeAgentRows: (args: Record<string, unknown>) => void
}
type Build = {
fold: Fold
agentDotState: (row: Row, now: number) => string
agentDisplayLabel: (row: Row, now: number) => string
host: HostRowModules
}
/** A working session's summary as the new host publishes it, JSON round-tripped. */
const STOPPING_SUMMARY: Summary = JSON.parse(
JSON.stringify({
sessionId: 'session-1',
workspaceId: WORKTREE_ID,
agent: 'claude',
status: 'working',
latestPrompt: 'ship it',
updatedAt: NOW,
stopping: true
})
)
/** A host's `worktree ps` rows for one structured session, JSON round-tripped. */
function publishRow(host: HostRowModules, mainAgent: Record<string, unknown>): Row {
const summary: Record<string, unknown> = {
worktreeId: WORKTREE_ID,
status: 'inactive',
hasHostSidebarActivity: false,
agents: []
}
const snapshot = {
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
tabId: 'tab-1',
worktreeId: WORKTREE_ID,
connectionId: null,
structuredHost: 'owned',
state: 'working',
prompt: 'ship it',
lastAssistantMessage: 'running the tests',
agentType: 'claude',
receivedAt: NOW - 1_000,
stateStartedAt: NOW - 60_000,
mainAgent
}
const summaries = new Map([[WORKTREE_ID, summary]])
host.attachRuntimeWorktreeAgentRows({
summaries,
pathIndex: { byPath: new Map(), byRealPath: new Map() },
missingWorktreeIds: new Set(),
// Builds read workingTerminalEvidenceByWorktreeId, then terminalTitleEvidenceByWorktreeId, now terminalTitleEvidenceByRow.
workingTerminalEvidenceByWorktreeId: new Map(),
terminalTitleEvidenceByWorktreeId: new Map(),
terminalTitleEvidenceByRow: new Map(),
rowSources: host.collectRuntimeWorktreeAgentSources({
hookSnapshots: [snapshot],
mirroredWorktreeIdByTabId: new Map(),
connectedPtyEvidence: {
tabIds: new Set(),
paneKeys: new Set(),
ptyIdByTerminalHandle: new Map()
}
}),
orchestrationByPaneKey: null,
// Old builds pass (summaries, pathIndex, missingIds, id), then (id), now (id, hostId).
getSummary: (...args: unknown[]) =>
summaries.get(String(args.find((arg) => arg === WORKTREE_ID))) ?? null
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse of the rows the host just attached, which are plain objects.
const [row] = JSON.parse(JSON.stringify(summary.agents)) as Row[]
if (!row) {
throw new Error('expected one row')
}
return row
}
async function loadBuild(ref: string | null): Promise<Build> {
if (ref === null) {
const [fold, display, sources, rows] = await Promise.all([
import('../../../src/shared/agent-session/view/structured-agent-session-agent-status'),
importWorkingTreeModuleCopy(PHONE_ROW_READER),
import('../../../src/main/runtime/workspaces/process-listing/runtime-worktree-agent-sources'),
import('../../../src/main/runtime/workspaces/process-listing/runtime-worktree-agent-rows')
])
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this spec drives both builds' fold through one untyped surface.
fold: fold.structuredAgentSessionAgentStatus as unknown as Fold,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import of a copy is typed unknown; this is the current phone's row reader by path.
agentDotState: display.agentDotState as unknown as Build['agentDotState'],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the current phone's row label.
agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed by its module; this spec drives both builds through one untyped surface.
host: { ...sources, ...rows } as unknown as HostRowModules
}
}
const checkout = await materializeReleaseCheckout(ref)
const [fold, display, sources, rows] = await Promise.all([
importReleaseCheckoutModule(checkout, STATUS_FOLD),
importReleaseCheckoutModule(checkout, PHONE_ROW_READER),
importReleaseCheckoutModule(
checkout,
'src/main/runtime/workspaces/process-listing/runtime-worktree-agent-sources.ts'
),
importReleaseCheckoutModule(
checkout,
'src/main/runtime/workspaces/process-listing/runtime-worktree-agent-rows.ts'
)
])
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old build's summary fold by path.
fold: fold.structuredAgentSessionAgentStatus as unknown as Fold,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old phone's row reader by path.
agentDotState: display.agentDotState as unknown as Build['agentDotState'],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the old phone's row label.
agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; these two modules are the old host's row surface by path.
host: { ...sources, ...rows } as unknown as HostRowModules
}
}
let oldBuild: Build
let newBuild: Build
beforeAll(async () => {
;[oldBuild, newBuild] = await Promise.all([loadBuild(PRE_CHANGE_REF), loadBuild(null)])
}, SUITE_TIMEOUT_MS)
describe('cross-version Stopping', () => {
it('an OLD desktop folds a new host summary into the plain working row it always drew', () => {
expect(oldBuild.fold(STOPPING_SUMMARY)).toEqual({
state: 'working',
mainAgent: { state: 'working' }
})
})
it("an OLD phone reads a new host's row as working, with its usual line", () => {
const row = publishRow(newBuild.host, {
state: 'working',
stopping: true,
stateStartedAt: NOW - 60_000
})
expect(row.mainAgent).toMatchObject({ stopping: true })
expect(oldBuild.agentDotState(row, NOW)).toBe('working')
expect(oldBuild.agentDisplayLabel(row, NOW)).toBe('running the tests')
expect(newBuild.agentDisplayLabel(row, NOW)).toBe('Stopping…')
})
it('a NEW reader of an old host never reads Stopping, which that host never publishes', () => {
const { stopping: _stopping, ...oldSummary } = STOPPING_SUMMARY
expect(newBuild.fold(oldSummary)).toEqual({ state: 'working', mainAgent: { state: 'working' } })
const row = publishRow(oldBuild.host, { state: 'working', stateStartedAt: NOW - 60_000 })
expect(newBuild.agentDotState(row, NOW)).toBe('working')
expect(newBuild.agentDisplayLabel(row, NOW)).toBe('running the tests')
})
})