mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-02 03:50:36 +00:00
fix(transcript): finish settlement reads from liveness and inbox evidence (#1187)
A settlement whose terminal event fell inside a disconnect window had no trigger: finishOpenCodeDataEvent never ran, so the SDK's settlement read was never finished and a tool row stayed "pending" until a reload. A queued prompt delivered during the gap also vanished from both the queue and the transcript. The settlement read from #1169 is extracted as completeSettlementRead and now also runs from two pieces of evidence the client already gathers: the 30-second liveness pass reports each run it believed working that the final activity read no longer lists (only while the transcript still holds an unsettled tool), and the inbox sync reports an admitted item the authoritative inbox no longer lists. Neither path has a terminal event that invalidated the SDK's message key, so both invalidate it before the read; otherwise the sync engine would skip it on any reducer that synced before. The execution-epoch fence still lets only a newer execution suppress the publication, and the display window keeps its membership. Regressions start from a reducer whose first sync completed and expect exactly one read: a liveness-observed settlement heals a stranded tool, a newer execution suppresses that publication while the reducer still reconciles, an inbox sync admits the delivered prompt, the production liveness pass heals end to end, and a still-active run reads nothing. Store suites and the UI typecheck pass.
This commit is contained in:
parent
e1fbd7a3f8
commit
1577662068
5 changed files with 310 additions and 17 deletions
|
|
@ -12,7 +12,7 @@ Message content visibility and presets, token lines, tool renderers, native sess
|
|||
- Transcript pages carry large tool images by reference: the UI's native message list reads (`lib/sdk-manager.ts`) and history windows (`fetchHistoryWindow`) send the `x-codenomad-media: deferred` opt-in (`server/src/opencode/session-media-contract.ts`), and the server replaces each tool image data URI over `INLINE_MEDIA_LIMIT` (32 KiB) with a `/api/workspaces/<id>/session-media/<session>/<message>/<part>/<index>/<sha256>` path (`opencode/session-media.ts`). `toolImageSource` resolves that path on the API base, so the lazy `<img>` loads only shown images; `routes/session-media.ts` serves the bytes of the owned session's native message, pinned by the digest and refused at or after an undo boundary, as an immutable sandboxed image. Single-message reads (`session.message.get`: pruning revisions, previews), live events, user attachments and requests without the opt-in stay whole, so revisions hash native content.
|
||||
- Code Mode reads native `metadata.toolCalls` in `tool-call/renderers/execute-data.ts`; keep call-list styling in `styles/messaging/tool-call/execute.css`, with native image/output ownership in the shared tool shell.
|
||||
- Native websearch text is parsed conservatively by `tool-call/renderers/websearch-data.ts`; unknown/oversized responses use the shared raw renderer. Recognized provider-consent Forms retain the generic schema/answer path, with layout in `styles/messaging/tool-call/websearch.css`.
|
||||
- Session lifecycle features use OpenCode V2's published signals: one `session.execution.{succeeded,failed,interrupted}` per busy period (`lib/native-execution-events.ts`) and `session.retry.scheduled` for provider backoff. The schema-declared `session.idle`/`session.status` are never published; never wire a feature only to them. A terminal event is a hint until `session.active` confirms settlement, which alone announces "A session finished", only for root sessions ending in success, failure or an inactivity stop. Transcript reducers retire at settlement, deferred while prompts stay parked in their inbox or a user shell or unsettled tool call still needs reconciling there. A tool part still `streaming` (rendered "pending") once a settlement drained has lost its `called` transition: `session.tool.success` reduces solely from the running status (a later `failed` can still settle it). The SDK reducer already answers a terminal event whose transcript holds a streaming or running tool by invalidating and re-reading messages, but that refresh is dropped while the stream is not connected and never publishes its result; the terminal event therefore finishes the read itself (`finishOpenCodeDataEvent`: one `message.sync` that shares the SDK's in-flight read when connected and starts it when not) and projects the reconciled page, which keeps the display window's membership. Only a newer admitted execution suppresses that publication — same-round activity such as a late terminal tool event must not leave the repair unpublished. Token/cost lines (`components/message-usage.tsx`) appear once per agent message: the row of an assistant message's final text (the one carrying "delete tools and reasoning") renders, once that message has native usage, the sum (`sumMessageUsage`) of its own usage and of every message holding a tool or reasoning part that cleanup selects (`technicalCleanupParts`). Reuse that selection rather than a separate projection. The line renders inside the content row, so it never shifts `<Index>` items or splits a technical group (whose Delete needs two tools); tool-only steps get no line. A message whose text is followed by tools also counts in the next message's line, as its tools belong to that selection. Sums cover the loaded transcript window only, like cleanup. Capture real streams with `scripts/test-session-lifecycle-native.mjs`.
|
||||
- Session lifecycle features use OpenCode V2's published signals: one `session.execution.{succeeded,failed,interrupted}` per busy period (`lib/native-execution-events.ts`) and `session.retry.scheduled` for provider backoff. The schema-declared `session.idle`/`session.status` are never published; never wire a feature only to them. A terminal event is a hint until `session.active` confirms settlement, which alone announces "A session finished", only for root sessions ending in success, failure or an inactivity stop. Transcript reducers retire at settlement, deferred while prompts stay parked in their inbox or a user shell or unsettled tool call still needs reconciling there. A tool part still `streaming` (rendered "pending") once a settlement drained has lost its `called` transition: `session.tool.success` reduces solely from the running status (a later `failed` can still settle it). The SDK reducer already answers a terminal event whose transcript holds a streaming or running tool by invalidating and re-reading messages, but that refresh is dropped while the stream is not connected and never publishes its result; the terminal event therefore finishes the read itself (`finishOpenCodeDataEvent`: one `message.sync` that shares the SDK's in-flight read when connected and starts it when not) and projects the reconciled page, which keeps the display window's membership. Only a newer admitted execution suppresses that publication — same-round activity such as a late terminal tool event must not leave the repair unpublished. Non-event settlement evidence finishes the same read through the same fence: the liveness activity read observing that a run the UI still believed working has settled (`observeNonEventSettlement`, reported by `session-api`), and an authoritative inbox sync that drops an item the reducer admitted (a delivery or cancellation without events, whose user message may never be admitted either). Token/cost lines (`components/message-usage.tsx`) appear once per agent message: the row of an assistant message's final text (the one carrying "delete tools and reasoning") renders, once that message has native usage, the sum (`sumMessageUsage`) of its own usage and of every message holding a tool or reasoning part that cleanup selects (`technicalCleanupParts`). Reuse that selection rather than a separate projection. The line renders inside the content row, so it never shifts `<Index>` items or splits a technical group (whose Delete needs two tools); tool-only steps get no line. A message whose text is followed by tools also counts in the next message's line, as its tools belong to that selection. Sums cover the loaded transcript window only, like cleanup. Capture real streams with `scripts/test-session-lifecycle-native.mjs`.
|
||||
- Native transcript events reduce only through `stores/opencode-data.ts` (never add another partial-part reducer), which publishes bounded snapshots through `hydrateMessages` (message-v2/instance-store.ts); these per-event re-projections must not invalidate unchanged messages: `hydrateMessages` reuses the resident `MessageRecord` object whenever content, explicit timestamps and prompt-display metadata are unchanged, and an identical whole snapshot returns before any store write or session-revision bump. Part/info comparisons use allocation-free structural equality (`partDataEqual`), never `JSON.stringify`. The SDK reduce mutates rows in place, so reference identity can never prove a session unchanged — never add a reference-anchored projection skip. A session's reducer is replaced (authoritative resync after an overflow, rotation failure or reconnect; pruning invalidation) only through `replaceTranscriptEntry`, which bumps the transcript's `entryRevision`; reactive readers of the reducer, such as the inbox behind queued badges (`getOpenCodeSessionInbox`), track it so they rebind instead of keeping the disposed reducer's last state (`opencode-inbox-rebinding.test.ts`, `tests/browser/session-rendering.test.ts`).
|
||||
- Reducer page reads (the SDK's `message.sync` through the `message.list` proxy in `createDataEntry`) flush buffered compaction text, snapshot the session's live rows, fetch, flush again and answer with the page merged against that snapshot (`stores/opencode-data-read-merge.ts`), so buffered text is reduced exactly once: the page is the base and keeps its cursor, a live row new or changed since the snapshot keeps its live version (a row changed only in its root `time.completed`, which SDK step boundaries stamp, takes the page's version) except for its identified content parts, such as tool calls, left unchanged since the snapshot, which take the page's version so progress on one tool never hides authority for another, and rows created during the fetch and absent from the page ride along newest-first. Only events that remove or replace content (`invalidatesFetchedPage`: deletion, revert commit, inbox cancellation, compaction start/end/failure) restart the read; streamed text, reasoning, tool progress, compaction text and new rows merge, so a continuously streaming session completes its read in one fetch. Live versions are reconciled by the native full-text endings and the SDK's end-of-round refresh. A first latest-window load (`loadMessages` with no resident rows and no stored window) merges its 200-row page the same way and commits it while live revisions advance, then, unless the session is known idle, seeds a session reducer that holds no rows with the page's newest 20 rows, the SDK's own page size, without another read (`seedOpenCodeSessionMessages`), because the reducer applies streamed events only to messages it holds. Reloads and history windows keep the revision checks. Covered by `opencode-data-trailing-read.test.ts`, `opencode-data-read-merge.test.ts` and `session-api-running-open.test.ts`.
|
||||
- Shared speech byte/MediaSource transport lives in `lib/audio-utils.ts`; playback ownership and cancellation stay with its callers.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createEffect, createRoot } from "solid-js"
|
|||
import { messageStoreBus } from "./message-v2/bus.ts"
|
||||
import { seedSessionMessagesV2 } from "./message-v2/bridge.ts"
|
||||
import { normalizeSessionMessage } from "./message-v2/normalizers.ts"
|
||||
import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, getOpenCodeMessageRevision, getOpenCodeMutationRevision, getOpenCodeSessionInbox, projectOpenCodeMessages } from "./opencode-data.ts"
|
||||
import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, getOpenCodeMessageRevision, getOpenCodeMutationRevision, getOpenCodeSessionInbox, observeNonEventSettlement, projectOpenCodeMessages, seedOpenCodeSessionMessages, syncOpenCodeSessionInbox } from "./opencode-data.ts"
|
||||
import { emptyLatestWindow } from "./message-v2/message-window.ts"
|
||||
import { getRootClient } from "./opencode-client.ts"
|
||||
import { sdkManager } from "../lib/sdk-manager.ts"
|
||||
|
|
@ -1324,6 +1324,158 @@ describe("OpenCode data projection", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("reconciles a stranded tool from a liveness-observed settlement", async () => {
|
||||
const instanceId = "opencode-data-liveness-settled"
|
||||
const sessionId = "session"
|
||||
const client = getRootClient(instanceId)
|
||||
// The settlement event itself was lost across a disconnect window, so no
|
||||
// terminal event will finish the SDK read. The liveness pass's activity
|
||||
// read is the only settlement evidence; its observation must finish the
|
||||
// same read and publish it (session-api reports the observation).
|
||||
const authoritative = {
|
||||
id: "assistant", type: "assistant", agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
time: { created: 1, completed: 3 },
|
||||
content: [{
|
||||
type: "tool", id: "tool", name: "bash", time: { created: 2, completed: 3 },
|
||||
state: { status: "completed", input: { command: "pwd" }, metadata: {}, content: [{ type: "text", text: "ok" }] },
|
||||
}],
|
||||
}
|
||||
let reads = 0
|
||||
stubAuthoritativeSession(client, sessionId, async () => {
|
||||
reads += 1
|
||||
return { data: [authoritative], cursor: {} }
|
||||
})
|
||||
const base = { sessionID: sessionId, assistantMessageID: "assistant" }
|
||||
const apply = (type: string, data: Record<string, unknown>, created = 1) =>
|
||||
applyOpenCodeDataEvent(instanceId, "/work", { id: type, type, created, data } as any)
|
||||
// A reducer an earlier sync completed (opened, seeded or reconnected) must still read.
|
||||
await seedOpenCodeSessionMessages(instanceId, sessionId, "/work", [])
|
||||
try {
|
||||
apply("session.step.started", { ...base, agent: "build", model: { providerID: "provider", id: "model" } }, 1)
|
||||
apply("session.tool.input.started", { ...base, id: "tool", name: "bash" }, 2)
|
||||
const data = apply("session.tool.success", { ...base, id: "tool", content: [{ type: "text", text: "ok" }], metadata: {} }, 3)
|
||||
projectOpenCodeMessages(instanceId, sessionId, data)
|
||||
assert.equal((messageStoreBus.getOrCreate(instanceId).getMessage("assistant")?.parts.tool?.data as any)?.state?.status, "pending")
|
||||
|
||||
observeNonEventSettlement(instanceId, sessionId)
|
||||
|
||||
await waitForReplay(() => (messageStoreBus.getOrCreate(instanceId).getMessage("assistant")?.parts.tool?.data as any)?.state?.status === "completed")
|
||||
assert.equal(reads, 1, "the liveness-observed settlement finished the read")
|
||||
const healed = messageStoreBus.getOrCreate(instanceId).getMessage("assistant")
|
||||
assert.equal((healed?.parts.tool?.data as any)?.state?.status, "completed")
|
||||
assert.equal((healed?.parts.tool?.data as any)?.state?.output, "ok")
|
||||
} finally {
|
||||
destroyOpenCodeData(instanceId)
|
||||
if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId)
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
it("suppresses the liveness-observed publication when a newer execution was admitted", async () => {
|
||||
const instanceId = "opencode-data-liveness-newer-round"
|
||||
const sessionId = "session"
|
||||
const client = getRootClient(instanceId)
|
||||
const authoritative = {
|
||||
id: "assistant", type: "assistant", agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
time: { created: 1, completed: 3 },
|
||||
content: [{
|
||||
type: "tool", id: "tool", name: "bash", time: { created: 2, completed: 3 },
|
||||
state: { status: "completed", input: { command: "pwd" }, metadata: {}, content: [{ type: "text", text: "ok" }] },
|
||||
}],
|
||||
}
|
||||
const read = deferred<{ data: unknown[]; cursor: Record<string, string> }>()
|
||||
stubAuthoritativeSession(client, sessionId, () => read.promise)
|
||||
const base = { sessionID: sessionId, assistantMessageID: "assistant" }
|
||||
const apply = (type: string, data: Record<string, unknown>, created = 1) =>
|
||||
applyOpenCodeDataEvent(instanceId, "/work", { id: type, type, created, data } as any)
|
||||
// A reducer an earlier sync completed (opened, seeded or reconnected) must still read.
|
||||
await seedOpenCodeSessionMessages(instanceId, sessionId, "/work", [])
|
||||
try {
|
||||
apply("session.step.started", { ...base, agent: "build", model: { providerID: "provider", id: "model" } }, 1)
|
||||
apply("session.tool.input.started", { ...base, id: "tool", name: "bash" }, 2)
|
||||
const data = apply("session.tool.success", { ...base, id: "tool", content: [{ type: "text", text: "ok" }], metadata: {} }, 3)
|
||||
projectOpenCodeMessages(instanceId, sessionId, data)
|
||||
|
||||
observeNonEventSettlement(instanceId, sessionId)
|
||||
|
||||
// A new round's start was admitted while the observation's read is held.
|
||||
const next = { sessionID: sessionId, assistantMessageID: "round-2" }
|
||||
apply("session.execution.started", { sessionID: sessionId }, 10)
|
||||
apply("session.step.started", { ...next, agent: "build", model: { providerID: "provider", id: "model" } }, 11)
|
||||
apply("session.text.started", next, 12)
|
||||
const streamed = apply("session.text.delta", { ...next, ordinal: 0, delta: "kept" }, 13)
|
||||
projectOpenCodeMessages(instanceId, sessionId, streamed)
|
||||
|
||||
read.resolve({ data: [authoritative], cursor: {} })
|
||||
// Wait until the observation's read landed in the reducer, then let its
|
||||
// completion run before checking that nothing was published.
|
||||
await waitForReplay(() => (streamed.session.message.get(sessionId, "assistant") as any)
|
||||
?.content?.find((part: any) => part.type === "tool")?.state?.status === "completed")
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
assert.equal((messageStoreBus.getOrCreate(instanceId).getMessage("round-2")?.parts["round-2-text-0"]?.data as any)?.text, "kept")
|
||||
// The newer admitted execution fences the observation's publication; the
|
||||
// newer round's own settlement reconciles instead.
|
||||
assert.equal((messageStoreBus.getOrCreate(instanceId).getMessage("assistant")?.parts.tool?.data as any)?.state?.status, "pending", "a newer execution supersedes the observation's publication")
|
||||
|
||||
// The fenced read still reconciled the reducer: the next projection (as
|
||||
// the event dispatcher runs for the newer round's settlement) shows it.
|
||||
const settled = { id: "settled-2", type: "session.execution.succeeded", created: 20, data: { sessionID: sessionId } } as any
|
||||
projectOpenCodeMessages(instanceId, sessionId, applyOpenCodeDataEvent(instanceId, "/work", settled))
|
||||
finishOpenCodeDataEvent(instanceId, settled)
|
||||
await waitForReplay(() => (messageStoreBus.getOrCreate(instanceId).getMessage("assistant")?.parts.tool?.data as any)?.state?.status === "completed")
|
||||
assert.equal((messageStoreBus.getOrCreate(instanceId).getMessage("round-2")?.parts["round-2-text-0"]?.data as any)?.text, "kept", "the round keeps its events")
|
||||
} finally {
|
||||
destroyOpenCodeData(instanceId)
|
||||
if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId)
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
it("reconciles a delivered queued prompt through an authoritative inbox sync", async () => {
|
||||
const instanceId = "opencode-data-inbox-delivery"
|
||||
const sessionId = "session"
|
||||
const client = getRootClient(instanceId)
|
||||
// The queued prompt was delivered while its delivery events were lost:
|
||||
// the reducer still lists it, the authoritative inbox does not, and the
|
||||
// delivered user message never reached the transcript. The sync's
|
||||
// disappearance evidence owes the settlement read.
|
||||
const delivered = {
|
||||
id: "user", type: "user", text: "queued prompt", time: { created: 1, completed: 1 },
|
||||
}
|
||||
let reads = 0
|
||||
stubAuthoritativeSession(client, sessionId, async () => {
|
||||
reads += 1
|
||||
return { data: [delivered], cursor: {} }
|
||||
})
|
||||
// A reducer an earlier sync completed (opened, seeded or reconnected) must still read.
|
||||
await seedOpenCodeSessionMessages(instanceId, sessionId, "/work", [])
|
||||
try {
|
||||
applyOpenCodeDataEvent(instanceId, "/work", {
|
||||
id: "enqueue", type: "session.inbox.enqueued", created: 1,
|
||||
data: { sessionID: sessionId, inboxID: "queued", item: { type: "user", payload: { text: "queued prompt" }, delivery: "queue" } },
|
||||
} as any)
|
||||
assert.deepEqual(getOpenCodeSessionInbox(instanceId, sessionId, "/work").map((item) => item.id), ["queued"])
|
||||
|
||||
await syncOpenCodeSessionInbox(instanceId, sessionId, "/work")
|
||||
|
||||
assert.deepEqual(getOpenCodeSessionInbox(instanceId, sessionId, "/work").map((item) => item.id), [], "the queue reflects the authoritative delivery")
|
||||
await waitForReplay(() => Boolean(messageStoreBus.getOrCreate(instanceId).getMessage("user")))
|
||||
const healed = messageStoreBus.getOrCreate(instanceId).getMessage("user")
|
||||
assert.equal(healed?.role, "user")
|
||||
const text = healed && Object.values(healed.parts)
|
||||
.map((entry: unknown) => (entry as { data?: { type?: string; text?: string } })?.data)
|
||||
.find((part) => part?.type === "text")?.text
|
||||
assert.equal(text, "queued prompt", "the delivered prompt's user message is admitted")
|
||||
assert.equal(reads, 1, "the disappearance owed one settlement read")
|
||||
} finally {
|
||||
destroyOpenCodeData(instanceId)
|
||||
if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId)
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps another session's live reducer after a terminal event", () => {
|
||||
const instanceId = "opencode-data-session-isolation"
|
||||
const event = (sessionID: string, type: string, data: Record<string, unknown> = {}) => ({
|
||||
|
|
|
|||
|
|
@ -798,7 +798,22 @@ export function getOpenCodeSessionInbox(instanceId: string, sessionId: string, d
|
|||
}
|
||||
|
||||
export async function syncOpenCodeSessionInbox(instanceId: string, sessionId: string, directory: string): Promise<void> {
|
||||
await ensureTranscript(instanceId, sessionId, directory).entry.data.session.pending.sync(sessionId)
|
||||
const transcript = ensureTranscript(instanceId, sessionId, directory)
|
||||
const admitted = new Set(transcript.entry.data.session.pending.list(sessionId).map((item) => item.id))
|
||||
await transcript.entry.data.session.pending.sync(sessionId)
|
||||
// An item the reducer admitted that the authoritative inbox no longer lists
|
||||
// was delivered or cancelled without events; a delivery's user message may
|
||||
// never be admitted either. The disappearance owes the settlement read that
|
||||
// reconciles both; nothing invalidated the SDK's message key, so it does
|
||||
// here (an owed dropping-mode resync keeps the projection fenced).
|
||||
const remaining = new Set(transcript.entry.data.session.pending.list(sessionId).map((item) => item.id))
|
||||
if ([...admitted].some((id) => !remaining.has(id))) {
|
||||
const current = transcriptEntries.get(messageRevisionKey(instanceId, sessionId))
|
||||
if (current === transcript && isTranscriptCurrent(instanceId, sessionId, transcript)) {
|
||||
transcript.entry.data.session.message.invalidate(sessionId)
|
||||
completeSettlementRead(instanceId, sessionId, transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The session reducer's rows when a REST load starts its fetch. */
|
||||
|
|
@ -917,24 +932,47 @@ export function finishOpenCodeDataEvent(instanceId: string, event: OpenCodeEvent
|
|||
// window's membership. A newer execution supersedes the publication;
|
||||
// same-round activity does not.
|
||||
if (hasUnsettledToolRow(transcript.entry.data.session.message.list(sessionId))) {
|
||||
const epoch = transcript.executionEpoch
|
||||
void transcript.entry.data.session.message.sync(sessionId)
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
// Publish unless a newer execution superseded this settlement:
|
||||
// same-round activity (a late terminal tool event) must not
|
||||
// suppress the repair.
|
||||
if (!isTranscriptCurrent(instanceId, sessionId, transcript)
|
||||
|| transcript.executionEpoch !== epoch) return
|
||||
projectOpenCodeMessages(instanceId, sessionId, transcript.entry.data, true)
|
||||
retireDrainedTranscript(instanceId, sessionId, transcript)
|
||||
})
|
||||
completeSettlementRead(instanceId, sessionId, transcript)
|
||||
}
|
||||
}
|
||||
// A settlement deferred by parked or running work completes once that clears.
|
||||
retireDrainedTranscript(instanceId, sessionId, transcript)
|
||||
}
|
||||
|
||||
/** Finish the SDK's own settlement read for this transcript: share its
|
||||
* in-flight read when connected, start it when not, then project the
|
||||
* reconciled page without touching the display window's membership. A newer
|
||||
* admitted execution supersedes the publication; same-round activity does
|
||||
* not. */
|
||||
function completeSettlementRead(instanceId: string, sessionId: string, transcript: TranscriptEntry): void {
|
||||
const epoch = transcript.executionEpoch
|
||||
void transcript.entry.data.session.message.sync(sessionId)
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
// Publish unless a newer execution superseded this settlement:
|
||||
// same-round activity (a late terminal tool event) must not
|
||||
// suppress the repair.
|
||||
if (!isTranscriptCurrent(instanceId, sessionId, transcript)
|
||||
|| transcript.executionEpoch !== epoch) return
|
||||
projectOpenCodeMessages(instanceId, sessionId, transcript.entry.data, true)
|
||||
retireDrainedTranscript(instanceId, sessionId, transcript)
|
||||
})
|
||||
}
|
||||
|
||||
/** Non-event settlement evidence: the liveness activity read observed a run
|
||||
* the UI still believed working has settled, so the terminal event may never
|
||||
* be delivered. The observation owes the same settlement read the terminal
|
||||
* event would have finished. */
|
||||
export function observeNonEventSettlement(instanceId: string, sessionId: string): void {
|
||||
const transcript = transcriptEntries.get(messageRevisionKey(instanceId, sessionId))
|
||||
if (!transcript || !isTranscriptCurrent(instanceId, sessionId, transcript)) return
|
||||
if (!hasUnsettledToolRow(transcript.entry.data.session.message.list(sessionId))) return
|
||||
// Unlike a terminal event, nothing invalidated the SDK's message key; a key
|
||||
// an earlier sync completed would make this read a no-op.
|
||||
transcript.entry.data.session.message.invalidate(sessionId)
|
||||
completeSettlementRead(instanceId, sessionId, transcript)
|
||||
}
|
||||
|
||||
/** The SDK reducer's own settlement-refresh condition: an assistant tool whose
|
||||
* terminal event may still be owed (or was swallowed from `streaming`). */
|
||||
function hasUnsettledToolRow(messages: SessionMessageInfo[]): boolean {
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ import { fetchCommands } from "./commands"
|
|||
import { locationAuthorityKey, requestLocationOptions, toRequestLocation } from "./request-locations"
|
||||
import {
|
||||
captureOpenCodeRowSnapshot, getOpenCodeInstanceGeneration, getOpenCodeMessageRevision, getOpenCodeMutationRevision,
|
||||
mergeOpenCodeLoadedPage, seedOpenCodeSessionMessages,
|
||||
mergeOpenCodeLoadedPage, observeNonEventSettlement, seedOpenCodeSessionMessages,
|
||||
} from "./opencode-data"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
|
@ -463,6 +463,13 @@ async function refreshSessionRuntimeStatus(instanceId: string, signal?: AbortSig
|
|||
// including without a delivered SSE event; the later activity read wins.
|
||||
if (outcomes.size) active = await getRootClient(instanceId).session.active(signal ? { signal } : undefined)
|
||||
if (signal?.aborted || !generationCurrent() || instances().get(instanceId)?.client !== client) return
|
||||
// A run the UI still believed working that the final activity read does not
|
||||
// list settled during an SSE gap; its terminal event may never be delivered.
|
||||
// The observation owes the transcript's settlement read.
|
||||
const settledWithoutEvent: string[] = []
|
||||
for (const [id, baseline] of captured) {
|
||||
if (baseline.status === "working" && !Object.prototype.hasOwnProperty.call(active, id)) settledWithoutEvent.push(id)
|
||||
}
|
||||
setSessions(previous => {
|
||||
const next = new Map(previous)
|
||||
const current = new Map(next.get(instanceId) ?? [])
|
||||
|
|
@ -481,6 +488,7 @@ async function refreshSessionRuntimeStatus(instanceId: string, signal?: AbortSig
|
|||
return next
|
||||
})
|
||||
reconcilePendingSessionIndicators(instanceId)
|
||||
for (const sessionId of settledWithoutEvent) observeNonEventSettlement(instanceId, sessionId)
|
||||
return active
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { serverApi } from "../lib/api-client.ts"
|
|||
import type { Session } from "../types/session.ts"
|
||||
import { addInstance, removeInstance } from "./instances.ts"
|
||||
import { fetchSessions, refreshSessionRuntimeStatus, removeSessionRuntimeState } from "./session-api.ts"
|
||||
import { applyOpenCodeDataEvent } from "./opencode-data.ts"
|
||||
import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages, seedOpenCodeSessionMessages } from "./opencode-data.ts"
|
||||
import { handleNativeSessionEvent } from "./session-events.ts"
|
||||
import { beginSessionGenerationAdmission, hydrateSessionGenerationRecovery, sessions, setSessions, setSessionStatus } from "./session-state.ts"
|
||||
import { messageStoreBus } from "./message-v2/bus.ts"
|
||||
|
|
@ -312,4 +312,99 @@ describe("native-outcome recovery through real session stores", () => {
|
|||
assert.equal(fixture.current()?.generationRecovery, null)
|
||||
} finally { fixture.cleanup() }
|
||||
})
|
||||
|
||||
it("heals a stranded transcript when liveness observes a settlement without its terminal event", async () => {
|
||||
const id = "recovery-liveness-settlement-read"
|
||||
const fixture = setup(id)
|
||||
// The transcript strands a tool at "streaming" (its terminal tool event and
|
||||
// the execution settlement both fell inside a disconnect window), the
|
||||
// persisted page holds the completed truth, and the liveness activity read
|
||||
// is the only settlement evidence.
|
||||
const authoritative = {
|
||||
id: "assistant", type: "assistant", agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
time: { created: 1, completed: 3 },
|
||||
content: [{
|
||||
type: "tool", id: "tool", name: "bash", time: { created: 2, completed: 3 },
|
||||
state: { status: "completed", input: { command: "pwd" }, metadata: {}, content: [{ type: "text", text: "ok" }] },
|
||||
}],
|
||||
}
|
||||
let messageReads = 0
|
||||
;(fixture.client as any).message = { list: async () => {
|
||||
messageReads += 1
|
||||
return { data: [authoritative], cursor: {} }
|
||||
} }
|
||||
;(fixture.client.session as any).inbox = { list: async () => [] }
|
||||
;(fixture.client as any).permission = { list: async () => [] }
|
||||
;(fixture.client.session as any).form = { list: async () => [] }
|
||||
const base = { sessionID: "session", assistantMessageID: "assistant" }
|
||||
const apply = (type: string, data: Record<string, unknown>, created = 1) =>
|
||||
applyOpenCodeDataEvent(id, "/work", { id: type, type, created, data } as any)
|
||||
// An opened session's reducer has completed its first message sync.
|
||||
await seedOpenCodeSessionMessages(id, "session", "/work", [])
|
||||
try {
|
||||
apply("session.step.started", { ...base, agent: "build", model: { providerID: "provider", id: "model" } }, 1)
|
||||
apply("session.tool.input.started", { ...base, id: "tool", name: "bash" }, 2)
|
||||
const data = apply("session.tool.success", { ...base, id: "tool", content: [{ type: "text", text: "ok" }], metadata: {} }, 3)
|
||||
projectOpenCodeMessages(id, "session", data)
|
||||
assert.equal((messageStoreBus.getOrCreate(id).getMessage("assistant")?.parts.tool?.data as any)?.state?.status, "pending")
|
||||
setSessionStatus(id, "session", "working")
|
||||
|
||||
await refreshSessionRuntimeStatus(id)
|
||||
|
||||
assert.equal(fixture.current()?.status, "idle")
|
||||
for (let turn = 0; turn < 500 && !((messageStoreBus.getOrCreate(id).getMessage("assistant")?.parts.tool?.data as any)?.state?.status === "completed"); turn++) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
const healed = messageStoreBus.getOrCreate(id).getMessage("assistant")
|
||||
assert.equal((healed?.parts.tool?.data as any)?.state?.status, "completed", "the liveness observation finished the settlement read")
|
||||
assert.equal((healed?.parts.tool?.data as any)?.state?.output, "ok")
|
||||
assert.equal(messageReads, 1)
|
||||
} finally {
|
||||
destroyOpenCodeData(id)
|
||||
fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it("reads no messages for a session the activity map still lists", async () => {
|
||||
const id = "recovery-liveness-still-active"
|
||||
const fixture = setup(id)
|
||||
const authoritative = {
|
||||
id: "assistant", type: "assistant", agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
time: { created: 1, completed: 3 },
|
||||
content: [{
|
||||
type: "tool", id: "tool", name: "bash", time: { created: 2, completed: 3 },
|
||||
state: { status: "completed", input: { command: "pwd" }, metadata: {}, content: [{ type: "text", text: "ok" }] },
|
||||
}],
|
||||
}
|
||||
let messageReads = 0
|
||||
;(fixture.client as any).message = { list: async () => {
|
||||
messageReads += 1
|
||||
return { data: [authoritative], cursor: {} }
|
||||
} }
|
||||
;(fixture.client.session as any).inbox = { list: async () => [] }
|
||||
;(fixture.client as any).permission = { list: async () => [] }
|
||||
;(fixture.client.session as any).form = { list: async () => [] }
|
||||
const base = { sessionID: "session", assistantMessageID: "assistant" }
|
||||
const apply = (type: string, data: Record<string, unknown>, created = 1) =>
|
||||
applyOpenCodeDataEvent(id, "/work", { id: type, type, created, data } as any)
|
||||
try {
|
||||
apply("session.step.started", { ...base, agent: "build", model: { providerID: "provider", id: "model" } }, 1)
|
||||
apply("session.tool.input.started", { ...base, id: "tool", name: "bash" }, 2)
|
||||
const data = apply("session.tool.success", { ...base, id: "tool", content: [{ type: "text", text: "ok" }], metadata: {} }, 3)
|
||||
projectOpenCodeMessages(id, "session", data)
|
||||
setSessionStatus(id, "session", "working")
|
||||
fixture.client.session.active = async () => ({ session: {} })
|
||||
|
||||
await refreshSessionRuntimeStatus(id)
|
||||
|
||||
assert.equal(fixture.current()?.status, "working")
|
||||
assert.equal(messageReads, 0, "an observed run reads no transcript page")
|
||||
assert.equal((messageStoreBus.getOrCreate(id).getMessage("assistant")?.parts.tool?.data as any)?.state?.status, "pending")
|
||||
} finally {
|
||||
destroyOpenCodeData(id)
|
||||
fixture.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue