mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
* docs: allow-list opencode tool-readout follow-up note * fix(opencode): attribute shared-server sessions to their panes The v2 shared server stamps every hook post with its own frozen pane, so all panes' status lands on the starter pane (#21359). - shared: session->pane registry plus ingest-time envelope rewrite; bound sessions resolve to their real pane, tab and live launch token before disposition, unbound sessions keep the stamped identity. - main: binder poll (SQLite session store, PTY-registry pane snapshots, argv-aware client sweep) with directory-containment plus client-lifetime correlation; 60s loop plus debounced SessionStart kick, wired into the hook server lifecycle. * fix(opencode): newest-wins pane dedupe, macOS private/tmp normalization Live verification against the dev instance found two binder gaps: remint rows for one pane counted as an ambiguous tie, and /tmp vs /private/tmp spellings never met on macOS. * fix(opencode): review fixes — newest-wins worktree, drop dead constant - applyBinderOwnerships now overwrites per-pane worktree, matching the round's newest-wins pane dedupe; a remint's live row wins over a stale row (pinned by test). - remove the unused OPENCODE_CLIENT_PRE_CREATE_WINDOW_MS export and the nowMs residue from clientCouldCreate. - give the per-pane launch-token cache its own named cap constant. * fix(opencode): address thread review — cursor, native table, tokens, lifecycle - composite (time_created, id) store cursor advanced past handled rows only, so same-millisecond pagination and full unbound maps no longer drop sessions silently. - Windows sweep reads the native process table instead of forking powershell.exe; quote-aware argv parsing on both platforms. - directory keys via normalizeRuntimePathForComparison (Windows case-fold, POSIX backslash literals) plus narrow macOS /tmp|/var|/etc aliases and lexical dot-segment resolution. - bound sessions always take the stored pane token (never the frozen stamp); token tracking runs after resolution. - binder generation guard discards post-stop rounds; first round runs immediately at loop start. - unbind/move use exact pane-key match; pane launch-token cache gets its own cap constant. - move the tool-readout note out of this PR for its own branch. * fix(opencode): second review round — executable field, worktree scope, round lifecycle - POSIX sweep reads comm= alongside args= and classifies on the kernel executable name, so unquoted install paths with spaces no longer split argv[0] and reject the client; Windows rows carry the native table name. Degrades to argv[0] when comm is unavailable. - bound sessions take only the binding's worktree (never the stamped pane's), so a worktree-less binding cannot file a row under the wrong worktree. - the binder generation is captured before the round body and the running flag clears only for the current generation, so an obsolete post-stop round cannot admit an overlapping round. --------- Co-authored-by: orca-agent <orca-agent@local>
177 lines
6.0 KiB
TypeScript
177 lines
6.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
|
|
import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry'
|
|
import { makePaneKey } from '../../shared/stable-pane-id'
|
|
import type { ProcessIdentityRow } from './opencode-client-sweep'
|
|
import {
|
|
advanceBinderCursor,
|
|
applyBinderOwnerships,
|
|
OPENCODE_SESSION_CURSOR_START,
|
|
runOpenCodeBinderRound,
|
|
type BinderPaneSnapshot
|
|
} from './opencode-session-binder'
|
|
|
|
const NOW = 1_700_000_100_000
|
|
const DIR = '/Users/jin/work/mocitec'
|
|
const PANE_A = makePaneKey('tab-a', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
|
|
const PANE_B = makePaneKey('tab-b', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
|
|
|
|
function proc(
|
|
pid: number,
|
|
ppid: number,
|
|
argv: string[],
|
|
startedAtMs = NOW - 120_000
|
|
): ProcessIdentityRow {
|
|
return { pid, ppid, startedAtMs, executable: argv[0] ?? '', argv }
|
|
}
|
|
|
|
function pane(paneKey: string, shellPid: number | null): BinderPaneSnapshot {
|
|
return { paneKey, directory: DIR, worktreeId: 'repo::/Users/jin/work/mocitec', shellPid }
|
|
}
|
|
|
|
describe('runOpenCodeBinderRound', () => {
|
|
it('attributes a client to its pane subtree and binds the session', () => {
|
|
const { ownerships } = runOpenCodeBinderRound({
|
|
nowMs: NOW,
|
|
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
|
panes: [pane(PANE_A, 100), pane(PANE_B, 200)],
|
|
processes: [
|
|
proc(100, 1, ['zsh']),
|
|
proc(200, 1, ['zsh']),
|
|
proc(210, 200, ['opencode'], NOW - 90_000)
|
|
],
|
|
knownOwners: new Map(),
|
|
parentBySessionId: new Map()
|
|
})
|
|
expect(ownerships).toEqual([
|
|
{ sessionId: 'ses_1', paneKey: PANE_B, basis: 'creation-correlation' }
|
|
])
|
|
})
|
|
|
|
it('ignores clients outside every pane subtree', () => {
|
|
const { ownerships } = runOpenCodeBinderRound({
|
|
nowMs: NOW,
|
|
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
|
panes: [pane(PANE_A, 100)],
|
|
processes: [proc(100, 1, ['zsh']), proc(999, 1, ['opencode'], NOW - 90_000)],
|
|
knownOwners: new Map(),
|
|
parentBySessionId: new Map()
|
|
})
|
|
expect(ownerships).toEqual([])
|
|
})
|
|
|
|
it('inherits a root owner across the watermark via the parent map', () => {
|
|
const { ownerships } = runOpenCodeBinderRound({
|
|
nowMs: NOW,
|
|
sessions: [
|
|
{ id: 'ses_child', directory: DIR, createdAtMs: NOW - 30_000, parentId: 'ses_root' }
|
|
],
|
|
panes: [pane(PANE_A, 100)],
|
|
processes: [proc(100, 1, ['zsh']), proc(101, 100, ['opencode'], NOW - 3_600_000)],
|
|
knownOwners: new Map([['ses_root', PANE_A]]),
|
|
parentBySessionId: new Map([['ses_child', 'ses_root']])
|
|
})
|
|
expect(ownerships).toEqual([
|
|
{ sessionId: 'ses_child', paneKey: PANE_A, basis: 'creation-correlation' }
|
|
])
|
|
})
|
|
|
|
it('dedupes same-key snapshots newest-wins', () => {
|
|
const { ownerships } = runOpenCodeBinderRound({
|
|
nowMs: NOW,
|
|
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
|
panes: [
|
|
{ ...pane(PANE_A, 100), directory: '/elsewhere' },
|
|
{ ...pane(PANE_A, 101), directory: DIR }
|
|
],
|
|
processes: [
|
|
proc(100, 1, ['zsh']),
|
|
proc(101, 1, ['zsh']),
|
|
proc(102, 101, ['opencode'], NOW - 90_000)
|
|
],
|
|
knownOwners: new Map(),
|
|
parentBySessionId: new Map()
|
|
})
|
|
expect(ownerships).toEqual([
|
|
{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'single-pane-directory' }
|
|
])
|
|
})
|
|
|
|
it('advances the cursor past handled rows only', () => {
|
|
const fresh = [
|
|
{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null },
|
|
{ id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null }
|
|
]
|
|
expect(
|
|
advanceBinderCursor({
|
|
fresh,
|
|
isHandled: () => true,
|
|
current: OPENCODE_SESSION_CURSOR_START
|
|
})
|
|
).toEqual({ ms: NOW - 10_000, id: 'ses_2' })
|
|
})
|
|
|
|
it('freezes the cursor before the first unhandled row so it is re-listed', () => {
|
|
const fresh = [
|
|
{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null },
|
|
{ id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null }
|
|
]
|
|
expect(
|
|
advanceBinderCursor({
|
|
fresh,
|
|
isHandled: (id) => id === 'ses_1',
|
|
current: OPENCODE_SESSION_CURSOR_START
|
|
})
|
|
).toEqual({ ms: NOW - 60_000, id: 'ses_1' })
|
|
})
|
|
|
|
it('keeps the cursor when nothing was handled', () => {
|
|
const current = { ms: NOW - 120_000, id: 'ses_0' }
|
|
expect(
|
|
advanceBinderCursor({
|
|
fresh: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
|
|
isHandled: () => false,
|
|
current
|
|
})
|
|
).toBe(current)
|
|
})
|
|
})
|
|
|
|
describe('applyBinderOwnerships', () => {
|
|
it('writes bindings with the pane worktree into the registry', () => {
|
|
const state = createHookListenerState()
|
|
const applied = applyBinderOwnerships(
|
|
state,
|
|
[pane(PANE_A, 100)],
|
|
[{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }],
|
|
NOW
|
|
)
|
|
expect(applied).toBe(1)
|
|
expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({
|
|
paneKey: PANE_A,
|
|
worktreeId: 'repo::/Users/jin/work/mocitec'
|
|
})
|
|
})
|
|
|
|
it('takes the newest row worktree when a pane remints', () => {
|
|
const state = createHookListenerState()
|
|
// Registry insertion order puts the stale row first; the live remint row
|
|
// carries a different worktree and must win, matching the round's
|
|
// newest-wins pane dedupe.
|
|
const applied = applyBinderOwnerships(
|
|
state,
|
|
[
|
|
{ paneKey: PANE_A, directory: '/elsewhere', worktreeId: 'repo::/elsewhere', shellPid: 100 },
|
|
{ ...pane(PANE_A, 101) }
|
|
],
|
|
[{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }],
|
|
NOW
|
|
)
|
|
expect(applied).toBe(1)
|
|
expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({
|
|
paneKey: PANE_A,
|
|
worktreeId: 'repo::/Users/jin/work/mocitec'
|
|
})
|
|
})
|
|
})
|