Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d16aecb
fix(claude): settle a queued send the CLI withdrew from its own cance…
brennanb2025 Sep 29, 2026
4461244
fix(claude): release a doubted send when the CLI reports its session …
brennanb2025 Sep 29, 2026
45212a9
fix(claude): keep a command's started mark when a redelivery re-emits…
brennanb2025 Sep 29, 2026
15078d1
fix(claude): settle every terminal lifecycle state of a send the CLI …
brennanb2025 Sep 29, 2026
0a36dab
fix(claude): release a send the CLI took but left unanswered when it …
brennanb2025 Sep 29, 2026
2e13528
fix(native-chat): keep the idle sweep off a Claude child that holds a…
brennanb2025 Sep 29, 2026
8681349
fix(claude): count only a started send at idle and as a held send
brennanb2025 Sep 29, 2026
a71b3ec
docs(claude): say only what the CLI's lifecycle frames and idle actua…
brennanb2025 Sep 29, 2026
72b6481
Merge origin/main into #23898
brennanb2025 Sep 29, 2026
e97c0ed
Merge origin/main (main CI fix #23923)
brennanb2025 Sep 29, 2026
6f2c729
fix(claude): hold the idle sweep while Claude has a send queued, not …
brennanb2025 Sep 30, 2026
52f2717
Merge origin/main into #23898
brennanb2025 Sep 30, 2026
ff9d0a4
refactor(native-chat): give provider-proven late dispatch settlement …
brennanb2025 Sep 30, 2026
5471ca4
test(claude): pin a steer a Stop interrupts after it started as doubt…
brennanb2025 Sep 30, 2026
8dd0f81
Merge origin/main into brennanb2025/claude-lifecycle-pr2
brennanb2025 Sep 30, 2026
d6bf23b
Merge origin/main into brennanb2025/claude-lifecycle-pr2
brennanb2025 Sep 30, 2026
7e867da
fix(native-chat): one ordered journal writer
brennanb2025 Sep 30, 2026
5179d92
fix(native-chat): a failed Stop holds the lane until its queue pause …
brennanb2025 Sep 30, 2026
1e9bee1
fix(native-chat): a journal write body is typed synchronous
brennanb2025 Sep 30, 2026
9dbfb39
fix(native-chat): a stream's first window snapshot always lands
brennanb2025 Sep 30, 2026
6931820
test(native-chat): streamed text rewritten in place above a Stop note
brennanb2025 Sep 30, 2026
6e6dc65
chore(native-chat): drop comments that still describe coalesced journ…
brennanb2025 Sep 30, 2026
047f018
fix(native-chat): type the draft-table write body synchronous too
brennanb2025 Sep 30, 2026
8bf5445
fix(native-chat): an empty delta owes no streamed-text emit
brennanb2025 Sep 30, 2026
64da79e
fix(native-chat): a mutation's open pays an owed import first
brennanb2025 Sep 30, 2026
fdce158
chore(native-chat): whenImported says mutations await it too
brennanb2025 Sep 30, 2026
ac33cb8
test(native-chat): a Stop interrupts before its withdrawal or pause s…
brennanb2025 Sep 30, 2026
6a066aa
test(native-chat): a Stop ends a starting child before its withdrawal…
brennanb2025 Sep 30, 2026
6985274
test(native-chat): Stop order tests wait for the interrupt, not a 50 …
brennanb2025 Sep 30, 2026
a80d698
test(native-chat): settlement-order test reads rows through the journ…
brennanb2025 Sep 30, 2026
5f20c7b
fix(native-chat): an empty delta still owes its emit, just not a forc…
brennanb2025 Sep 30, 2026
0adeecf
Merge remote-tracking branch 'origin/main' into brennanb2025/journal-…
brennanb2025 Sep 30, 2026
19cc13b
Merge remote-tracking branch 'origin/main' into brennanb2025/journal-…
brennanb2025 Oct 1, 2026
be2a130
Merge remote-tracking branch 'origin/main' into brennanb2025/journal-…
brennanb2025 Oct 1, 2026
e62687f
Merge remote-tracking branch 'origin/main' into brennanb2025/journal-…
brennanb2025 Oct 1, 2026
052e6a9
Merge remote-tracking branch 'origin/main' into brennanb2025/journal-…
brennanb2025 Oct 1, 2026
ea165f4
Merge remote-tracking branch 'origin/main' into brennanb2025/journal-…
brennanb2025 Oct 1, 2026
575193b
Merge remote-tracking branch 'origin/main' into brennanb2025/journal-…
brennanb2025 Oct 2, 2026
a18b3e8
revert(native-chat): drop the first-text row write from the delta coa…
brennanb2025 Oct 2, 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
26 changes: 7 additions & 19 deletions src/main/claude/claude-background-task-row-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,25 +111,19 @@ describe('Claude background task row journal', () => {
expect(appendAndPublish).toHaveBeenCalledTimes(2)
})

it('keeps fallback append coalescing separate from its ordered publication', () => {
const calls: { operation: 'append' | 'publish'; coalescingKey?: string }[] = []
it('appends the row, then publishes it', () => {
const calls: ('append' | 'publish')[] = []
const task = row()
const sink: StructuredAgentSessionEventSink = {
appendItem: vi.fn(),
appendTombstone: vi.fn(),
publish: vi.fn(),
tryAppendResolvedItem: vi.fn((_identity, _body, _resolve, options) => {
calls.push({
operation: 'append',
...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {})
})
tryAppendResolvedItem: vi.fn(() => {
calls.push('append')
return { accepted: true as const }
}),
tryPublish: vi.fn((options) => {
calls.push({
operation: 'publish',
...(options?.coalescingKey ? { coalescingKey: options.coalescingKey } : {})
})
tryPublish: vi.fn(() => {
calls.push('publish')
return { accepted: true as const }
})
}
Expand All @@ -143,13 +137,7 @@ describe('Claude background task row journal', () => {
() => AGENT_JOURNAL_THREAD_SCOPE
)
).toEqual({ accepted: true })
expect(calls).toEqual([
{
operation: 'append',
coalescingKey: JSON.stringify(['claude-background-task', 'task-1', 'tool-1'])
},
{ operation: 'publish' }
])
expect(calls).toEqual(['append', 'publish'])
expect(task.lastSerialized).not.toBeNull()
})

Expand Down
5 changes: 0 additions & 5 deletions src/main/claude/claude-background-task-row-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,7 @@ export function writeClaudeBackgroundTaskRow(
}
beforeAppend?.()
const identity = claudeBackgroundTaskIdentity(id, row.generation)
// Generation is translator-local and resets when a provider stream is
// recreated. Keep unresolved writes from distinct provider runs queued side
// by using the provider's parent tool identity as the coalescing discriminator.
const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null])
const appendOptions = {
coalescingKey,
turnScope: turnScope(),
...(lifecycle ? { lifecycle: true } : {})
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-open-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export class ClaudeOpenTurn {
{ lifecycle: item.body, ...(contextUsage ? { contextUsage } : {}) },
{ publish: false, options: item.options }
)
// Preserve first-work evidence when completion arrives before the journal drains.
// Keyed apart, so this never replaces the start's publication while it still waits to run.
this.deps.sink.publish({ coalescingKey: item.publishCoalescingKey })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import { blockOf } from './claude-background-task-row-test-support'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'

// The frames below are the ones the reported session actually carried: two real
Expand Down Expand Up @@ -144,7 +145,7 @@ function persistedTarget(
): StructuredAgentSessionEventTarget {
const journal =
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test double implements the journal methods exercised by the deferred sink.
{
withJournalQueueMembers({
appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
persisted.set(agentJournalItemKey(identity), body)
return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 }
Expand All @@ -163,7 +164,7 @@ function persistedTarget(
}) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'],
itemBody: (itemId: string) => persisted.get(itemId) ?? null,
epoch: 'test'
} as unknown as AgentSessionJournal
}) as unknown as AgentSessionJournal
return { journal, fence: 1, publish: vi.fn() }
}

Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-structured-prompt-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ export async function cancelClaudeStructuredTurn(input: {
)
}
// Judge against the published journal, because that is the only turn a client could have been
// shown — but only while it HAS an answer. The journal drains through a serialized async queue,
// so a null read means the row has not landed yet, not that nothing is running; falling back to
// shown — but only while it HAS an answer. A null read can mean the row never landed (a sink not
// yet bound, or refusing under backpressure), not that nothing is running; falling back to
// the in-memory turn there keeps Stop from being gated on bookkeeping. No live turn either way
// means nothing has published an identity this request can contradict.
const ownsRequestedTurn = (): boolean => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
identityFor,
PROVIDER_SESSION_ID
} from './claude-structured-session-test-support'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'

function controlledSink(): {
Expand Down Expand Up @@ -49,7 +50,7 @@ function persistedTarget(
): StructuredAgentSessionEventTarget {
const journal =
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test double implements the journal methods exercised by the deferred sink.
{
withJournalQueueMembers({
appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
persisted.set(agentJournalItemKey(identity), body)
return { cursor: { epoch: 'test', sequence: persisted.size }, itemId: '', revision: 1 }
Expand All @@ -68,7 +69,7 @@ function persistedTarget(
}) satisfies StructuredAgentSessionLinkageJournal['visitItemsWithLinkage'],
itemBody: (itemId: string) => persisted.get(itemId) ?? null,
epoch: 'test'
} as unknown as AgentSessionJournal
}) as unknown as AgentSessionJournal
return { journal, fence: 1, publish: vi.fn() }
}

Expand Down
7 changes: 2 additions & 5 deletions src/main/claude/claude-subagent-group-row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,12 @@ export function writeClaudeSubagentGroupRow(
group: RosterGroup
): void {
const agents = [...group.entries.values()].map((tracked) => tracked.entry)
const options = { coalescingKey: `claude-subagents:${group.groupId}`, turnScope: group.turnScope }
if (agents.length === 0) {
// The row's last child turned out not to be a subagent. An empty roster is
// not a roster of nothing, so the row goes rather than reading "Ran 0".
if (group.lastSerialized !== null) {
group.lastSerialized = null
sink.appendTombstone(group.identity, options)
sink.appendTombstone(group.identity)
sink.publish()
}
return
Expand All @@ -110,8 +109,6 @@ export function writeClaudeSubagentGroupRow(
return
}
group.lastSerialized = serialized
sink.appendItem(group.identity, body, options)
// Publish keeps the sink's own coalescing slot: sharing the row's key makes
// each queued publish evict the append it was meant to flush.
sink.appendItem(group.identity, body, { turnScope: group.turnScope })
sink.publish()
}
9 changes: 5 additions & 4 deletions src/main/claude/claude-subagent-roster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'

const TURN_1 = 'claude-session:turn-1'
Expand Down Expand Up @@ -456,13 +457,14 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => {
it('lands every revision, not just the one that was already in flight', async () => {
const appended: AgentJournalItemBody[] = []
let published = 0
const journal = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones.
const journal = withJournalQueueMembers({
appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
appended.push(body)
return { cursor: { epoch: 'e', sequence: appended.length } }
},
appendTombstone: async () => ({ epoch: 'e', sequence: 0 })
} as unknown as AgentSessionJournal
}) as unknown as AgentSessionJournal
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
deferred.bind({
journal,
Expand All @@ -477,8 +479,7 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => {
currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})

// The first append is in flight while the rest are submitted, so a publish
// sharing the row's coalescing key would evict them.
// The first append is in flight while the rest are submitted; each must still land.
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' }))
roster.observeSystemFrame(
Expand Down
7 changes: 2 additions & 5 deletions src/main/codex/codex-structured-item-streams.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalRowAttribution
Expand Down Expand Up @@ -118,10 +117,8 @@ export function createCodexStructuredItemStreams(
if (!translated.body) {
return true
}
return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, {
coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}`,
...attributionOf(key)
}).accepted
return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, attributionOf(key))
.accepted
}

const persist = (key: string, text: string, force: boolean): boolean => {
Expand Down
1 change: 0 additions & 1 deletion src/main/codex/codex-structured-journal-generic-frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ export class CodexJournalGenericFrames {
}
// A summary across evicted turns names no producer and belongs to no turn.
const options = {
coalescingKey: `provider-frame-suppressed:codex:${bucket}`,
...(summary.producer
? this.deps.attributionFor(summary.producer.threadId, summary.producer.turnId)
: { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
Expand Down
5 changes: 3 additions & 2 deletions src/main/codex/codex-structured-journal-goal-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexJournalGoals } from './codex-structured-journal-goals'
import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'

const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc'
Expand Down Expand Up @@ -49,7 +50,7 @@ function goalJournal(
...options
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake journal exposing only the members the goal translator and deferred sink call.
const journal = {
const journal = withJournalQueueMembers({
get epoch() {
return `epoch-${epochNumber}`
},
Expand Down Expand Up @@ -81,7 +82,7 @@ function goalJournal(
visit(item.itemId, item.sequence, item.body)
}
}
} as unknown as StructuredAgentSessionEventTarget['journal']
}) as unknown as StructuredAgentSessionEventTarget['journal']
const target = {
journal,
fence: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
CODEX_USER_INPUT_METHOD
} from './codex-structured-prompt-replies'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'

const SESSION_ID = 'session-1'
Expand Down Expand Up @@ -105,7 +106,8 @@ function deferredTarget(
): StructuredAgentSessionEventTarget {
return {
fence: 7,
journal: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones.
journal: withJournalQueueMembers({
appendItem: vi.fn(async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
log.push(body)
return { cursor: { epoch: 'e', sequence: log.length } }
Expand All @@ -121,7 +123,7 @@ function deferredTarget(
return { epoch: 'e', sequence: log.length }
}
)
} as unknown as StructuredAgentSessionEventTarget['journal'],
}) as unknown as StructuredAgentSessionEventTarget['journal'],
publish: vi.fn(() => {
publishes.push('publish')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function publishCodexTurnLifecycle(input: {
} else {
input.sink.appendItem(identity, body, appendOptions)
}
// Preserve first-work evidence when completion arrives before the journal drains.
// Keyed apart, so a completion's publication never replaces a start one still waiting to run.
const publishOptions = {
lifecycle: true,
...(input.state === 'running'
Expand Down
6 changes: 4 additions & 2 deletions src/main/codex/codex-structured-journal-translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
CODEX_USER_INPUT_METHOD
} from './codex-structured-prompt-replies'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'

const SESSION_ID = 'session-1'
Expand Down Expand Up @@ -103,7 +104,8 @@ function deferredTarget(
): StructuredAgentSessionEventTarget {
return {
fence: 7,
journal: {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones.
journal: withJournalQueueMembers({
appendItem: vi.fn(async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
log.push(body)
return { cursor: { epoch: 'e', sequence: log.length } }
Expand All @@ -119,7 +121,7 @@ function deferredTarget(
return { epoch: 'e', sequence: log.length }
}
)
} as unknown as StructuredAgentSessionEventTarget['journal'],
}) as unknown as StructuredAgentSessionEventTarget['journal'],
publish: vi.fn(() => {
publishes.push('publish')
})
Expand Down
Loading
Loading