fix(client): внутреннее ревью миграции FSM — F1..F4 (#488)
F1 [correctness] — supersede: перекрытие стримов рушило свежий run. ai@6 AbstractChat.makeRequest в finally читает и обнуляет общий activeResponse, поэтому параллельные стрим-A и стрим-B корёжат друг друга, а незастемпленный onFinish мёртвого A уводил ЖИВОЙ новый run в ложный reconnect / сбрасывал runFact. Фикс: (1) FINISH_*/STREAM_INCOMPLETE штампуются per-stream generation (turnEpochRef, взводится в момент старта стрима) → фильтр I1 отбрасывает финиш чужого поколения; (2) sendNow-supersede АБОРТИТ A и стартует B только из onFinish A (микротаск, после того как finally A обнулил activeResponse) — гарантия отсутствия перекрытия. Тест на поздний isDisconnect A после SUPERSEDE_REQUESTED: машина НЕ уходит в reconnect, B отправлен. MUTATION-VERIFY: снятие epoch-штампа у FINISH_DISCONNECT → тест краснеет («Connection lost — reconnecting»). F2 [correctness] — гонка mount getRun→ATTACH_START с локальным send. Редьюсер ATTACH_START теперь игнорирует любую не-idle фазу, поэтому поздний резолв getRun не перехватывает начавшийся локальный турн в observer-attach. Тест на гонку. F3 [ghost feature] — RUN_SUPERSEDED объявлен+покрыт тестом, но НИКОГДА не диспатчился. Удалён (событие+обработчик+тест+postRun-reason observer-follow) как сознательный scope-cut: наблюдатель убитого supersede-рана и так следует за новым через деградированный поллинг (свежие строки истории, независимо от runId). F4 [hygiene] — мёртвые события. STREAM_START подключён (первый ассистент-фрейм локального турна: sending→streaming, спека↔код совпали). RECONNECT_BEGIN и POLL_ACTIVITY удалены (не диспатчились). Множество событий редьюсера = множеству диспатчащихся; редьюсер тотален. Тесты: FSM 35 + chat-thread 37 (+error 26 +adopt 16) = 114 зелёных; tsc ai-chat 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,9 +193,9 @@ describe("ChatThread — send now", () => {
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("#488 commit 5: autonomous live sendNow with a known runId POSTs a supersede body", () => {
|
||||
// A streaming assistant message carrying the start-metadata runId -> the FSM
|
||||
// adopts the run-fact; sendNow then interrupts via the server CAS.
|
||||
// A streaming assistant message carrying the start-metadata runId -> the FSM
|
||||
// adopts the run-fact so sendNow can interrupt via the server CAS.
|
||||
const withRunId = () => {
|
||||
h.state.status = "streaming";
|
||||
h.state.messages = [
|
||||
{
|
||||
@@ -205,19 +205,58 @@ describe("ChatThread — send now", () => {
|
||||
metadata: { runId: "run-1" },
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
it("#488 commit 5 / F1: sendNow ABORTS stream A first, then sends B (CAS) only after A finalizes", async () => {
|
||||
withRunId();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
// The message is sent...
|
||||
// F1: A is ABORTED, and B is NOT sent yet (no overlap).
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
// A's onFinish fires -> B is scheduled on a microtask (after A finalizes).
|
||||
await act(async () => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a1", role: "assistant", parts: [] },
|
||||
isAbort: true,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
// ...and the NEXT POST carries supersede:{runId} (read-and-cleared once).
|
||||
// B's POST carries supersede:{runId} (read-and-cleared once).
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
const body = prep({ messages: [], body: {} }).body as Record<string, unknown>;
|
||||
expect(body.supersede).toEqual({ runId: "run-1" });
|
||||
// One-shot: a subsequent request has no supersede.
|
||||
expect(prep({ messages: [], body: {} }).body.supersede).toEqual({
|
||||
runId: "run-1",
|
||||
});
|
||||
expect(prep({ messages: [], body: {} }).body.supersede).toBeUndefined();
|
||||
});
|
||||
|
||||
it("#488 F1: a superseded stream's LATE disconnect does NOT falsely reconnect (epoch stamp drops it)", async () => {
|
||||
// MUTATION-VERIFY: drop `epoch: stampEpoch` from the supersede-branch
|
||||
// FINISH_DISCONNECT dispatch and this goes red (A's disconnect -> reconnecting).
|
||||
withRunId();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now")); // -> superseding, aborts A
|
||||
// A ends via isDisconnect (the server CAS closed it). The OLD overlap bug would
|
||||
// route the LIVE new run into a false reconnect banner.
|
||||
await act(async () => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a1", role: "assistant", parts: [{ type: "text", text: "x" }] },
|
||||
isAbort: false,
|
||||
isDisconnect: true,
|
||||
isError: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.queryByText(/reconnecting/i)).toBeNull();
|
||||
// ...and B was still sent.
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("#488 commit 5: a supersede POST that 409s SUPERSEDE_TIMEOUT is returned as-is (NO retry ladder)", async () => {
|
||||
h.state.status = "streaming";
|
||||
h.state.messages = [
|
||||
@@ -494,6 +533,31 @@ describe("ChatThread — resume (attach) machinery", () => {
|
||||
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("#488 F2: a local send DURING the mount getRun round-trip is NOT hijacked by the late attach", async () => {
|
||||
// getRun stays pending while the user sends locally; its late resolve would
|
||||
// otherwise ATTACH_START and flip the local turn into an observer-attach.
|
||||
let resolveGetRun!: (v: {
|
||||
run: { id: string; status: string } | null;
|
||||
message: unknown;
|
||||
}) => void;
|
||||
h.state.getRun.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolveGetRun = r;
|
||||
}),
|
||||
);
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
// Local send in flight of getRun -> SEND_LOCAL (phase sending, ownership local).
|
||||
fireEvent.click(screen.getByTestId("send-btn"));
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "typed text" });
|
||||
// getRun resolves with an ACTIVE run -> the F2 guard ignores ATTACH_START.
|
||||
await act(async () => {
|
||||
resolveGetRun({ run: { id: "run-1", status: "running" }, message: null });
|
||||
await Promise.resolve();
|
||||
});
|
||||
// The local turn was NOT hijacked into a resume/attach.
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips the streaming tail from the seed, keeps a user tail whole", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
expect(h.state.seededMessages).toHaveLength(1);
|
||||
|
||||
@@ -313,6 +313,18 @@ export default function ChatThread({
|
||||
// (like chatIdRef/roleIdRef), the single replacement for the three REMOVED
|
||||
// one-shot flags flushOnAbortRef/interruptNextSendRef/supersedeRetryRef.
|
||||
const pendingSupersedeRef = useRef<string | null>(null);
|
||||
// #488 F1: the interrupt-and-send text, held until the superseded stream A has
|
||||
// FULLY finalized (its SDK `finally` cleared `activeResponse`) — only THEN is
|
||||
// stream B started, so A cannot corrupt B (ai@6 AbstractChat.makeRequest reads
|
||||
// `this.activeResponse.state.message` in its finally and then nulls it, so an
|
||||
// overlapping B is clobbered). Send-plumbing DATA, not a lifecycle flag.
|
||||
const pendingSupersedeTextRef = useRef<string | null>(null);
|
||||
// #488 F1: the epoch under which the CURRENTLY-OWNED stream started, used to
|
||||
// STAMP its onFinish (I1). A superseded/dead stream's late finish carries an OLD
|
||||
// generation and is dropped by the reducer, so it cannot drive the live machine
|
||||
// into a false reconnect or reset its run-fact. Set at each honored stream start
|
||||
// (local send, resume/reconnect attach, the supersede B-send).
|
||||
const turnEpochRef = useRef(0);
|
||||
|
||||
// --- Effect runner: executes the reducer's command effects ---------------
|
||||
const runEffect = useCallback(
|
||||
@@ -324,6 +336,9 @@ export default function ChatThread({
|
||||
// seed already stripped it), so the live replay's text-start rebuilds it
|
||||
// without duplicating parts (#430).
|
||||
pendingAttachEpochRef.current = epoch;
|
||||
// The resumed stream's onFinish is stamped with THIS attach generation
|
||||
// (F1), so a superseded attempt's late finish is dropped.
|
||||
turnEpochRef.current = epoch;
|
||||
if (machineRef.current.phase.name === "reconnecting") {
|
||||
const anchor = strippedRowRef.current;
|
||||
if (anchor)
|
||||
@@ -415,6 +430,8 @@ export default function ChatThread({
|
||||
// FIFO dequeue + local send of the next queued message (no-op when empty).
|
||||
const localSend = useCallback((text: string) => {
|
||||
dispatchRef.current({ type: "SEND_LOCAL" });
|
||||
// F1: this local stream's onFinish is stamped with the just-bumped generation.
|
||||
turnEpochRef.current = epochRef.current;
|
||||
sendMessageRef.current?.({ text });
|
||||
}, []);
|
||||
const flushNext = useCallback(() => {
|
||||
@@ -581,6 +598,10 @@ export default function ChatThread({
|
||||
transport,
|
||||
experimental_throttle: STREAM_THROTTLE_MS,
|
||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||
// #488 F1: STAMP this finish with the generation the stream STARTED under
|
||||
// (I1). A superseded/dead stream's late finish carries an OLD generation and
|
||||
// is dropped by the reducer, so it cannot drive the live machine.
|
||||
const stampEpoch = turnEpochRef.current;
|
||||
// Ownership (I2) is the FSM ctx: a resumed/attached/reconnected turn is an
|
||||
// OBSERVER; a local send is the owner. The queue flushes ONLY under local
|
||||
// ownership; an observer never flushes (invariant 7).
|
||||
@@ -589,22 +610,50 @@ export default function ChatThread({
|
||||
// for #161); fires even while unmounting.
|
||||
onTurnFinished(extractServerChatId(message), threadKey);
|
||||
|
||||
// A missing message (a pre-first-frame break) has no visible content.
|
||||
const msgHasVisible = message
|
||||
? assistantMessageHasVisibleContent(message)
|
||||
: false;
|
||||
|
||||
// #488 F1: the SUPERSEDED stream A just finalized (we are still `superseding`
|
||||
// and stream B has not been sent yet). Record A's terminal outcome STAMPED —
|
||||
// I1 drops it (superseding bumped the epoch), so A cannot drive a false
|
||||
// reconnect / reset the run-fact — then start B NOW that A is fully done. B is
|
||||
// deferred to a microtask so A's SDK `finally` (`activeResponse = void 0`)
|
||||
// runs BEFORE B's makeRequest sets `activeResponse`, else the dying A clobbers
|
||||
// B (ai@6). This is the no-overlap guarantee the CAS supersede needs.
|
||||
if (
|
||||
machineRef.current.phase.name === "superseding" &&
|
||||
pendingSupersedeTextRef.current !== null
|
||||
) {
|
||||
if (isError) dispatch({ type: "FINISH_ERROR", kind: "stream", epoch: stampEpoch });
|
||||
else if (isAbort) dispatch({ type: "FINISH_ABORT", epoch: stampEpoch });
|
||||
else if (isDisconnect)
|
||||
dispatch({ type: "FINISH_DISCONNECT", hasVisibleContent: msgHasVisible, epoch: stampEpoch });
|
||||
else dispatch({ type: "FINISH_CLEAN", epoch: stampEpoch });
|
||||
const text = pendingSupersedeTextRef.current;
|
||||
pendingSupersedeTextRef.current = null;
|
||||
setStopNotice(null);
|
||||
queueMicrotask(() => {
|
||||
if (!mountedRef.current) return;
|
||||
turnEpochRef.current = epochRef.current; // B's (superseding) generation
|
||||
sendMessageRef.current?.({ text });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
dispatch({ type: "FINISH_ERROR", kind: "stream" });
|
||||
dispatch({ type: "FINISH_ERROR", kind: "stream", epoch: stampEpoch });
|
||||
setStopNotice(null);
|
||||
return;
|
||||
}
|
||||
if (isAbort) {
|
||||
// A user Stop / an interrupt abort finished. The FSM stopping/idle exit is
|
||||
// by DATA (this terminal outcome, I4).
|
||||
dispatch({ type: "FINISH_ABORT" });
|
||||
dispatch({ type: "FINISH_ABORT", epoch: stampEpoch });
|
||||
setStopNotice("manual");
|
||||
return;
|
||||
}
|
||||
// A missing message (a pre-first-frame break) has no visible content.
|
||||
const msgHasVisible = message
|
||||
? assistantMessageHasVisibleContent(message)
|
||||
: false;
|
||||
if (isDisconnect) {
|
||||
if (wasObserver) {
|
||||
// A resumed/attached OBSERVER stream dropped. Recover via the degraded
|
||||
@@ -624,6 +673,7 @@ export default function ChatThread({
|
||||
dispatch({
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: hasVisible,
|
||||
epoch: stampEpoch,
|
||||
});
|
||||
}
|
||||
setStopNotice(null);
|
||||
@@ -659,10 +709,15 @@ export default function ChatThread({
|
||||
dispatch({
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: msgHasVisible,
|
||||
epoch: stampEpoch,
|
||||
});
|
||||
setStopNotice(null);
|
||||
} else {
|
||||
dispatch({ type: "FINISH_DISCONNECT", hasVisibleContent: false });
|
||||
dispatch({
|
||||
type: "FINISH_DISCONNECT",
|
||||
hasVisibleContent: false,
|
||||
epoch: stampEpoch,
|
||||
});
|
||||
setStopNotice("disconnect");
|
||||
}
|
||||
return;
|
||||
@@ -680,17 +735,17 @@ export default function ChatThread({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
dispatch({ type: "STREAM_INCOMPLETE", reason: "starved" });
|
||||
dispatch({ type: "STREAM_INCOMPLETE", reason: "starved", epoch: stampEpoch });
|
||||
} else {
|
||||
// Healthy resumed finish — nothing to restore/arm, just settle.
|
||||
dispatch({ type: "FINISH_CLEAN" });
|
||||
dispatch({ type: "FINISH_CLEAN", epoch: stampEpoch });
|
||||
}
|
||||
}
|
||||
setStopNotice(null);
|
||||
return;
|
||||
}
|
||||
// Local clean finish: settle + flush the queue (gated on liveness, #486).
|
||||
dispatch({ type: "FINISH_CLEAN" });
|
||||
dispatch({ type: "FINISH_CLEAN", epoch: stampEpoch });
|
||||
setStopNotice(null);
|
||||
if (mountedRef.current) flushNext();
|
||||
},
|
||||
@@ -730,9 +785,16 @@ export default function ChatThread({
|
||||
if (machineRef.current.phase.name === "stopping")
|
||||
onServerStopRef.current?.(serverChatId);
|
||||
}
|
||||
// #488 F4: the FIRST assistant frame of a LOCAL turn moves the FSM
|
||||
// `sending -> streaming` (and adopts the runId), matching the spec's
|
||||
// STREAM_START transition. Later frames / observer turns (already streaming)
|
||||
// just refresh the run-fact.
|
||||
const runId = extractRunId(tail);
|
||||
if (runId && machineRef.current.ctx.runFact?.runId !== runId)
|
||||
if (machineRef.current.phase.name === "sending") {
|
||||
dispatch({ type: "STREAM_START", runId, epoch: epochRef.current });
|
||||
} else if (runId && machineRef.current.ctx.runFact?.runId !== runId) {
|
||||
dispatch({ type: "RUN_FACT", runFact: { runId } });
|
||||
}
|
||||
}, [messages, onServerChatId, dispatch]);
|
||||
|
||||
// Live "turn was interrupted" marker (a manual Stop vs a dropped connection — a
|
||||
@@ -839,9 +901,16 @@ export default function ChatThread({
|
||||
runId !== "pending"
|
||||
) {
|
||||
// CAS supersede: the server stops the old run + starts this one atomically.
|
||||
// #488 F1: do NOT start stream B synchronously here — the local stream A is
|
||||
// still live, and overlapping streams corrupt each other in ai@6 (A's
|
||||
// `finally` reads/ nulls the shared `activeResponse`). Instead ABORT A now
|
||||
// and stash the text; A's onFinish (dropped by the epoch stamp) starts B in
|
||||
// a microtask, AFTER A is fully finalized (no overlap). The POST then
|
||||
// carries the supersede body (pendingSupersedeRef, armed by the effect).
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
pendingSupersedeTextRef.current = msg.text;
|
||||
dispatch({ type: "SUPERSEDE_REQUESTED", targetRunId: runId });
|
||||
sendMessageRef.current?.({ text: msg.text }); // POST carries supersede body
|
||||
stopFnRef.current?.(); // abort A -> its onFinish sends B
|
||||
return;
|
||||
}
|
||||
if (liveStreaming) {
|
||||
|
||||
@@ -36,36 +36,47 @@ Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
|
||||
| `FINISH_DISCONNECT` (no runFact, not liveFollow) | streaming | idle | runFact←null (plain terminal "connection lost") |
|
||||
| `STREAM_INCOMPLETE{reason}` (observer starved/torn clean finish) | streaming(observer) | polling(reason) | `[armPoll(reason)]` |
|
||||
| `FINISH_ERROR{kind}` (onFinish isError) | any | error(kind) | `[disarmPoll, cancelReconnect]`, runFact←null |
|
||||
| `ATTACH_START{runId}` (mount resume) | idle | attaching **†** | `[resumeStream]`, ownership=observer, runFact←runId |
|
||||
| `STREAM_START{runId}` (first assistant frame of a local turn) | sending | streaming | runFact←runId, `[cancelReconnect, disarmPoll]` |
|
||||
| `ATTACH_START{runId}` (mount resume) | **idle only** (F2) | attaching **†** | `[resumeStream]`, ownership=observer, runFact←runId; ignored from any non-idle phase |
|
||||
| `ATTACH_LIVE` (attach GET 2xx) | attaching | streaming | — |
|
||||
| `ATTACH_NONE` (attach GET 204/err/throw) | attaching | polling(attach-none) | `[armPoll(attach-none)]` |
|
||||
| `RECONNECT_BEGIN` | streaming, idle | reconnecting(1) **†** *iff runFact* | `[scheduleReconnect(1)]`, ownership=observer |
|
||||
| `RECONNECT_ATTEMPT{n}` (backoff timer) | reconnecting | reconnecting(n) **†** | `[resumeStream]` |
|
||||
| `RECONNECT_ATTACHED` (reconnect GET 2xx) | reconnecting | streaming | `[cancelReconnect, disarmPoll]` — **counter reset** (commit 3) |
|
||||
| `RECONNECT_NONE` (reconnect GET 204/err), attempt<MAX | reconnecting | reconnecting(n+1) **†** | `[armPoll(attach-none), scheduleReconnect(n+1)]` |
|
||||
| `RECONNECT_NONE`, attempt=MAX | reconnecting | reconnecting(MAX, failed) | `[armPoll(reconnect-exhausted)]` |
|
||||
| `RETRY` (manual, failed banner) | reconnecting(failed) | reconnecting(1) **†** | `[resumeStream]` |
|
||||
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
||||
| `POLL_ACTIVITY` (rows changed) | polling, reconnecting | (same) | — (runtime resets inactivity clock) |
|
||||
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
||||
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
||||
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
||||
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
||||
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect]` |
|
||||
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
||||
| `SUPERSEDE_REQUESTED{targetRunId}` (interrupt+send) | streaming, reconnecting, polling, error | superseding **†** | `[supersede(target), cancelReconnect, disarmPoll]` |
|
||||
| `SUPERSEDE_READY{runId}` (CAS ok) | superseding | streaming | ownership=local, runFact←runId |
|
||||
| `SUPERSEDE_MISMATCH{currentRunId}` (409 SUPERSEDE_TARGET_MISMATCH) | superseding | error(supersede-mismatch) | `[postRun(verify)]`, runFact←currentRunId |
|
||||
| `SUPERSEDE_TIMEOUT` (409 SUPERSEDE_TIMEOUT) | superseding | error(supersede-timeout) | — (composer keeps text; no auto-retry) |
|
||||
| `SUPERSEDE_INVALID` (409 SUPERSEDE_INVALID) | superseding | error(supersede-invalid) | — |
|
||||
| `RUN_ALREADY_ACTIVE` (409 A_RUN_ALREADY_ACTIVE, plain POST) | sending | error(run-already-active) | — (composer offers supersede; NO auto-retry) |
|
||||
| `RUN_SUPERSEDED` (observer's run aborted by supersede) | streaming(observer), polling | attaching **†** | `[postRun(observer-follow)]`, ownership=observer |
|
||||
| `DISPOSE` (unmount) | any | idle **†** | `[abortAttach, cancelReconnect, disarmPoll]` (I1/I5 — epoch++ kills late callbacks) |
|
||||
|
||||
**Epoch filter (I1):** the reducer FIRST drops any event carrying an `epoch` that
|
||||
does not equal the current `ctx.epoch`. Outcome events (`STREAM_START`, `ATTACH_*`,
|
||||
`RECONNECT_*`, `SUPERSEDE_*`, `FINISH_*`, `RUN_FACT`) are stamped with the epoch of
|
||||
the command that could produce them; trigger events (user actions, fresh
|
||||
disconnects) carry no epoch.
|
||||
`RECONNECT_*`, `SUPERSEDE_*`, **`FINISH_*`/`STREAM_INCOMPLETE`**, `RUN_FACT`) are
|
||||
stamped with the generation the corresponding STREAM started under (the runtime
|
||||
holds a per-owned-stream `turnEpoch`); trigger events (user actions, fresh
|
||||
disconnects) carry no epoch. **F1:** this is what makes a SUPERSEDED stream's late
|
||||
`onFinish` (a dead stream A closing after the CAS started stream B) get dropped, so
|
||||
A cannot drive the live new run into a false reconnect or reset its run-fact. The
|
||||
supersede path additionally ABORTS A and starts B only from A's onFinish (a
|
||||
microtask), because ai@6 `AbstractChat.makeRequest` corrupts overlapping streams
|
||||
(A's `finally` reads then nulls the shared `activeResponse`).
|
||||
|
||||
**Removed events (scope-cut, internal review):** `RUN_SUPERSEDED` (a ghost feature —
|
||||
never dispatched; the observer-superseded case is handled by the degraded poll,
|
||||
which follows the latest rows regardless of runId), `RECONNECT_BEGIN` (reconnect is
|
||||
entered by `FINISH_DISCONNECT`), and `POLL_ACTIVITY` (the window's activity clock was
|
||||
removed when the idle-cap moved into the thread). The reducer and this table now
|
||||
share exactly the dispatched event set.
|
||||
|
||||
### 409-code → event map (the real #487 contract consumed here)
|
||||
|
||||
|
||||
@@ -309,16 +309,23 @@ describe("run-fsm — commit 5: supersede CAS + error classification", () => {
|
||||
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
||||
expect(m.effects).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("RUN_SUPERSEDED (observer's run killed) → attaching + postRun(observer-follow)", () => {
|
||||
const ctx = { epoch: 0, ownership: "observer" as const, runFact: { runId: "run-dead" }, liveFollow: false };
|
||||
const streaming: Machine = { phase: { name: "streaming" }, ctx, effects: [] };
|
||||
const m = reduce(streaming, { type: "RUN_SUPERSEDED" });
|
||||
// #488 F2 — a late mount `getRun → ATTACH_START` must not hijack a local turn.
|
||||
describe("run-fsm — F2: ATTACH_START only from idle", () => {
|
||||
it("ATTACH_START from a local `sending` turn is ignored (no observer hijack)", () => {
|
||||
const sending = reduce(initialMachine(), { type: "SEND_LOCAL" }); // idle -> sending, local
|
||||
const m = reduce(sending, { type: "ATTACH_START", runId: "r" });
|
||||
expect(m.phase.name).toBe("sending");
|
||||
expect(m.ctx.ownership).toBe("local"); // NOT flipped to observer
|
||||
expect(m.effects).toEqual([]); // no resumeStream
|
||||
});
|
||||
|
||||
it("ATTACH_START from idle attaches as normal", () => {
|
||||
const m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||
expect(m.phase.name).toBe("attaching");
|
||||
expect(m.effects.find((e) => e.type === "postRun")).toEqual({
|
||||
type: "postRun",
|
||||
reason: "observer-follow",
|
||||
});
|
||||
expect(m.ctx.ownership).toBe("observer");
|
||||
expect(hasEffect(m, "resumeStream")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface Machine {
|
||||
|
||||
export type Effect =
|
||||
/** POST /run to (re)establish or verify the run-fact. `reason` is diagnostic. */
|
||||
| { type: "postRun"; reason: "mount" | "verify" | "observer-follow" }
|
||||
| { type: "postRun"; reason: "mount" | "verify" }
|
||||
/** Trigger the SDK `resumeStream()` (attach GET via prepareReconnectToStream). */
|
||||
| { type: "resumeStream" }
|
||||
/** Schedule a reconnect attempt after a backoff, then dispatch RECONNECT_ATTEMPT. */
|
||||
@@ -153,15 +153,12 @@ export type Event =
|
||||
| { type: "ATTACH_START"; runId?: string }
|
||||
| { type: "ATTACH_LIVE"; epoch?: number }
|
||||
| { type: "ATTACH_NONE"; epoch?: number }
|
||||
// -- reconnect after a live disconnect --
|
||||
/** #488 commit 2: entered by the RUN-FACT, not by an assistant message. */
|
||||
| { type: "RECONNECT_BEGIN" }
|
||||
// -- reconnect after a live disconnect (entered by FINISH_DISCONNECT, #488 c2) --
|
||||
| { type: "RECONNECT_ATTEMPT"; attempt: number; epoch?: number }
|
||||
| { type: "RECONNECT_ATTACHED"; epoch?: number }
|
||||
| { type: "RECONNECT_NONE"; epoch?: number }
|
||||
| { type: "RETRY" }
|
||||
// -- degraded poll --
|
||||
| { type: "POLL_ACTIVITY" }
|
||||
| { type: "POLL_TERMINAL" }
|
||||
| { type: "POLL_IDLE_CAP" }
|
||||
// -- run-fact (server-confirmed active run) --
|
||||
@@ -175,10 +172,6 @@ export type Event =
|
||||
| { type: "SUPERSEDE_TIMEOUT"; epoch?: number }
|
||||
| { type: "SUPERSEDE_INVALID"; epoch?: number }
|
||||
| { type: "RUN_ALREADY_ACTIVE" }
|
||||
/** An observer's attached run was aborted because it was superseded server-side:
|
||||
* ask for the latest run and follow it (else an observer tab freezes on the
|
||||
* killed run). */
|
||||
| { type: "RUN_SUPERSEDED" }
|
||||
// -- lifecycle --
|
||||
| { type: "DISPOSE" };
|
||||
|
||||
@@ -332,6 +325,12 @@ export function reduce(m: Machine, event: Event): Machine {
|
||||
// ---- mount attach (resume) ----------------------------------------
|
||||
case "ATTACH_START":
|
||||
// A reopened tab attaches to a still-running run: observer ownership.
|
||||
// #488 F2: ONLY from idle. The mount `getRun` round-trip resolves async, and
|
||||
// a local send may have started meanwhile (phase `sending`, ownership local);
|
||||
// a late ATTACH_START must NOT hijack that local turn into an observer-attach
|
||||
// (queue would stop flushing, "Send now" would hide). Guarding in the reducer
|
||||
// covers every dispatch source.
|
||||
if (m.phase.name !== "idle") return stay(m);
|
||||
return command(m, { name: "attaching" }, [{ type: "resumeStream" }], {
|
||||
ownership: "observer",
|
||||
runFact: event.runId ? { runId: event.runId } : m.ctx.runFact,
|
||||
@@ -351,17 +350,6 @@ export function reduce(m: Machine, event: Event): Machine {
|
||||
});
|
||||
|
||||
// ---- reconnect after a live disconnect ----------------------------
|
||||
case "RECONNECT_BEGIN":
|
||||
// Explicit begin (used when the runtime decides to reconnect from a signal
|
||||
// other than FINISH_DISCONNECT). Requires a run-fact (I3).
|
||||
if (!m.ctx.runFact) return stay(m);
|
||||
return command(
|
||||
m,
|
||||
{ name: "reconnecting", attempt: 1, failed: false },
|
||||
[{ type: "scheduleReconnect", attempt: 1, delayMs: reconnectDelayMs(1) }],
|
||||
{ ownership: "observer" },
|
||||
);
|
||||
|
||||
case "RECONNECT_ATTEMPT":
|
||||
// A scheduled backoff fired — fire the attach GET. epoch++ so the previous
|
||||
// attempt's late outcome cannot drive this one.
|
||||
@@ -420,11 +408,6 @@ export function reduce(m: Machine, event: Event): Machine {
|
||||
return stay(m);
|
||||
|
||||
// ---- degraded poll -------------------------------------------------
|
||||
case "POLL_ACTIVITY":
|
||||
// The polled rows changed (progress). No phase change — the runtime resets
|
||||
// its inactivity clock. Only meaningful while a poll-bearing phase is active.
|
||||
return stay(m);
|
||||
|
||||
case "POLL_TERMINAL":
|
||||
// The run reached a terminal row via the poll (or the reconcile merge). Go
|
||||
// idle and disarm everything (I4: this is a DATA-driven exit, incl. exit
|
||||
@@ -527,13 +510,6 @@ export function reduce(m: Machine, event: Event): Machine {
|
||||
// offers "interrupt and send" (supersede) instead.
|
||||
return to(m, { name: "error", kind: "run-already-active" });
|
||||
|
||||
case "RUN_SUPERSEDED":
|
||||
// An observer's attached run was killed by a server-side supersede. Ask for
|
||||
// the latest run and follow it, instead of freezing on the dead run.
|
||||
return command(m, { name: "attaching" }, [{ type: "postRun", reason: "observer-follow" }], {
|
||||
ownership: "observer",
|
||||
});
|
||||
|
||||
// ---- lifecycle -----------------------------------------------------
|
||||
case "DISPOSE":
|
||||
// Unmount: abort in-flight controllers, drop timers, and bump the epoch so
|
||||
|
||||
Reference in New Issue
Block a user