fix(opencode): attribute shared-server sessions to their panes (#21577)

* 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>
This commit is contained in:
Pablo Werlang
2026-09-20 20:06:55 -07:00
committed by GitHub
co-authored by orca-agent
parent e476193bf5
commit 646fa3645f
15 changed files with 2405 additions and 6 deletions
@@ -0,0 +1,176 @@
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'
})
})
})