mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* 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.
218 lines
8.6 KiB
TypeScript
218 lines
8.6 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest'
|
|
import {
|
|
importReleaseCheckoutModule,
|
|
importWorkingTreeModuleCopy,
|
|
materializeReleaseCheckout
|
|
} from './release-checkout'
|
|
|
|
// Pin pre-mainAgent readers: old phones must read new rows by interrupted; new phones must keep that fallback for old hosts.
|
|
const PRE_CHANGE_REF = 'v1.4.212'
|
|
const SUITE_TIMEOUT_MS = 180_000
|
|
// 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 HookSnapshot = Record<string, unknown>
|
|
type AgentRow = Record<string, unknown>
|
|
type DotState = (row: AgentRow, now: number) => string
|
|
|
|
type HostRowModules = {
|
|
collectRuntimeWorktreeAgentSources: (args: Record<string, unknown>) => unknown
|
|
attachRuntimeWorktreeAgentRows: (args: Record<string, unknown>) => void
|
|
}
|
|
|
|
type Build = {
|
|
label: string
|
|
host: HostRowModules
|
|
agentDotState: DotState
|
|
}
|
|
|
|
function snapshot(
|
|
tabId: string,
|
|
fields: Pick<HookSnapshot, 'state'> & Partial<HookSnapshot>
|
|
): HookSnapshot {
|
|
return {
|
|
paneKey: `${tabId}:11111111-1111-4111-8111-111111111111`,
|
|
tabId,
|
|
worktreeId: WORKTREE_ID,
|
|
connectionId: null,
|
|
prompt: 'ship it',
|
|
agentType: 'claude',
|
|
receivedAt: NOW - 1_000,
|
|
stateStartedAt: NOW - 600_000,
|
|
...fields
|
|
}
|
|
}
|
|
|
|
// Separate main-agent failure, failure with working children, and an explicit user stop.
|
|
const SNAPSHOTS: HookSnapshot[] = [
|
|
snapshot('failed-done', {
|
|
state: 'done',
|
|
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 600_000 }
|
|
}),
|
|
snapshot('failed-working', {
|
|
state: 'working',
|
|
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 120_000 }
|
|
}),
|
|
snapshot('stopped', {
|
|
state: 'done',
|
|
interrupted: true,
|
|
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: NOW - 600_000 }
|
|
}),
|
|
// Host-observed crash/unknown endings set no interrupted flag, so old readers still see done.
|
|
snapshot('crash-cut', {
|
|
state: 'done',
|
|
mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: NOW - 600_000 }
|
|
}),
|
|
snapshot('unproven', {
|
|
state: 'done',
|
|
mainAgent: { state: 'done', outcome: 'unconfirmed', stateStartedAt: NOW - 600_000 }
|
|
}),
|
|
// A turn a newer request replaced: recorded, not a user's stop, so it sets no flag either.
|
|
snapshot('replaced', {
|
|
state: 'done',
|
|
mainAgent: { state: 'done', outcome: 'superseded', stateStartedAt: NOW - 600_000 }
|
|
})
|
|
]
|
|
|
|
/** A host's `worktree ps` rows, JSON round-tripped as the wire carries them. */
|
|
function publishRows(host: HostRowModules): Record<string, AgentRow> {
|
|
const summary: Record<string, unknown> = {
|
|
worktreeId: WORKTREE_ID,
|
|
status: 'inactive',
|
|
hasHostSidebarActivity: false,
|
|
agents: []
|
|
}
|
|
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: SNAPSHOTS,
|
|
mirroredWorktreeIdByTabId: new Map(),
|
|
connectedPtyEvidence: {
|
|
tabIds: new Set(SNAPSHOTS.map((row) => row.tabId)),
|
|
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 keyed by paneKey.
|
|
const rows = JSON.parse(JSON.stringify(summary.agents)) as AgentRow[]
|
|
return Object.fromEntries(rows.map((row) => [String(row.paneKey).split(':')[0], row]))
|
|
}
|
|
|
|
function dotStates(build: Build, rows: Record<string, AgentRow>): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(rows).map(([tabId, row]) => [tabId, build.agentDotState(row, NOW)])
|
|
)
|
|
}
|
|
|
|
async function loadBuild(ref: string | null): Promise<Build> {
|
|
if (ref === null) {
|
|
const [sources, rows, display] = await Promise.all([
|
|
import('../../../src/main/runtime/workspaces/process-listing/runtime-worktree-agent-sources'),
|
|
import('../../../src/main/runtime/workspaces/process-listing/runtime-worktree-agent-rows'),
|
|
importWorkingTreeModuleCopy(PHONE_ROW_READER)
|
|
])
|
|
return {
|
|
label: 'current',
|
|
// 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,
|
|
// 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 DotState
|
|
}
|
|
}
|
|
const checkout = await materializeReleaseCheckout(ref)
|
|
const [sources, rows, display] = await Promise.all([
|
|
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'
|
|
),
|
|
importReleaseCheckoutModule(checkout, PHONE_ROW_READER)
|
|
])
|
|
return {
|
|
label: ref,
|
|
// 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,
|
|
// 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 DotState
|
|
}
|
|
}
|
|
|
|
let oldBuild: Build
|
|
let newBuild: Build
|
|
let agentRowTimeAt: (row: AgentRow) => number
|
|
|
|
beforeAll(async () => {
|
|
;[oldBuild, newBuild] = await Promise.all([loadBuild(PRE_CHANGE_REF), loadBuild(null)])
|
|
const display = await importWorkingTreeModuleCopy(PHONE_ROW_READER)
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rows are JSON the current host published, so the current reader's row type holds.
|
|
agentRowTimeAt = display.agentRowTimeAt as unknown as (row: AgentRow) => number
|
|
}, SUITE_TIMEOUT_MS)
|
|
|
|
describe('cross-version worktree ps verdict', () => {
|
|
it('an OLD phone reads a new host row by the interrupted flag it always read', () => {
|
|
const rows = publishRows(newBuild.host)
|
|
expect(rows.stopped).toMatchObject({ interrupted: true })
|
|
expect(dotStates(oldBuild, rows)).toEqual({
|
|
'failed-done': 'done',
|
|
'failed-working': 'working',
|
|
stopped: 'interrupted',
|
|
'crash-cut': 'done',
|
|
unproven: 'done',
|
|
replaced: 'done'
|
|
})
|
|
expect(rows['crash-cut']).toMatchObject({ interrupted: false })
|
|
expect(rows.unproven).toMatchObject({ interrupted: false })
|
|
expect(rows.replaced).toMatchObject({ interrupted: false })
|
|
})
|
|
|
|
it('a NEW phone reads an old host row, which has no mainAgent, by the same flag', () => {
|
|
const rows = publishRows(oldBuild.host)
|
|
expect(dotStates(newBuild, rows)).toEqual({
|
|
'failed-done': 'done',
|
|
'failed-working': 'working',
|
|
// Both phones draw a user's Stop interrupted; only the new one draws it muted.
|
|
stopped: 'interrupted',
|
|
'crash-cut': 'done',
|
|
unproven: 'done',
|
|
replaced: 'done'
|
|
})
|
|
// Without the main agent's clock the row dates itself, as it always did.
|
|
expect(agentRowTimeAt(rows['failed-working'])).toBe(NOW - 600_000)
|
|
})
|
|
|
|
it('a NEW phone reads a new host failure as failed, dated by the main agent clock', () => {
|
|
const rows = publishRows(newBuild.host)
|
|
expect(dotStates(newBuild, rows)).toEqual({
|
|
'failed-done': 'failed',
|
|
'failed-working': 'failed',
|
|
stopped: 'interrupted',
|
|
'crash-cut': 'failed',
|
|
unproven: 'unconfirmed',
|
|
replaced: 'interrupted'
|
|
})
|
|
expect(rows['failed-working']).toMatchObject({
|
|
state: 'working',
|
|
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 120_000 }
|
|
})
|
|
expect(agentRowTimeAt(rows['failed-working'])).toBe(NOW - 120_000)
|
|
})
|
|
})
|