mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(mobile): ask before running orca.yaml default-tab commands from the phone The host's repo.hooks reply now carries desktop's setup config, so the phone gates the Run/Skip choice and the trust prompt on defaultTabs commands too. * fix(setup): keep the local script in run-both setup config without orca.yaml commands * test(setup): drop the shared duplicate-host trust test desktop already covers * refactor(setup): derive host setup trust from the shared setup config * fix(mobile): count every host's repo rows before honouring repo-wide setup trust * fix(mobile): show desktop's PR lanes and failed/interrupted status dots on the phone The host builds worktree.ps linkedPR with the desktop's PR-lane cache reader and sends the desktop sidebar dot as an optional sidebarStatus field. * fix(mobile): keep uncached PR badges and let finished turns show through working titles * fix(mobile): suppress agent panes' permission and parent titles in the host status dot * test(runtime): expect sidebarStatus on the compact ps summary * test(runtime): keep only host-specific sidebar status cases * refactor(mobile): keep host title suppression and the uncached PR lane in one place each * test(wire): pass the terminal title evidence map to new host builds * fix(runtime): read each workspace's PR through its own host's repo
205 lines
7.2 KiB
TypeScript
205 lines
7.2 KiB
TypeScript
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
|
|
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
|
|
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
|
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
|
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
|
|
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
|
import { AgentHookServer, _internals } from '../agent-hooks/server'
|
|
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
|
|
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
|
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
|
|
|
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
|
vi.mock('../telemetry/cohort-classifier', () => ({
|
|
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
|
|
}))
|
|
|
|
/** Exercise journal to worktree-status liveness: feed summaries never retract, but the store drops closed chats, including unanswered approvals whose retained status stays attention. */
|
|
const WORKTREE_ID = 'repo-1::/workspace/app'
|
|
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
|
|
const SUBJECT = makeStructuredAgentStatusSubject(
|
|
{
|
|
executionHostId: 'local',
|
|
wslDistro: null,
|
|
workspaceId: WORKTREE_ID,
|
|
workspaceKind: 'git-worktree'
|
|
},
|
|
SESSION
|
|
)
|
|
const IDENTITY = {
|
|
provider: 'codex',
|
|
threadId: 'thread-1',
|
|
turnId: 'turn-1',
|
|
ordinal: 0
|
|
} as const
|
|
|
|
let root: string
|
|
const journals = createTrackedJournalOpener()
|
|
|
|
beforeEach(async () => {
|
|
_internals.resetCachesForTests()
|
|
root = await mkdtemp(join(tmpdir(), 'orca-structured-ps-liveness-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await journals.closeAll()
|
|
await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
/** A session parked on an approval nobody answered — the state a deliberate close leaves behind. */
|
|
async function awaitingApproval() {
|
|
const journal = await journals.open({
|
|
identity: {
|
|
sessionId: SESSION,
|
|
workspaceId: WORKTREE_ID,
|
|
hostId: 'local',
|
|
agent: 'codex',
|
|
providerHandle: codexProviderHandle('thread-1')
|
|
},
|
|
stateDirectory: join(root, SESSION)
|
|
})
|
|
await journal.appendItem(
|
|
{ ...IDENTITY, ordinal: 1 },
|
|
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] },
|
|
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
|
)
|
|
await journal.appendItem(
|
|
{ ...IDENTITY, ordinal: 2 },
|
|
{
|
|
kind: 'approval',
|
|
title: 'Run the command?',
|
|
detail: null,
|
|
options: [{ id: 'allow', label: 'Allow' }],
|
|
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
|
},
|
|
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
|
)
|
|
const sessions = new Map([
|
|
[
|
|
SESSION,
|
|
{
|
|
journal,
|
|
child: { phase: 'ready' as const, generation: 'child-1', fence: 1 },
|
|
params: {
|
|
location: {
|
|
executionHostId: 'local' as const,
|
|
wslDistro: null,
|
|
workspaceId: WORKTREE_ID,
|
|
workspaceKind: 'git-worktree' as const
|
|
},
|
|
provider: 'codex' as const
|
|
}
|
|
}
|
|
]
|
|
])
|
|
const store = new AgentHookServer()
|
|
const published: AgentSessionStatusSummary[] = []
|
|
const feed = new StructuredAgentSessionStatusFeed({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
sessions,
|
|
getConversation: () => null,
|
|
now: () => Date.now(),
|
|
statusSink: () => ({
|
|
publish: (summary, subject) => {
|
|
published.push(summary)
|
|
store.ingestStructuredStatus(summary, subject)
|
|
},
|
|
forget: (sessionId) => store.dropStructuredStatus(sessionId)
|
|
})
|
|
})
|
|
feed.publish(SESSION, journal)
|
|
return { feed, sessions, store, published }
|
|
}
|
|
|
|
function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary {
|
|
const row = {
|
|
worktreeId: WORKTREE_ID,
|
|
status: 'inactive',
|
|
agents: []
|
|
} as unknown as RuntimeWorktreePsSummary
|
|
const summaries = new Map([[WORKTREE_ID, row]])
|
|
attachRuntimeWorktreeAgentRows({
|
|
summaries,
|
|
terminalTitleEvidenceByWorktreeId: new Map(),
|
|
rowSources: collectRuntimeWorktreeAgentSources({
|
|
mirroredWorktreeIdByTabId: new Map(),
|
|
connectedPtyEvidence: {
|
|
tabIds: new Set(),
|
|
paneKeys: new Set(),
|
|
ptyIdByTerminalHandle: new Map()
|
|
},
|
|
hookSnapshots: store.getStatusSnapshot()
|
|
}),
|
|
orchestrationByPaneKey: null,
|
|
getSummary: (id) => summaries.get(id) ?? null
|
|
})
|
|
return row
|
|
}
|
|
|
|
describe('worktree ps and a closed structured chat', () => {
|
|
it('reports the blocked row while the session is still held', async () => {
|
|
const { store } = await awaitingApproval()
|
|
const row = worktreeFor(store)
|
|
expect(row.agents).toHaveLength(1)
|
|
expect(row.agents[0]?.state).toBe('blocked')
|
|
expect(row.status).toBe('permission')
|
|
})
|
|
|
|
it('stops reporting it once close forgets the session', async () => {
|
|
const { feed, sessions, store } = await awaitingApproval()
|
|
// What the host does after eviction: the feed keeps its projection, the store drops the row.
|
|
sessions.delete(SESSION)
|
|
feed.close(SESSION)
|
|
|
|
const row = worktreeFor(store)
|
|
expect(row.agents).toHaveLength(0)
|
|
expect(row.status).toBe('inactive')
|
|
})
|
|
|
|
it('keeps an aged host-held working state authoritative', async () => {
|
|
const { store, published } = await awaitingApproval()
|
|
const aged = {
|
|
...published.at(-1)!,
|
|
hostExecutionOwned: true as const,
|
|
updatedAt: Date.now() - 30 * 60 * 1000 - 1,
|
|
status: 'working' as const
|
|
}
|
|
store.ingestStructuredStatus(aged, SUBJECT)
|
|
const row = worktreeFor(store)
|
|
expect(row.agents).toHaveLength(1)
|
|
expect(row.agents[0]?.state).toBe('working')
|
|
expect(row.status).toBe('working')
|
|
expect(row.agents[0]?.updatedAt).toBe(aged.updatedAt)
|
|
})
|
|
|
|
it('keeps an aged host-held approval state authoritative', async () => {
|
|
const { store, published } = await awaitingApproval()
|
|
const aged = {
|
|
...published.at(-1)!,
|
|
hostExecutionOwned: true as const,
|
|
updatedAt: Date.now() - 30 * 60 * 1000 - 1
|
|
}
|
|
store.ingestStructuredStatus(aged, SUBJECT)
|
|
const row = worktreeFor(store)
|
|
expect(row.agents).toHaveLength(1)
|
|
expect(row.agents[0]?.state).toBe('blocked')
|
|
expect(row.status).toBe('permission')
|
|
expect(row.agents[0]?.updatedAt).toBe(aged.updatedAt)
|
|
})
|
|
|
|
it('lets an aged approval decay once the host no longer owns the child', async () => {
|
|
const { store, published } = await awaitingApproval()
|
|
const { hostExecutionOwned: _owned, ...held } = published.at(-1)!
|
|
store.ingestStructuredStatus({ ...held, updatedAt: Date.now() - 30 * 60 * 1000 - 1 }, SUBJECT)
|
|
const row = worktreeFor(store)
|
|
expect(row.agents).toHaveLength(1)
|
|
expect(row.agents[0]?.state).toBe('blocked')
|
|
expect(row.status).toBe('inactive')
|
|
})
|
|
})
|