Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 88 additions & 5 deletions app/harness/HarnessHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { resetHarnessImageSession } from '../../lib/harnessImages';
import { resetHarnessMathSession } from '../../lib/harnessMath';
import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote } from '../../lib/detachTurn';
import { decideHotResume, type HeapApplied } from '../../lib/turnAttach';
import {
HarnessBridge,
HARNESS_PROTOCOL_VERSION,
Expand Down Expand Up @@ -60,6 +61,9 @@ import HarnessLoading from './HarnessLoading';

type Phase = 'loading' | 'ready' | 'error';

type RunPromptAttach = { runId: string; startIndex: number; dedup: boolean };
type RunPromptOpts = { pushUser?: boolean; attach?: RunPromptAttach };

type DvuiModule = {
dvui: (
canvas: string | HTMLCanvasElement,
Expand Down Expand Up @@ -199,6 +203,15 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
const pollRef = useRef<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const inflightRef = useRef(false);
/**
* Plan #813 (E19) — SSE frames **this JS heap** applied for the current
* `turnRunId`. Null after F5 / adopt / switch (ring rebuilt from Blob).
* Hot resume reads this, never envelope `C`.
*/
const heapAppliedRef = useRef<HeapApplied | null>(null);
const runPromptRef = useRef<(prompt: string, opts?: RunPromptOpts) => Promise<void>>(
async () => {},
);
/** Bumped on detach so a late runPrompt persist cannot clobber a switched session. */
const turnEpochRef = useRef(0);
/**
Expand Down Expand Up @@ -309,6 +322,8 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
if (bridge) {
hydrateRingWindow(bridge, merged, latestRingStart(merged.messages.length));
}
// Ring rebuilt from Blob/local — this heap has not applied the stream.
heapAppliedRef.current = null;
},
[writeLocalSession, hydrateRingWindow],
);
Expand Down Expand Up @@ -348,14 +363,31 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
});
}, []);

/**
* Plan #813 — cold attach after the ring was rebuilt from Blob/local
* (boot / adopt / switch-back). Always `startIndex=0` + dedup. No-ops when
* not `running` or a turn is already inflight.
*/
const kickColdAttach = useCallback(() => {
if (inflightRef.current) return;
const s = sessionRef.current;
if (s.turnStatus !== 'running' || !s.turnRunId) return;
heapAppliedRef.current = null;
void runPromptRef.current('', {
attach: { runId: s.turnRunId, startIndex: 0, dedup: true },
});
}, []);

/** Activate a session (canonical id) on local state + Wasm ring + URL + picker. */
const activateSession = useCallback(
(next: SessionSnapshot) => {
adoptCloudSession(next);
setActiveSessionId(next.id);
void refreshSessions();
// Plan #813: F5/login/new tab/switch-back rebuilt the ring — cold attach.
queueMicrotask(kickColdAttach);
},
[adoptCloudSession, refreshSessions],
[adoptCloudSession, refreshSessions, kickColdAttach],
);

const persist = useCallback(
Expand Down Expand Up @@ -395,12 +427,22 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
}, []);

const runPrompt = useCallback(
async (prompt: string, opts?: { pushUser?: boolean }) => {
async (prompt: string, opts?: RunPromptOpts) => {
const bridge = bridgeRef.current;
if (!bridge || inflightRef.current) return;

// Adversarial #857: Send while a durable run is live (503 subscribe-fail,
// empty-EOF idle) must cold-reattach — never POST (C15 409 mixes Turn
// ended + Error with keep-running).
const live = sessionRef.current;
const attach: RunPromptAttach | undefined =
opts?.attach ??
(live.turnStatus === 'running' && live.turnRunId
? { runId: live.turnRunId, startIndex: 0, dedup: true }
: undefined);
const attaching = attach != null;
const modelId = bridge.getSelectedModel();
if (!modelId) {
if (!attaching && !modelId) {
setHostNote('No model selected — catalog empty, failed to load, or not granted.');
try {
bridge.pushMessage(
Expand Down Expand Up @@ -465,14 +507,15 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
signal: controller.signal,
// Default false: Wasm already painted the user line in queueSubmitFromUi.
// true when host snapped from a historical ring window before the turn.
pushUser: opts?.pushUser ?? false,
modelId,
pushUser: attaching ? false : opts?.pushUser ?? false,
...(modelId ? { modelId } : {}),
// Phase 2 (#627 / #625): persist every mid-turn session patch
// (cwd change, sandbox switch) via the same persist callback the
// turn-end path uses — local write + coalesced cloud PUT.
// Adversarial #844: late patches after detach take decideDetachPersist
// (never writeLocal onto a switched session; never PUT a Clear'd id).
onSessionPatch: persistTurn,
...(attach ? { attach } : {}),
},
);
if (turnEpochRef.current !== epoch) {
Expand All @@ -491,9 +534,40 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
// stream). Dropping session on signal.aborted left SessionStore behind Wasm:
// Load earlier / refresh could wipe the cancelled turn from the ring.
persistTurn(folded);
if (folded.turnStatus === 'running' && folded.turnRunId) {
heapAppliedRef.current = {
runId: folded.turnRunId,
count: folded.turnStreamCursor ?? 0,
};
} else {
heapAppliedRef.current = null;
}
if (!result.ok && shouldSetHostTurnNote(folded.turnStatus)) {
setHostNote(result.error);
}
// Plan #813: SSE drop while still mounted → hot resume at this-heap C.
// Empty-EOF GET (applied == startIndex) must not reconnect (spin).
// F5 is never this path (heapApplied was nulled; activateSession is cold).
if (folded.turnStatus === 'running' && folded.turnRunId) {
const resume = decideHotResume({
turnRunId: folded.turnRunId,
turnStatus: folded.turnStatus,
envelopeCursor: folded.turnStreamCursor,
heapApplied: heapAppliedRef.current,
attachStart: attach?.startIndex,
});
if (resume.kind === 'hot') {
queueMicrotask(() => {
void runPromptRef.current('', {
attach: {
runId: folded.turnRunId!,
startIndex: resume.startIndex,
dedup: false,
},
});
});
}
}
} finally {
const detached = turnEpochRef.current !== epoch;
const pendingId = pendingMintBindRef.current;
Expand Down Expand Up @@ -530,6 +604,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
},
[persist, setUrlSessionId, writeLocalSession],
);
runPromptRef.current = runPrompt;

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -561,6 +636,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
// re-checks getLocal, but this host guard is authoritative for the active id.
if (snap.id !== sessionRef.current.id) return;
adoptCloudSession(snap);
queueMicrotask(kickColdAttach);
},
});
repoRef.current = repo;
Expand Down Expand Up @@ -710,6 +786,12 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
// or refresh can recover — don't permanently strand local-only this page load.
if (result.kind === 'local') setCloudEnabled(r.enabled);
void refreshSessions();
// Plan #813: after Blob/local hydrate, cold-attach a still-running
// turn. activateSession also kicks; inflightRef de-dupes the pair.
// Do not auto-attach completed sessions (`turnStatus !== 'running'`).
if (!cancelled) {
queueMicrotask(kickColdAttach);
}
})();

const poll = () => {
Expand Down Expand Up @@ -816,6 +898,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) {
setUrlSessionId,
applySessionModel,
foldPendingModelChange,
kickColdAttach,
]);

/**
Expand Down
Loading