Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
776eba2
feat(agent-launch): a click's follow-up survives a window reload, once
brennanb2025 Oct 7, 2026
8ab49df
fix(agent-launch): never let a settle announcement throw; cover each …
brennanb2025 Oct 7, 2026
d65948d
fix(agent-launch): wait on recorded follow-ups one take at a time, an…
brennanb2025 Oct 7, 2026
84cf1d5
fix(agent-launch): ask again soon for a click's follow-up its own tak…
brennanb2025 Oct 7, 2026
128843a
fix(agent-launch): a launch past the record's cap runs its click's fo…
brennanb2025 Oct 7, 2026
36990e3
Merge updated first-prompt branch into follow-ups
brennanb2025 Oct 7, 2026
af8f9bd
Merge first-prompt validation fix into follow-ups
brennanb2025 Oct 7, 2026
966c272
Merge current first-prompt baseline into follow-ups
brennanb2025 Oct 7, 2026
4c4bdf3
Merge branch 'brennanb2025/host-owns-first-prompt' into brennanb2025/…
brennanb2025 Oct 7, 2026
ff4f183
Merge branch 'brennanb2025/host-owns-first-prompt' into brennanb2025/…
brennanb2025 Oct 7, 2026
a42e12a
Merge branch 'brennanb2025/host-owns-first-prompt' into brennanb2025/…
brennanb2025 Oct 7, 2026
0bb108d
Merge updated host-owns-first-prompt into follow-ups
brennanb2025 Oct 8, 2026
f711760
Remove restart redelivery while retaining host-owned follow-ups
brennanb2025 Oct 8, 2026
aa27e9f
Wait for host settlement before finishing launch follow-ups
brennanb2025 Oct 8, 2026
5845c19
Merge final lower launch bounds into follow-up stack
brennanb2025 Oct 8, 2026
abb2889
Type launch ownership and keep integration imports within test projects
brennanb2025 Oct 8, 2026
cb44471
Merge current lower branch into durable launch follow-ups
brennanb2025 Oct 8, 2026
c6dbbe4
fix(agent-launch): declare the workspace permission on the follow-up …
brennanb2025 Oct 8, 2026
6aa6d2d
Merge branch 'brennanb2025/host-owns-first-prompt' into brennanb2025/…
brennanb2025 Oct 9, 2026
c746688
Merge branch 'brennanb2025/host-owns-first-prompt' into brennanb2025/…
brennanb2025 Oct 9, 2026
0a15ac4
Merge branch 'brennanb2025/host-owns-first-prompt' into brennanb2025/…
brennanb2025 Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/scripts/vitest-sqlite-database-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/opencode-usage/scanner.test.ts',
'src/main/rate-limits/opencode-go-api-key-source.test.ts',
'src/main/rate-limits/opencode-go-managed-account-source.test.ts',
'src/main/runtime/agent-launch-persisted-obligations.test.ts',
'src/main/runtime/agent-session-record-store-slot.test.ts',
'src/main/runtime/claude-structured-exit-mid-response.test.ts',
'src/main/runtime/claude-structured-failed-start-resume.test.ts',
Expand Down Expand Up @@ -121,6 +122,8 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/runtime/orchestration/worker-dispatch-settlement.test.ts',
'src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts',
'src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts',
'src/main/runtime/rpc/methods/agent-launch-follow-ups.test.ts',
'src/main/runtime/rpc/methods/agent-launch-follow-up-late-settlement.test.ts',
'src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts',
'src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts',
'src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts',
Expand Down
30 changes: 30 additions & 0 deletions src/main/native-chat/agent-session-journal/journal-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,36 @@ export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean {
}
}

/** A launch-record field whose presence means the host still owes that launch work. */
export type LaunchOperationObligation = 'launchFollowUp'

/** Whether any unexpired launch record holds `field`, read-only: an expired one is skipped by
* everything that reads it, and nothing prunes it until the store next opens. */
export function journalDatabaseHoldsLaunchObligation(
dbPath: string,
field: LaunchOperationObligation,
now: number
): boolean {
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
try {
const table = db
.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'agent_session_operations'"
)
.get()
return (
table !== undefined &&
db
.prepare(
`SELECT 1 FROM agent_session_operations WHERE json_extract(row_json, '$.${field}') IS NOT NULL AND json_extract(row_json, '$.expiresAt') > ? LIMIT 1`
)
.get(now) !== undefined
)
} finally {
db.close()
}
}

export function openJournalDatabase(dbPath: string): OpenJournalDatabase {
const probe = new Database(dbPath)
let stored: number
Expand Down
194 changes: 194 additions & 0 deletions src/main/runtime/agent-launch-follow-up-record.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest'
import {
agentSessionOperationKey,
type AgentSessionOperationRow
} from '../../shared/agent-session-operation-ledger'
import type { AgentLaunchResult } from '../../shared/agent-launch-intent'
import {
listSettledLaunchFollowUps,
takeLaunchFollowUpsInto
} from './agent-launch-follow-up-record'

const DESKTOP = 'trusted-local:desktop'
const FOLLOW_UP = { kind: 'review-notes-delivered', version: 1, payload: { noteIds: ['n1'] } }

function launch(
promptOutcome: 'handed-to-terminal' | 'not-delivered' | 'unconfirmed',
unobserved = false
) {
const result: AgentLaunchResult = {
outcome: { kind: 'terminal', handle: 'term_1', paneKey: 'tab:leaf' },
worktreeId: 'wt-1',
receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'x' },
prompt:
promptOutcome === 'handed-to-terminal' && unobserved
? { delivery: 'submit', outcome: 'handed-to-terminal', composerUnobserved: true }
: { delivery: 'submit', outcome: promptOutcome }
}
return result
}

function row(
operationId: string,
overrides: Partial<AgentSessionOperationRow> = {}
): AgentSessionOperationRow {
return {
callerKey: DESKTOP,
operationId,
fingerprint: 'fp',
operationTimestamp: 1,
recordedAt: 1,
expiresAt: 10_000,
outcome: { status: 'succeeded', sessionId: '', launch: launch('handed-to-terminal') },
launchFollowUp: FOLLOW_UP,
...overrides
}
}

function rows(...list: AgentSessionOperationRow[]) {
return {
operations: new Map(list.map((r) => [agentSessionOperationKey(r.callerKey, r.operationId), r]))
}
}

const take = (
state: ReturnType<typeof rows>,
args: { callerKey?: string; operationId?: string; running?: string[] } = {}
) =>
takeLaunchFollowUpsInto(state, {
callerKey: args.callerKey ?? DESKTOP,
...(args.operationId ? { operationId: args.operationId } : {}),
now: 100,
isLaunchRunning: (key) => (args.running ?? []).includes(key)
})

describe('taking a click’s follow-up off its launch’s row', () => {
it('takes it once its prompt was handed over, removing it in the same write', () => {
const state = rows(row('op-1'))
expect(take(state)).toEqual({
taken: [
{
operationId: 'op-1',
followUp: FOLLOW_UP,
promptHandedOver: true,
composerUnobserved: false
}
],
pending: []
})
expect(state.operations.get(agentSessionOperationKey(DESKTOP, 'op-1'))).not.toHaveProperty(
'launchFollowUp'
)
// Taken once: whoever took it is the one runner.
expect(take(state)).toEqual({ taken: [], pending: [] })
})

it('says when the host wrote the prompt without seeing the composer', () => {
const state = rows(
row('op-1', {
outcome: {
status: 'succeeded',
sessionId: '',
launch: launch('handed-to-terminal', true)
}
})
)
expect(take(state).taken[0]).toMatchObject({ promptHandedOver: true, composerUnobserved: true })
})

it('reports a provisional prompt as pending only while its original launch runs', () => {
const state = rows(
row('op-1', {
outcome: { status: 'succeeded', sessionId: '', launch: launch('unconfirmed') }
})
)
const key = agentSessionOperationKey(DESKTOP, 'op-1')
expect(take(state, { running: [key] })).toEqual({
taken: [],
pending: [{ operationId: 'op-1', followUp: FOLLOW_UP }]
})
expect(state.operations.get(key)?.launchFollowUp).toEqual(FOLLOW_UP)
expect(listSettledLaunchFollowUps(state.operations.values(), DESKTOP, 100, () => true)).toEqual(
[]
)
expect(take(state).taken[0]).toMatchObject({ promptHandedOver: false })
})

it('takes a final handoff even while its operation promise is finishing', () => {
const state = rows(row('op-1'))
const key = agentSessionOperationKey(DESKTOP, 'op-1')
expect(listSettledLaunchFollowUps(state.operations.values(), DESKTOP, 100, () => true)).toEqual(
['op-1']
)
expect(take(state, { running: [key] }).taken[0]).toMatchObject({ promptHandedOver: true })
})

it('takes, with nothing to run, a launch that never handed its prompt over', () => {
const state = rows(
row('not-delivered', {
outcome: { status: 'succeeded', sessionId: '', launch: launch('not-delivered') }
}),
row('failed', { outcome: { status: 'failed', code: 'worktree_not_found' } }),
// Claimed, then the host died before it recorded anything.
row('died', { outcome: { status: 'unknown' } })
)
expect(take(state).taken.map((t) => [t.operationId, t.promptHandedOver])).toEqual([
['not-delivered', false],
['failed', false],
['died', false]
])
})

it.each(['owed', 'writing', 'paused'])(
'ignores an old %s prompt field and consumes an interrupted follow-up without running it',
(state) => {
const saved = Object.assign(
row('op-1', {
outcome: { status: 'succeeded', sessionId: '', launch: launch('unconfirmed') }
}),
{ promptDelivery: { state, text: 'old text' } }
)
const operations = rows(saved)
expect(take(operations).taken[0]).toMatchObject({ promptHandedOver: false })
expect(take(operations)).toEqual({ taken: [], pending: [] })
}
)

it('consumes a bare terminal creation without authorizing the follow-up', () => {
const result = launch('handed-to-terminal')
delete result.prompt
const state = rows(
row('op-1', { outcome: { status: 'succeeded', sessionId: '', launch: result } })
)
expect(take(state).taken[0]).toMatchObject({ promptHandedOver: false })
})

it('waits on a launch this process is still running', () => {
const state = rows(row('op-1', { outcome: { status: 'unknown' } }))
expect(take(state, { running: [agentSessionOperationKey(DESKTOP, 'op-1')] })).toEqual({
taken: [],
pending: [{ operationId: 'op-1', followUp: FOLLOW_UP }]
})
})

it('never shows or takes another caller’s follow-ups', () => {
const state = rows(row('op-1'))
expect(take(state, { callerKey: 'device-1' })).toEqual({ taken: [], pending: [] })
expect(take(state, { callerKey: 'trusted-local:runtime' })).toEqual({ taken: [], pending: [] })
expect(take(state).taken).toHaveLength(1)
})

it('takes only the named launch when the click asks for its own', () => {
const state = rows(row('op-1'), row('op-2'))
expect(take(state, { operationId: 'op-2' }).taken.map((t) => t.operationId)).toEqual(['op-2'])
expect(take(state).taken.map((t) => t.operationId)).toEqual(['op-1'])
})

it('ignores an expired row and a value this build cannot read', () => {
const state = rows(
row('expired', { expiresAt: 50 }),
Object.assign(row('malformed'), { launchFollowUp: { kind: 'x' } })
)
expect(take(state)).toEqual({ taken: [], pending: [] })
})
})
114 changes: 114 additions & 0 deletions src/main/runtime/agent-launch-follow-up-record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Taking a click's follow-up off its launch's row (`agent-launch-follow-up`).
*
* Taken once its prompt has settled, in the same write that removes it, so whoever takes it is the
* one runner: a window that crashes between taking and running loses the follow-up, never repeats
* it. A follow-up still waiting on its prompt is reported, never removed.
*/

import {
isAgentLaunchFollowUp,
type AgentLaunchFollowUpTake
} from '../../shared/agent-launch-follow-up'
import { isAgentLaunchResult } from '../../shared/agent-launch-intent'
import {
agentSessionOperationKey,
type AgentSessionOperationRow
} from '../../shared/agent-session-operation-ledger'

type OperationRows = { operations: Map<string, AgentSessionOperationRow> }

/** Whether the launch has handed its prompt over, or never will; null while it still may. */
function settledPrompt(
row: AgentSessionOperationRow,
isLaunchRunning: (operationKey: string) => boolean
): { handedOver: boolean; composerUnobserved: boolean } | null {
const { outcome } = row
if (outcome.status === 'succeeded') {
const prompt = isAgentLaunchResult(outcome.launch) ? outcome.launch.prompt : undefined
if (
prompt?.outcome === 'unconfirmed' &&
isLaunchRunning(agentSessionOperationKey(row.callerKey, row.operationId))
) {
return null
}
return prompt?.outcome === 'handed-to-terminal'
? { handedOver: true, composerUnobserved: prompt.composerUnobserved === true }
: { handedOver: false, composerUnobserved: false }
}
// A claimed launch this process no longer runs died before it recorded anything: its prompt was
// never handed over, and waiting for it would hold its follow-up until the row expires.
if (
outcome.status !== 'failed' &&
isLaunchRunning(agentSessionOperationKey(row.callerKey, row.operationId))
) {
return null
}
return { handedOver: false, composerUnobserved: false }
}

export function takeLaunchFollowUpsInto(
state: OperationRows,
args: {
callerKey: string
operationId?: string
now: number
isLaunchRunning: (operationKey: string) => boolean
}
): AgentLaunchFollowUpTake {
const take: AgentLaunchFollowUpTake = { taken: [], pending: [] }
let next: Map<string, AgentSessionOperationRow> | null = null
for (const [key, row] of state.operations) {
const followUp: unknown = row.launchFollowUp
if (
row.callerKey !== args.callerKey ||
row.expiresAt <= args.now ||
(args.operationId !== undefined && row.operationId !== args.operationId) ||
!isAgentLaunchFollowUp(followUp)
) {
continue
}
const settled = settledPrompt(row, args.isLaunchRunning)
if (!settled) {
take.pending.push({
operationId: row.operationId,
followUp
})
continue
}
take.taken.push({
operationId: row.operationId,
followUp,
promptHandedOver: settled.handedOver,
composerUnobserved: settled.composerUnobserved
})
const { launchFollowUp: _taken, ...rest } = row
next ??= new Map(state.operations)
next.set(key, rest)
}
if (next) {
state.operations = next
}
return take
}

/** The launches whose follow-up now waits only on its caller to take it. */
export function listSettledLaunchFollowUps(
rows: Iterable<AgentSessionOperationRow>,
callerKey: string,
now: number,
isLaunchRunning: (operationKey: string) => boolean
): string[] {
const settled: string[] = []
for (const row of rows) {
if (
row.callerKey === callerKey &&
row.expiresAt > now &&
isAgentLaunchFollowUp(row.launchFollowUp) &&
settledPrompt(row, isLaunchRunning) !== null
) {
settled.push(row.operationId)
}
}
return settled
}
Loading
Loading