diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index edda80a2..48a8449e 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -25,6 +25,7 @@ import { ember, teal } from '../../lib/palette'; import { createDefaultSessionStore, createEmptySession, + appendMessage, type SessionSnapshot, type SessionStore, } from '../../lib/sessionStore'; @@ -52,6 +53,16 @@ import { discardPendingModelChange, } from '../../lib/harnessHostModelPersist'; import { paintQuotaAfterRebuild, tryLocalSave } from '../../lib/hostQuotaError'; +import { + TURN_QUEUE_DRAIN_MAX_ATTEMPTS, + queueAppend, + queueHydratePlan, + queueOf, + queueRestoreHead, + rearmQueueFromMirror, + removeQueuedText, + type QueueHydrateKind, +} from '../../lib/turnQueue'; import { AUTO_CONTINUE_PROMPT, migrateAutoContinueFlag, @@ -223,6 +234,19 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { * operator submit. Not persisted. */ const didAutoContinueBySessionRef = useRef(new Map()); + /** + * backend-agents F21 (plan #815) — per-session failed-queue-start attempts. + * A persisted queue item whose POST /api/turns start failed (non-durable + * error: pre-header network/5xx/subscribe-fail — never a server-side run) + * is restored to the Wasm band head (`queuedInsertFront`) and the mirror + * (`queueRestoreHead`). Failed-start `setFailLifecycle` arms promote-gate + * false + Error, so this does **not** auto-promote on a later poll tick; + * retries are Play / a later Ready that allows promote. This in-memory + * counter bounds those host-side retries per session. Cleared when a queue + * item durably starts and on give-up (drop-with-paint resets the budget). + * A reload starts a fresh budget (the mirror re-arms with a fresh 5). + */ + const drainAttemptsRef = useRef(new Map()); /** * Plan #813 (E19) — SSE frames **this JS heap** applied for the current * `turnRunId`. Null after F5 / adopt / switch (ring rebuilt from Blob). @@ -268,12 +292,34 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { const [personaPick, setPersonaPick] = useState(undefined); const hydrateRingWindow = useCallback( - (bridge: HarnessBridge, session: SessionSnapshot, windowStart: number) => { + ( + bridge: HarnessBridge, + session: SessionSnapshot, + windowStart: number, + kind: QueueHydrateKind = 'cold', + ) => { + const plan = queueHydratePlan(kind); const start = pushSessionToBridge(bridge, session, { clear: true, windowStart, + ...(plan.preserveQueue ? { preserveQueue: true } : {}), }); ringWindowStartRef.current = start; + // ── backend-agents F21 (plan #815): reload hydration ── + // Cold (boot/adopt/switch): default `hydrateMessages` clear wipes the + // Wasm submit FIFO; re-arm it from the persisted mirror (`session.queue`). + // Live (Load-earlier / needSnap): `inv_clear_ring` keeps the FIFO and + // we must NOT re-arm — a just-promoted head is already out of the band + // and still in the mirror until runPrompt strips it (adversarial #901 + // HEAD Major). Guards inside rearm: skip when the Wasm queue is + // non-empty and on any insert reject (fail-closed). + if (plan.rearm) { + try { + rearmQueueFromMirror(bridge, session); + } catch { + /* torn-down bridge / stub without queue exports */ + } + } return start; }, [], @@ -490,6 +536,19 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { const attaching = attach != null; const sendWhileRunning = opts?.attach == null && attaching && (prompt ?? '').trim().length > 0; + // ── backend-agents F21 (plan #815): submit while a run is live ── + // The prompt did NOT start a turn (it joins the Wasm band as a queued + // follow-up); persist it into the mirror so it survives a reload. The + // drain-start reconcile below removes it again when its own turn is + // accepted. Host-known items only (band-internal enqueues are not + // host-observable without a protocol bump — documented residual). + if (sendWhileRunning) { + const liveNow = sessionRef.current; + const p = (prompt ?? '').trim(); + if (p && !(liveNow.queue ?? []).includes(p)) { + persist(queueAppend(liveNow, p)); + } + } const modelId = bridge.getSelectedModel(); if (!attaching && !modelId) { setHostNote('No model selected — catalog empty, failed to load, or not granted.'); @@ -548,6 +607,20 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { setHostNote(null); try { + // ── backend-agents F21 (adversarial #901 Major L1) ── + // Strip the drained prompt from the mirror BEFORE runHarnessTurn so + // onTurnStarted → onSessionPatch cannot persist the in-flight prompt. + // A crash between accept and terminal would otherwise re-arm it and + // double-POST on the next Ready. Attach / send-while-running leaves + // the mirror alone (that call did not start this prompt's turn). + const pendingText = (prompt ?? '').trim(); + const drainingQueued = + !attaching && + pendingText.length > 0 && + (queueOf(sessionRef.current) ?? []).includes(pendingText); + if (drainingQueued) { + persist(removeQueuedText(sessionRef.current, pendingText)); + } const { result, session: next } = await runHarnessTurn( bridge, sessionRef.current, @@ -572,8 +645,54 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { ...(attach ? { attach } : {}), }, ); + // ── backend-agents F21 (plan #815): persisted submit-queue reconcile ── + // The drain-start strip above already dropped the prompt from the + // mirror (so mid-turn patches never carry it). This block only: + // - durable start (result.ok OR blended x-workflow-run-id): reset + // the failed-start budget; mirror already matches. + // - failed start before any durable begin: restore the head + // (queueRestoreHead persist + Wasm queuedInsertFront) and count + // a failed attempt; give-up paints an Error (already stripped). + // - attach / drain-attach: untouched (drainingQueued is false). + let reconciled = next; + if (!attaching) { + // AgentFailure/AgentSuccess blend `turnRunId` from the response + // header (post-headers aborts included, adversarial #844); the + // legacy chat result never carries one. + const resultRunId = + 'turnRunId' in result && typeof result.turnRunId === 'string' + ? result.turnRunId + : undefined; + if (result.ok || resultRunId !== undefined) { + drainAttemptsRef.current.delete(reconciled.id); + } else if (drainingQueued) { + const attempts = + (drainAttemptsRef.current.get(reconciled.id) ?? 0) + 1; + if (attempts >= TURN_QUEUE_DRAIN_MAX_ATTEMPTS) { + // Give-up: already stripped at drain-start; paint, never silent. + drainAttemptsRef.current.delete(reconciled.id); + const dropLine = `Queued prompt dropped after ${attempts} failed starts: ${result.error}`; + try { + bridge.pushMessage(MessageKind.Error, dropLine); + } catch { + /* torn-down bridge */ + } + // F21 adversarial #901 Minor: persist the Error so F5 is not silent. + reconciled = appendMessage(reconciled, 'error', dropLine); + } else { + // Defer: persist + re-arm the Wasm band head. + drainAttemptsRef.current.set(reconciled.id, attempts); + reconciled = queueRestoreHead(reconciled, pendingText); + try { + bridge.queuedInsertFront(pendingText); + } catch { + /* torn-down bridge — mirror is already restored */ + } + } + } + } if (turnEpochRef.current !== epoch) { - persistTurn(next, next.turnStatus !== 'running'); + persistTurn(reconciled, reconciled.turnStatus !== 'running'); return; } // Plan #616 (source #610): fold the LIVE selection into the snapshot before @@ -582,8 +701,8 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // just carries that same truth forward into the snapshot). const liveId = bridge.getSelectedModel(); const folded: SessionSnapshot = liveId - ? { ...next, selectedModel: liveId } - : next; + ? { ...reconciled, selectedModel: liveId } + : reconciled; // Always persist — including user Stop/cancel (and late abort after a finished // stream). Dropping session on signal.aborted left SessionStore behind Wasm: // Load earlier / refresh could wipe the cancelled turn from the ring. @@ -596,6 +715,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { } else { heapAppliedRef.current = null; } + // Adversarial #857: Send-while-running that finished the run (`done` / // 404 / post-start SSE error) re-POSTs the remapped prompt — C15 409 // no longer applies. Wasm follow-up was stripped; pushUser paints it. @@ -990,7 +1110,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { if (switched !== 'switched' && b.takePendingLoadEarlier()) { const session = sessionRef.current; const nextStart = earlierRingStart(ringWindowStartRef.current); - hydrateRingWindow(b, session, nextStart); + hydrateRingWindow(b, session, nextStart, 'live'); // Adversarial #870: Load-earlier `clear:true` wipes a ring-only // Error; re-paint if the once-flag is still set. Do not fold // this into hydrateRingWindow — adopt paints from the @@ -1008,7 +1128,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { const latest = latestRingStart(sessionRef.current.messages.length); const needSnap = ringWindowStartRef.current !== latest; if (needSnap) { - hydrateRingWindow(b, sessionRef.current, latest); + hydrateRingWindow(b, sessionRef.current, latest, 'live'); paintQuotaAfterRebuild( b, localSaveQuotaWarnedRef, @@ -1166,6 +1286,8 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // Adversarial #844: mark discarded BEFORE remove so a late persistTurn // preserve PUT cannot LWW-upsert this row back into the picker. discardedSessionIdsRef.current.add(clearedId); + // F21: drop the cleared session's failed-drain budget (fresh session). + drainAttemptsRef.current.delete(clearedId); // INTENTIONAL ack-only (not flushPendingThenRestore). Clear deletes this // row. Fold-after-remove resurrects via a new-epoch PUT; fold-before-remove // is a wasted PUT then DELETE. New/switch flush; Clear acks. See diff --git a/docs/session-model.md b/docs/session-model.md index e621d627..a001a4bf 100644 --- a/docs/session-model.md +++ b/docs/session-model.md @@ -38,15 +38,22 @@ missing) but the Redis envelope is still `running` with a `turnRunId`, boot overlays those three turn carriers onto the kept local snapshot, keeps `?s=` pinned, and cold-attaches. Messages stay the local (or LWW-winning) transcript until attach SSE catches up. The Blob object at `transcriptPointer` is the -**latest** transcript chunk (`id`, `updatedAt`, `messages`, optional `prev`, -optional `depth`). -Worker persist writes **this-run messages** plus `prev` pointing at the previous +**latest** transcript chunk (`id`, `updatedAt`, `messages`, optional `queue`, +optional `prev`, optional `depth`). `queue` is the F21 persisted submit-queue +mirror (host-known prompts not yet durably started; sanitized on read; omitted += no queue). It is first-class transcript-body state, not `meta`, and must be +copy-forwarded onto worker this-run chunks (minus this-run's user prompt) and +folded by host `trimForCloudPut`. Same-id adopt field-merges it with local +(`mergeAdoptedUsage`) so a newer worker clock cannot drop a `queueAppend` that +lost the coalesced-PUT race, and a stale-long server queue cannot re-arm an +in-flight drain. Worker persist writes **this-run messages** plus `prev` pointing at the previous object and `depth` (1-based length of the chain ending at that object). Persist is head-only: it will not append when `depth` is already **256**. Legacy / host-flattened objects omit `prev` and `depth` and are a one-node chain. Reconstruct walks `prev` (max **256** objects, each id bound to this session) and suffix-merges oldest→newest. Host terminal PUT may **flatten** to a full -trimmed snapshot with `prev` omitted (new root). Extra keys are ignored. The +trimmed snapshot with `prev` omitted (new root). Unknown extra keys besides +`queue` are ignored. The worker writes a chunk after the first model delta of a turn that still has tools to run, after each successful tool **batch**, and when a model round has no tools (the turn is finished). Mid-turn diff --git a/lib/agent/turnPersistSeam.test.ts b/lib/agent/turnPersistSeam.test.ts index 1a6c4403..e48701c3 100644 --- a/lib/agent/turnPersistSeam.test.ts +++ b/lib/agent/turnPersistSeam.test.ts @@ -24,6 +24,7 @@ import { reachableImports } from '../workflows/staticGraph'; import { parseCloudSessionSnapshot } from '../sessionRepository'; import { reconstructTranscriptChain } from '../sessions/transcriptChunks'; import { HARNESS_SESSION_MAX_BODY_BYTES, TRANSCRIPT_CHUNK_WALK_MAX } from '../sessionCloudCaps'; +import { formatPromptWithHistory, makeMessage } from '../sessionStore'; const scope: ObjectScope = { tenantId: 'tenant-1', @@ -502,6 +503,167 @@ describe('createTurnPersistSeam — real B7/B8/B6 persist (backend-agents B13)', ]); }); + it('copy-forwards session.queue onto worker this-run chunks (F21 adversarial #901)', async () => { + const { seam, blobStore } = await makeSeam(); + const first = await seam.persist({ + turnRunId: realRunId, + deltas: [{ d: 1 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [ + { id: 'cp_0', role: 'user', text: 'turn-1 user', at: 1 }, + { id: 'cp_1', role: 'assistant', text: 'turn-1 assistant', at: 2 }, + ], + queue: ['follow-up B', 'follow-up C'], + }), + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + const firstBody = JSON.parse((await blobStore.read(first.objectId!)) ?? 'null') as { + queue?: string[]; + }; + expect(firstBody.queue).toEqual(['follow-up B', 'follow-up C']); + + const second = await seam.persist({ + turnRunId: 'wr_0000_2a3b4c5d6e7f', + deltas: [{ d: 2 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [ + { id: 'cp_0', role: 'user', text: 'turn-2 user', at: 1 }, + { id: 'cp_1', role: 'assistant', text: 'turn-2 assistant', at: 2 }, + ], + }), + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + const chunk = JSON.parse((await blobStore.read(second.objectId!)) ?? 'null') as { + queue?: string[]; + prev?: string; + }; + expect(chunk.queue).toEqual(['follow-up B', 'follow-up C']); + expect(typeof chunk.prev).toBe('string'); + const parsed = parseCloudSessionSnapshot(chunk, scope.sessionId); + expect(parsed?.queue).toEqual(['follow-up B', 'follow-up C']); + }); + + it('copy-forward drops this-run user prompt from the queue (F21 adversarial #901 HEAD)', async () => { + const { seam, blobStore } = await makeSeam(); + const first = await seam.persist({ + turnRunId: realRunId, + deltas: [{ d: 1 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [ + { id: 'cp_0', role: 'user', text: 'turn-1 user', at: 1 }, + { id: 'cp_1', role: 'assistant', text: 'turn-1 assistant', at: 2 }, + ], + queue: ['follow-up B', 'follow-up C'], + }), + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + + const second = await seam.persist({ + turnRunId: 'wr_0000_2a3b4c5d6e7f', + deltas: [{ d: 2 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [ + { id: 'cp_0', role: 'user', text: 'follow-up B', at: 1 }, + { id: 'cp_1', role: 'assistant', text: 'turn-2 assistant', at: 2 }, + ], + }), + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + const chunk = JSON.parse((await blobStore.read(second.objectId!)) ?? 'null') as { + queue?: string[]; + }; + expect(chunk.queue).toEqual(['follow-up C']); + const parsed = parseCloudSessionSnapshot(chunk, scope.sessionId); + expect(parsed?.queue).toEqual(['follow-up C']); + }); + + it('copy-forward strips a history-folded this-run userMessage (F21 adversarial #901)', async () => { + const { seam, blobStore } = await makeSeam(); + const first = await seam.persist({ + turnRunId: realRunId, + deltas: [{ d: 1 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [ + { id: 'cp_0', role: 'user', text: 'turn-1 user', at: 1 }, + { id: 'cp_1', role: 'assistant', text: 'turn-1 assistant', at: 2 }, + ], + queue: ['follow-up B', 'follow-up C'], + }), + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + + const folded = formatPromptWithHistory( + [makeMessage('user', 'turn-1 user'), makeMessage('assistant', 'turn-1 assistant')], + 'follow-up B', + ); + expect(folded).not.toBe('follow-up B'); + + const second = await seam.persist({ + turnRunId: 'wr_0000_2a3b4c5d6e7f', + deltas: [{ d: 2 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [ + { id: 'cp_0', role: 'user', text: folded, at: 1 }, + { id: 'cp_1', role: 'assistant', text: 'turn-2 assistant', at: 2 }, + ], + }), + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + const chunk = JSON.parse((await blobStore.read(second.objectId!)) ?? 'null') as { + queue?: string[]; + }; + expect(chunk.queue).toEqual(['follow-up C']); + const parsed = parseCloudSessionSnapshot(chunk, scope.sessionId); + expect(parsed?.queue).toEqual(['follow-up C']); + }); + + it('copy-forward unsets the carrier when this-run user was the last queued item', async () => { + const { seam, blobStore } = await makeSeam(); + const first = await seam.persist({ + turnRunId: realRunId, + deltas: [{ d: 1 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [ + { id: 'cp_0', role: 'user', text: 'turn-1 user', at: 1 }, + { id: 'cp_1', role: 'assistant', text: 'turn-1 assistant', at: 2 }, + ], + queue: ['follow-up B'], + }), + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + + const second = await seam.persist({ + turnRunId: 'wr_0000_2a3b4c5d6e7f', + deltas: [{ d: 2 }], + content: JSON.stringify({ + id: scope.sessionId, + messages: [{ id: 'cp_0', role: 'user', text: 'follow-up B', at: 1 }], + }), + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + const chunk = JSON.parse((await blobStore.read(second.objectId!)) ?? 'null') as { + queue?: string[]; + }; + expect('queue' in chunk).toBe(false); + const parsed = parseCloudSessionSnapshot(chunk, scope.sessionId); + expect('queue' in (parsed ?? {})).toBe(false); + }); + it('host-shaped prior that already ends with this turn is not duplicated', async () => { const blobStore = new MemoryBlobTranscriptStore(); const envelopeStore = new MemorySessionStore(); diff --git a/lib/agent/turnPersistSeam.ts b/lib/agent/turnPersistSeam.ts index 1ee3b469..09d0ba32 100644 --- a/lib/agent/turnPersistSeam.ts +++ b/lib/agent/turnPersistSeam.ts @@ -72,6 +72,7 @@ import type { PersistStepSeam, } from '../workflows/persistStep'; import { persistOverlayStatus, stampSnapshotUpdatedAt } from '../workflows/persistStep'; +import { queueTextFromUserContent, queueWithoutText, sanitizeQueue } from '../turnQueue'; /** Worker-authored envelope clock source for the terminal B8 overlay (LWW). */ export type OverlayClock = (storedUpdatedAt: number) => number; @@ -105,6 +106,24 @@ export interface TurnPersistSeamDeps { const toMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); +/** F21 queue mirror on a snapshot-shaped body; undefined = no/poisoned carrier. */ +function queueFromBody(body: unknown): string[] | undefined { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return undefined; + return sanitizeQueue((body as Record).queue); +} + +/** First non-blank this-run user prompt (unwraps a history-fold userMessage). */ +function firstUserText( + messages: Array<{ role: string; text: string }>, +): string | undefined { + for (const m of messages) { + if (m.role !== 'user') continue; + const t = queueTextFromUserContent(m.text); + if (t) return t; + } + return undefined; +} + /** This-run snapshot + optional `prev`/`depth`; non-snapshot test bodies keep stamp-only. */ function buildThisRunChunk(opts: { content: string; @@ -112,6 +131,8 @@ function buildThisRunChunk(opts: { updatedAt: number; prev: string | undefined; depth: number | undefined; + /** Prior blob's sanitized `queue` (copy-forward when this-run content omits it). */ + priorQueue?: string[]; }): string | null { let parsed: unknown; try { @@ -133,6 +154,20 @@ function buildThisRunChunk(opts: { }; if (opts.prev) rec.prev = opts.prev; if (opts.depth !== undefined) rec.depth = opts.depth; + // F21 adversarial #901: worker this-run chunks must copy-forward the + // submit-queue mirror (field rides the transcript blob, not meta). Dropping + // it lets a cloud adopt wipe a localStorage re-arm. Copy-forward of the + // *in-flight* prompt (HEAD Major): persistStep content is `{id, messages}` + // so fromContent is always unset; a coalesced host strip PUT cannot beat + // B7. Strip this-run's user prompt (removeQueuedText semantics) so a drain + // that has durably started cannot re-arm itself on F5. Production + // userMessage is a formatPromptWithHistory fold, not the raw queue item — + // queueTextFromUserContent unwraps the last `User:` line. + const fromContent = queueFromBody(parsed); + let queue = fromContent ?? opts.priorQueue; + const started = firstUserText(incoming); + if (started) queue = queueWithoutText(queue, started); + if (queue !== undefined && queue.length > 0) rec.queue = queue; return JSON.stringify(rec); } @@ -231,6 +266,7 @@ export function createTurnPersistSeam( // walking (reconstruct fail-closes the whole blob at 256). let chunkPrev: string | undefined; let chunkDepth: number | undefined; + let priorQueue: string[] | undefined; const pointer = stored?.meta?.transcriptPointer; if (typeof pointer === 'string' && isObjectIdBoundTo(pointer, scope)) { let raw: string | null; @@ -288,6 +324,7 @@ export function createTurnPersistSeam( } chunkPrev = pointer; chunkDepth = chainLen + 1; + priorQueue = queueFromBody(parsed); } } @@ -297,6 +334,7 @@ export function createTurnPersistSeam( updatedAt, prev: chunkPrev, depth: chunkDepth, + priorQueue, }); if (stampedRaw === null) { return await failWrite({ diff --git a/lib/detachTurn.test.ts b/lib/detachTurn.test.ts index d631cb32..bec957a4 100644 --- a/lib/detachTurn.test.ts +++ b/lib/detachTurn.test.ts @@ -383,7 +383,7 @@ describe('HarnessHost detach wiring source-lock (plan #812 D18)', () => { // still-running post-turn persist also skips paint (hot resume attach). expect(run).toContain('onSessionPatch: (s) => persistTurn(s, false)'); expect(run).toContain("persistTurn(folded, folded.turnStatus !== 'running')"); - expect(run).toContain("persistTurn(next, next.turnStatus !== 'running')"); + expect(run).toContain("persistTurn(reconciled, reconciled.turnStatus !== 'running')"); }); it('preserve + finally mint-bind first-turn UUID (adversarial #844)', () => { diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 4e5b261f..f5e2eec9 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -4974,11 +4974,14 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { ]); }); - it('test 2h: F5 cold attach (empty prompt) still clears the submit FIFO', async () => { + it('test 2h: F5 cold attach (empty prompt) keeps the submit FIFO (F21 adversarial #901)', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); bridge.pushMessage(MessageKind.User, 'hello'); - exp.__queue.push('stale from previous session'); + // hydrateRingWindow re-armed these from the persisted mirror; kickColdAttach + // must not wipe them (inv_clear_ring, not inv_clear_messages). + exp.__queue.push('follow-up B'); + exp.__queue.push('follow-up C'); const session = runningSession([['user', 'hello']]); await runHarnessTurn(bridge, session, '', { attach: { @@ -4993,7 +4996,7 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { }, }, }); - expect(exp.__queue).toEqual([]); + expect(exp.__queue).toEqual(['follow-up B', 'follow-up C']); }); it('test 2i: Send-while-running 503 keeps FIFO and arms promote false', async () => { diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 39a4f843..8e5b483b 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -1174,9 +1174,12 @@ export async function runHarnessTurn( const attachOpts = opts?.attach; const dedup = attachOpts?.dedup === true; // Send-while-running (non-empty composer / promoted queue head) is a live - // session: keep the Wasm FIFO. kickColdAttach / hot-resume microtask pass - // empty prompt — F5 / switch may clear the (empty) queue. - const preserveQueue = attaching && (rawPrompt ?? '').trim().length > 0; + // session: keep the Wasm FIFO. F21 (adversarial #901): kickColdAttach's + // empty prompt is ALSO a live session — hydrateRingWindow already + // clearMessages'd + re-armed from the persisted mirror; a preserveQueue + // false here would inv_clear_messages the FIFO we just restored. + // F5/switch stale-FIFO wipe is hydrateRingWindow's job, not this attach. + const preserveQueue = attaching; let heapC = attachOpts != null ? (sanitizeTurnStreamCursor(attachOpts.startIndex) ?? 0) : 0; @@ -1228,7 +1231,7 @@ export async function runHarnessTurn( * and let skip see an empty this-run window. Always re-hydrate the prefix * (even with no suffix) so a Wasm follow-up cannot remain as the last user * and so boot-scheduled images are re-put after the ring clear - * (`inv_clear_ring` when Send-while-running, else `inv_clear_messages`). + * (`inv_clear_ring` — attach always preserves the FIFO; F21 adversarial #901). */ let coldBackup: typeof next.messages | null = null; if (attaching && dedup) { diff --git a/lib/hostQuotaError.test.ts b/lib/hostQuotaError.test.ts index e50eceaf..2af8f913 100644 --- a/lib/hostQuotaError.test.ts +++ b/lib/hostQuotaError.test.ts @@ -204,7 +204,7 @@ describe('HarnessHost wiring lock — quota save (#870)', () => { it('post-turn persistTurn does not paint while the durable turn is still running', () => { expect(src).toContain("persistTurn(folded, folded.turnStatus !== 'running')"); - expect(src).toContain("persistTurn(next, next.turnStatus !== 'running')"); + expect(src).toContain("persistTurn(reconciled, reconciled.turnStatus !== 'running')"); }); it('adopt and Clear rebuild the ring before painting quota', () => { @@ -215,10 +215,10 @@ describe('HarnessHost wiring lock — quota save (#870)', () => { it('Load earlier and needSnap re-paint after hydrate so a wiped row does not spend the episode', () => { expect(src).toMatch( - /hydrateRingWindow\(b, session, nextStart\);[\s\S]{0,500}?paintQuotaAfterRebuild\(\s*b,\s*localSaveQuotaWarnedRef,\s*localSaveQuotaWarnedRef\.current,\s*session,/, + /hydrateRingWindow\(b, session, nextStart, 'live'\);[\s\S]{0,500}?paintQuotaAfterRebuild\(\s*b,\s*localSaveQuotaWarnedRef,\s*localSaveQuotaWarnedRef\.current,\s*session,/, ); expect(src).toMatch( - /hydrateRingWindow\(b, sessionRef\.current, latest\);[\s\S]{0,400}?paintQuotaAfterRebuild\(\s*b,\s*localSaveQuotaWarnedRef,\s*localSaveQuotaWarnedRef\.current,\s*sessionRef\.current,/, + /hydrateRingWindow\(b, sessionRef\.current, latest, 'live'\);[\s\S]{0,400}?paintQuotaAfterRebuild\(\s*b,\s*localSaveQuotaWarnedRef,\s*localSaveQuotaWarnedRef\.current,\s*sessionRef\.current,/, ); }); }); diff --git a/lib/sessionRepository.test.ts b/lib/sessionRepository.test.ts index 3cf1a7bb..b4f60f01 100644 --- a/lib/sessionRepository.test.ts +++ b/lib/sessionRepository.test.ts @@ -24,6 +24,8 @@ import { type SessionSummary, } from './sessionRepository'; import type { SessionSnapshot } from './sessionStore'; +import { formatPromptWithHistory, makeMessage } from './sessionStore'; +import { flattenReconstructedBody } from './sessions/transcriptChunks'; function snap( partial: Partial & { messages?: SessionSnapshot['messages'] }, @@ -970,6 +972,47 @@ describe('mergeAdoptedUsage (plan #626 test 5)', () => { expect(out.usage).toEqual(usageA); expect(out.id).toBe('b'); }); + + it('same id: local queueAppend survives a worker head that omitted it (F21 adversarial #901)', () => { + const out = mergeAdoptedUsage( + { id: 'a', updatedAt: 10, messages: [{ id: 'm1', role: 'user', text: 'turn-1 user', at: 1 }] }, + { id: 'a', updatedAt: 5, messages: [], queue: ['follow-up B'] }, + ); + expect(out.queue).toEqual(['follow-up B']); + }); + + it('same id: stale-long server queue strips the in-flight folded last user', () => { + const folded = formatPromptWithHistory( + [makeMessage('user', 'turn-1 user'), makeMessage('assistant', 'turn-1 assistant')], + 'follow-up B', + ); + const out = mergeAdoptedUsage( + { + id: 'a', + updatedAt: 10, + messages: [{ id: 'm1', role: 'user', text: folded, at: 1 }], + queue: ['follow-up B', 'follow-up C'], + }, + { id: 'a', updatedAt: 5, messages: [], queue: ['follow-up C'] }, + ); + expect(out.queue).toEqual(['follow-up C']); + }); + + it('different id: server queue only (no merge)', () => { + const out = mergeAdoptedUsage( + { id: 'b', updatedAt: 10, messages: [], queue: ['x'] }, + { id: 'a', updatedAt: 5, messages: [], queue: ['y'] }, + ); + expect(out.queue).toEqual(['x']); + expect(out.id).toBe('b'); + }); + + it('source-lock: same-id adopt field-merges queue via mergeQueues (F21 adversarial #901)', () => { + const src = readFileSync('lib/sessionRepository.ts', 'utf8'); + const fn = src.slice(src.indexOf('export function mergeAdoptedUsage')); + expect(fn).toContain('mergeQueues('); + expect(fn).toContain('lastUserContent(server.messages)'); + }); }); describe('parseSessionSummaryList', () => { @@ -1644,3 +1687,90 @@ describe('createHttpSessionRepository — envelope carrier (phase 0 #515)', () = expect(res).toEqual({ action: 'notfound' }); }); }); + +describe('backend-agents F21 — persisted submit-queue mirror (plan #815)', () => { + const idA = '11111111-1111-4111-8111-111111111111'; + const UPLOAD_URL = 'https://blob.example/upload'; + + it('trimForCloudPut folds the queue mirror into the record body; omits when absent', () => { + const out = trimForCloudPut({ + id: 's', + updatedAt: 1, + messages: [], + queue: ['one', 'two'], + }); + expect(out.queue).toEqual(['one', 'two']); + const bare = trimForCloudPut({ id: 's', updatedAt: 1, messages: [] }); + expect('queue' in bare).toBe(false); + }); + + it('trimForCloudPut drops a poisoned mirror (never PUTs junk prompts)', () => { + const out = trimForCloudPut({ + id: 's', + updatedAt: 1, + messages: [], + queue: 'not an array' as unknown as string[], + }); + expect('queue' in out).toBe(false); + }); + + it('parseCloudSessionSnapshot restores the queue mirror; poison / empty stays unset', () => { + const out = parseCloudSessionSnapshot({ + id: 'sess_x', + updatedAt: 1, + messages: [], + queue: [' alpha ', 42, ''], + }); + expect(out?.queue).toEqual(['alpha']); + + const empty = parseCloudSessionSnapshot({ + id: 'sess_x', + updatedAt: 1, + messages: [], + queue: [], + }); + expect('queue' in (empty ?? {})).toBe(false); + + const junk = parseCloudSessionSnapshot({ + id: 'sess_x', + updatedAt: 1, + messages: [], + queue: 'nope', + }); + expect('queue' in (junk ?? {})).toBe(false); + }); + + it('overlayEnvelopeMeta leaves the queue mirror untouched (meta is not its carrier)', () => { + const transcript: SessionSnapshot = { + id: 's', + updatedAt: 1, + messages: [], + queue: ['p1'], + }; + const over = overlayEnvelopeMeta(transcript, { + transcriptPointer: 'tx_1', + turnStatus: 'running', + }); + expect(over.queue).toEqual(['p1']); + }); + + it('GET flatten+parse keeps the queue mirror (F21 adversarial #901)', () => { + const flat = flattenReconstructedBody( + { + id: 'sess_x', + updatedAt: 1, + messages: [], + prev: 't_old', + depth: 2, + queue: ['follow-up B', 'follow-up C'], + }, + 'sess_x', + [{ id: 'm1', role: 'user', text: 'turn-1', at: 1 }], + ); + expect(flat.prev).toBeUndefined(); + const parsed = parseCloudSessionSnapshot(flat, 'sess_x'); + expect(parsed?.queue).toEqual(['follow-up B', 'follow-up C']); + expect(parsed?.messages.map((m) => m.text)).toEqual(['turn-1']); + }); +}); + diff --git a/lib/sessionRepository.ts b/lib/sessionRepository.ts index 5abc34cc..5e5ad7a5 100644 --- a/lib/sessionRepository.ts +++ b/lib/sessionRepository.ts @@ -36,6 +36,7 @@ import { decodeUsageMetaString, encodeUsageMetaString, } from './agent/usageSummary'; +import { mergeQueues, lastUserContent, sanitizeQueue } from './turnQueue'; // Must stay in sync with the server-side role allowlist (`harnessSessions.ts`): // a kind-7 `skill_attached` row rides the transcript the host PUTs after `/foo`, @@ -296,6 +297,12 @@ export function parseCloudSessionSnapshot( const usage = decodeUsageMetaString(meta.usage); if (usage !== undefined) snapshot.usage = usage; } + // backend-agents F21 (plan #815): restore the persisted submit-queue mirror + // from the transcript body. Fail-closed sanitize (drop blanks/over-cap items, + // cap depth); an empty/absent result stays unset — a poisoned blob can never + // inject junk prompts into the host drain. + const queue = sanitizeQueue(o.queue); + if (queue !== undefined && queue.length > 0) snapshot.queue = queue; return snapshot; } @@ -364,6 +371,12 @@ export function overlayEnvelopeMeta( if (turnStreamCursor !== undefined) out.turnStreamCursor = turnStreamCursor; else delete out.turnStreamCursor; + // NOTE: the F21 submit-queue mirror (`snapshot.queue`) rides the TRANSCRIPT + // blob body (parseCloudSessionSnapshot), NOT the envelope meta — it is + // transcript-bulk state, not a scalar carrier. overlayEnvelopeMeta must not + // clear it (meta is not the queue's carrier), so there is deliberately no + // queue handling here. + return out; } @@ -456,11 +469,15 @@ export function bootCloudSnapshot(input: { } /** - * Merge `usage` on same-id adopt so a server snapshot without `meta.usage` - * (other tab on a pre-#626 bundle, or any prior persist that omitted usage) - * does not wipe an honest local last-completed value. + * Merge `usage` and F21 `queue` on same-id adopt. * - * - Same id: `server.usage ?? local.usage` — server wins when it has one. + * - `usage`: `server.usage ?? local.usage` — server wins when it has one + * (plan #626: a server snapshot without `meta.usage` must not wipe an + * honest local last-completed value). + * - `queue`: union server+local then strip the in-flight last user + * (adversarial #901: whole-snapshot server-wins dropped a `queueAppend` + * that lost the coalesced-PUT race to a later worker B7; a stale-long + * server queue would re-arm a drain that already started). * - Different id: server-only (a switch is a different session). */ export function mergeAdoptedUsage( @@ -468,7 +485,18 @@ export function mergeAdoptedUsage( local: SessionSnapshot, ): SessionSnapshot { if (server.id === local.id) { - return { ...server, usage: server.usage ?? local.usage }; + const queue = mergeQueues( + server.queue, + local.queue, + lastUserContent(server.messages), + ); + const out: SessionSnapshot = { + ...server, + usage: server.usage ?? local.usage, + }; + if (queue !== undefined) out.queue = queue; + else delete out.queue; + return out; } return server; } @@ -497,11 +525,17 @@ export function parseSessionSummaryList(body: unknown): SessionSummary[] | null return out; } -/** The PUT wire body: `{ id, updatedAt, messages, meta? }` (P1/GAP-1 folds the session-carrier fields into `meta`). */ +/** The PUT wire body: `{ id, updatedAt, messages, queue?, meta? }` (P1/GAP-1 folds the session-carrier fields into `meta`; F21 adds the `queue` mirror). */ export type CloudPutBody = { id: string; updatedAt: number; messages: SessionMessage[]; + /** + * backend-agents F21 (plan #815) — the persisted submit-queue mirror + * (ordered host-known prompts, oldest first). Record-body (transcript-blob) + * state, never `meta` (scalar-only). Omitted = no queued prompts. + */ + queue?: string[]; meta?: { activeSandboxId?: string; logicalCwd?: string; @@ -652,11 +686,19 @@ export function trimForCloudPut( : m.text, at: m.at, })); + // backend-agents F21 (plan #815): the submit-queue mirror rides the record + // body (the transcript object on the envelope carrier / the roll-forward + // record). Re-sanitized (fail-closed) — a poisoned local value is dropped, + // never PUT. Empty mirror omits the field (absent = no queued prompts on + // this PUT). Same-id adopt does NOT treat omit as a clear — mergeQueues + // unions server+local then strips the in-flight last user (adversarial #901). + const queue = sanitizeQueue(snapshot.queue); const meta = cloudMetaFor(snapshot); const fresh = (ms: CloudPutBody['messages']): CloudPutBody => ({ id: snapshot.id, updatedAt: snapshot.updatedAt, messages: ms, + ...(queue !== undefined && queue.length > 0 ? { queue } : {}), ...(meta !== undefined ? { meta } : {}), }); @@ -1002,9 +1044,9 @@ export function createHttpSessionRepository( * with the object's `transcriptPointer`. **Fail-closed:** if the object upload * fails (non-2xx / network), the envelope pointer is NOT advanced — a later restore * can never land on a missing/empty object hole (reader's Minor L1). The transcript - * object stores the same `{ id, updatedAt, messages, meta? }` shape the full-record - * PUT sends, so the envelope read (`getEnvelope`) reconstructs the identical - * `SessionSnapshot`. + * object stores `{ id, updatedAt, messages, queue?, meta? }` (F21 `queue` mirror) + * — the same shape `trimForCloudPut` sends — so the envelope read (`getEnvelope`) + * reconstructs the identical `SessionSnapshot`. */ async function putEnvelopeOnce( id: string, diff --git a/lib/sessionStore.test.ts b/lib/sessionStore.test.ts index e3ce5209..016ec3b2 100644 --- a/lib/sessionStore.test.ts +++ b/lib/sessionStore.test.ts @@ -525,6 +525,58 @@ describe('backend-agents A1–A3 — turn-carrier local mirror sanitize', () => }); }); +describe('backend-agents F21 — persisted submit-queue local mirror (plan #815 / adversarial #901)', () => { + function installMemoryLocalStorage() { + const map = new Map(); + const ls = { + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + setItem: (k: string, v: string) => { + map.set(k, String(v)); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => { + map.clear(); + }, + }; + vi.stubGlobal('localStorage', ls); + return ls; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('LocalStorage load round-trips a sanitized queue; poison / empty stays unset', () => { + installMemoryLocalStorage(); + const key = 'test-f21-queue-key'; + localStorage.setItem( + key, + JSON.stringify({ + id: 's', + messages: [], + updatedAt: 1, + queue: [' alpha ', '', 'beta'], + }), + ); + const store = new LocalStorageSessionStore(key); + expect(store.load()?.queue).toEqual(['alpha', 'beta']); + + localStorage.setItem( + key, + JSON.stringify({ id: 's', messages: [], updatedAt: 1, queue: [] }), + ); + expect('queue' in (store.load() ?? {})).toBe(false); + + localStorage.setItem( + key, + JSON.stringify({ id: 's', messages: [], updatedAt: 1, queue: 'nope' }), + ); + expect('queue' in (store.load() ?? {})).toBe(false); + }); +}); + describe('isQuotaExceededError', () => { it('is true for QuotaExceededError name', () => { const err = new Error('The quota has been exceeded.'); diff --git a/lib/sessionStore.ts b/lib/sessionStore.ts index 0febd5db..a717de0a 100644 --- a/lib/sessionStore.ts +++ b/lib/sessionStore.ts @@ -96,6 +96,18 @@ export type SessionSnapshot = { * on poison; `0` is a valid value, preserved). */ turnStreamCursor?: number; + /** + * backend-agents F21 (plan #815) — the persisted submit-queue MIRROR: an + * ordered list of host-known prompts not yet durably started (composer + * submits made while a turn is live). Oldest first. Rides the existing + * transcript blob (localStorage JSON locally; the transcript object on the + * envelope+Blob carrier) — never a reserved `meta` key, never a secret. + * Absent = no queue. Sanitized on read via `sanitizeQueue` (lib/turnQueue.ts; + * drop blanks / over-cap items, cap depth — a poisoned value never sticks). + * AS-BUILT scope: host-known items only; Wasm-internal band enqueues are not + * host-observable without a protocol bump (documented residual on #815). + */ + queue?: string[]; }; import { @@ -109,6 +121,7 @@ import { sanitizeTurnStreamCursor, } from './sessionCloudCaps'; import { sanitizeUsageSummary } from './agent/usageSummary'; +import { sanitizeQueue } from './turnQueue'; export { MAX_MODEL_ID_LEN, isRedisSafeOpaqueId, sanitizeSessionCwd } from './sessionCloudCaps'; /** @@ -220,6 +233,7 @@ export class LocalStorageSessionStore implements SessionStore { turnRunId?: unknown; turnStatus?: unknown; turnStreamCursor?: unknown; + queue?: unknown; }; if (!data || typeof data !== 'object' || !Array.isArray(data.messages)) return null; // Tolerant: keep only safe workspace-relative cwd strings (parent #270 / phase 2), @@ -241,6 +255,7 @@ export class LocalStorageSessionStore implements SessionStore { turnRunId: rawTurnRunId, turnStatus: rawTurnStatus, turnStreamCursor: rawTurnStreamCursor, + queue: rawQueue, ...rest } = data; const cwd = sanitizeSessionCwd(rawCwd); @@ -262,6 +277,11 @@ export class LocalStorageSessionStore implements SessionStore { const turnRunId = sanitizeTurnRunId(rawTurnRunId); const turnStatus = sanitizeTurnStatus(rawTurnStatus); const turnStreamCursor = sanitizeTurnStreamCursor(rawTurnStreamCursor); + // backend-agents F21 (plan #815): the persisted queue mirror re-sanitizes + // on local load (drop blanks/over-cap items, cap depth) so a stale or + // hand-edited localStorage value never sticks. An EMPTY sanitized list + // drops to unset (absent carrier), matching removeQueuedText. + const queue = sanitizeQueue(rawQueue); const out: SessionSnapshot = { ...rest } as SessionSnapshot; if (cwd !== undefined) out.cwd = cwd; if (activeSandboxId !== undefined) out.activeSandboxId = activeSandboxId; @@ -278,6 +298,8 @@ export class LocalStorageSessionStore implements SessionStore { else delete out.turnStatus; if (turnStreamCursor !== undefined) out.turnStreamCursor = turnStreamCursor; else delete out.turnStreamCursor; + if (queue !== undefined && queue.length > 0) out.queue = queue; + else delete out.queue; return out; } catch { return null; diff --git a/lib/sessions/transcriptChunks.test.ts b/lib/sessions/transcriptChunks.test.ts index 5db337f8..5077ff90 100644 --- a/lib/sessions/transcriptChunks.test.ts +++ b/lib/sessions/transcriptChunks.test.ts @@ -197,4 +197,22 @@ describe('flatten', () => { expect(body.id).toBe(SESSION); expect((body.messages as { text: string }[])[0].text).toBe('x'); }); + + it('keeps F21 queue on the head (spread, not a field whitelist) [adversarial #901]', () => { + const head = { + ...snap([msg('m1', 'x')], 't_old', 2), + queue: ['follow-up B', 'follow-up C'], + }; + const body = flattenReconstructedBody(head, SESSION, [ + msg('m1', 'x'), + msg('m2', 'y'), + ]); + expect(body.queue).toEqual(['follow-up B', 'follow-up C']); + expect(body.prev).toBeUndefined(); + expect(body.depth).toBeUndefined(); + expect((body.messages as { text: string }[]).map((m) => m.text)).toEqual([ + 'x', + 'y', + ]); + }); }); diff --git a/lib/turnQueue.test.ts b/lib/turnQueue.test.ts new file mode 100644 index 00000000..8206cb3d --- /dev/null +++ b/lib/turnQueue.test.ts @@ -0,0 +1,341 @@ +/** + * backend-agents F21 (plan #815) — persisted submit-queue mirror helpers. + * Pure unit rows: sanitize/append/remove/restore-head semantics + the + * reload re-arm (stub bridge, mirrors the lib/harnessChat.test.ts stub). + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + TURN_QUEUE_DRAIN_MAX_ATTEMPTS, + TURN_QUEUE_MAX_ITEMS, + TURN_QUEUE_TEXT_MAX_CHARS, + lastUserContent, + mergeQueues, + queueAppend, + queueClear, + queueHydratePlan, + queueOf, + queueRestoreHead, + queueWithoutText, + queueTextFromUserContent, + rearmQueueFromMirror, + removeQueuedText, + sanitizeQueue, +} from './turnQueue'; +import { createEmptySession, formatPromptWithHistory, makeMessage, type SessionSnapshot } from './sessionStore'; +import type { HarnessBridge } from './harnessBridge'; +import { HARNESS_QUEUE_MAX_ITEMS } from './harnessChat'; + +function sess(partial: Partial = {}): SessionSnapshot { + return { ...createEmptySession(), ...partial }; +} + +/** Minimal queue-surface stub (matches the lib/harnessChat.test.ts pattern). */ +function stubBridge(opts?: { queued?: number; insertOk?: boolean }) { + const inserts: string[] = []; + let queued = opts?.queued ?? 0; + const bridge = { + queuedCount: () => queued, + queuedInsertFront: (text: string) => { + inserts.push(text); + const ok = opts?.insertOk ?? true; + if (ok) queued += 1; + return ok; + }, + } as unknown as HarnessBridge; + return { bridge, inserts, queuedNow: () => queued }; +} + +describe('sanitizeQueue (F21)', () => { + it('drops non-string items from an array; undefined for non-arrays', () => { + expect(sanitizeQueue(undefined)).toBeUndefined(); + expect(sanitizeQueue('nope')).toBeUndefined(); + expect(sanitizeQueue({ 0: 'a' })).toBeUndefined(); + expect(sanitizeQueue(['a', 42, 'b'])).toEqual(['a', 'b']); + }); + + it('trims items and drops blanks', () => { + expect(sanitizeQueue([' hello ', ' ', ''])).toEqual(['hello']); + }); + + it('drops over-cap items (fail-closed, never truncates a prompt)', () => { + const big = 'x'.repeat(TURN_QUEUE_TEXT_MAX_CHARS + 1); + const ok = 'y'.repeat(TURN_QUEUE_TEXT_MAX_CHARS); + expect(sanitizeQueue([big, ok])).toEqual([ok]); + }); + + it('caps depth at TURN_QUEUE_MAX_ITEMS (Wasm MAX_ITEMS parity)', () => { + const items = Array.from( + { length: TURN_QUEUE_MAX_ITEMS + 5 }, + (_, i) => `p${i}`, + ); + const out = sanitizeQueue(items); + expect(out).toHaveLength(TURN_QUEUE_MAX_ITEMS); + expect(out?.[0]).toBe('p0'); + }); +}); + +describe('queueAppend / queueOf (F21)', () => { + it('queueOf: empty array reads as unset', () => { + expect(queueOf(sess())).toBeUndefined(); + expect(queueOf(sess({ queue: [] }))).toBeUndefined(); + expect(queueOf(sess({ queue: [' a '] }))).toEqual(['a']); + }); + + it('queueAppend appends ordered items (duplicates legal — one removal per accepted start)', () => { + let s = sess(); + s = queueAppend(s, 'first'); + s = queueAppend(s, ' second '); + expect(s.queue).toEqual(['first', 'second']); + s = queueAppend(s, 'first'); + expect(s.queue).toEqual(['first', 'second', 'first']); + }); + + it('queueAppend no-ops on blank/over-cap text and at depth cap', () => { + let s = sess(); + expect(queueAppend(s, ' ')).toBe(s); + expect(queueAppend(s, 'x'.repeat(TURN_QUEUE_TEXT_MAX_CHARS + 1))).toBe(s); + for (let i = 0; i < TURN_QUEUE_MAX_ITEMS; i++) { + s = queueAppend(s, `p${i}`); + } + expect(queueAppend(s, 'one more')).toBe(s); + expect(s.queue).toHaveLength(TURN_QUEUE_MAX_ITEMS); + }); + + it('queueAppend bumps updatedAt', () => { + const next = queueAppend(sess({ updatedAt: 1 }), 'hello'); + expect(next.updatedAt).toBeGreaterThan(1); + }); +}); + +describe('queueWithoutText (F21 adversarial #901 HEAD)', () => { + it('removes the FIRST matching copy; empty result is unset', () => { + expect(queueWithoutText(['a', 'b', 'a'], 'a')).toEqual(['b', 'a']); + expect(queueWithoutText(['only'], 'only')).toBeUndefined(); + }); + + it('no-ops (same reference) on blank / absent / missing carrier', () => { + const q = ['a']; + expect(queueWithoutText(q, '')).toBe(q); + expect(queueWithoutText(q, 'zzz')).toBe(q); + expect(queueWithoutText(undefined, 'a')).toBeUndefined(); + }); +}); + +describe('queueTextFromUserContent (F21 adversarial #901 fold unwrap)', () => { + it('passes a bare prompt through (no history)', () => { + expect(queueTextFromUserContent('follow-up B')).toBe('follow-up B'); + expect(queueTextFromUserContent(' hello ')).toBe('hello'); + }); + + it('unwraps the last User line of a formatPromptWithHistory fold', () => { + const folded = formatPromptWithHistory( + [makeMessage('user', 'turn-1 user'), makeMessage('assistant', 'turn-1 assistant')], + 'follow-up B', + ); + expect(folded).toContain('User: turn-1 user'); + expect(folded).not.toBe('follow-up B'); + expect(queueTextFromUserContent(folded)).toBe('follow-up B'); + }); + + it('does not treat an earlier User line as this-run', () => { + const folded = formatPromptWithHistory( + [makeMessage('user', 'follow-up C'), makeMessage('assistant', 'ok')], + 'follow-up B', + ); + expect(queueTextFromUserContent(folded)).toBe('follow-up B'); + }); +}); + +describe('mergeQueues (F21 adversarial #901 adopt)', () => { + it('keeps a local queueAppend the worker head omitted', () => { + expect(mergeQueues(undefined, ['follow-up B'], 'turn-1 user')).toEqual([ + 'follow-up B', + ]); + }); + + it('strips an in-flight last user from a stale-long server queue (fold unwrap)', () => { + const folded = formatPromptWithHistory( + [makeMessage('user', 'turn-1 user'), makeMessage('assistant', 'turn-1 assistant')], + 'follow-up B', + ); + expect( + mergeQueues(['follow-up B', 'follow-up C'], ['follow-up C'], folded), + ).toEqual(['follow-up C']); + }); + + it('union appends local extras after server order; empty union is unset', () => { + expect(mergeQueues(['a'], ['a', 'b'])).toEqual(['a', 'b']); + expect(mergeQueues(undefined, undefined)).toBeUndefined(); + expect(mergeQueues(['only'], ['only'], 'only')).toBeUndefined(); + }); + + it('lastUserContent is the TAIL user (reconstructed history), not the first', () => { + expect( + lastUserContent([ + { role: 'user', text: 'turn-1' }, + { role: 'assistant', text: 'ok' }, + { role: 'user', text: 'follow-up B' }, + ]), + ).toBe('follow-up B'); + expect(lastUserContent([])).toBeUndefined(); + }); + + it('TURN_QUEUE_MAX_ITEMS tracks HARNESS_QUEUE_MAX_ITEMS (Wasm MAX_ITEMS pin)', () => { + expect(TURN_QUEUE_MAX_ITEMS).toBe(HARNESS_QUEUE_MAX_ITEMS); + }); +}); + +describe('removeQueuedText / queueRestoreHead (F21)', () => { + it('removes the FIRST matching copy only', () => { + let s = sess({ queue: ['a', 'b', 'a'] }); + s = removeQueuedText(s, 'a'); + expect(s.queue).toEqual(['b', 'a']); + }); + + it('deletes the carrier when the last item is removed (absent = unset, no [] noise)', () => { + let s = sess({ queue: ['only'] }); + s = removeQueuedText(s, 'only'); + expect('queue' in s).toBe(false); + }); + + it('no-ops on blank text / absent match / missing carrier (same object out)', () => { + const s = sess({ queue: ['a'] }); + expect(removeQueuedText(s, '')).toBe(s); + expect(removeQueuedText(s, 'zzz')).toBe(s); + const bare = sess(); + expect(removeQueuedText(bare, 'a')).toBe(bare); + }); + + it('restore-head puts the text at the FRONT and re-inserts an absent text', () => { + let s = sess({ queue: ['a', 'b', 'c'] }); + s = queueRestoreHead(s, 'a'); + expect(s.queue).toEqual(['a', 'b', 'c']); + s = queueRestoreHead(s, 'z'); + expect(s.queue).toEqual(['z', 'a', 'b', 'c']); + expect(queueRestoreHead(s, ' ')).toBe(s); + expect( + queueRestoreHead(s, 'x'.repeat(TURN_QUEUE_TEXT_MAX_CHARS + 1)), + ).toBe(s); + }); + + it('restore-head refuses at depth cap (fail-closed, never drops a sibling)', () => { + let s = sess(); + for (let i = 0; i < TURN_QUEUE_MAX_ITEMS; i++) s = queueAppend(s, `p${i}`); + const before = s.queue; + expect(queueRestoreHead(s, 'new head')).toBe(s); + expect(s.queue).toEqual(before); + }); +}); + +describe('queueClear (F21)', () => { + it('drops the whole mirror; absent stays untouched (same object)', () => { + const s = queueClear(sess({ queue: ['a', 'b'] })); + expect('queue' in s).toBe(false); + const bare = sess(); + expect(queueClear(bare)).toBe(bare); + }); +}); + +describe('rearmQueueFromMirror (F21 reload hydration)', () => { + it('inserts in REVERSE order so queuedInsertFront rebuilds the FIFO', () => { + const { bridge, inserts } = stubBridge(); + const s = sess({ queue: ['one', 'two', 'three'] }); + const n = rearmQueueFromMirror(bridge, s); + expect(n).toBe(3); + expect(inserts).toEqual(['three', 'two', 'one']); + }); + + it('skips entirely when the Wasm queue is non-empty (never double-enqueues)', () => { + const { bridge, inserts } = stubBridge({ queued: 1 }); + const n = rearmQueueFromMirror(bridge, sess({ queue: ['a'] })); + expect(n).toBe(0); + expect(inserts).toEqual([]); + }); + + it('no-ops on an empty/absent mirror', () => { + const { bridge, inserts } = stubBridge(); + expect(rearmQueueFromMirror(bridge, sess())).toBe(0); + expect(rearmQueueFromMirror(bridge, sess({ queue: [] }))).toBe(0); + expect(inserts).toEqual([]); + }); + + it('stops on an insert reject (fail-closed: the rest stay in the mirror)', () => { + const { bridge, inserts } = stubBridge({ insertOk: false }); + const s = sess({ queue: ['a', 'b', 'c'] }); + const n = rearmQueueFromMirror(bridge, s); + expect(n).toBe(0); + expect(inserts).toEqual(['c']); + expect(s.queue).toEqual(['a', 'b', 'c']); // mirror untouched + }); + + it('re-arms with a fresh budget available (drain cap exported and generous)', () => { + expect(TURN_QUEUE_DRAIN_MAX_ATTEMPTS).toBeGreaterThanOrEqual(3); + }); +}); + +describe('queueHydratePlan (F21 adversarial #901 HEAD)', () => { + it('cold: wipe FIFO then re-arm from the mirror', () => { + expect(queueHydratePlan('cold')).toEqual({ preserveQueue: false, rearm: true }); + }); + + it('live: keep FIFO and never re-arm (just-promoted head is still in the mirror)', () => { + expect(queueHydratePlan('live')).toEqual({ preserveQueue: true, rearm: false }); + }); +}); + +describe('HarnessHost F21 wiring source-lock (adversarial #901)', () => { + const host = readFileSync( + resolve(process.cwd(), 'app/harness/HarnessHost.tsx'), + 'utf8', + ); + const harnessChat = readFileSync( + resolve(process.cwd(), 'lib/harnessChat.ts'), + 'utf8', + ); + + it('strips a drained prompt from the mirror BEFORE runHarnessTurn (not after the terminal)', () => { + const start = host.indexOf('await runHarnessTurn('); + expect(start).toBeGreaterThan(0); + const before = host.slice(0, start); + const after = host.slice(start); + expect(before).toContain('drainingQueued'); + expect(before).toContain('removeQueuedText('); + expect(before).toContain('queueOf('); + // Give-up / restore may mention removeQueuedText only before the call. + expect(after.indexOf('removeQueuedText(')).toBe(-1); + expect(after).toContain('queueRestoreHead('); + expect(after).toContain('drainingQueued'); + }); + + it('give-up Error is appendMessage\'d onto the snapshot (F5 is not silent)', () => { + const start = host.indexOf('await runHarnessTurn('); + const after = host.slice(start); + expect(after).toContain("appendMessage(reconciled, 'error'"); + }); + + it('empty-prompt cold attach preserves the FIFO (kickColdAttach must not wipe a re-arm)', () => { + expect(harnessChat).toMatch(/const preserveQueue = attaching;/); + expect(harnessChat).not.toMatch( + /preserveQueue = attaching && \(rawPrompt/, + ); + }); + + it('Load-earlier and needSnap hydrates are live (no re-arm of a just-promoted head)', () => { + // Both live ring snaps pass kind 'live' so queueHydratePlan skips re-arm. + expect(host).toContain("hydrateRingWindow(b, session, nextStart, 'live')"); + expect(host).toContain( + "hydrateRingWindow(b, sessionRef.current, latest, 'live')", + ); + expect(host).toContain('queueHydratePlan(kind)'); + expect(host).toContain('if (plan.rearm)'); + // Cold sites stay default-cold (3-arg / explicit default), not 'live'. + expect(host).toContain( + 'hydrateRingWindow(bridge, merged, latestRingStart(merged.messages.length))', + ); + expect(host).toContain( + 'hydrateRingWindow(bridge, restored, latestRingStart(restored.messages.length))', + ); + }); +}); diff --git a/lib/turnQueue.ts b/lib/turnQueue.ts new file mode 100644 index 00000000..71d3b5b3 --- /dev/null +++ b/lib/turnQueue.ts @@ -0,0 +1,299 @@ +/** + * backend-agents F21 (plan #815) — persisted backend submit queue + drain. + * + * AS-BUILT scope (operator override 2026-08-30: "ship it, Wasm untouched, + * no plan-review cycle"): the Wasm submit FIFO (protocol v18/v20/v21) stays the + * runtime source of truth; this module adds the **host-known persisted mirror** + * of that queue on the session snapshot (`SessionSnapshot.queue`), so the queue + * survives a reload and re-arms the Wasm FIFO. + * + * Deliberate scope (documented residuals, see the F21 issue comment): + * - The mirror carries prompts the HOST has seen (composer submits, drain + * steps). Items enqueued while busy from inside Wasm (band chips via + * `enqueueFromUi`, band edit/remove/Clear) are NOT observable by the host + * without a new `inv_*` export (protocol bump) — out of scope per override. + * They still drain at runtime (promote → pending_submit → poll POST); they + * are just not crash-safe persisted. + * - Storage rides the EXISTING transcript blob (`SessionSnapshot.queue` field, + * serialized inside the transcript object) — never `meta`, no new route, no + * new server surface, no protocol bump. + * - Reload hydration re-arms the Wasm FIFO in REVERSE order via the v20 + * `queuedInsertFront` (each insert becomes the new head), only when the Wasm + * queue is empty (never double-enqueues), only after a full ring rebuild + * (the v21 default `hydrateMessages` clear wipes the FIFO). + * - Drain failure budget: give-up drops the item from the mirror with a + * painted error after `TURN_QUEUE_DRAIN_MAX_ATTEMPTS` failed attempts + * (NEW cap; generous-by-default — the existing in-send 5× retry loop and the + * route's own 429/409/503 gates are untouched). + */ + +import type { SessionSnapshot } from './sessionStore'; +import type { HarnessBridge } from './harnessBridge'; + +/** + * Mirror-depth cap — parity with the Wasm `submit_queue.MAX_ITEMS` (16, + * mirrored as `HARNESS_QUEUE_MAX_ITEMS` in lib/harnessChat.ts). Pinned here + * (not imported) to keep this module dependency-light and cycle-free. + */ +export const TURN_QUEUE_MAX_ITEMS = 16; + +/** + * Per-item persisted text ceiling. F21's product reality (umbrella #794 + * post-mortem): the queue is for SMALL follow-up prompts; giant prompts are an + * edge case that belongs on the transcript, not a crash-safe queue mirror. + * Longer items stay on the ephemeral Wasm band for this page-life; they are + * just not persisted. (Not a transport cap — `PROMPT_BODY_MAX_CHARS` owns the + * wire; this only bounds the persisted mirror.) + */ +export const TURN_QUEUE_TEXT_MAX_CHARS = 5000; + +/** + * F21 Caps-table row (plan #815): NEW per-item drain attempt budget before + * drop-with-paint. Generous by default; the route's own 429/409/503 gates and + * the in-send 5× retry loop are unchanged. + */ +export const TURN_QUEUE_DRAIN_MAX_ATTEMPTS = 5; + +/** + * Sanitize a queue-mirror value (local JSON parse or cloud blob read): + * keep string items, trim, drop blanks and over-cap items (fail-closed — an + * over-cap item is dropped, never truncated into a different prompt), cap + * depth. Returns `undefined` for a non-array so a poisoned value never sticks. + */ +export function sanitizeQueue(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: string[] = []; + for (const item of value) { + if (out.length >= TURN_QUEUE_MAX_ITEMS) break; + if (typeof item !== 'string') continue; + const text = item.trim(); + if (!text) continue; + if (text.length > TURN_QUEUE_TEXT_MAX_CHARS) continue; + out.push(text); + } + // Empty (or fully-poisoned) input = UNSET carrier, not `[]` — mirrors how + // removeQueuedText deletes the field and how the read sites guard. + return out.length > 0 ? out : undefined; +} + +/** Read the sanitized mirror from a snapshot (undefined = no queue carrier). */ +export function queueOf(session: SessionSnapshot): string[] | undefined { + return session.queue === undefined ? undefined : sanitizeQueue(session.queue); +} + +/** + * Append one host-known prompt to the mirror. No-op (returns the input + * session) when the text is blank/over-cap or the mirror is at depth cap — + * the Wasm band still holds it for this page-life; only persistence skips. + */ +export function queueAppend( + session: SessionSnapshot, + text: string, +): SessionSnapshot { + const t = (text ?? '').trim(); + if (!t || t.length > TURN_QUEUE_TEXT_MAX_CHARS) return session; + const current = queueOf(session) ?? []; + if (current.length >= TURN_QUEUE_MAX_ITEMS) return session; + return { ...session, queue: [...current, t], updatedAt: Date.now() }; +} + +/** + * Remove the FIRST array entry equal to `text` (exact-trim). Returns the same + * reference when text is blank/absent; `undefined` when the result is empty + * (unset carrier). Worker copy-forward uses this so a this-run user prompt + * cannot stay on the blob after durable start (adversarial #901 HEAD Major). + */ +export function queueWithoutText( + queue: string[] | undefined, + text: string, +): string[] | undefined { + const t = (text ?? '').trim(); + if (!t || !queue || queue.length === 0) return queue; + const idx = queue.indexOf(t); + if (idx === -1) return queue; + const next = queue.slice(0, idx).concat(queue.slice(idx + 1)); + return next.length > 0 ? next : undefined; +} + +/** + * Remove the FIRST mirror entry equal to `text` (the copy whose durable start + * was accepted). No-op when absent (e.g. an unobserved Wasm-internal enqueue). + * Equality is exact-trim; duplicate texts remove one copy per accepted start. + */ +export function removeQueuedText( + session: SessionSnapshot, + text: string, +): SessionSnapshot { + const t = (text ?? '').trim(); + if (!t) return session; + const current = queueOf(session); + const next = queueWithoutText(current, t); + if (next === current) return session; + const out: SessionSnapshot = { ...session, updatedAt: Date.now() }; + if (next === undefined || next.length === 0) delete out.queue; + else out.queue = next; + return out; +} + +/** + * Defer-restore (drain failure): return `text` to the FRONT of the mirror — + * it was the drained head, and the Wasm band restore is `queuedInsertFront` + * (also front), so both stay order-consistent. A text not currently in the + * mirror is inserted at front anyway (first host observation). No-op on + * blank / over-cap text or at depth cap. + */ +export function queueRestoreHead( + session: SessionSnapshot, + text: string, +): SessionSnapshot { + const t = (text ?? '').trim(); + if (!t || t.length > TURN_QUEUE_TEXT_MAX_CHARS) return session; + const current = queueOf(session) ?? []; + const idx = current.indexOf(t); + const rest = + idx === -1 ? current : current.slice(0, idx).concat(current.slice(idx + 1)); + if (rest.length >= TURN_QUEUE_MAX_ITEMS) return session; + return { ...session, queue: [t, ...rest], updatedAt: Date.now() }; +} + +/** + * The this-run user text that {@link queueWithoutText} should match. + * + * persistStep checkpoints carry `turnWorkflow` `userMessage`, which production + * `runHarnessTurn` POSTs as `formatPromptWithHistory(session.messages, prompt)` + * (`lib/harnessChat.ts` `apiPrompt`). After the first turn that string is the + * folded blob, not the raw queue item — exact-match against `session.queue` + * no-ops and copy-forward re-arms the in-flight prompt (adversarial #901). + * + * Bare prompts (no history) pass through. A history fold ends with + * `\nUser: ${newUserPrompt}\n\nAssistant:` — take that last User line. + */ +export function queueTextFromUserContent(text: string): string { + const t = (text ?? '').trim(); + if (!t) return t; + const suffix = '\n\nAssistant:'; + const head = t.endsWith(suffix) ? t.slice(0, t.length - suffix.length) : t; + const marker = '\nUser: '; + const idx = head.lastIndexOf(marker); + if (idx === -1) return t; + return head.slice(idx + marker.length).trim(); +} + +/** + * Last `user` row text on a snapshot (adopted reconstruct is full history — + * the tail user is this-run, not the first prompt). + */ +export function lastUserContent( + messages: ReadonlyArray<{ role: string; text: string }>, +): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (!m || m.role !== 'user') continue; + const t = (m.text ?? '').trim(); + if (t) return m.text; + } + return undefined; +} + +function countItem(arr: readonly string[], item: string): number { + let n = 0; + for (const x of arr) if (x === item) n += 1; + return n; +} + +/** + * Same-id adopt merge for the F21 queue mirror (adversarial #901). + * + * Whole-snapshot server-wins drops a `queueAppend` that lost the coalesced-PUT + * race to a later worker B7 (copy-forward of an older prior). Union keeps + * local extras; then strip the in-flight last user (unwrap a history fold) + * so a stale-long server queue cannot re-arm a drain that already started. + * Capped at {@link TURN_QUEUE_MAX_ITEMS}. Empty/absent → unset. + */ +export function mergeQueues( + serverQueue: unknown, + localQueue: unknown, + lastUser?: string, +): string[] | undefined { + const server = sanitizeQueue(serverQueue) ?? []; + const local = sanitizeQueue(localQueue) ?? []; + const out = server.slice(); + for (const item of local) { + if (out.length >= TURN_QUEUE_MAX_ITEMS) break; + if (countItem(out, item) < countItem(local, item)) out.push(item); + } + const started = lastUser ? queueTextFromUserContent(lastUser) : ''; + const merged = out.length > 0 ? out : undefined; + const stripped = started ? queueWithoutText(merged, started) : merged; + return stripped !== undefined && stripped.length > 0 ? stripped : undefined; +} + +/** Drop the whole mirror (Clear/New semantics; used when a session is reset). */ +export function queueClear(session: SessionSnapshot): SessionSnapshot { + if (session.queue === undefined) return session; + const out = { ...session }; + delete out.queue; + return out; +} + +/** + * Reload hydration (F21 "Next load"): re-arm the Wasm FIFO from the persisted + * mirror. Inserts in REVERSE order via the v20 `queuedInsertFront` (each + * insert becomes the new head, so reverse yields the original FIFO). Guards: + * - only when the Wasm queue is currently EMPTY (never double-enqueues; + * `hydrateMessages` default clear wiped it — a `preserveQueue` rebuild + * keeps a live FIFO and must not re-arm); + * - a full/blank insert reject stops (fail-closed — the item stays in the + * mirror, never silently dropped). + * Returns the number of items re-armed (parity: callers may compare against + * `bridge.queuedCount()`). + * + * Callers: only **cold** hydrates (boot / adopt / switch). Live ring snaps + * (Load-earlier / needSnap) must use {@link queueHydratePlan} `'live'` and + * must not call this — a just-promoted head is already out of the band and + * still in the mirror until drain-start strip (adversarial #901 HEAD Major). + */ +export function rearmQueueFromMirror( + bridge: HarnessBridge, + session: SessionSnapshot, +): number { + const items = queueOf(session); + if (!items || items.length === 0) return 0; + if (bridge.queuedCount() > 0) return 0; // live FIFO — never double-enqueue + let inserted = 0; + for (let i = items.length - 1; i >= 0; i--) { + let ok = false; + try { + ok = bridge.queuedInsertFront(items[i]); + } catch { + ok = false; + } + if (!ok) break; // fail-closed: leave the rest in the mirror + inserted += 1; + } + return inserted; +} + +/** + * Cold vs live ring hydrate (adversarial #901 HEAD Major). + * + * - **cold** (F5 / boot / adopt / switch): wipe the stale FIFO + * (`inv_clear_messages`) then re-arm from this session's mirror. + * - **live** (Load-earlier / needSnap before pending submit): keep the current + * FIFO (`inv_clear_ring`) and **do not re-arm**. A just-promoted head is + * already out of the band and still in the mirror until `runPrompt` strips + * it; re-arming would duplicate it and double-POST. + * + * Do not key re-arm on `queuedCount()===0` after a live clear: that is the + * post-promote empty-FIFO (last-item) case. + */ +export type QueueHydrateKind = 'cold' | 'live'; + +export function queueHydratePlan(kind: QueueHydrateKind): { + preserveQueue: boolean; + rearm: boolean; +} { + if (kind === 'live') return { preserveQueue: true, rearm: false }; + return { preserveQueue: false, rearm: true }; +} +